Compare commits

...
94 Commits
Author SHA1 Message Date
dev 16aba3efb0 fix(settings): constrain logo preview size so it does not overflow the card 2026-09-23 16:56:31 +03:00
dev cb3f010cd9 fix(photo): preserve photo_jobs history in backup/restore; paginate photo history on worker page 2026-09-23 16:37:27 +03:00
dev 30cf04b0cf feat(branding): logo and system name on share and report pages, drop logo border-radius 2026-09-23 16:25:13 +03:00
dev f2d465e0c4 feat(branding): system name/logo, horizontal logo on all pages 2026-09-23 15:19:23 +03:00
dev e435b4ad43 feat(ui): show current commit version and date in admin sidebar 2026-09-23 14:14:29 +03:00
dev 218c3f825d fix(backup): restore new fields, share_links and photo originals
- export/restore share_links (was silently dropped, FK blocked restore)
- keep groups.tutor_id and groups.cover_path, entries.photo_original_path,
  project_files.detached_at on restore
- include uploads/.originals files in backup archive
- insert users before groups to satisfy tutor_id FK
- return 500 JSON instead of hanging when restore fails
2026-09-23 13:49:25 +03:00
dev 4623358f21 feat(students): multi-photo gallery, group photos in export, KIBERone rebrand 2026-09-23 13:32:09 +03:00
dev 5e2533b876 feat(groups): assign tutor to group 2026-09-19 13:17:40 +03:00
dev 1ef81f9d1c wip: student profile/report 2026-09-19 13:06:21 +03:00
dev 72eeb5cf9b fix(entries): keep AI description and status when editing entry 2026-09-18 23:32:55 +03:00
dev d7d4cc1133 feat(modules): soft delete with restore and active filter 2026-09-18 20:04:02 +03:00
dev d3dd922e32 feat(modules): batch module import in admin, module usage in journal 2026-09-18 19:36:32 +03:00
dev 12a527b3ee chore(report): add design reference and screenshots 2026-09-18 19:26:34 +03:00
dev e6291a0235 feat(modules): module topics for student form, admin CRUD with pagination
- db: modules table (name, lessons_count) + entries.module_id (ON DELETE SET NULL), migration + idempotent startup ensure
- api: GET /api/modules (public, search + limit/offset, entries_count), POST/PUT/DELETE (admin, audit-logged)
- entries: accept/validate module_id on create/update, return module_name, module_id filter
- backup/restore: include modules and entries.module_id
- student form: required module select, hidden while no modules exist
- admin: modules.html + js/modules.js list with pagination, search, create/edit/delete modal
- journal: module filter, module select in edit modal, module badge, CSV column
2026-09-18 18:56:42 +03:00
dev d434732f41 feat(report): student report page with works/gallery/files sections 2026-09-18 17:50:50 +03:00
dev 12c612eccd feat(audit): translate photo job audit actions to Russian 2026-09-17 22:25:17 +03:00
dev 5874e2b619 feat: photo jobs apply/reject flow with applied flag in history
- DB: add photo_jobs.applied column (init + migration + ensure)
- Worker: generate preview only (after_path), no longer mutates entry
- New POST /api/entries/:id/photo/jobs/:jobId/apply — apply done job
  result to entry (backs up current photo, marks applied)
- New POST /api/entries/:id/photo/jobs/:jobId/reject — discard result,
  delete temp file, mark rejected
- saveEnhance: apply AI result directly when sliders are at defaults
- Photo history: '✓ Применить' action for unapplied done AI jobs;
  'rejected' status label
- sweepOrphanedUploads keeps done-not-applied preview files
2026-09-17 18:51:26 +03:00
dev 881b5b4c67 fix: repair photo worker broken code; add pending queue view
- Fix runAiEnhance missing return/closing brace from previous commit
- Remove duplicate code fragments in worker.js
- Add renderPhotoPending() to show pending/processing jobs in worker dashboard
- New pending queue table in worker.html photo section
2026-09-17 17:52:51 +03:00
dev 1145be02f3 feat: persistent photo job queue, worker, history with rollback
- Replace in-memory photoAiJobs Map with DB-backed photo_jobs table
- Background photo worker (worker.js) with retry, backoff, stale reset
  - Handles both AI (Real-ESRGAN) and server-side (sharp) enhancement
  - Controlled via photo_worker_enabled setting
- Photo job history in enhance modal with before/after thumbnails + rollback
- Worker dashboard: photo jobs section with status counts, recent table,
  compare slider for before/after, rollback from worker UI
- New endpoints: /api/photo-jobs/status|wake|enabled|requeue-failed,
  /api/entries/:id/photo/jobs (history), .../rollback
- swapEntryPhotoFiles logs every mutation to photo_jobs table
2026-09-17 17:36:56 +03:00
dev 27a2238082 feat: compare slider UI for photo enhance modal
- Side-by-side before/after comparison with draggable divider
- Zoom (scroll wheel) and pan when zoomed in, double-click to reset
- Sliders moved to sidebar panel, responsive layout
- Original/Result badges on the comparison view
- Helper functions: setEnhanceClip, showEnhanceResult, applyEnhanceTransform
2026-09-17 17:02:35 +03:00
dev dec8790a3a fix: AI photo enhance modal stays open with result preview; fix basicsr build
- Modal no longer auto-closes after Real-ESRGAN completes; result loads into
  canvas so user can review, adjust sliders, and choose to apply or discard
- Backend skips swapEntryPhotoFiles until user confirms via Применить
- New DELETE /api/entries/:id/photo/enhance-ai/preview for temp file cleanup
- photo-ai Dockerfile: patch basicsr via find+sed instead of import (avoids
  torchvision.functional_tensor import crash)
2026-09-17 16:44:44 +03:00
dev 3a345cbefd feat: AI photo enhancement (Real-ESRGAN container) + restore original
- new photo-ai service: FastAPI + Real-ESRGAN x2plus on CPU, internal only
- async job queue in server (POST enhance-ai / GET status), 5min timeout, sequential processing
- keep original photo backup (entries.photo_original_path, uploads/.originals), restore-original endpoint
- UI: AI button and restore-original button in enhance modal
- db: photo_original_path column (init.sql, migration.sql, runtime ensure)
2026-09-17 15:37:49 +03:00
dev c4fd31cd53 feat: hybrid photo enhance engine (server sharp + client canvas), denoise
- enhance endpoint accepts JSON params, processes original on disk via sharp pipeline (median, modulate, linear, sharpen, mozjpeg)
- client fallback renders full-size canvas, keeps multipart path
- engine selection setting: auto/server/client, auto falls back to client on error/timeout
- denoise slider (median filter), noise-aware auto mode, preview capped at 1600px
2026-09-17 15:16:42 +03:00
dev 049df61e55 feat: photo quality improvements
- webcam capture resolution/quality configurable in admin settings (defaults 640x480 / 0.92)
- enhance photo modal in journal: original vs preview with sliders (brightness, contrast, saturation, sharpen) and auto-levels button
- new endpoint PUT /api/entries/:id/photo/enhance replaces photo, cleans old file and thumb
- sharper HEIC conversion (0.92) and webp thumbnails (85)
- worker: fail explicitly on empty AI response
2026-09-17 15:07:43 +03:00
dev 57e48a9d4f fix(ai): adaptive max_tokens per backend (reasoning profiles get 1024+, native llama.cpp stays compact; per-profile max_tokens override); include entry texts in ai_status SSE notify so journal updates live without reload; audit log action filter; links pagination; settings validation & UI improvements 2026-09-17 14:12:36 +03:00
dev a93e37a6b1 fix(settings): prevent aiActiveProfile select from overflowing card-foot (shrinkable label+select, min-width:0 on grid cards) 2026-09-17 12:11:18 +03:00
dev 0b4243fe91 feat(docker): add host.docker.internal:host-gateway to app so it can reach host Ollama (AI profiles) 2026-09-17 12:06:11 +03:00
dev 66f7fdc1a8 refactor(settings): AI profile add/edit moved into a modal dialog (overlay click + Esc to close) 2026-09-17 12:00:19 +03:00
dev d7c793c67a fix(settings): constrain aiActiveProfile select width so it stays inside card-foot 2026-09-17 11:58:34 +03:00
dev efe2a42d1d feat(settings): AI model profiles management — native llama.cpp + custom OpenAI-compatible providers, default model selection, connection test 2026-09-17 11:54:08 +03:00
dev bbfde902a5 feat(journal): live AI status updates in entry cards via Postgres NOTIFY trigger + SSE ai_status events 2026-09-17 11:14:27 +03:00
dev cc9a9e6c2d feat(journal): beautiful entrance animation for entries — cascade fade-up on load, yellow glow pop for newly submitted entries 2026-09-16 20:14:09 +03:00
dev 2784084c68 feat(journal): live updates on new entry via SSE (/api/events) — admin journal auto-refreshes when a student submits 2026-09-16 20:09:06 +03:00
dev e8337c2845 feat(share): group entries by date on public /s/<token> page 2026-09-16 15:06:30 +03:00
dev 6cfb13310d chore: checkpoint before date grouping on share page 2026-09-16 15:05:56 +03:00
dev 2c8beedc4b feat: анти-спам блокировка повторной отправки на главной (cooldown по spam_interval_min)
- index.html/index.js: экран подтверждения отправки с обратным отсчётом
- локальный запрет повторной отправки через localStorage (по настройке spam_interval_min)
- server.js: отдаём spam_interval_min в public-settings (default 30)
- Dockerfile: timeout для apk add, чтобы сборка не зависала на недоступном зеркале
- docker-compose.yml: отключаем cloudflared сервис (закомментирован)
2026-09-13 12:27:35 +03:00
dev 0b763e5738 feat(cloudflared): timeout+fallback for WG handshake (WG_HANDSHAKE_TIMEOUT) so tunnel still starts when VPN peer is down 2026-09-13 00:34:43 +03:00
dev 1fc17225b0 fix(cloudflared): chmod 600 wg config in /tmp before wg-quick so WG starts regardless of host perms 2026-09-13 00:29:41 +03:00
dev a7692b1c35 chore: exclude wg/ from docker build context so WG config stays host-only 2026-09-13 00:20:51 +03:00
dev fac668567e feat: редизайн страницы отчёта ученика — портфолио-стиль, галерея с листанием и видео
- renderStudentReport: детский учебный дизайн (крупные скругления, sticky-навигация, секции-карточки)

- журнал занятий в две колонки (одна на мобильных)

- единая галерея фото и видео: листание кнопками/стрелками, счётчик

- воспроизведение видео прямо в лайтбоксе

- кнопка закрытия ✕, закрытие по фону и Esc

- модалка экспорта отчёта ученика (период и выбор содержимого)
2026-09-12 23:55:35 +03:00
dev 3c40e94a57 fix: get cloudflared WireGuard tunnel actually serving
- Dockerfile.cloudflared: add iptables + ip6tables (wg-quick needs them for ::/0 full tunnel)

- docker-compose: privileged:true for cloudflared so wg-quick can set net.ipv4.conf.all.src_valid_mark

- start-cloudflared.sh: restore resolv.conf after wg-quick (resolvconf wiped docker DNS 127.0.0.11 => app unresolvable => Host Error)
2026-09-12 18:51:19 +03:00
dev dd4d306a4e feat: cloudflared container with optional WireGuard egress
- Dockerfile.cloudflared builds cloudflared (official static binary) on Alpine with wireguard-tools
2026-09-12 17:40:42 +03:00
dev 9203eee5d2 fix: требовать фотографию при создании записи (POST /api/entries) на уровне API 2026-09-12 17:26:51 +03:00
dev b263a735ac fix: обязательная фотография в форме отправки; модалка редактирования шире и в две колонки; поддержка .jfif в прикреплении 2026-09-12 17:19:53 +03:00
dev 854f2d4650 feat: публикация наружу через Cloudflare Quick Tunnel (cloudflared) 2026-09-12 15:06:56 +03:00
dev 779270fb73 UI: пагинация, вынос пагинатора, быстрые действия 2026-09-12 14:18:15 +03:00
dev 1d706f1356 Dashboard: add Блокировки link to navigation 2026-09-12 12:52:23 +03:00
dev a8e1aa743d Build app image on host network to bypass bridge egress/TLS issues 2026-09-12 12:22:59 +03:00
dev 1e38419f07 Dockerfile: cascade apk mirror fallback to HTTP (signature-verified) with diagnostics on total failure 2026-09-12 12:09:15 +03:00
dev 21b00a8e09 Dockerfile: fall back to edge.kernel.org mirror when dl-cdn TLS fetch fails 2026-09-12 11:30:13 +03:00
dev db684efe9e Dockerfile: retry apk add on transient Alpine mirror TLS failures 2026-09-12 11:04:40 +03:00
dev fd753c1318 Add cookie notice with settings, jfif preview support, and share page title from link name 2026-09-12 10:05:02 +03:00
dev 192de5e690 Sidebar: color inactive nav icons by section meaning via data-nav attribute 2026-09-12 01:18:57 +03:00
dev 809b978c4b Bans: confirm unban via alert; audit labels for ip.ban/ip.unban; worker page lucide icons 2026-09-12 01:14:58 +03:00
dev c54a5b180c Settings bans card: unban also requires confirmation 2026-09-12 01:06:24 +03:00
dev e69426882a Bans: unban requires inline confirmation row with yes/cancel 2026-09-12 01:01:59 +03:00
dev 5aee2ce3f5 Bans page: users-style table with badges, client-side pagination, manual ban modal (POST /api/bans with ip/reason/hours), unban row buttons 2026-09-12 00:59:32 +03:00
dev 7dd816cfce Add dedicated Bans page (bans.html) with active IP bans table, unban and refresh; nav item for admins; settings card kept 2026-09-12 00:53:35 +03:00
dev 19b0c855a9 Cache HTML/JS/CSS with no-cache revalidation (ETag 304): browsers always get fresh pages after docker restart; vendor and image caches unchanged 2026-09-12 00:47:18 +03:00
dev d5359dd31f Add honeypot field to login form: instant 24h IP ban on autofill 2026-09-12 00:40:29 +03:00
dev 2c7ec17c8b Add IP autoban system: banned_ips table, global ipGuard middleware, 24h bans on honeypot fill / 10 failed logins / 10 wrong share passwords; trust proxy for real client IPs behind funnel; admin API and settings UI to manage active bans 2026-09-12 00:35:51 +03:00
dev 2bebfc071c Replace emoji icons with lucide icons on settings page 2026-09-12 00:04:55 +03:00
dev fd9b470c4c Add server-side image thumbnails (sharp): /uploads/thumb/<name> route, ?thumb=1 on file endpoints, disk-cached 480px WebP previews; grids now load thumbnails with lazy loading 2026-09-12 00:02:23 +03:00
dev 9798872f20 Add in-memory caching layer with TTL and invalidation for settings, groups, students, entries, share payloads and stats; add static asset caching headers 2026-09-11 23:52:58 +03:00
dev f9d310c8ea Add .dockerignore to reduce build context from 542MB to 5kB 2026-09-11 23:29:58 +03:00
dev f8036fae79 Fix: backup/restore now includes group_photos and entry_photos tables; increase AI request timeout to 120s (configurable via AI_REQUEST_TIMEOUT_MS) 2026-09-11 23:12:16 +03:00
dev 4db02d75bc Add AI-check details modal on worker page and raise backup upload limit 2026-09-11 16:26:30 +03:00
dev ee19da7fa0 Add audit details modal and translate remaining action labels
- Make Детали cell clickable to open a modal with the full details text\n- Translate missing audit action keys (auth.login, user.*, branch.*, group.photo.*, entry.photo.*)
2026-09-11 14:45:07 +03:00
dev 7044505915 Fix AI status badge sizing on worker page
Restore .ai-badge as a text pill and scope the compact icon circle to .ai-badge-ic (used by journal); worker page text badges now render at correct size
2026-09-11 14:38:46 +03:00
dev ad81adfe71 Migrate UI to Lucide icons, reorder sidebar, and polish journal/groups UX
- Replace emoji icons with Lucide across admin pages; add vendor/lucide.min.js and renderIcons() helper\n- Reorder sidebar logically (Dashboard, Journal, Students, Groups, Files, Links, Trash + admin sections)\n- Groups: open photo chronology only via the Фото button; covers no longer clickable\n- Journal: show group badge over card photo, compact AI-status icon beside description, and date range in empty-state message\n- Update README (AI worker, Lucide, worker.js)
2026-09-11 14:33:41 +03:00
dev 275ed46dfb Add HF model auto-download with configurable AI env, and share link visibility toggles 2026-09-11 13:12:13 +03:00
dev b7e798b867 Add AI auto-check worker, share link message/link fields, and update journal/settings UI 2026-09-11 10:29:49 +03:00
dev 3850fe35e4 Update project files 2026-09-10 11:26:51 +03:00
dev 7ccc9199e0 Add cover_path column to groups table for group cover photo feature 2026-09-10 01:16:32 +03:00
dev 0d6d059f5b Add JFIF to JPG conversion for student photo uploads 2026-09-10 00:41:16 +03:00
dev 63494f322b Add pagination to audit page 2026-09-10 00:18:56 +03:00
dev 89152295ea Add AI text correction button to journal edit modal 2026-09-10 00:06:38 +03:00
dev 5ae476551d Fix trash: add missing deleted_at IS NOT NULL filter to main query 2026-09-09 23:44:37 +03:00
dev 0681831aaf Add debug logging to trash and restore endpoints 2026-09-09 23:39:30 +03:00
dev 6197f57c43 Fix trash: restore/perm delete buttons not working when clicking card content 2026-09-09 23:32:06 +03:00
dev 0f267ca6a9 Fix trash: improve error handling and add logging for restore/perm delete denials 2026-09-09 23:28:02 +03:00
dev bc3487639d Fix user edit modal: pass full user object instead of just ID 2026-09-09 12:25:49 +03:00
dev 018c65adc5 refactor: move frontend JS to external files and enable strict CSP 2026-09-09 10:06:49 +03:00
dev d6e589d2f5 feat: add branches feature, security audit, and multi-branch support 2026-09-09 09:41:07 +03:00
dev 7a003e5df6 Add system info dashboard to settings
- New /api/system-info endpoint returning DB size, table sizes, photo/file counts, uploads stats, disk usage
- System info cards in settings page (responsive grid)
- Replaced inline onclick handlers with data attributes + event delegation in groups.html
2026-09-09 01:08:48 +03:00
dev 616dabb595 remove name autocomplete/datalist from index form 2026-09-08 12:16:38 +03:00
dev e0cec0f943 add admin audit log (11), hide stacktraces via NODE_ENV=production (13), validate spam_interval_min>=1 (16) 2026-09-08 12:12:29 +03:00
dev 441bdfe0fe group photo upload: respond 500 on DB error instead of hanging (async throw in express4) 2026-09-08 12:00:25 +03:00
dev eef33e457f support HEIC/HEIF uploads: convert to JPEG via heic-convert; graceful multer errors for group photos 2026-09-08 11:54:38 +03:00
dev ea14fd654f fix backup restore: stream-peek gzip content instead of zlib.gunzipFile (node20 API) 2026-09-08 11:38:38 +03:00
dev 8a50b46b7b harden anti-spam and file serving; backup restore to disk; json body limit
- Add honeypot field to public submission form + server-side check
- Serve shared files only in context of a valid share link (/api/share/:shareToken/files/:fileToken)
- Switch backup restore upload to diskStorage (50MB) with temp-dir cleanup
- Limit JSON body to 1mb
- Document fixed audit items
2026-09-08 11:36:34 +03:00
dev 57f2ea4f41 add share-link password/expiry, journal touch-ups, ddns helper and audit docs 2026-09-08 10:54:24 +03:00
dev dd5a2ea288 drop caddy and cloudflared; publish via tailscale funnel; rewrite README 2026-09-08 10:40:20 +03:00
dev 6844d659fc harden security and add public TLS scaffold
- require ADMIN_PASSWORD (no default), remove CORS
- close public DB port, move DB credentials to .env (DB_PASSWORD)
- fix HTML escaping, add helmet + sec headers (no CSP due to inline scripts)
- rate limit public routes by IP (express-rate-limit)
- validate restore data and confine file unlinking to uploads/
- block dangerous upload extensions, 30MB per-entry limit, SVG not served inline
- return 400 on unknown group_id in POST /api/entries
- add commented Caddy/Let's Encrypt reverse-proxy scaffold + Caddyfile.example
- update README
2026-09-07 11:27:04 +03:00
85 changed files with 22603 additions and 2279 deletions
+11 -1
View File
@@ -1,4 +1,14 @@
node_modules
uploads
.git
uploads
backups
certs
models
wg
*.log
*.tar.gz
.env
.env.*
.vscode
.idea
.DS_Store
+33
View File
@@ -0,0 +1,33 @@
ADMIN_USERNAME=admin
ADMIN_PASSWORD=сложный-пароль-админки
DB_PASSWORD=случайная-длинная-строка
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
BACKUP_UPLOAD_LIMIT_MB=500
# Hugging Face token (нужен для закрытых/приватных репозиториев моделей)
HUGGINGFACE_TOKEN=
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
AI_MODEL=qwen2.5-1.5b-instruct-q4_k_m.gguf
# Репозиторий Hugging Face, откуда скачивается модель
AI_MODEL_REPO=Qwen/Qwen2.5-1.5B-Instruct-GGUF
# Промпт по умолчанию для автоисправления текстов (переопределяет встроенный, если в настройках не сохранён свой)
AI_PROMPT=Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.
# Таймаут запроса к AI (мс), по умолчанию 120000 (2 минуты)
AI_REQUEST_TIMEOUT_MS=120000
# === Cloudflare Tunnel (контейнер cloudflared) ===
# По умолчанию — Quick Tunnel на trycloudflare.com: случайный публичный URL,
# адрес печатается в логах: docker compose logs -f cloudflared.
# Чтобы привязать свой домен, замените command сервиса cloudflared
# в docker-compose.yml на: command: ["tunnel", "run", "--token", "${CLOUDFLARE_TUNNEL_TOKEN}"]
# (токен вида "eyJ..." создаётся в Cloudflare Zero Trust -> Networks -> Tunnels).
CLOUDFLARE_TUNNEL_TOKEN=
# Внутренний адрес приложения, на который указывает Quick Tunnel.
CLOUDFLARE_TUNNEL_URL=http://app:3003
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
WG_HANDSHAKE_TIMEOUT=60
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
PHOTO_AI_URL=
PHOTO_AI_MAX_PIXELS=4000000
+10
View File
@@ -6,6 +6,7 @@
*.key
*.crt
certs/
wg/
# --- Run-time data / uploads ---
uploads/
@@ -22,3 +23,12 @@ node_modules/
tmp/
.DS_Store
Thumbs.db
# --- Playwright test artifacts ---
.playwright-mcp/
# --- Generated version data ---
public/version.json
# --- Internal audit (not for commit) ---
SECURITY_AUDIT.md
+186
View File
@@ -0,0 +1,186 @@
# AGENT.md — Developer Agent Guidelines for WhatIDo
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
---
## Project Overview
**WhatIDo** — Accounting system for an educational center: attendance journal, student project works, group gallery, detached files, and public showcase pages (share links).
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Docker Compose, Tailscale (Serve/Funnel)
- **Architecture**: Single Express server (`server.js`) + static frontend in `public/`
- **Deployment**: Docker Compose (app + db + tailscale), bind-mounted uploads, named volume for Postgres data
- **Auth**: Admin-only via `X-Admin-Token` header (value = `ADMIN_PASSWORD` env var). No user sessions.
---
## Development Rules
### 1. Code Style
- **No comments** unless explicitly requested
- **ES modules not used** — CommonJS (`require`) throughout
- **Error handling**: try/catch with explicit status codes, no global error handler
- **Validation**: Inline helper functions (`reqInt`, `reqStr`, `optInt`, etc.) — use them
- **Security first**: All uploads validated, path traversal blocked, rate limits on public routes
### 2. Database
- **Schema**: Defined in `db/init.sql` (runs on first container start)
- **Migrations**: `db/migration.sql` for existing DBs — update both when changing schema
- **Connection**: Single `Pool` from `pg`, `DATABASE_URL` from env
- **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation
- **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for multi-statement ops
### 3. File Uploads
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
- **Limits**: 10 MB/file, 30 MB total per entry
- **Storage**: `uploads/` bind-mounted to host, filenames = `timestamp-random.ext`
- **HEIC**: Auto-converted to JPEG via `heic-convert`
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/`
### 4. API Patterns
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min)
- **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
### 5. Frontend (public/)
- Vanilla HTML/CSS/JS, no build step
- Each page = single HTML file + shared `admin.js` / `admin.css`
- API calls via `fetch` with `X-Admin-Token` from `localStorage`
- Share pages (`share.html`, `links.html`) work without auth
### 6. Docker / Compose
- **Dockerfile**: Node 20 Alpine, installs deps, generates self-signed TLS cert
- **docker-compose.yml**: 3 services (db, app, tailscale)
- `db`: postgres:16-alpine, healthcheck, init.sql mounted
- `app`: builds from Dockerfile, exposes 3003/3443, mounts uploads
- `tailscale`: host network, NET_ADMIN, runs `start-tailscale.sh` (funnel to 127.0.0.1:3443)
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`
- **Port 443 on host** must be free (tailscale listens directly)
### 7. Tailscale Publication
- No external IP / port forwarding needed
- Access: `https://whatido.<tailnet>.ts.net` (inside tailnet + internet via Funnel)
- First run: `docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido` → authorize in browser
- Enable Serve/Funnel in Tailscale admin console for the node
- Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container
### 8. Backup / Restore
- **Admin UI**: `/api/backup` (download tar.gz), `/api/restore` (upload tar.gz)
- **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level)
- Backup format: `data.json` (all tables) + `uploads/` directory
- Restore validates all data, resets sequences, sweeps orphans
---
## Common Tasks
### Add a new API endpoint
1. Add route in `server.js` (group with related routes)
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
3. Validate input with helper functions
4. Use parameterized queries, transactions if multi-table
5. Call `logAudit(req, 'action.name', { ... })` for mutations
6. Return JSON, handle errors with appropriate status codes
### Add a database column/table
1. Update `db/init.sql` (CREATE TABLE / ALTER TABLE)
2. Update `db/migration.sql` (idempotent ALTERs)
3. Update `server.js` queries that SELECT/INSERT the table
4. Test: `docker compose down && docker compose up -d --build`
### Add a frontend page
1. Create `public/newpage.html` (copy structure from existing)
2. Link in `public/admin.html` navigation if admin page
3. Use `admin.js` utilities: `api()`, `requireAuth()`, `formatDate()`, etc.
4. No build step — just refresh browser
### Modify file upload rules
- Edit `BLOCKED_EXT`, `ALLOWED_IMAGE_EXT`, `ADMIN_ALLOWED_EXT` constants
- Update Multer `fileFilter` functions
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync
---
## Testing & Verification
No automated test suite exists. Verify manually:
```bash
# Start stack
docker compose up -d --build
# Check logs
docker compose logs -f app
# Test API (replace TOKEN)
curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
# Run backup/restore scripts
./scripts/backup.sh
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz
```
---
## Security Checklist (before any change)
- [ ] No SQL interpolation — only `$1`, `$2`...
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
- [ ] Rate limiter on new public routes
- [ ] Admin routes behind `requireAdmin`
- [ ] No secrets in code — only via env vars
- [ ] Helmet headers present (already global)
- [ ] CORS disabled (no `cors` middleware)
---
## File Map (key files)
| File | Purpose |
|------|---------|
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
| `worker.js` | Background AI auto-check worker for entry messages |
| `db/init.sql` | Initial schema (runs on fresh DB) |
| `db/migration.sql` | Idempotent migrations for existing DBs |
| `docker-compose.yml` | Service definitions (app, db, tailscale) |
| `Dockerfile` | App image build |
| `public/*.html` | Frontend pages |
| `public/admin.js` | Shared frontend logic |
| `scripts/backup.sh` | Host-level backup script |
| `scripts/restore.sh` | Host-level restore script |
| `start-tailscale.sh` | Tailscale container entrypoint |
| `.env.example` | Env var template |
---
## Do Not
- ❌ Add dependencies without updating `package.json` and rebuilding
- ❌ Write files outside `uploads/` or `certs/`
- ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
- ❌ Expose DB port (5432) outside docker network
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
- ❌ Add comments to code (this file excepted)
---
## Quick Commands
```bash
# Full rebuild
docker compose down && docker compose up -d --build
# App logs
docker compose logs -f app
# DB shell
docker compose exec db psql -U app -d whereldo
# Tailscale status
docker exec -it whatido-tailscale-1 tailscale status
# Manual funnel restart
docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443
```
+29
View File
@@ -0,0 +1,29 @@
# Пример конфигурации Caddy для публичного развёртывания WhatIDo.
#
# Порядок включения:
# 1. Скопируйте этот файл в ./Caddyfile (cp Caddyfile.example Caddyfile)
# 2. Замените yourdomain.example на реальный домен/WWW, указывающий на сервер
# 3. В docker-compose.yml расскомментируйте сервис caddy (и тома caddy_data/caddy_config)
# и переведите блок ports сервиса app в expose (см. комментарии в compose)
# 4. docker compose up -d --build
#
# Caddy автоматически получит Let's Encrypt сертификат на 80/443 портах.
# Запрос к app идёт по HTTPS на внутренний порт 3443 (самоподписанный серт
# приложения, поэтому tls_insecure_skip_verify).
kiberone.dns.army {
reverse_proxy app:3443 {
transport http {
tls_insecure_skip_verify
}
}
}
# Внутренний HTTP-листенер для Cloudflare Quick Tunnel (без редиректов).
:8080 {
reverse_proxy app:3443 {
transport http {
tls_insecure_skip_verify
}
}
}
+20
View File
@@ -0,0 +1,20 @@
# Пример конфигурации Caddy для публичного развёртывания WhatIDo.
#
# Порядок включения:
# 1. Скопируйте этот файл в ./Caddyfile (cp Caddyfile.example Caddyfile)
# 2. Замените yourdomain.example на реальный домен/WWW, указывающий на сервер
# 3. В docker-compose.yml расскомментируйте сервис caddy (и тома caddy_data/caddy_config)
# и переведите блок ports сервиса app в expose (см. комментарии в compose)
# 4. docker compose up -d --build
#
# Caddy автоматически получит Let's Encrypt сертификат на 80/443 портах.
# Запрос к app идёт по HTTPS на внутренний порт 3443 (самоподписанный серт
# приложения, поэтому tls_insecure_skip_verify).
yourdomain.example {
reverse_proxy app:3443 {
transport http {
tls_insecure_skip_verify
}
}
}
+19 -2
View File
@@ -5,9 +5,26 @@ COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY . .
RUN mkdir -p uploads certs
RUN apk add --no-cache openssl && \
RUN set -e; \
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
try_repo() { \
printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \
echo "Trying apk mirror: $1"; \
timeout 45 apk add --no-cache --timeout 20 openssl; \
}; \
if ! try_repo "https://dl-cdn.alpinelinux.org/alpine" && \
! try_repo "https://mirrors.edge.kernel.org/alpine" && \
! try_repo "http://mirrors.edge.kernel.org/alpine"; then \
echo "apk failed on all mirrors (HTTPS and HTTP). Diagnostics:"; \
echo "-- date:"; date; \
echo "-- resolv.conf:"; cat /etc/resolv.conf; \
echo "-- proxy env:"; env | grep -i proxy || echo none; \
wget -qO- --timeout=15 https://mirrors.edge.kernel.org/alpine/$V/main/x86_64/APKINDEX.tar.gz >/dev/null && echo "https probe: ok" || echo "https probe: fail"; \
wget -qO- --timeout=15 http://mirrors.edge.kernel.org/alpine/$V/main/x86_64/APKINDEX.tar.gz >/dev/null && echo "http probe: ok" || echo "http probe: fail"; \
exit 1; \
fi && \
openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
-keyout certs/key.pem -out certs/cert.pem \
-subj "/CN=whatido.local" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
EXPOSE 3000 3443
EXPOSE 3003 3443
CMD ["node", "server.js"]
+12
View File
@@ -0,0 +1,12 @@
# Сборка контейнера cloudflared с опциональным WireGuard.
# Официальный образ cloudflare/cloudflared — distroless (без оболочки/apk), поэтому:
# 1) берём из него статичный бинарник cloudflared;
# 2) саму сборку делаем на Alpine, где ставим wireguard-tools.
# Если в wg/wg0.conf нет конфига — VPN не поднимается, туннель запускается как обычно.
FROM cloudflare/cloudflared:latest AS cf
FROM alpine:3.20
RUN apk add --no-cache wireguard-tools iproute2 iptables ip6tables
COPY --from=cf /usr/local/bin/cloudflared /usr/bin/cloudflared
COPY start-cloudflared.sh /start-cloudflared.sh
ENTRYPOINT ["/bin/sh", "/start-cloudflared.sh"]
+11
View File
@@ -0,0 +1,11 @@
FROM alpine:3.20
RUN apk add --no-cache curl bash
WORKDIR /app
COPY update-dynv6.sh /app/update-dynv6.sh
RUN chmod +x /app/update-dynv6.sh
# Run once on startup, then every 5 minutes in a loop
CMD /app/update-dynv6.sh && while true; do sleep 300; /app/update-dynv6.sh; done
+142
View File
@@ -0,0 +1,142 @@
# План: Мультифилиальность и роли пользователей
## Проблема
Сейчас приложение однофилиальное и одно-user-овое. Нужно поддержать:
- 5-20 филиалов
- Несколько тьюторов/админов
- Филиал студента определяется автоматически через его группу
---
## 1. База данных — схема
### Новые таблицы
```sql
-- Филиалы
CREATE TABLE branches (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Пользователи системы
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'tutor', -- 'admin' или 'tutor'
branch_id INT REFERENCES branches(id), -- NULL для супер-админа
created_at TIMESTAMPTZ DEFAULT now()
);
```
### Изменения в существующих таблицах
| Таблица | Новое поле | Тип | Связь |
|---------|-----------|-----|-------|
| `groups` | `branch_id` | `INT` | `REFERENCES branches(id)` |
| `students` | `branch_id` | `INT` | `REFERENCES branches(id)` |
| `entries` | `branch_id` | `INT` | `REFERENCES branches(id)` |
| `group_photos` | `branch_id` | `INT` | `REFERENCES branches(id)` |
### Логика определения филиала
- Студент → в группе → группа привязана к филиалу → филиал определён
- При отправке записи `branch_id` берётся из группы студента
- Студенту не нужно выбирать филиал — он определяется автоматически
---
## 2. Аутентификация
- Вход по **имени + пароль** (без email)
- Пароли хранятся в `bcrypt` хеше
- Токен (JWT) выдаётся при логине, хранится в `sessionStorage`
- `requireAdmin` заменяется на `requireAuth` + проверку роли
---
## 3. Роли
| Роль | Возможности |
|------|-------------|
| **admin** | Всё: управление пользователями, филиалами, настройками, бэкапами + все данные |
| **tutor** | Управление записями, группами, студентами, файлами, ссылками — видит все группы |
---
## 4. Публичная форма
- Ссылка на форму одна (как сейчас)
- Студент вводит имя → система находит студента → определяет группу → определяет филиал
- Если студент новый (нет в базе) — варианты на обсуждение:
- Не пускать (только зарегистрированные студенты)
- Автоматически создать в группе "Не распределён"
---
## 5. Фронтенд — изменения
### Страница логина
- Поле "Имя" + "Пароль" вместо одного пароля
### Админ-панель
- В хедере/сайдбаре выпадающий список филиалов для фильтрации
- Группы — отображение филиала у каждой группы
- Студенты — отображение филиала у каждого студента
- Журнал — фильтр по филиалу
- Настройки — управление пользователями и филиалами (только для admin)
---
## 6. API — новые эндпоинты
| Метод | Путь | Описание |
|-------|------|----------|
| `POST` | `/api/auth/login` | Вход (имя + пароль → токен) |
| `GET` | `/api/branches` | Список филиалов |
| `POST` | `/api/branches` | Создать филиал (admin) |
| `DELETE` | `/api/branches/:id` | Удалить филиал (admin) |
| `GET` | `/api/users` | Список пользователей (admin) |
| `POST` | `/api/users` | Создать пользователя (admin) |
| `DELETE` | `/api/users/:id` | Удалить пользователя (admin) |
---
## 7. Миграция данных
- Существующие группы/студенты/записи автоматически попадут в филиал "Основной" (или первый созданный)
- `ADMIN_PASSWORD` из `.env` конвертируется в запись `users` с ролью `admin`
---
## 8. Структура файлов (рефакторинг)
```
server.js → рефакторинг на модули:
├── routes/
│ ├── auth.js (логин/аутентификация)
│ ├── branches.js (CRUD филиалов)
│ ├── users.js (CRUD пользователей)
│ ├── groups.js (существующие + branch_id)
│ ├── students.js (существующие + branch_id)
│ ├── entries.js (существующие + branch_id)
│ └── ...
├── middleware/
│ ├── auth.js (requireAuth, requireRole)
│ └── branch.js (branch scoping)
└── db.js (подключение к БД)
```
---
## Порядок реализации
1. **Миграция БД** — создать таблицы `branches`, `users`, добавить `branch_id` к существующим
2. **Миграция данных** — создать филиал "Основной", перенести существующие данные
3. **Бэкенд: аутентификация** — `auth.js` (логин, JWT, middleware)
4. **Бэкенд: CRUD филиалов и пользователей** — новые роуты
5. **Бэкенд: branch scoping** — добавить `branch_id` ко всем существующим запросам
6. **Фронтенд: логин** — новая страница логина
7. **Фронтенд: admin panel** — добавить фильтр филиалов, страницы управления
8. **Тестирование** — проверить все сценарии
+289
View File
@@ -0,0 +1,289 @@
# PRD.md — Product Requirements Document: WhatIDo
**Version**: 1.0
**Status**: Active
**Last Updated**: 2026-09-09
---
## 1. Product Summary
**WhatIDo** is a self-hosted accounting system for an educational center (youth club, coding school, art studio). It tracks attendance, student project works, group photo chronicles, and publishes public showcase pages via share links. Designed for zero-public-IP deployment using Tailscale Funnel.
**Target Users**:
- **Administrators/Teachers** — manage groups, students, entries, files, settings
- **Parents/Students** — view public showcase pages (read-only, no auth)
**Core Value**: Simple, secure, zero-infrastructure publishing. Runs on any Linux box with Docker.
---
## 2. Functional Requirements
### 2.1 Groups Management
| ID | Requirement | Priority |
|----|-------------|----------|
| GRP-1 | CRUD groups: name, schedule (day of week, start/end time), branch assignment | Must |
| GRP-2 | Group cover photo (auto-set from latest group photo or manual) | Must |
| GRP-3 | Photo chronicle per group: upload, caption, date taken, pagination | Must |
| GRP-4 | Active groups endpoint (filters by current day/time in Europe/Moscow) | Must |
| GRP-5 | Branches (locations): CRUD with address, phone; groups link to branch | Should |
### 2.2 Students Management
| ID | Requirement | Priority |
|----|-------------|----------|
| STU-1 | CRUD students: name, group assignment | Must |
| STU-2 | Batch assign students to group | Should |
| STU-3 | Unique name constraint per student | Must |
### 2.3 Journal Entries (Attendance + Project Works)
| ID | Requirement | Priority |
|----|-------------|----------|
| ENT-1 | Create entry: student name (free text), group, description, photo, multiple files | Must |
| ENT-2 | List entries with filters: group, date range, student name, search (name/description), deleted flag | Must |
| ENT-3 | Update entry: description, photo, files | Must |
| ENT-4 | Soft delete / restore (deleted_at timestamp) | Must |
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must |
| ENT-6 | Pagination (limit/offset) + total count | Must |
| ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must |
| ENT-8 | Files attached to entry: upload (max 10 files, 10 MB each, 30 MB total), download by token | Must |
### 2.4 Files Management (Centralized)
| ID | Requirement | Priority |
|----|-------------|----------|
| FIL-1 | List all files with filters: search, student, group, date range | Must |
| FIL-2 | Detached files tab: files with `entry_id = NULL` | Must |
| FIL-3 | Detach file from entry (sets `detached_at`) | Must |
| FIL-4 | Delete file (removes from disk + DB) | Must |
| FIL-5 | Public file access by token (image inline, others download) | Must |
| FIL-6 | File size display in list | Should |
### 2.5 Share Links (Public Showcase Pages)
| ID | Requirement | Priority |
|----|-------------|----------|
| SHR-1 | Create share link: name, optional group, student, date range, anonymize names, expiry (default 7 days), optional password | Must |
| SHR-2 | List/Edit/Delete share links (admin) | Must |
| SHR-3 | Public page (`/s/:token`): shows filtered entries + group photos | Must |
| SHR-4 | Password protection on share link (bcrypt) | Must |
| SHR-5 | Expiry enforcement (410 Gone after expires_at) | Must |
| SHR-6 | Anonymize student names on public page (Student 1, Student 2...) | Should |
| SHR-7 | Group photos on public page (latest 12) | Should |
| SHR-8 | File download from share page (validates link + password + filters) | Must |
### 2.6 Dashboard & Statistics
| ID | Requirement | Priority |
|----|-------------|----------|
| DSH-1 | Stats cards: total entries, trash count, groups, unique students | Must |
| DSH-2 | Active groups right now (schedule match) | Must |
| DSH-3 | Activity chart: entries per day (last 14 days) | Should |
| DSH-4 | Top students by entry count | Should |
| DSH-5 | Recent entries list (last 10) | Should |
### 2.7 Settings
| ID | Requirement | Priority |
|----|-------------|----------|
| SET-1 | Footer left/right text (displayed on public pages) | Must |
| SET-2 | Anti-spam interval (minutes) | Must |
| SET-3 | Admin-only access | Must |
### 2.8 Backup & Restore
| ID | Requirement | Priority |
|----|-------------|----------|
| BAK-1 | Download full backup: tar.gz with data.json (all tables) + uploads/ | Must |
| BAK-2 | Restore from backup file: validates format, replaces all data, resets sequences | Must |
| BAK-3 | Host-level scripts: `backup.sh`, `restore.sh` | Should |
| BAK-4 | Audit log entry for backup download/restore | Must |
### 2.9 Audit Log
| ID | Requirement | Priority |
|----|-------------|----------|
| AUD-1 | Log all mutating actions: action name, target JSON, IP, timestamp | Must |
| AUD-2 | Admin view: paginated list (default 100, max 1000) | Must |
### 2.10 Security & Infrastructure
| ID | Requirement | Priority |
|----|-------------|----------|
| SEC-1 | Admin auth via `X-Admin-Token` header (env `ADMIN_PASSWORD`, no default) | Must |
| SEC-2 | Rate limiting: entries 10/15min, files/share 300/15min | Must |
| SEC-3 | Upload validation: block dangerous extensions, MIME check, size limits | Must |
| SEC-4 | Path traversal protection on file delete/serve | Must |
| SEC-5 | Helmet headers (X-Frame-Options, nosniff, HSTS, Referrer-Policy) | Must |
| SEC-6 | No CORS (cross-origin blocked) | Must |
| SEC-7 | DB port not exposed publicly | Must |
| SEC-8 | TLS termination by Tailscale (Let's Encrypt), app uses self-signed cert internally | Must |
| SEC-9 | Tailscale Funnel publication (no public IP, no port forward) | Must |
---
## 3. Non-Functional Requirements
| Category | Requirement |
|----------|-------------|
| **Performance** | API responses < 500ms for typical queries; pagination for large lists |
| **Reliability** | DB healthcheck; app restarts on crash; uploads persisted on host |
| **Scalability** | Single-instance design; PostgreSQL connection pooling via `pg.Pool` |
| **Maintainability** | Single `server.js` file; vanilla frontend; no build step |
| **Portability** | Docker Compose; runs on any Linux/ARM64/AMD64 with Docker |
| **Backup/Recovery** | Full restore < 5 min for typical dataset (< 1 GB) |
| **Security** | No secrets in image; env vars only; regular dependency updates |
---
## 4. Data Model
```
groups
id PK, name UK, created_at, day_of_week (0-6), time_start, time_end, branch_id FK, cover_path
students
id PK, name UK, group_id FK, created_at
entries
id PK, student_name, group_id FK, description, photo_path, deleted_at, created_at
project_files
id PK, entry_id FK (nullable), token UK, path, name, created_at, detached_at
group_photos
id PK, group_id FK, photo_path, caption, taken_at, created_at
share_links
id PK, token UK, name, group_id FK, student_name, date_from, date_to,
anonymize_names, expires_at, access_password_hash, created_at
settings
key PK, value
audit_log
id PK, action, target JSONB, ip, created_at
branches
id PK, name UK, address, phone, created_at
```
---
## 5. API Surface (Key Endpoints)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/entries` | Admin | List entries (filters, pagination) |
| POST | `/api/entries` | Admin | Create entry (photo + files) |
| PUT | `/api/entries/:id` | Admin | Update entry |
| DELETE | `/api/entries/:id` | Admin | Soft delete |
| POST | `/api/entries/:id/restore` | Admin | Restore from trash |
| GET | `/api/files` | Admin | All files (filters) |
| GET | `/api/files/detached` | Admin | Detached files |
| POST | `/api/files/:id/detach` | Admin | Detach file |
| GET | `/api/files/:token` | Public | Download file by token |
| GET | `/api/groups` | Public | List groups |
| POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups |
| GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle |
| GET | `/api/share/:token` | Public | Share page data |
| GET | `/api/links` | Admin | List share links |
| POST/PUT/DELETE | `/api/links` | Admin | CRUD share links |
| GET | `/api/backup` | Admin | Download backup |
| POST | `/api/restore` | Admin | Upload & restore backup |
| GET | `/api/dashboard` | Admin | Dashboard data |
| GET | `/api/stats` | Admin | Stats cards |
| GET | `/api/audit` | Admin | Audit log |
---
## 6. User Flows
### 6.1 Teacher Creates Attendance Entry
1. Opens `/journal.html`
2. Fills form: student name (typeahead from existing), group, description
3. Adds photo (optional) + project files (optional)
4. Submits → entry appears in list, files accessible by token
### 6.2 Admin Publishes Showcase for Parents
1. Opens `/links.html`
2. Creates share link: selects group, date range, sets password
3. Copies link `https://whatido.tailnet.ts.net/s/abc123`
4. Sends to parents → they open, enter password, view entries + photos
### 6.3 Admin Restores from Backup
1. Opens `/settings.html` → Backups tab
2. Uploads `.tar.gz` backup file
3. Confirms → all data replaced, sequences reset, orphans cleaned
---
## 7. Deployment Architecture
```
Internet / Tailnet
│
▼
┌──────────────────┐
│ Tailscale │ (network_mode: host, port 443)
│ Funnel/Serve │ TLS: Let's Encrypt (*.ts.net)
└────────┬─────────┘
│ HTTPS (trusts app self-signed cert)
▼
┌──────────────────┐
│ App (Node.js) │ 127.0.0.1:3443 (HTTPS), :3003 (HTTP→HTTPS redirect)
│ Express + pg │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ PostgreSQL 16 │ Internal docker network only
│ (named volume) │
└──────────────────┘
Host filesystem:
./uploads ──────► /app/uploads (bind mount)
./certs ──────► /etc/tailscale/app-certs (ro)
```
---
## 8. Configuration
| Variable | Required | Description |
|----------|----------|-------------|
| `ADMIN_PASSWORD` | Yes | Admin token value (no default, server refuses start) |
| `DB_PASSWORD` | Yes | Postgres `app` user password |
---
## 9. Release Criteria
- [ ] All Must-have requirements implemented and tested
- [ ] Docker Compose starts cleanly on fresh host (`docker compose up -d --build`)
- [ ] Tailscale Funnel publishes successfully (manual verification)
- [ ] Backup/restore roundtrip works (data + files intact)
- [ ] No critical security findings (rate limits, upload validation, auth)
- [ ] README.md updated with accurate setup instructions
---
## 10. Future Considerations (Not in Scope v1)
- Multi-user auth (teachers with own logins)
- Email/push notifications
- Mobile app / PWA
- Rich text editor for descriptions
- Bulk import students (CSV)
- Webhooks for external integrations
- Automated scheduled backups to S3/remote
- Role-based access (read-only vs admin)
---
## 11. Acceptance Test Scenarios
| Scenario | Steps | Expected |
|----------|-------|----------|
| Fresh deploy | `cp .env.example .env` → edit → `docker compose up -d --build` | App healthy, DB migrated, HTTPS on 3443 |
| Create entry | POST `/api/entries` with photo + 2 files | Entry created, files downloadable by token |
| Share link | Create link with password → open `/s/token` → enter password | Entries filtered, photos shown, files download |
| Backup/restore | Download backup → delete entry → restore → verify entry back | Full state restored, sequences correct |
| Tailscale publish | `tailscale up` → enable Funnel → `tailscale funnel` | Public URL accessible via HTTPS |
| Upload rejection | POST `.html` file → 400 error | Dangerous extensions blocked |
| Rate limit | 11 rapid POST `/api/entries` → 429 on 11th | Entry limiter enforced |
---
+177 -19
View File
@@ -4,60 +4,204 @@
## Возможности
- **Журнал записей** — отметки о занятиях с фото и прикреплёнными файлами (проектные работы), мягкое удаление и корзина
- **Журнал записей** — отметки о занятиях с фото и прикреплёнными файлами (проектные работы), мягкое удаление и корзина; в режиме карточек у записи показывается бейдж группы поверх фото и иконка статуса AI-проверки
- **AI-проверка (воркер)** — фоновый авто-чек текста записей (`worker.js`); статус каждой записи (очередь / проверка / проверено / пропущено / ошибка) отображается компактной иконкой в журнале с всплывающей подсказкой
- **Файлы** — централизованный раздел со всеми загруженными файлами, фильтры (имя воспитанника, группа, даты, поиск) и вкладка «Откреплённые»
- **Группы** — учебные группы, расписание (день недели, время), фотохроника группы
- **Воспитанники** — справочник с привязкой к группам
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Настройки** — научные тексты футера, анти-спам интервал
- **HTTPS** — самоподписанный TLS-сертификат, автогенерация при сборке
- **Настройки** — тексты футера, анти-спам интервал
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
## Технологии
- Node.js + Express
- PostgreSQL (pg)
- Multer (загрузка файлов), Tar (бэкапы)
- Lucide (иконки UI)
- Docker / Docker Compose
- Tailscale (Serve / Funnel) — публикация по HTTPS
## Быстрый старт
### Требования
- Docker + Docker Compose
- Linux-хост с Docker и плагином `docker compose`
- Free порта `443` на хосте (его займёт tailscale для Funnel)
- Аккаунт Tailscale (для публикации по ссылке)
### Запуск
```bash
# создайте .env с паролем администратора (см. раздел «Конфигурация»)
# 1) создайте .env из примера и задайте свои пароли
cp .env.example .env
$EDITOR .env
# 2) соберите и поднимите стек
docker compose up -d --build
```
После старта:
После старта (без публикации через tailscale):
- **HTTP** `http://localhost:3000` — редирект на HTTPS
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, принимайте предупреждение браузера)
- **PostgreSQL** `localhost:5432` — `app:app`, база `whereldo`
- **HTTP** `http://localhost:3003` — редирект на HTTPS
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
Управление:
```bash
docker compose ps # статус
docker compose logs -f app
docker compose down # остановка (данные сохраняются)
docker compose ps # статус
docker compose logs -f app # логи приложения
docker compose down # остановка (данные сохраняются)
```
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
## Конфигурация
Переменные окружения (`.env`):
| Переменная | По умолчанию | Назначение |
|------------------|--------------|-------------------------------------|
| `ADMIN_PASSWORD` | `admin` | Пароль администратора (X-Admin-Token) |
| `DATABASE_URL` | см. compose | Строка подключения к PostgreSQL |
| Переменная | По умолчанию | Назначение |
|------------------|--------------------|-------------------------------------|
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
Внутри контейнера `db` также задаются `POSTGRES_DB=whereldo`, `POSTGRES_USER=app`, `POSTGRES_PASSWORD=app`, `TZ=Europe/Moscow`.
Пример `.env` (в репозитории — `.env.example`):
```
ADMIN_PASSWORD=сложный-пароль
DB_PASSWORD=случайная-длинная-строка
```
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
## Публичный доступ через Tailscale
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
Цепочка:
```
Интернет / tailnet → https://whatido.<tailnet>.ts.net (TLS от Tailscale)
→ localhost:443 контейнера tailscale
→ https://127.0.0.1:3443 (приложение, самоподписанный cert)
```
Контейнер доверяет самоподписанному сертификату приложения через `SSL_CERT_FILE=/etc/tailscale/app-certs/cert.pem` (файл монтируется из `./certs/cert.pem`).
### 1. Вход в tailnet при первом запуске
При первом старте контейнер автоматически выполняет `tailscale up` и печатает ссылку для авторизации — откройте её в браузере и добавьте устройство в аккаунт. Ввести устройство вручную там не нужно: `tailscale status` покажет статус:
```bash
docker exec -it whatido-tailscale-1 tailscale status
```
Если авторизация по какой-то причине не прошла, выполните вход вручную:
```bash
docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido
# откроется ссылка вида https://login.tailscale.com/a/... — войдите в браузер
```
### 2. Включите функции и найдите адрес
После авторизации узел получит имя вида `whatido` и адрес:
```bash
docker exec -it whatido-tailscale-1 tailscale status
# что-то вроде: whatido.taile47725.ts.net 100.x.x.x receiver online
```
Чтобы публиковать сайт, для tailnet должны быть включены:
- **HTTPS Certificates** — автоматически выдается при первом Serve/Funnel
- **Serve** (доступ из tailnet) и **Funnel** (доступ из интернета) — включаются в админ-консоли Tailscale, например по прямой ссылке на узел:
`https://login.tailscale.com/f/serve?node=<node-id>` и `https://login.tailscale.com/funnel?node=<node-id>`
(id узла берётся из `tailscale status`)
### 3. Запуск Serve / Funnel
Контейнер при старте сам выполняет:
```bash
tailscale funnel --bg --yes https://127.0.0.1:3443
```
(`funnel` включает и serve-часть; флаг `--bg` — работа в фоне, `--yes` — не спрашивать подтверждения.)
Если сервис уже запущен и команды в compose не отработали (например, функции только что включили в консоли), выполните вручную:
```bash
docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443
```
Проверить состояние:
```bash
docker exec whatido-tailscale-1 tailscale funnel status
# https://whatido.<tailnet>.ts.net/ (Funnel on)
```
### 4. Готово
- Из любого устройства вашей tailnet: `https://whatido.<tailnet>.ts.net/`
- Из интернета (при включённом Funnel): тот же адрес
- Админка: `https://whatido.<tailnet>.ts.net/admin`
### Важные замечания
- **Порт 443 на хосте должен быть свободен** — tailscale слушает его напрямую (поэтому у сервиса `network_mode: host`, а приложение опубликовано на `127.0.0.1:3003/3443`).
- **Сертификат приложения**: контейнер приложения генерирует self-signed `cert.pem` при сборке образа (это не секрет — публичный сертификат). Он монтируется в tailscale через `./certs/cert.pem`. Если образ приложения пересобирали впервые на новом хосте — скопируйте сертификат и перезапустите tailscale:
```bash
docker compose up -d --build app
docker cp $(docker compose ps -q app):/app/certs/cert.pem certs/cert.pem
docker compose up -d tailscale
```
- **`/lib/modules`** монтируется в контейнер tailscale, чтобы на LC/дистрибутивах без авто-загрузки модулей корректно инициализировался netfilter (иначе `tailscaled` падает с `Table does not exist` и узел мигает online/offline).
- **Ограничения сети**: если у провайдера нет глобального IPv6 и соединения к ПК-ядрам Tailscale нестабильные (CGNAT), отвечать наружу узел может с перебоями — классический признак: `tailscale status` показывает `online`, а из интернета URL не открывается. Внутри tailnet сайт работает всегда.
## Альтернативная публикация (Caddy)
Исторически проект публиковался через reverse-proxy Caddy + Let's Encrypt (файлы `Caddyfile.example`, закомментированный сервис в старых версиях compose). Если нужно классическое публичное HTTPS на собственном домене с проброшенными портами 80/443 — этот вариант остаётся возможным: раскомментируйте/восстановите сервис `caddy`, укажите домен в `Caddyfile` и уберите сетевые блокировки. По умолчанию сейчас рекомендуется Tailscale-схема выше.
## Публикация наружу через Cloudflare (Quick Tunnel)
Помимо Tailscale, стек умеет публиковать приложение через контейнер `cloudflared` (сервис в `docker-compose.yml`). **Quick Tunnel** даёт случайный публичный адрес `*.trycloudflare.com` через Cloudflare — без своего домена, банковской карты и проброса портов (подойдёт, если Zero Trust требует привязать карту).
Узнать текущий адрес для доступа:
```bash
docker compose logs cloudflared | grep trycloud
```
### Опциональный WireGuard для эгресса Cloudflare
`cloudflared` собирается из `Dockerfile.cloudflared`, в который добавлена поддержка **опционального WireGuard** — полезно, когда Cloudflare не работает с прямого IP хоста, и исход туннеля нужно пустить через VPN.
- Положите рабочий конфиг провайдера в `wg/wg0.conf` (папка `wg/` монтируется в контейнер как `/etc/wireguard`). Пример-заглушка уже лежит в `wg/wg0.conf` — замените значения на свои.
- Если `wg/wg0.conf` есть → контейнер **сначала поднимает WireGuard и ждёт handshake** (весь эгресс Cloudflare идёт через VPN). Если handshake не установился за `WG_HANDSHAKE_TIMEOUT` сек (по умолчанию 60) — **fallback: туннель стартует напрямую без VPN**, а в логах пишется предупреждение (сайт не лежит из-за недоступного VPN-провайдера).
- Если `wg0.conf` нет или папка пустая → VPN не включается, туннель стартует сразу, как в базовой схеме.
Проверка:
```bash
docker compose logs -f cloudflared # в идеале сначала "WireGuard is up", затем URL
docker compose exec cloudflared wg show # есть handshake — VPN поднят
```
Примечания:
- `wg/wg0.conf` содержит приватный ключ, поэтому папка `wg/` добавлена в `.gitignore` (в репозиторий не попадёт).
- При полном туннеле (`AllowedIPs = 0.0.0.0/0`) существующий маршрут docker-сети сохраняется, поэтому `cloudflared` по-прежнему достаёт `app` локально, а наружу уходит через wg0. Если вдруг `app` станет недоступен из-за VPN — переключите `CLOUDFLARE_TUNNEL_URL` в `.env` на docker-шлюз (`http://<gateway-ip>:3003`).
Адрес Quick Tunnel меняется при каждом перезапуске контейнера `cloudflared`. Для постоянного адреса используйте именованный туннель (для этого в `start-cloudflared.sh` замените последнюю команду на `exec cloudflared --no-autoupdate tunnel run --token "$CLOUDFLARE_TUNNEL_TOKEN"` и задайте токен; токен — из Cloudflare Zero Trust **Networks → Tunnels**) или схему через Tailscale выше.
## Структура данных
@@ -89,6 +233,17 @@ docker compose down # остановка (данные сохраняютс
./scripts/restore.sh # восстановление из архива
```
## Безопасность
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет.
- **CORS отключён** — кросс-доменные запросы к API запрещены.
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
- **TLS**: снаружи HTTPS терминируется Tailscale (сертификат Let's Encrypt для `*.ts.net`); между tailscale и приложением используется самоподписанный сертификат приложения.
## Основные API
| Метод | Путь | Назначение |
@@ -113,9 +268,12 @@ docker compose down # остановка (данные сохраняютс
## Структура проекта
```
├── docker-compose.yml # сервисы app + db
├── docker-compose.yml # сервисы: app + db + tailscale
├── .env.example # шаблон переменных окружения
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
├── server.js # Express-приложение
├── worker.js # фоновый worker AI-проверки записей
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
├── db/
│ ├── init.sql # схема при первом запуске
│ └── migration.sql # миграции существующей БД
@@ -123,4 +281,4 @@ docker compose down # остановка (данные сохраняютс
├── scripts/ # вспомогательные скрипты
├── uploads/ # загруженные файлы (bind-монт, вне git)
└── backups/ # локальные бэкапы
```
```
+248
View File
@@ -0,0 +1,248 @@
# Аудит безопасности и антиспама — WhatIDo
> Приложение предназначено для публичного развёртывания. Ниже — результаты аудита
> по уровню важности: 🔴 критично (исправить обязательно), 🟠 высокий приоритет,
> 🟡 средний приоритет, 🔵 замечания по антиспаму.
---
## 🔴 КРИТИЧНО (исправить обязательно перед публикацией)
### 1. Админ-токен в открытом виде + уязвимость по времени
**Файл:** `server.js:52-56`
```js
function requireAdmin(req, res, next) {
const token = req.headers['x-admin-token'];
if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' });
next();
}
```
- Токен сравнивается через `===` — **уязвим к атакам по времени** (timing attack).
- Нет хеширования (bcrypt/argon2) — при утечке `.env` или логов токен сразу компрометирован.
- Реальный пароль лежит в `.env` на диске.
**Рекомендация:** использовать `crypto.timingSafeEqual` и хранить bcrypt-хеш.
---
### 2. Файлы доступны анонимно по токену
**Статус:** 🟢 Частично исправлено (share-файлы привязаны к ссылке)
- ✅ Публичные share-файлы теперь отдаются **только** через `GET /api/share/:shareToken/files/:fileToken`, где проверяется принадлежность файла к записям активной ссылки (expiry, пароль, фильтры группы/имени/периода) — `server.js:612-647`.
- ⚠️ `GET /api/files/:token` и статика `/uploads/*` по-прежнему доступны по токену (нужны для админ-панели). Токены криптостойкие (16 байт hex).
**Файлы:** `server.js:612-647`, `public/share.html:106-121`
---
### 3. Публичные share-ссылки раскрывают ПИД (персональные данные)
**Файл:** `server.js:480-531`
- `/api/share/:token` возвращает: имена учеников, фото, описания, файлы проектов.
- Нет срока действия ссылки (`expires_at`).
- Юридический риск (152-ФЗ, GDPR) — данные детей в открытую.
**Рекомендация:** добавить `expires_at` в `share_links`, опцию анонимизации имён, требование пароля к ссылке.
---
## 🟠 ВЫСОКИЙ ПРИОРИТЕТ
### 4. Нет IP-based rate limit на публичный POST
**Файл:** `server.js:969`
- Только интервал по имени студента (`spam_interval_min`, дефолт 30 мин).
- Бот может менять имена — ограничение обходится.
- Нет лимита по IP — легко завалить сервер или перебрать `/api/files/:token`.
**Рекомендация:** добавить `express-rate-limit` по IP на `POST /api/entries`, `GET /api/share/:token`, `GET /api/files/:token`.
---
### 5. Нет honeypot / CAPTCHA
**Файл:** `public/index.html:305`
- Форма отправки полностью открыта для ботов.
- Скрытое поле-ловушка (honeypot) остановит 90% простых ботов.
**Рекомендация:** добавить `<input name="website" style="display:none" tabindex="-1" autocomplete="off">` и проверку на сервере.
---
### 6. Restore бэкапа загружает 300 МБ в память
**Файл:** `server.js:191-194`
```js
const uploadBackup = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 300 * 1024 * 1024 },
});
```
- `memoryStorage()` — под нагрузкой DoS (OOM killer).
- Архив не проверяется на содержимое до распаковки.
**Рекомендация:** использовать `diskStorage` во временную директорию, лимит 50 МБ.
---
### 7. CSP отключён
**Файл:** `server.js:47`
```js
app.use(helmet({ contentSecurityPolicy: false }));
```
- Весь фронтенд на inline-скриптах и атрибутных обработчиках — строгий CSP их заблокирует.
- Без CSP — риск XSS через инъекции в ошибки/настройки.
**Рекомендация:** рефакторинг фронтенда на внешние JS-файлы → включить CSP.
---
### 8. Сравнение токена без timing-safe
**Файл:** `server.js:54`
- `token !== ADMIN_PASSWORD` — уязвим к timing attack.
**Рекомендация:** `crypto.timingSafeEqual(Buffer.from(token), Buffer.from(ADMIN_PASSWORD))`.
---
### 9. Поле `files` принимает любые расширения
**Файл:** `server.js:83-93`
- `fileFilter` проверяет только `photo` (image MIME).
- Поле `files` принимает **что угодно** — `.html`, `.js`, `.svg` (SVG может содержать JS).
- Имена генерируются случайно, но при угадывании токена — вредоносный файл отдаётся как есть.
**Рекомендация:** добавить тот же блок-лист расширений для `files`, отдавать как `download` (не inline).
---
### 10. Нет лимита на размер JSON-body
**Файл:** `server.js:48`
```js
app.use(express.json());
```
- Нет `limit` — можно слать огромные JSON, забивать память.
**Рекомендация:** `express.json({ limit: '1mb' })`.
---
## 🟡 СРЕДНИЙ ПРИОРИТЕТ
| # | Проблема | Файл/Место |
|---|----------|------------|
| 11 | Share-ссылки никогда не истекают | `db/init.sql:32-41` — нет `expires_at` |
| 12 | Нет аудит-лога админ-действий | — |
| 13 | Стектрейсы утекают в non-production | `server.js:1155-1157` |
| 14 | Нет HSTS / secure cookies / принудительного HTTPS | `server.js:1169-1180` |
| 15 | Имена учеников в URL параметрах share | `public/share.html:105` |
| 16 | Отсутствует валидация `spam_interval_min` ≥ 1 | `server.js:1004` — можно поставить 0 и отключить антиспам |
---
## 🔵 АНТИСПАМ — ПРОБЕЛЫ
| Мера | Статус | Что нужно |
|------|--------|-----------|
| IP-based rate limit | ❌ | `rateLimit` по IP на `/api/entries` |
| Honeypot поле | ❌ | Скрытый input в форме + проверка сервером |
| CAPTCHA / Turnstile | ❌ | Опционально: Cloudflare Turnstile |
| Мин. интервал спама | ⚠️ Можно 0 | Валидация: минимум 1 минута |
| Квота суммарного объёма на IP/день | ❌ | Добавить (напр. 100 МБ/день) |
| Лимит файлов на запрос | ✅ 10 файлов | Оставить |
| Суммарный размер на запрос | ✅ 30 МБ | Оставить |
---
## ✅ УЖЕ ИСПРАВЛЕНО (подтверждено в текущем коде)
- ❌ Убран фолбэк-пароль `'admin'` — старт невозможен без `ADMIN_PASSWORD`
- ❌ CORS полностью удалён
- ❌ Порт БД 5432 не опубликован, креды из `.env`
- ❌ `escapeHtml` исправлен (`&`)
- ❌ `express-rate-limit` на API роутах
- ❌ Валидация restore-данных + безопасный `safeUnlink` (path traversal защита)
- ❌ `helmet` + security-заголовки (кроме CSP)
- ❌ Блок-лист расширений загрузки + лимит 30 МБ/запись
- ❌ Параметризованные запросы (нет SQL-инъекций)
- ❌ Токены файлов криптостойкие (16 байт hex)
- ❌ Файлы не перезаписываются (случайные имена)
- ❌ Multer-лимиты на размеры есть
---
## 📋 ПЛАН ДЕЙСТВИЙ (must-do перед публикацией)
### Фаза 1 — Критично (до публичного запуска)
1. **Хеш админ-токена** (bcrypt) + `crypto.timingSafeEqual` для сравнения
2. **Защита файлов** — отдача только в контексте валидной share-ссылки или подписанные URL
3. **IP rate limit** на `POST /api/entries` (10 req / 15 мин на IP)
4. **Honeypot** в публичной форме
5. **Backup restore на диск** (diskStorage, лимит 50 МБ)
6. **Мин. spam_interval_min = 1** (валидация в settings)
### Фаза 2 — Укрепление (высокий приоритет)
7. **CSP** — рефакторинг inline-скриптов → внешние файлы
8. **HSTS** через reverse-proxy (Caddy/nginx)
9. **Блок-лист расширений для `files`** + отдача как download
10. **JSON body limit** (`1mb`)
11. **`expires_at` для share_links** + опция анонимизации
12. **Аудит-лог** админ-действий (логин, CRUD, backup/restore)
### Фаза 3 — Приватность и соответствие
13. **Анонимизация share-ссылок** (опция скрыть имена)
14. **Политика хранения** — автоудаление старых записей
15. **Privacy notice** на публичной форме
---
## БЫСТРЫЕ ПОБЕДЫ (можно внедрить сегодня)
```javascript
// 1. Timing-safe админ-проверка (server.js:52-56)
const crypto = require('crypto');
function requireAdmin(req, res, next) {
const token = req.headers['x-admin-token'];
const expected = Buffer.from(ADMIN_PASSWORD);
const provided = Buffer.from(token || '');
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
// 2. IP rate limit на публичную запись (server.js:969)
const entryIpLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: req => req.ip,
message: { error: 'Too many submissions from this IP' }
});
app.post('/api/entries', entryIpLimiter, entryLimiter, ...);
// 3. Honeypot в форме (index.html) — скрытое поле
// <input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none">
// В хендлере: if (req.body.website) return res.status(400).json({ error: 'Spam detected' });
// 4. JSON body limit (server.js:48)
app.use(express.json({ limit: '1mb' }));
// 5. Блок-лист для project files (server.js:83-93)
const BLOCKED_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
if (file.fieldname === 'files' && ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
```
---
## Файлы для доработки (приоритет)
| Файл | Что править |
|------|-------------|
| `server.js:52-56` | timing-safe compare, bcrypt-хеш |
| `server.js:969` | IP rate limiter + honeypot проверка |
| `server.js:191-194` | diskStorage для backup restore |
| `server.js:83-93` | блок-лист для `files` |
| `server.js:48` | `express.json({ limit: '1mb' })` |
| `server.js:1004` | валидация `spam_interval_min >= 1` |
| `public/index.html` | honeypot input |
| `db/init.sql` | добавить `expires_at` в `share_links` |
| `docker-compose.yml` | раскомментировать Caddy для TLS |
---
*Аудит выполнен: 2026-09-07*
*Статус: готово к внедрению Фазы 1*
+249
View File
@@ -0,0 +1,249 @@
# Аудит безопасности и антиспама — WhatIDo
> Приложение предназначено для публичного развёртывания. Ниже — результаты аудита
> по уровню важности: 🔴 критично (исправить обязательно), 🟠 высокий приоритет,
> 🟡 средний приоритет, 🔵 замечания по антиспаму.
---
## 🔴 КРИТИЧНО (исправить обязательно перед публикацией)
### 1. Админ-токен в открытом виде + уязвимость по времени
**Файл:** `server.js:52-56`
```js
function requireAdmin(req, res, next) {
const token = req.headers['x-admin-token'];
if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' });
next();
}
```
- Токен сравнивается через `===` — **уязвим к атакам по времени** (timing attack).
- Нет хеширования (bcrypt/argon2) — при утечке `.env` или логов токен сразу компрометирован.
- Реальный пароль лежит в `.env` на диске.
**Рекомендация:** использовать `crypto.timingSafeEqual` и хранить bcrypt-хеш.
---
### 2. Файлы доступны анонимно по токену
**Файлы:** `server.js:877-885`, `server.js:49`
- `GET /api/files/:token` и статика `/uploads/*` отдают любой файл любому, кто знает токен.
- Токены криптостойкие (32 hex), но **фото детей и учебные проекты не должны быть публично доступны по угадываемому ключу**.
- Share-ссылки (`/api/share/:token`) также выдают все файлы записи.
**Рекомендация:** отдавать файлы только в контексте действующей share-ссылки (проверка принадлежности entry к ссылке) либо подписанные URL с TTL.
---
### 3. Публичные share-ссылки раскрывают ПИД (персональные данные)
**Файл:** `server.js:480-531`
- `/api/share/:token` возвращает: имена учеников, фото, описания, файлы проектов.
- Нет срока действия ссылки (`expires_at`).
- Юридический риск (152-ФЗ, GDPR) — данные детей в открытую.
**Рекомендация:** добавить `expires_at` в `share_links`, опцию анонимизации имён, требование пароля к ссылке.
---
## 🟠 ВЫСОКИЙ ПРИОРИТЕТ
### 4. Нет IP-based rate limit на публичный POST
**Файл:** `server.js:969`
- Только интервал по имени студента (`spam_interval_min`, дефолт 30 мин).
- Бот может менять имена — ограничение обходится.
- Нет лимита по IP — легко завалить сервер или перебрать `/api/files/:token`.
**Рекомендация:** добавить `express-rate-limit` по IP на `POST /api/entries`, `GET /api/share/:token`, `GET /api/files/:token`.
---
### 5. Нет honeypot / CAPTCHA
**Файл:** `public/index.html:305`
- Форма отправки полностью открыта для ботов.
- Скрытое поле-ловушка (honeypot) остановит 90% простых ботов.
**Рекомендация:** добавить `<input name="website" style="display:none" tabindex="-1" autocomplete="off">` и проверку на сервере.
---
### 6. Restore бэкапа загружает 300 МБ в память
**Файл:** `server.js:191-194`
```js
const uploadBackup = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 300 * 1024 * 1024 },
});
```
- `memoryStorage()` — под нагрузкой DoS (OOM killer).
- Архив не проверяется на содержимое до распаковки.
**Рекомендация:** использовать `diskStorage` во временную директорию, лимит 50 МБ.
---
### 7. CSP отключён
**Файл:** `server.js:47`
```js
app.use(helmet({ contentSecurityPolicy: false }));
```
- Весь фронтенд на inline-скриптах и атрибутных обработчиках — строгий CSP их заблокирует.
- Без CSP — риск XSS через инъекции в ошибки/настройки.
**Рекомендация:** рефакторинг фронтенда на внешние JS-файлы → включить CSP.
---
### 8. Сравнение токена без timing-safe
**Файл:** `server.js:54`
- `token !== ADMIN_PASSWORD` — уязвим к timing attack.
**Рекомендация:** `crypto.timingSafeEqual(Buffer.from(token), Buffer.from(ADMIN_PASSWORD))`.
---
### 9. Поле `files` принимает любые расширения
**Файл:** `server.js:83-93`
- `fileFilter` проверяет только `photo` (image MIME).
- Поле `files` принимает **что угодно** — `.html`, `.js`, `.svg` (SVG может содержать JS).
- Имена генерируются случайно, но при угадывании токена — вредоносный файл отдаётся как есть.
**Рекомендация:** добавить тот же блок-лист расширений для `files`, отдавать как `download` (не inline).
---
### 10. Нет лимита на размер JSON-body
**Файл:** `server.js:48`
```js
app.use(express.json());
```
- Нет `limit` — можно слать огромные JSON, забивать память.
**Рекомендация:** `express.json({ limit: '1mb' })`.
---
## 🟡 СРЕДНИЙ ПРИОРИТЕТ
| # | Проблема | Файл/Место |
|---|----------|------------|
| 11 | Share-ссылки никогда не истекают | `db/init.sql:32-41` — нет `expires_at` |
| 12 | Нет аудит-лога админ-действий | — |
| 13 | Стектрейсы утекают в non-production | `server.js:1155-1157` |
| 14 | Нет HSTS / secure cookies / принудительного HTTPS | `server.js:1169-1180` |
| 15 | Имена учеников в URL параметрах share | `public/share.html:105` |
| 16 | Отсутствует валидация `spam_interval_min` ≥ 1 | `server.js:1004` — можно поставить 0 и отключить антиспам |
---
## 🔵 АНТИСПАМ — ПРОБЕЛЫ
| Мера | Статус | Что нужно |
|------|--------|-----------|
| IP-based rate limit | ❌ | `rateLimit` по IP на `/api/entries` |
| Honeypot поле | ❌ | Скрытый input в форме + проверка сервером |
| CAPTCHA / Turnstile | ❌ | Опционально: Cloudflare Turnstile |
| Мин. интервал спама | ⚠️ Можно 0 | Валидация: минимум 1 минута |
| Квота суммарного объёма на IP/день | ❌ | Добавить (напр. 100 МБ/день) |
| Лимит файлов на запрос | ✅ 10 файлов | Оставить |
| Суммарный размер на запрос | ✅ 30 МБ | Оставить |
---
## ✅ УЖЕ ИСПРАВЛЕНО (подтверждено в текущем коде)
- ❌ Убран фолбэк-пароль `'admin'` — старт невозможен без `ADMIN_PASSWORD`
- ❌ CORS полностью удалён
- ❌ Порт БД 5432 не опубликован, креды из `.env`
- ❌ `escapeHtml` исправлен (`&`)
- ❌ `express-rate-limit` на API роутах
- ❌ Валидация restore-данных + безопасный `safeUnlink` (path traversal защита)
- ❌ `helmet` + security-заголовки (кроме CSP)
- ❌ Блок-лист расширений загрузки + лимит 30 МБ/запись
- ❌ Параметризованные запросы (нет SQL-инъекций)
- ❌ Токены файлов криптостойкие (16 байт hex)
- ❌ Файлы не перезаписываются (случайные имена)
- ❌ Multer-лимиты на размеры есть
---
## 📋 ПЛАН ДЕЙСТВИЙ (must-do перед публикацией)
### Фаза 1 — Критично (до публичного запуска)
1. **Хеш админ-токена** (bcrypt) + `crypto.timingSafeEqual` для сравнения
2. **Защита файлов** — отдача только в контексте валидной share-ссылки или подписанные URL
3. **IP rate limit** на `POST /api/entries` (10 req / 15 мин на IP)
4. **Honeypot** в публичной форме
5. **Backup restore на диск** (diskStorage, лимит 50 МБ)
6. **Мин. spam_interval_min = 1** (валидация в settings)
### Фаза 2 — Укрепление (высокий приоритет)
7. **CSP** — рефакторинг inline-скриптов → внешние файлы
8. **HSTS** через reverse-proxy (Caddy/nginx)
9. **Блок-лист расширений для `files`** + отдача как download
10. **JSON body limit** (`1mb`)
11. **`expires_at` для share_links** + опция анонимизации
12. **Аудит-лог** админ-действий (логин, CRUD, backup/restore)
### Фаза 3 — Приватность и соответствие
13. **Анонимизация share-ссылок** (опция скрыть имена)
14. **Политика хранения** — автоудаление старых записей
15. **Privacy notice** на публичной форме
---
## БЫСТРЫЕ ПОБЕДЫ (можно внедрить сегодня)
```javascript
// 1. Timing-safe админ-проверка (server.js:52-56)
const crypto = require('crypto');
function requireAdmin(req, res, next) {
const token = req.headers['x-admin-token'];
const expected = Buffer.from(ADMIN_PASSWORD);
const provided = Buffer.from(token || '');
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
// 2. IP rate limit на публичную запись (server.js:969)
const entryIpLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: req => req.ip,
message: { error: 'Too many submissions from this IP' }
});
app.post('/api/entries', entryIpLimiter, entryLimiter, ...);
// 3. Honeypot в форме (index.html) — скрытое поле
// <input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none">
// В хендлере: if (req.body.website) return res.status(400).json({ error: 'Spam detected' });
// 4. JSON body limit (server.js:48)
app.use(express.json({ limit: '1mb' }));
// 5. Блок-лист для project files (server.js:83-93)
const BLOCKED_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
if (file.fieldname === 'files' && ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
```
---
## Файлы для доработки (приоритет)
| Файл | Что править |
|------|-------------|
| `server.js:52-56` | timing-safe compare, bcrypt-хеш |
| `server.js:969` | IP rate limiter + honeypot проверка |
| `server.js:191-194` | diskStorage для backup restore |
| `server.js:83-93` | блок-лист для `files` |
| `server.js:48` | `express.json({ limit: '1mb' })` |
| `server.js:1004` | валидация `spam_interval_min >= 1` |
| `public/index.html` | honeypot input |
| `db/init.sql` | добавить `expires_at` в `share_links` |
| `docker-compose.yml` | раскомментировать Caddy для TLS |
---
*Аудит выполнен: 2026-09-07*
*Статус: готово к внедрению Фазы 1*
+175
View File
@@ -1,26 +1,104 @@
CREATE TABLE IF NOT EXISTS branches (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
address TEXT,
phone VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS banned_ips (
ip VARCHAR(64) PRIMARY KEY,
reason VARCHAR(100) NOT NULL,
banned_until TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS groups (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
day_of_week INT,
time_start TIME,
time_end TIME,
cover_path VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS modules (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
lessons_count INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
photo_path VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
CREATE TABLE IF NOT EXISTS students (
id SERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL UNIQUE,
group_id INT REFERENCES groups(id),
created_at TIMESTAMPTZ DEFAULT now(),
photo_path VARCHAR(255),
profile JSONB
);
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
CREATE TABLE IF NOT EXISTS student_photos (
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
CREATE TABLE IF NOT EXISTS entries (
id SERIAL PRIMARY KEY,
student_name VARCHAR(150) NOT NULL,
group_id INT NOT NULL REFERENCES groups(id),
module_id INT REFERENCES modules(id) ON DELETE SET NULL,
description TEXT NOT NULL,
description_original TEXT,
description_ai TEXT,
ai_status VARCHAR(20) NOT NULL DEFAULT 'pending',
ai_checked_at TIMESTAMPTZ,
ai_error TEXT,
photo_path VARCHAR(255),
photo_original_path VARCHAR(255),
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
@@ -28,6 +106,20 @@ CREATE TABLE IF NOT EXISTS settings (
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('share_show_student_message', 'false')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('share_show_entry_date', 'false')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('share_show_student_names', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS share_links (
id SERIAL PRIMARY KEY,
@@ -37,9 +129,19 @@ CREATE TABLE IF NOT EXISTS share_links (
student_name VARCHAR(150),
date_from DATE,
date_to DATE,
show_student_names BOOLEAN,
expires_at TIMESTAMPTZ,
access_password_hash VARCHAR(255),
message TEXT,
link_url VARCHAR(500),
show_student_message BOOLEAN,
show_entry_date BOOLEAN,
show_group_photos BOOLEAN,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at);
INSERT INTO groups (name) VALUES
('Beginner'),
('Intermediate'),
@@ -52,6 +154,7 @@ CREATE TABLE IF NOT EXISTS group_photos (
photo_path VARCHAR(255) NOT NULL,
caption TEXT,
taken_at DATE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
@@ -66,3 +169,75 @@ CREATE TABLE IF NOT EXISTS project_files (
created_at TIMESTAMPTZ DEFAULT now(),
detached_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS entry_photos (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
caption TEXT,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(150),
role VARCHAR(20) NOT NULL DEFAULT 'tutor' CHECK (role IN ('admin','tutor')),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS user_branches (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, branch_id)
);
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
action VARCHAR(20) NOT NULL DEFAULT 'ai',
params JSONB,
before_path VARCHAR(255),
after_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied BOOLEAN NOT NULL DEFAULT false,
attempts INT NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
target JSONB,
ip VARCHAR(45),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
+190 -1
View File
@@ -4,7 +4,16 @@ CREATE TABLE IF NOT EXISTS students (
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS banned_ips (
ip VARCHAR(64) PRIMARY KEY,
reason VARCHAR(100) NOT NULL,
banned_until TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id);
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -13,6 +22,14 @@ CREATE TABLE IF NOT EXISTS settings (
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.')
ON CONFLICT (key) DO NOTHING;
UPDATE settings
SET value = 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'
WHERE key = 'ai_prompt' AND value IN (
'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.',
'Ты — редактор текстов. Исправь ошибки. Верни ТОЛЬКО исправленный текст.'
);
CREATE TABLE IF NOT EXISTS share_links (
id SERIAL PRIMARY KEY,
@@ -25,6 +42,21 @@ CREATE TABLE IF NOT EXISTS share_links (
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_message BOOLEAN;
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_entry_date BOOLEAN;
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_group_photos BOOLEAN;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'share_links' AND column_name = 'anonymize_names') THEN
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_names BOOLEAN;
UPDATE share_links SET show_student_names = (NOT anonymize_names) WHERE anonymize_names IS NOT NULL;
ALTER TABLE share_links DROP COLUMN anonymize_names;
END IF;
END $$;
DO $$
BEGIN
INSERT INTO students (name)
@@ -48,7 +80,94 @@ CREATE UNIQUE INDEX IF NOT EXISTS project_files_token_key ON project_files(token
ALTER TABLE project_files ALTER COLUMN entry_id DROP NOT NULL;
ALTER TABLE project_files ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS entry_photos (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
caption TEXT,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('share_show_student_message', 'false')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('share_show_entry_date', 'false')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('share_show_student_names', 'true')
ON CONFLICT (key) DO NOTHING;
DELETE FROM settings WHERE key = 'share_anonymize_names';
INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
ON CONFLICT (key) DO NOTHING;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
UPDATE entries SET description_original = description WHERE description_original IS NULL;
UPDATE entries SET ai_status = 'skipped' WHERE ai_status = 'pending';
INSERT INTO settings (key, value) VALUES ('ai_autocheck_migrated', '1');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS branches (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
address TEXT,
phone VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS day_of_week INT;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_start TIME;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end TIME;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS cover_path VARCHAR(255);
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(150),
role VARCHAR(20) NOT NULL DEFAULT 'tutor' CHECK (role IN ('admin','tutor')),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_branches (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, branch_id)
);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS group_photos (
id SERIAL PRIMARY KEY,
@@ -59,4 +178,74 @@ CREATE TABLE IF NOT EXISTS group_photos (
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
ALTER TABLE group_photos ADD COLUMN IF NOT EXISTS sort_order INT DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
UPDATE entries e SET photo_path = p.photo_path
FROM (
SELECT DISTINCT ON (entry_id) entry_id, photo_path
FROM entry_photos ORDER BY entry_id, sort_order, id
) p
WHERE e.photo_path IS NULL AND p.entry_id = e.id;
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
CREATE TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
action VARCHAR(20) NOT NULL DEFAULT 'ai',
params JSONB,
before_path VARCHAR(255),
after_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied BOOLEAN NOT NULL DEFAULT false,
attempts INT NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS modules (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
lessons_count INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
CREATE TABLE IF NOT EXISTS student_photos (
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
+36
View File
@@ -0,0 +1,36 @@
-- Migration: Add anonymize_names, expires_at, access_password_hash to share_links
-- Run this on existing database
-- 1. Add anonymize_names column (default false = show names)
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS anonymize_names BOOLEAN DEFAULT false;
-- 2. Add expires_at column (default 7 days from creation, nullable for backward compat)
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
-- 3. Add access_password_hash column for bcrypt password protection
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS access_password_hash VARCHAR(255);
-- 4. Update existing links to have expires_at = created_at + 7 days (optional, for existing links)
-- UPDATE share_links SET expires_at = created_at + interval '7 days' WHERE expires_at IS NULL;
-- 5. Add index for cleanup of expired links
CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at);
-- 6. Add public message and external link shown on the share page
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
-- 7. Per-link visibility overrides (NULL = fall back to global settings)
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_message BOOLEAN;
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_entry_date BOOLEAN;
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_group_photos BOOLEAN;
-- 8. Replace anonymize_names with positive show_student_names (NULL = inherit global setting)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'share_links' AND column_name = 'anonymize_names') THEN
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_names BOOLEAN;
UPDATE share_links SET show_student_names = (NOT anonymize_names) WHERE anonymize_names IS NOT NULL;
ALTER TABLE share_links DROP COLUMN anonymize_names;
END IF;
END $$;
+101 -6
View File
@@ -6,13 +6,11 @@ services:
environment:
POSTGRES_DB: whereldo
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
TZ: Europe/Moscow
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d whereldo"]
interval: 2s
@@ -20,13 +18,27 @@ services:
retries: 10
app:
build: .
build:
context: .
network: host
extra_hosts:
- "host.docker.internal:host-gateway"
expose:
- "3003"
- "3443"
ports:
- "3000:3000"
- "3003:3003"
- "3443:3443"
environment:
DATABASE_URL: postgres://app:app@db:5432/whereldo
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
AI_PROMPT: ${AI_PROMPT:-}
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
NODE_ENV: production
TZ: Europe/Moscow
depends_on:
db:
@@ -34,5 +46,88 @@ services:
volumes:
- ./uploads:/app/uploads
# Публикация через Tailscale (Serve / Funnel) без проброса портов.
# Приложение доступно по https://whatido.<tailnet>.ts.net
# tailscale:
# image: tailscale/tailscale:latest
# hostname: whatido
# restart: unless-stopped
# network_mode: host
# cap_add:
# - NET_ADMIN
# - SYS_MODULE
# environment:
# SSL_CERT_FILE: /etc/tailscale/app-certs/cert.pem
# volumes:
# - /var/lib/tailscale:/var/lib/tailscale
# - /dev/net/tun:/dev/net/tun
# - /lib/modules:/lib/modules:ro
# - ./certs:/etc/tailscale/app-certs:ro
# - ./start-tailscale.sh:/start-tailscale.sh:ro
# command: ["/bin/sh", "/start-tailscale.sh"]
# depends_on:
# - app
# Публикация наружу через Cloudflare Tunnel (cloudflared), Quick Tunnel:
# случайный публичный URL *.trycloudflare.com, который печатается в логах:
# docker compose logs -f cloudflared
# Сборка из Dockerfile.cloudflared добавляет опциональный WireGuard:
# если в wg/wg0.conf лежит конфиг — контейнер сначала поднимает VPN и
# только потом запускает туннель (исход Cloudflare через VPN). Без конфига
# туннель стартует сразу, как в базовой схеме.
# cloudflared:
# build:
# context: .
# dockerfile: Dockerfile.cloudflared
# restart: unless-stopped
# cap_add:
# - NET_ADMIN
# privileged: true
# volumes:
# - ./wg:/etc/wireguard:ro
# environment:
# TZ: Europe/Moscow
# CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
# WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
# depends_on:
# - app
text-corrector:
image: ghcr.io/ggml-org/llama.cpp:server
container_name: text-corrector
restart: unless-stopped
entrypoint: ["/bin/sh", "/start-text-corrector.sh"]
environment:
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
AI_MODEL_REPO: ${AI_MODEL_REPO:-Qwen/Qwen2.5-1.5B-Instruct-GGUF}
HUGGINGFACE_TOKEN: ${HUGGINGFACE_TOKEN:-}
volumes:
- ./models:/models
- ./start-text-corrector.sh:/start-text-corrector.sh:ro
ports:
- "8080:8080"
# ИИ-улучшение фотографий (Real-ESRGAN x2, CPU).
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
photo-ai:
build: ./photo-ai
container_name: photo-ai
restart: unless-stopped
environment:
MODEL_PATH: /models/RealESRGAN_x2plus.pth
MAX_INPUT_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
TZ: Europe/Moscow
volumes:
- photo-ai-models:/models
volumes:
pgdata:
photo-ai-models:
Binary file not shown.
+2510
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -6,10 +6,18 @@
"start": "node server.js"
},
"dependencies": {
"cors": "^2.8.5",
"bcrypt": "^5.1.1",
"express": "^4.21.0",
"express-rate-limit": "^8.7.0",
"heic-convert": "^2.1.0",
"helmet": "^8.3.0",
"lucide": "^1.44.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.0",
"sharp": "^0.34.5",
"tar": "^7.4.3"
},
"allowScripts": {
"bcrypt@5.1.1": true
}
}
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.10-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir "typing-extensions==4.12.2" "numpy<2"
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
RUN pip install --no-cache-dir realesrgan==0.3.0 fastapi "uvicorn[standard]" python-multipart opencv-python-headless
RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py'))" 2>/dev/null) || \
BASICSR_DEG=$(find /usr/local/lib/python3.10 -path "*/basicsr/data/degradations.py" 2>/dev/null | head -1) && \
if [ -n "$BASICSR_DEG" ]; then \
sed -i 's/from torchvision.transforms.functional_tensor/from torchvision.transforms.functional/g' "$BASICSR_DEG" && \
echo "basicsr patch applied to $BASICSR_DEG"; \
else \
echo "basicsr degradations.py not found, skipping patch"; \
fi
COPY app.py .
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
VOLUME /models
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
+73
View File
@@ -0,0 +1,73 @@
import io
import os
import asyncio
import threading
import urllib.request
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import Response
import numpy as np
import cv2
import torch
from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer
MODEL_PATH = os.environ.get('MODEL_PATH', '/models/RealESRGAN_x2plus.pth')
MODEL_URL = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth'
MAX_PIXELS = int(os.environ.get('MAX_INPUT_PIXELS', str(4_000_000)))
lock = threading.Lock()
upsampler = None
app = FastAPI()
def ensure_model():
if not os.path.exists(MODEL_PATH):
os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
tmp = MODEL_PATH + '.tmp'
urllib.request.urlretrieve(MODEL_URL, tmp)
os.replace(tmp, MODEL_PATH)
def load_model():
global upsampler
ensure_model()
model = RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
upsampler = RealESRGANer(
scale=2,
model_path=MODEL_PATH,
model=model,
tile=256,
tile_pad=10,
pre_pad=0,
half=False,
device='cpu',
)
@app.on_event('startup')
async def startup():
await asyncio.to_thread(load_model)
@app.get('/health')
def health():
return {'ok': upsampler is not None}
@app.post('/enhance')
async def enhance(image: UploadFile = File(...), scale: int = Form(2)):
data = await image.read()
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return Response('bad image', status_code=400)
if img.shape[0] * img.shape[1] > MAX_PIXELS:
r = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
img = cv2.resize(img, (int(img.shape[1] * r), int(img.shape[0] * r)), interpolation=cv2.INTER_AREA)
outscale = min(max(int(scale), 2), 4)
with lock:
out, _ = upsampler.enhance(img, outscale=outscale)
ok, enc = cv2.imencode('.jpg', out, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
if not ok:
return Response('encode failed', status_code=500)
return Response(enc.tobytes(), media_type='image/jpeg')
+267 -22
View File
@@ -4,13 +4,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.layout{display:flex;min-height:100dvh}
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column}
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)}
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0}
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.user-box .user-role{color:var(--muted);font-size:.72rem}
.sidebar nav{flex:1;padding:12px 0}
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent}
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.06);border-left-color:var(--accent);font-weight:500}
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0}
.sidebar nav a[data-nav=dashboard] svg{color:#6366f1}
.sidebar nav a[data-nav=journal] svg{color:#0ea5e9}
.sidebar nav a[data-nav=students] svg{color:#14b8a6}
.sidebar nav a[data-nav=groups] svg{color:#8b5cf6}
.sidebar nav a[data-nav=files] svg{color:#f59e0b}
.sidebar nav a[data-nav=links] svg{color:#06b6d4}
.sidebar nav a[data-nav=trash] svg{color:#ef4444}
.sidebar nav a[data-nav=branches] svg{color:#10b981}
.sidebar nav a[data-nav=users] svg{color:#ec4899}
.sidebar nav a[data-nav=worker] svg{color:#a855f7}
.sidebar nav a[data-nav=audit] svg{color:#64748b}
.sidebar nav a[data-nav=bans] svg{color:#dc2626}
.sidebar nav a[data-nav=settings] svg{color:#475569}
.sidebar nav a.active svg{color:var(--accent)}
.sidebar .bottom a[data-nav=home] svg{color:#2563eb}
.sidebar .bottom{padding:12px 20px;border-top:1px solid var(--border)}
.sidebar .bottom a{display:block;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
.sidebar .version{color:var(--muted);font-size:.72rem;padding:0 20px 8px}
.sidebar .bottom a{display:flex;align-items:center;gap:10px;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
.sidebar .bottom a:hover{color:var(--text)}
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
@@ -21,14 +44,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px}
.stat .num{font-size:2rem;font-weight:700;color:var(--accent)}
.stat .label{color:var(--muted);font-size:.8rem;margin-top:4px}
.stat .stat-sub{font-size:1rem;color:var(--muted);font-weight:600}
.stat-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:14px}
.stat-bar i{display:block;height:100%;border-radius:3px}
.filters{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end}
.filters select,.filters input[type=date]{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem}
.filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px}
.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none}
.search-input:focus{border-color:var(--accent)}
#studentFilter,#linkStudent{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
#studentFilter:focus,#linkStudent:focus{border-color:var(--accent)}
#studentFilter,#linkStudent,#moduleFilter{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
#studentFilter:focus,#linkStudent:focus,#moduleFilter:focus{border-color:var(--accent)}
.pager{display:flex;gap:6px;align-items:center;justify-content:center;margin-top:20px;flex-wrap:wrap}
.pager button{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 12px;font-size:.85rem;cursor:pointer}
@@ -45,15 +71,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.card .group{color:var(--accent);font-size:.8rem;margin-top:2px}
.card .desc{color:var(--muted);font-size:.85rem;margin-top:8px;line-height:1.4;white-space:pre-wrap}
.card .time{color:var(--muted);font-size:.75rem;margin-top:8px}
.entry-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.entry-files .f-item{display:inline-flex;align-items:center;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:2px}
.entry-files{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:8px}
.entry-files .f-item{display:flex;align-items:center;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:2px;min-width:0}
.entry-files .f-item:hover{border-color:var(--accent)}
.entry-files .f-item a{display:inline-flex;align-items:center;gap:4px;color:var(--text);text-decoration:none;padding:2px 6px;font-size:.75rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.entry-files .f-item a{display:inline-flex;align-items:center;gap:4px;color:var(--text);text-decoration:none;padding:2px 6px;font-size:.75rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
.entry-files .f-item a:hover{color:var(--accent)}
.entry-files .f-item a::before{content:'📎'}
.entry-files .f-detach{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.75rem;line-height:1;padding:2px 5px;border-radius:4px}
.entry-files .f-detach{flex-shrink:0;background:none;border:none;color:var(--muted);cursor:pointer;font-size:.75rem;line-height:1;padding:2px 5px;border-radius:4px}
.entry-files .f-detach:hover{color:#ef4444;background:rgba(239,68,68,.1)}
.entry-files a{display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);text-decoration:none;border-radius:6px;padding:4px 8px;font-size:.75rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.entry-files a{display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);text-decoration:none;border-radius:6px;padding:3px 8px;font-size:.75rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.entry-files a:hover{border-color:var(--accent);color:var(--accent)}
.entry-files a::before{content:'📎'}
.f-actions{display:flex;align-items:center;flex-shrink:0}
@@ -71,14 +97,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.view-toggle button:hover{color:var(--text)}
.view-toggle button.active{background:var(--accent);color:#fff}
.journal-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
.journal-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column}
.journal-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column;position:relative}
.journal-card img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:pointer;display:block;flex-shrink:0}
.group-badge{position:absolute;top:8px;left:8px;z-index:2;max-width:calc(100% - 16px);background:rgba(0,0,0,.55);backdrop-filter:blur(4px);color:#fff;font-size:.72rem;font-weight:600;line-height:1.3;padding:3px 9px;border-radius:999px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.journal-card .no-photo{aspect-ratio:4/3;background:var(--bg);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.journal-card .no-photo svg{width:64%;height:64%;color:#c7cbd0}
.journal-card .no-photo svg *{fill:currentColor}
.journal-card .body{padding:12px 14px;display:flex;flex-direction:column;gap:6px;flex:1;min-width:0}
.journal-card .name{font-weight:600;font-size:.95rem}
.journal-card .group{color:var(--accent);font-size:.8rem}
.journal-card .desc{color:var(--muted);font-size:.85rem;line-height:1.4;white-space:pre-wrap;flex:1}
.journal-card .foot{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem}
.journal-card .foot .edit,.journal-card .foot .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1rem;padding:4px}
@@ -105,6 +131,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.item .name-input:focus{border-color:var(--accent)}
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
.item .group-select:focus{border-color:var(--accent)}
.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
.item.expired:hover{opacity:.85}
.badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px}
.badge-soon{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(245,158,11,.14);color:#f59e0b;vertical-align:middle;margin-left:6px}
.attach-list{max-height:280px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;border:1px solid var(--border);border-radius:8px;padding:8px}
.attach-list label{display:flex;gap:8px;align-items:center;font-size:.85rem;color:var(--text);margin:0;cursor:pointer}
.attach-list .cur{color:var(--muted);font-size:.75rem;margin-left:auto}
@@ -128,7 +158,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.group-card .name-input:focus{border-color:var(--accent)}
.group-card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.2rem;padding:4px;flex-shrink:0}
.group-card .del:hover{color:#ef4444}
.group-card .group-schedule{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;align-items:start}
.group-card .group-schedule{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:8px;align-items:start}
.group-card .group-schedule label{font-size:.75rem;color:var(--muted);display:block;margin-bottom:4px}
.group-card .group-schedule select,.group-card .group-schedule input[type=time]{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem}
.group-card .group-foot{display:flex;justify-content:space-between;align-items:center;gap:8px}
@@ -136,7 +166,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.group-card .save-sched:hover{opacity:.85}
.group-card .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
.group-card .btn-link:hover{border-color:var(--accent)}
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;max-width:680px;display:flex;flex-direction:column;gap:16px}
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;display:flex;flex-direction:column;gap:16px}
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
.settings-row label{font-weight:600;display:block;margin-bottom:4px}
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
@@ -144,7 +174,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.settings-card .btn-link:hover{opacity:.85;border-color:transparent}
.group-card .sched-status{font-size:.8rem;color:var(--muted)}
@media(max-width:1024px){.group-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:640px){.group-grid{grid-template-columns:1fr}}
@media(max-width:640px){
.group-grid{grid-template-columns:1fr}
.group-card .group-schedule{grid-template-columns:1fr 1fr}
}
@media(max-width:480px){
.group-card .group-schedule{grid-template-columns:1fr}
}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
.modal-overlay.open{display:flex}
@@ -164,11 +200,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.edit-modal .actions button:hover{opacity:.85}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
.toast.show{opacity:1}
.pf-photos{display:flex;flex-wrap:wrap;gap:10px}
.pf-photo{position:relative;width:96px;height:96px;border-radius:10px;overflow:hidden;border:2px solid var(--border);cursor:pointer;flex:0 0 auto}
.pf-photo.main{border-color:var(--accent)}
.pf-photo img{width:100%;height:100%;object-fit:cover;display:block}
.pf-badge{position:absolute;left:4px;bottom:4px;background:var(--accent);color:#fff;font-size:.65rem;font-weight:600;padding:2px 6px;border-radius:6px}
.pf-del{position:absolute;top:4px;right:4px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}
.pf-del:hover{background:#ef4444}
/* --- Фото группы (хронология) --- */
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;cursor:pointer;display:block;background:var(--bg)}
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;display:block;background:var(--bg)}
.group-cover:hover{opacity:.9}
.empty-cover{background:var(--bg);border:1px dashed var(--border);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:1.6rem;cursor:pointer}
.empty-cover{background:var(--bg);border:1px dashed var(--border);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:1.6rem}
.empty-cover:hover{border-color:var(--accent);color:var(--accent)}
.photo-btn{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer;transition:all .15s}
.photo-btn:hover{border-color:var(--accent);opacity:.85}
@@ -181,15 +224,22 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.gallery-foot .cancel{background:var(--border);color:var(--text);border:none;border-radius:8px;padding:10px 20px;font-size:.9rem;font-weight:600;cursor:pointer;transition:opacity .15s}
.gallery-foot .cancel:hover{opacity:.85}
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:2px}
.photo-item{background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
.photo-item img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:pointer;display:block;transition:opacity .15s}
.photo-item img:hover{opacity:.9}
.photo-item{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;cursor:grab}
.photo-item.dragging{opacity:.5;border-color:#8b5cf6;box-shadow:0 0 0 2px rgba(139,92,246,.3)}
.photo-item.drag-over{border-color:#8b5cf6}
.photo-item img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:grab;display:block;transition:opacity .15s}
.photo-item:hover img{opacity:.9}
.photo-item .drag-hint{position:absolute;top:6px;left:6px;z-index:2;background:rgba(0,0,0,.55);color:#fff;border-radius:6px;padding:2px 7px;font-size:.85rem;line-height:1.2;cursor:grab;user-select:none;pointer-events:none}
.photo-item .p-cap{padding:8px 10px;font-size:.78rem;color:var(--text);min-height:1.2em;white-space:pre-wrap;word-break:break-word}
.photo-item .p-date{font-size:.72rem;color:var(--muted);padding:0 10px 6px}
.photo-item .p-actions{display:flex;justify-content:flex-end;gap:4px;padding:6px 8px;border-top:1px solid var(--border)}
.photo-item .p-actions button{background:none;border:none;cursor:pointer;font-size:1rem;color:var(--muted);padding:4px}
.photo-item .p-actions .p-edit:hover{color:var(--accent)}
.photo-item .p-actions .p-del:hover{color:#ef4444}
.photo-item .p-actions .p-cover:hover{color:#8b5cf6}
.photo-item .p-actions .p-cover.active{color:#8b5cf6}
.photo-item .p-actions .p-cover:disabled{cursor:default;opacity:.75}
.photo-item .p-actions .p-cover:disabled:hover{color:var(--muted)}
.edit-modal input[type=file]{padding:8px;cursor:pointer}
.edit-modal input[type=file]::file-selector-button{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 12px;font-size:.8rem;font-weight:600;cursor:pointer;margin-right:10px;transition:border-color .15s}
.edit-modal input[type=file]::file-selector-button:hover{border-color:var(--accent)}
@@ -212,7 +262,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
.page-sub{color:var(--muted);font-size:.85rem}
.settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px}
.settings-stack{display:flex;flex-direction:column;gap:20px}
.link-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}
.link-fields > .full{grid-column:1/-1}
@media(max-width:600px){.link-fields{grid-template-columns:1fr}}
.settings-stack-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;width:100%}
@media(max-width:900px){.settings-stack-grid{grid-template-columns:1fr}}
.settings-stack-grid > .settings-card{max-width:none;min-width:0;height:100%}
.settings-stack-grid > .settings-card.wide{grid-column:1/-1}
.settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px}
.settings-card .card-head .ic{width:38px;height:38px;border-radius:11px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0}
.settings-card .card-head h3{font-size:1.05rem;font-weight:700;margin:0}
@@ -246,12 +303,24 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.save-bar .hint{color:var(--muted);font-size:.85rem;display:inline-flex;align-items:center;gap:8px}
@media(max-width:640px){.save-bar{left:16px;right:16px}}
.logo-picker{display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap}
.logo-preview{width:160px;max-width:100%;min-width:96px;min-height:72px;max-height:96px;height:auto;border:1px dashed var(--border);background:var(--bg);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;font-size:1.4rem;font-weight:700;color:var(--muted)}
.logo-preview img{max-width:100%;max-height:96px;width:auto;height:auto;object-fit:contain;display:none}
.logo-picker-actions{display:flex;flex-direction:column;gap:10px;align-items:flex-start;min-width:0}
.logo-picker-btns{display:flex;gap:10px;flex-wrap:wrap}
.logo-upload-status{font-size:.8rem;color:var(--muted);min-height:18px;display:flex;align-items:center;gap:8px}
.logo-upload-status .fail{color:#ef4444}
/* --- Дашборд --- */
.dash-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:20px;align-items:start}
.dash-col{display:flex;flex-direction:column;gap:20px;min-width:0}
.dash-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:20px;display:flex;flex-direction:column;gap:16px}
.dash-card h3{font-size:1rem;font-weight:700;display:flex;align-items:center;gap:9px;margin:0}
.dash-card h3 .ic{width:30px;height:30px;border-radius:9px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:.95rem;flex-shrink:0}
.dash-card h3 .ic i,.settings-card .card-head .ic i{width:17px;height:17px;flex-shrink:0}
.dash-card h3 .ic svg,.settings-card .card-head .ic svg{width:17px;height:17px;flex-shrink:0;color:var(--primary)}
.btn-primary svg{width:16px;height:16px;vertical-align:-3px}
.save-bar .hint svg{width:15px;height:15px;flex-shrink:0}
.dash-empty{color:var(--muted);font-size:.82rem;padding:8px 0}
.dash-link-row{margin-top:2px;text-align:right}
.dash-link-row a{color:var(--accent);text-decoration:none;font-size:.8rem;font-weight:600}
@@ -262,7 +331,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.bar:hover{opacity:1}
.bar.today{opacity:1}
.bar-day{font-size:.62rem;color:var(--muted);white-space:nowrap}
.recent{display:flex;flex-direction:column;gap:2px}
.recent{display:grid;grid-template-columns:repeat(2,1fr);gap:2px 16px}
.recent-item{display:flex;align-items:center;gap:12px;padding:8px 6px;border-radius:10px;transition:background .15s}
.recent-item:hover{background:var(--bg)}
.recent-item img{width:44px;height:44px;border-radius:8px;object-fit:cover;flex-shrink:0;cursor:pointer}
@@ -280,6 +349,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.actions-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.action-btn{display:flex;flex-direction:column;align-items:center;gap:8px;padding:16px 10px;background:var(--bg);border:1px solid var(--border);border-radius:12px;color:var(--text);text-decoration:none;font-size:.8rem;font-weight:600;transition:all .15s}
.action-btn .aic{font-size:1.3rem}
.action-btn .aic i{width:22px;height:22px}
.recent-item .thumb i{width:18px;height:18px}
.action-btn:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-2px)}
.active-list{display:flex;flex-direction:column;gap:8px}
.active-item{display:flex;justify-content:space-between;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:9px 12px}
@@ -296,4 +367,178 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.stat .label a{color:var(--accent);text-decoration:none;font-weight:600}
.stat .label a:hover{text-decoration:underline}
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}}
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}.recent{grid-template-columns:1fr}}
/* --- System Info Dashboard --- */
.sys-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
@media(max-width:480px){.sys-grid{grid-template-columns:1fr;gap:10px}}
.sys-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:8px;min-width:0}
.sys-card.loading{color:var(--muted);justify-content:center;align-items:center;min-height:80px}
.sys-card.err{color:#ef4444}
.sys-title{font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;padding-bottom:8px;border-bottom:1px solid var(--border)}
.sys-item{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:4px 0}
.sys-label{font-size:.82rem;color:var(--muted)}
.sys-value{font-size:.9rem;font-weight:600;color:var(--text);text-align:right;word-break:break-word}
.lucide-inline{display:inline-flex;align-items:center;vertical-align:-2px}
.lucide-inline svg{width:14px;height:14px;flex-shrink:0}
.sys-details{margin-top:8px;padding-top:8px;border-top:1px solid var(--border)}
.sys-details summary{cursor:pointer;font-size:.75rem;color:var(--muted);font-weight:600}
.sys-table-list{margin-top:8px;display:flex;flex-direction:column;gap:4px}
.sys-table-row{display:flex;justify-content:space-between;font-size:.75rem;padding:2px 0}
.sys-table-row span:first-child{color:var(--muted)}
.sys-table-row span:last-child{font-weight:600;color:var(--text)}
.sys-bar-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px}
.sys-bar{height:100%;transition:width .3s,background .3s}
.sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px}
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
.audit-table th{text-align:left;color:var(--muted);font-weight:600;padding:8px 10px;border-bottom:1px solid var(--border);font-size:.75rem;text-transform:uppercase;letter-spacing:.03em}
.audit-table td{padding:10px;border-bottom:1px solid var(--border);vertical-align:top}
.audit-table tr:last-child td{border-bottom:none}
.audit-table tr:hover td{background:rgba(37,99,235,.04)}
.audit-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto}
.audit-details{background:none;border:none;padding:0;margin:0;color:var(--accent);font-size:.8rem;text-align:left;cursor:pointer;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.audit-details:hover{text-decoration:underline}
.audit-details-body{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:60vh;overflow:auto}
.audit-details-body pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text)}
/* --- AI Correction --- */
.ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px}
.ai-btn:hover{color:var(--accent);background:rgba(37,99,235,.1)}
.ai-btn:disabled{opacity:.5;cursor:not-allowed}
#aiSuggestion{font-size:.9rem;line-height:1.5}
/* --- AI auto-check --- */
.ai-badge{display:inline-block;font-size:.68rem;font-weight:600;line-height:1;padding:3px 8px;border-radius:999px;margin-left:6px;vertical-align:middle;background:rgba(37,99,235,.1);color:var(--accent);white-space:nowrap}
.ai-badge-ic{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:8px;cursor:help;flex-shrink:0}
.ai-badge-ic svg{width:14px;height:14px;display:block}
.ai-badge-done{background:rgba(22,163,74,.12);color:#16a34a}
.ai-badge-error{background:rgba(239,68,68,.12);color:#ef4444}
.ai-badge-skipped,.ai-badge-reverted{background:rgba(148,163,184,.18);color:var(--muted)}
.ai-badge-ic.ai-badge-processing .lucide-loader{animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.ai-details{background:none;border:none;padding:0;margin:0;color:var(--accent);font-size:.8rem;cursor:pointer;white-space:nowrap}
.ai-details:hover{text-decoration:underline}
.ai-detail-block{margin-top:4px}
.ai-detail-block + .ai-detail-block{margin-top:14px}
.ai-detail-label{font-size:.75rem;color:var(--muted);margin-bottom:6px;font-weight:600}
.ai-detail-block pre{margin:0;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text);max-height:34vh;overflow:auto}
.ai-status{margin-top:6px;font-size:.78rem;color:var(--muted)}
.ai-queue{margin-top:8px;font-size:.78rem;color:var(--muted)}
/* --- AI worker monitor --- */
.worker-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex-shrink:0;background:#94a3b8}
.worker-dot.on{background:#16a34a;animation:wpulse 1.6s infinite}
.worker-dot.busy{background:#f59e0b;animation:wpulse 1.2s infinite}
.worker-dot.err{background:#ef4444}
@keyframes wpulse{0%{box-shadow:0 0 0 0 rgba(22,163,74,.45)}70%{box-shadow:0 0 0 7px rgba(22,163,74,0)}100%{box-shadow:0 0 0 0 rgba(22,163,74,0)}}
.worker-state{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600}
.worker-counts{display:flex;flex-wrap:wrap;gap:10px;margin-top:16px}
.worker-counts .chip{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:7px 14px;font-size:.8rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
.worker-counts .chip b{font-weight:700}
.worker-counts .chip.pending b{color:#f59e0b}
.worker-counts .chip.done b{color:#16a34a}
.worker-counts .chip.error b{color:#ef4444}
.worker-counts .chip.skipped b{color:var(--muted)}
.worker-counts .chip.reverted b{color:var(--accent)}
@keyframes entryEnter{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
@keyframes entryPopIn{0%{opacity:0;transform:translateY(26px) scale(.95)}55%{opacity:1;transform:translateY(-5px) scale(1.015)}100%{opacity:1;transform:none}}
@keyframes entryFadeOut{to{opacity:0}}
.entry-enter{animation:entryEnter .4s cubic-bezier(.22,1,.36,1) both}
.entry-appear{animation:entryPopIn .6s cubic-bezier(.22,1,.36,1) both;position:relative}
.entry-appear::before{content:'';position:absolute;inset:-1px;border-radius:inherit;box-shadow:0 0 0 3px rgba(247,201,14,.9),0 10px 30px rgba(247,201,14,.4);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none}
.entry-appear::after{content:'';position:absolute;inset:0;border-radius:inherit;background:linear-gradient(180deg,rgba(247,201,14,.16),transparent 55%);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none}
@media (prefers-reduced-motion: reduce){.entry-enter,.entry-appear,.entry-appear::before,.entry-appear::after{animation:none}}
.ai-profiles-list{margin-top:12px;display:flex;flex-direction:column;gap:8px}
.ai-profile-item{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--card)}
.ai-profile-item.active{border-color:var(--accent);background:rgba(37,99,235,.05)}
.ai-profile-item .pi-info{flex:1;min-width:0}
.ai-profile-item .pi-name{font-weight:600;font-size:.9rem;display:flex;align-items:center;gap:8px}
.ai-profile-item .pi-meta{color:var(--muted);font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ai-profile-item .pi-actions{display:flex;gap:6px;flex-shrink:0}
.pi-btn{background:none;border:1px solid var(--border);border-radius:8px;padding:5px 10px;font-size:.78rem;cursor:pointer;color:var(--text);transition:all .15s}
.pi-btn:hover{border-color:var(--accent);color:var(--accent)}
.pi-btn.danger:hover{border-color:#ef4444;color:#ef4444}
.pi-badge{font-size:.68rem;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(22,163,74,.12);color:#16a34a;white-space:nowrap}
/* --- Настройки (UX/UI v2) --- */
body[data-page=settings] .page-head{align-items:flex-end}
.page-status{margin-left:auto;display:inline-flex;align-items:center;gap:8px;font-size:.8rem;color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:6px 14px;white-space:nowrap;transition:opacity .3s}
.page-status.loaded{opacity:0;pointer-events:none}
.page-status.err{color:#ef4444;opacity:1}
.head-btn{display:inline-flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--border);color:var(--muted);border-radius:10px;padding:8px 14px;font-size:.82rem;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap;flex-shrink:0}
.head-btn:hover{color:var(--accent);border-color:var(--accent)}
.head-btn svg{width:14px;height:14px}
.settings-layout{display:grid;grid-template-columns:190px minmax(0,1fr);gap:28px;align-items:start}
.settings-nav{position:sticky;top:16px;display:flex;flex-direction:column;gap:2px}
.settings-nav a{display:block;padding:9px 13px;border-radius:10px;color:var(--muted);text-decoration:none;font-size:.86rem;font-weight:500;transition:all .15s;white-space:nowrap}
.settings-nav a:hover{color:var(--text);background:var(--card)}
.settings-nav a.active{color:var(--accent);background:var(--card);font-weight:600;box-shadow:inset 2px 0 0 var(--accent)}
.settings-nav a svg{width:15px;height:15px;flex-shrink:0;vertical-align:-3px;margin-right:2px}
.settings-layout .settings-stack,.settings-layout .settings-stack-grid{max-width:none}
.settings-card[id]{scroll-margin-top:16px}
.settings-card{transition:border-color .2s,box-shadow .2s}
.settings-card:target{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
.head-live{margin-left:auto;font-size:.72rem;color:var(--muted);white-space:nowrap}
.toggle{position:relative;display:flex;align-items:center;gap:12px;padding:10px 12px;background:var(--bg);border:1px solid transparent;border-radius:10px;cursor:pointer;transition:border-color .15s,background .15s}
.toggle:hover{border-color:var(--border);background:var(--card)}
.toggle input{position:absolute;opacity:0;width:1px;height:1px}
.toggle-track{position:relative;width:40px;height:22px;border-radius:999px;background:#d4d4d4;flex-shrink:0;transition:background .2s}
.toggle-thumb{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);transition:transform .2s}
.toggle input:checked ~ .toggle-track{background:var(--accent)}
.toggle input:checked ~ .toggle-track .toggle-thumb{transform:translateX(18px)}
.toggle input:focus-visible ~ .toggle-track{box-shadow:0 0 0 3px rgba(37,99,235,.25)}
.toggle-text{display:flex;flex-direction:column;gap:1px;min-width:0}
.toggle-text b{font-size:.87rem;font-weight:600}
.toggle-text small{font-size:.74rem;color:var(--muted);line-height:1.4}
.settings-field{position:relative}
.field-error{color:#ef4444;font-size:.75rem;font-weight:600;min-height:0;display:block}
.settings-input.invalid{border-color:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.1)}
.settings-input.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.84rem;line-height:1.5;min-height:110px;resize:vertical}
.preset-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px}
.preset-btn{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:5px 12px;font-size:.75rem;font-weight:600;color:var(--muted);cursor:pointer;transition:all .15s}
.preset-btn:hover{border-color:var(--accent);color:var(--accent)}
.preset-btn.active{background:rgba(37,99,235,.08);border-color:var(--accent);color:var(--accent)}
.toast.error{background:#dc2626}
.queue-link{color:var(--accent);text-decoration:none;font-weight:600;font-size:.8rem;white-space:nowrap}
.queue-link:hover{text-decoration:underline}
.ai-queue{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.ai-queue .chip{background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:6px 12px;font-size:.78rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
.ai-queue .chip b{color:var(--accent);font-weight:700}
.ban-chip{flex-direction:column;align-items:flex-start;gap:2px;padding-right:8px}
.chip-sub{font-size:.72rem;color:var(--muted);font-weight:400}
.unban-btn{background:none;border:none;color:var(--accent);cursor:pointer;font-weight:700;font-size:.9rem;padding:0 2px;line-height:1}
.unban-btn:hover{color:#ef4444}
.ban-chip.confirming{background:rgba(239,68,68,.07);border-color:rgba(239,68,68,.4)}
.chip-confirm{display:inline-flex;align-items:center;gap:8px;font-size:.74rem;font-weight:600;white-space:nowrap;padding-right:4px}
.chip-yes,.chip-no{background:none;border:none;cursor:pointer;font-weight:700;padding:0}
.chip-yes{color:#16a34a}
.chip-no{color:var(--muted)}
.btn-primary.danger-ghost{background:var(--card);border:1px solid rgba(239,68,68,.35);color:#ef4444}
.btn-primary.danger-ghost:hover{border-color:#ef4444;opacity:1}
.ai-test-status{font-size:.85rem;min-height:1.3em}
.ai-test-status .ok{color:#16a34a;font-weight:600}
.ai-test-status .fail{color:#ef4444;font-weight:600}
.save-bar{animation:saveBarIn .25s cubic-bezier(.22,1,.36,1)}
.save-bar.show{border-color:rgba(245,158,11,.5)}
.save-bar-actions{display:flex;gap:10px;flex-wrap:wrap}
@keyframes saveBarIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--muted);cursor:pointer;transition:all .15s}
.icon-btn:hover{color:var(--accent);border-color:var(--accent)}
.icon-btn svg{width:14px;height:14px}
.icon-btn.spinning svg{animation:spin 1s linear infinite}
.sys-card.warn{border-color:rgba(245,158,11,.5);background:rgba(245,158,11,.05)}
.sys-card.danger{border-color:rgba(239,68,68,.5);background:rgba(239,68,68,.05)}
@media(max-width:1024px){
.settings-layout{grid-template-columns:1fr;gap:14px}
.settings-nav{position:sticky;top:0;z-index:40;flex-direction:row;gap:6px;overflow-x:auto;background:var(--bg);padding:8px 0;scrollbar-width:none}
.settings-nav::-webkit-scrollbar{display:none}
.settings-nav a{border:1px solid var(--border);background:var(--card);border-radius:999px;padding:7px 14px;font-size:.8rem}
.settings-nav a.active{box-shadow:none;border-color:var(--accent)}
.settings-card[id]{scroll-margin-top:60px}
}
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<title>Админ-панель</title>
<script>location.replace('dashboard.html')</script>
<meta http-equiv="refresh" content="0;url=dashboard.html">
</head>
<body>
</body>
+130 -27
View File
@@ -1,52 +1,137 @@
const API = location.origin;
let token = sessionStorage.getItem('adminToken');
function hdr() { return { 'X-Admin-Token': token }; }
function hdrJson() { return { 'Content-Type': 'application/json', 'X-Admin-Token': token }; }
let token = sessionStorage.getItem('authToken');
let currentUser = null;
function hdr() { return { 'X-Auth-Token': token }; }
function hdrJson() { return { 'Content-Type': 'application/json', 'X-Auth-Token': token }; }
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function thumbSrc(p) {
if (!p) return p;
if (p.startsWith('/uploads/')) return `${API}/uploads/thumb/${p.slice('/uploads/'.length)}`;
if (p.includes('/api/files/')) return p + (p.includes('?') ? '&' : '?') + 'thumb=1';
return p;
}
// --- Auth check ---
async function checkAuth() {
if (!token) { location.href = 'login.html'; return false; }
try {
const res = await fetch(`${API}/api/stats`, { headers: hdr() });
if (!res.ok) { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; }
const res = await fetch(`${API}/api/auth/me`, { headers: hdr() });
if (!res.ok) { cleanupSession(); return false; }
currentUser = await res.json();
return true;
} catch { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; }
} catch { cleanupSession(); return false; }
}
function doLogout() {
sessionStorage.removeItem('adminToken');
function cleanupSession() {
sessionStorage.removeItem('authToken');
token = null;
currentUser = null;
location.href = 'login.html';
}
function isAdmin() { return currentUser && currentUser.role === 'admin'; }
async function requireAdminPage() {
if (!(await checkAuth())) return false;
if (!isAdmin()) { location.href = 'journal.html'; return false; }
return true;
}
async function doLogout() {
try { await fetch(`${API}/api/auth/logout`, { method: 'POST', headers: hdr() }); } catch {}
cleanupSession();
}
// --- Sidebar ---
const NAV = [
{ page: 'dashboard', label: 'Дашборд' },
{ page: 'journal', label: 'Журнал' },
{ page: 'trash', label: 'Корзина' },
{ page: 'files', label: 'Файлы' },
{ page: 'links', label: 'Ссылки' },
{ page: 'students', label: 'Ученики' },
{ page: 'groups', label: 'Группы' },
{ page: 'settings', label: 'Настройки' }
];
function buildSidebar(active) {
const el = document.getElementById('sidebar');
if (!el) return;
const base = [
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
{ page: 'groups', label: 'Группы', icon: 'users' },
{ page: 'files', label: 'Файлы', icon: 'folder' },
{ page: 'links', label: 'Ссылки', icon: 'link' },
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
];
const adminNav = [
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
{ page: 'audit', label: 'Аудит', icon: 'scroll-text' },
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
{ page: 'settings', label: 'Настройки', icon: 'settings' },
];
const nav = [...base, ...(isAdmin() ? adminNav : [])];
const userName = currentUser?.name || currentUser?.username || '';
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
el.innerHTML = `
<div class="logo">Админ-панель</div>
<div class="logo">
<span class="logo-ic" id="sidebarLogo"></span>
<span id="sidebarName">Админ-панель</span>
</div>
<div class="user-box">
<div class="user-name">${esc(userName)}</div>
<div class="user-role">${roleLabel}</div>
</div>
<nav>
${NAV.map(n => `<a href="${n.page}.html" class="${n.page === active ? 'active' : ''}">${n.label}</a>`).join('')}
${nav.map(n => `<a href="${n.page}.html" data-nav="${n.page}" class="${n.page === active ? 'active' : ''}"><i data-lucide="${n.icon}"></i>${n.label}</a>`).join('')}
</nav>
<div class="bottom">
<a href="/">На главную</a>
<a class="logout" onclick="doLogout()">Выйти</a>
<div class="version" id="sidebarVersion"></div>
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
</div>`;
renderIcons();
loadVersion();
loadBrand();
}
async function loadBrand() {
const logoEl = document.getElementById('sidebarLogo');
const nameEl = document.getElementById('sidebarName');
if (!logoEl || !nameEl) return;
try {
const res = await fetch(`${API}/api/public-settings`);
if (!res.ok) return;
const s = await res.json();
if ((s.system_name || '').trim()) nameEl.textContent = s.system_name;
if (s.system_logo) {
const img = document.createElement('img');
img.src = s.system_logo;
img.alt = '';
logoEl.classList.add('has-logo');
logoEl.appendChild(img);
}
} catch {}
}
async function loadVersion() {
const el = document.getElementById('sidebarVersion');
if (!el) return;
try {
const res = await fetch(`${API}/version.json?t=${Date.now()}`);
if (!res.ok) { el.textContent = ''; return; }
const v = await res.json();
if (!v.short) { el.textContent = ''; return; }
const d = (v.date || '').slice(0, 10);
el.textContent = `v${v.short}${d ? ' · ' + d : ''}`;
el.title = `${v.short} · ${v.date || ''}`;
} catch { el.textContent = ''; }
}
// --- Common UI ---
function renderIcons() {
if (window.lucide && typeof window.lucide.createIcons === 'function') {
window.lucide.createIcons();
}
}
function showToast(msg) {
const t = document.getElementById('toast');
if (!t) return;
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
@@ -59,7 +144,7 @@ function showImg(src) {
}
async function fillAutocomplete() {
const res = await fetch(`${API}/api/students`);
const res = await fetch(`${API}/api/students`, { headers: hdr() });
const students = await res.json();
const names = students.map(s => s.name);
const dlEdit = document.getElementById('studentEditList');
@@ -135,14 +220,32 @@ function copyText(text) {
function renderPager(el, page, totalPages, onPage) {
if (!el) return;
if (totalPages <= 1) { el.innerHTML = ''; return; }
let html = `<button ${page === 1 ? 'disabled' : ''} onclick="(${onPage})(${page - 1})">‹</button>`;
let html = `<button ${page === 1 ? 'disabled' : ''} data-nav="-1">‹</button>`;
for (let i = 1; i <= totalPages; i++) {
if (totalPages > 9 && i > 2 && i < totalPages - 1 && Math.abs(i - page) > 2) {
if (i === 3) html += `<button disabled>…</button>`;
continue;
}
html += `<button class="${i === page ? 'active' : ''}" onclick="(${onPage})(${i})">${i}</button>`;
html += `<button class="${i === page ? 'active' : ''}" data-page="${i}">${i}</button>`;
}
html += `<button ${page === totalPages ? 'disabled' : ''} onclick="(${onPage})(${page + 1})">›</button>`;
html += `<button ${page === totalPages ? 'disabled' : ''} data-nav="1">›</button>`;
el.innerHTML = html;
}
el._onPage = onPage;
el._page = page;
if (!el._bound) {
el._bound = true;
el.addEventListener('click', e => {
const btn = e.target.closest('button');
if (!btn || btn.disabled) return;
const p = btn.dataset.page ? parseInt(btn.dataset.page, 10) : el._page + parseInt(btn.dataset.nav, 10);
if (el._onPage) el._onPage(p);
});
}
}
document.addEventListener('click', e => {
const imgEl = e.target.closest('[data-img]');
if (imgEl) { e.preventDefault(); showImg(imgEl.dataset.img); return; }
const out = e.target.closest('[data-action="logout"]');
if (out) { e.preventDefault(); doLogout(); }
});
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Аудит — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="audit">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Аудит-журнал</h2>
<p class="page-sub">Действия администратора</p>
</div>
<div class="actions-row">
<select id="auditActionFilter" class="settings-input" style="max-width:280px">
<option value="">Все действия</option>
</select>
<input type="search" id="auditFilter" class="settings-input" placeholder="Фильтр по действию..." style="max-width:300px">
<button class="btn-link" id="auditRefreshBtn" type="button" title="Обновить">Обновить</button>
<span class="hint" id="auditCount"></span>
</div>
<div class="audit-wrap" style="margin-top:14px">
<table class="audit-table" id="auditTable">
<thead>
<tr>
<th>Время</th>
<th>Действие</th>
<th>Детали</th>
<th>IP</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="auditEmpty" style="display:none">Записей пока нет.</div>
</div>
<div class="pager" id="auditPager"></div>
</div>
</div>
<div class="modal-overlay" id="auditDetailsModal">
<div class="edit-modal" style="max-width:640px">
<h3 id="auditDetailsTitle">Детали действия</h3>
<div class="audit-details-body"><pre id="auditDetailsBody"></pre></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="cancel" id="auditDetailsCloseBtn">Закрыть</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/audit.js"></script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Блокировки IP — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="bans">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Блокировки IP</h2>
<p class="page-sub">Автобан на 24 часа за антиспам и брутфорс. Здесь можно разбанить или добавить вручную</p>
</div>
<div class="actions-row" style="margin-bottom:16px">
<button class="btn-primary" id="addBanBtn" type="button">
<span style="font-size:1.2rem">+</span> Забанить IP вручную
</button>
</div>
<div class="audit-wrap">
<table class="audit-table" id="bansTable">
<thead>
<tr>
<th>IP</th>
<th>Причина</th>
<th>Заблокирован</th>
<th>Разблокируется</th>
<th></th>
</tr>
</thead>
<tbody id="bansBody"></tbody>
</table>
<div class="empty" id="bansEmpty" style="display:none">Активных блокировок нет</div>
</div>
<div class="pager" id="pager"></div>
</div>
</div>
<div class="modal-overlay" id="banModal">
<div class="edit-modal" style="max-width:440px">
<h3>Заблокировать IP</h3>
<div class="settings-stack" style="gap:12px;max-width:none">
<div class="settings-field">
<label>IP-адрес <span style="color:#ef4444">*</span></label>
<input type="text" id="bIp" class="settings-input" placeholder="например: 185.220.101.34" autocomplete="off">
</div>
<div class="settings-field">
<label>Причина</label>
<input type="text" id="bReason" class="settings-input" placeholder="например: Спам с этого адреса" autocomplete="off">
</div>
<div class="settings-field">
<label>Срок (часов, 1–720)</label>
<input type="number" id="bHours" class="settings-input" min="1" max="720" value="24">
</div>
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
<button type="button" class="btn-primary ghost" id="bCancel">Отмена</button>
<button type="button" class="btn-primary" id="bSave">Заблокировать</button>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/bans.js"></script>
<style>
.reason-badge{font-size:.7rem;font-weight:600;padding:2px 10px;border-radius:999px;white-space:nowrap}
.reason-honeypot{color:#ea580c;background:rgba(234,88,12,.12)}
.reason-login-bruteforce,.reason-share-password-bruteforce{color:#7c3aed;background:rgba(124,58,237,.12)}
.reason-manual{color:#ef4444;background:rgba(239,68,68,.12)}
.row-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px 8px;border-radius:6px}
.row-btn:hover{background:var(--bg);color:var(--accent)}
</style>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Филиалы — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="branches">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Филиалы</h2>
<p class="page-sub">Управление филиалами и привязка групп</p>
</div>
<div class="actions-row" style="margin-bottom:16px">
<button class="btn-primary" id="addBranchBtn" type="button">
<span style="font-size:1.2rem">+</span> Добавить филиал
</button>
</div>
<div class="branches-grid" id="branchesGrid">
<div class="empty">Загрузка…</div>
</div>
</div>
</div>
<!-- Branch Modal -->
<div class="modal-overlay" id="branchModal">
<div class="edit-modal" style="max-width:480px">
<h3 id="branchModalTitle">Создать филиал</h3>
<form id="branchForm" class="settings-stack" style="gap:12px">
<input type="hidden" name="id" id="branchId">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Название <span style="color:#ef4444">*</span></label>
<input type="text" name="name" class="settings-input" required placeholder="Например: Центральный офис" autocomplete="off">
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Адрес</label>
<input type="text" name="address" class="settings-input" placeholder="Улица, дом, офис">
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Телефон</label>
<input type="text" name="phone" class="settings-input" placeholder="+7 (XXX) XXX-XX-XX">
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="branchCancelBtn">Отмена</button>
<button type="submit" class="btn-primary" id="branchSubmitBtn">Создать</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="imgModal">
<img id="imgModalSrc" alt="">
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/branches.js"></script>
<style>
.branches-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.branch-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
transition: box-shadow .15s, border-color .15s;
}
.branch-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,.08);
border-color: var(--accent);
}
.branch-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.branch-name {
font-weight: 600;
font-size: .95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.branch-badge {
font-size: .7rem;
font-weight: 600;
color: var(--accent);
background: rgba(37,99,235,.1);
padding: 2px 8px;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
}
.branch-meta {
font-size: .78rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.branch-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 4px;
}
.branch-actions button {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 1rem;
padding: 6px 8px;
border-radius: 6px;
transition: background .15s, color .15s;
}
.branch-actions button:hover {
background: var(--bg);
color: var(--text);
}
.branch-actions .branch-delete:hover {
color: #ef4444;
background: rgba(239,68,68,.1);
}
@media (max-width: 640px) {
.branches-grid { grid-template-columns: 1fr; }
}
</style>
</body>
</html>
+17 -123
View File
@@ -19,17 +19,17 @@
<div class="dash-grid">
<div class="dash-col">
<div class="dash-card">
<h3><span class="ic">📊</span> Динамика записей (14 дней)</h3>
<h3><span class="ic"><i data-lucide="bar-chart-3"></i></span> Динамика записей (14 дней)</h3>
<div class="chart" id="activityChart" title=""></div>
<div class="dash-link-row"><a href="journal.html">Открыть журнал →</a></div>
</div>
<div class="dash-card">
<h3><span class="ic">🕒</span> Последние записи</h3>
<h3><span class="ic"><i data-lucide="clock"></i></span> Последние записи</h3>
<div class="recent" id="recentList"></div>
<div class="dash-link-row"><a href="journal.html">Все записи →</a></div>
</div>
<div class="dash-card">
<h3><span class="ic">📸</span> Фото групп</h3>
<h3><span class="ic"><i data-lucide="camera"></i></span> Фото групп</h3>
<div class="photos-grid-dash" id="dashPhotos"></div>
<div class="dash-link-row"><a href="groups.html">Фото групп →</a></div>
</div>
@@ -37,22 +37,24 @@
<div class="dash-col">
<div class="dash-card">
<h3><span class="ic">⚡</span> Быстрые действия</h3>
<h3><span class="ic"><i data-lucide="zap"></i></span> Быстрые действия</h3>
<div class="actions-grid">
<a class="action-btn" href="journal.html"><span class="aic">📖</span>Журнал</a>
<a class="action-btn" href="files.html"><span class="aic">📎</span>Файлы</a>
<a class="action-btn" href="groups.html"><span class="aic">👥</span>Группы</a>
<a class="action-btn" href="links.html"><span class="aic">🔗</span>Ссылки</a>
<a class="action-btn" href="trash.html"><span class="aic">🗑️</span>Корзина</a>
<a class="action-btn" href="settings.html"><span class="aic">🛡️</span>Бэкап</a>
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
<a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a>
<a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a>
<a class="action-btn" href="worker.html"><span class="aic"><i data-lucide="bot"></i></span>Воркер ИИ</a>
<a class="action-btn" href="settings.html"><span class="aic"><i data-lucide="shield"></i></span>Бэкап</a>
<a class="action-btn" href="bans.html"><span class="aic"><i data-lucide="ban"></i></span>Блокировки</a>
</div>
</div>
<div class="dash-card">
<h3><span class="ic">🟢</span> Активные сейчас группы</h3>
<h3><span class="ic"><i data-lucide="activity"></i></span> Активные сейчас группы</h3>
<div class="active-list" id="activeList"></div>
</div>
<div class="dash-card">
<h3><span class="ic">🏆</span> Топ учеников</h3>
<h3><span class="ic"><i data-lucide="trophy"></i></span> Топ учеников</h3>
<div class="top-list" id="topList"></div>
</div>
</div>
@@ -60,120 +62,12 @@
</div>
</div>
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
<div class="modal-overlay" id="imgModal">
<img id="imgModalSrc" alt="">
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script>
function plural(n, one, few, many) {
n = Math.abs(n) % 100; const n1 = n % 10;
if (n > 10 && n < 20) return many;
if (n1 > 1 && n1 < 5) return few;
if (n1 === 1) return one;
return many;
}
function renderStats(s) {
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="num">${s.entries}</div><div class="label">Всего записей</div></div>
<div class="stat"><div class="num">${s.students}</div><div class="label">Учеников</div></div>
<div class="stat"><div class="num">${s.groups}</div><div class="label">Групп</div></div>
<div class="stat"><div class="num">${s.today}</div><div class="label">Сегодня</div></div>
<div class="stat"><div class="num" style="color:${s.trash ? '#ef4444' : 'var(--muted)'}">${s.trash}</div><div class="label"><a href="trash.html">В корзине →</a></div></div>
`;
}
function last14days() {
const out = [];
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
return out;
}
function renderActivity(activity) {
const map = {};
activity.forEach(a => { map[a.d] = a.n; });
const days = last14days().map(d => {
const iso = d.toISOString().slice(0, 10);
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
});
const max = Math.max(1, ...days.map(x => x.n));
const todayIso = new Date().toISOString().slice(0, 10);
document.getElementById('activityChart').innerHTML = days.map(x => `
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
<div class="bar ${x.iso === todayIso ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
<div class="bar-day">${x.label}</div>
</div>`).join('');
}
function renderRecent(rows) {
const el = document.getElementById('recentList');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Записей пока нет</div>'; return; }
el.innerHTML = rows.map(e => `
<div class="recent-item">
${e.photo_path
? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">`
: `<div class="thumb">🎓</div>`}
<div class="r-info">
<div class="r-name">${esc(e.student_name)}</div>
<div class="r-meta">${esc(e.group_name)} · ${new Date(e.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
</div>
<a class="r-link" href="journal.html" title="В журнал">→</a>
</div>`).join('');
}
function renderActive(rows) {
const el = document.getElementById('activeList');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Сейчас нет идущих групп</div>'; return; }
el.innerHTML = rows.map(g => `
<div class="active-item">
<span class="a-name">${esc(g.name)}</span>
<span class="a-right">
<span class="a-time">${g.day_label} ${g.time_start}–${g.time_end}</span>
<span class="a-now">сейчас</span>
</span>
</div>`).join('');
}
function renderTop(rows) {
const el = document.getElementById('topList');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Пока нет данных</div>'; return; }
el.innerHTML = rows.map((x, i) => `
<div class="top-item">
<span class="top-rank ${i > 2 ? 'alt' : ''}">${i + 1}</span>
<span class="top-name">${esc(x.student_name)}</span>
<span class="top-count">${x.n}</span>
</div>`).join('');
}
function renderPhotos(rows) {
const el = document.getElementById('dashPhotos');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Фото групп пока нет</div>'; return; }
el.innerHTML = rows.map(p => `
<div class="dash-photo" title="${esc(p.group_name)}">
<img src="${API}${p.photo_path}" onclick="showImg('${API}${p.photo_path}')" alt="">
${p.caption ? `<div class="pcap">${esc(p.caption)}</div>` : ''}
</div>`).join('');
}
async function loadDashboard() {
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
const d = await res.json();
renderStats(d.stats);
renderActivity(d.activity);
renderRecent(d.recent_entries);
renderActive(d.active_groups);
renderTop(d.top_students);
renderPhotos(d.photos);
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadDashboard();
}
})();
</script>
<script src="js/dashboard.js"></script>
</body>
</html>
+3 -18
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ошибка — WhatIDo</title>
<link rel="stylesheet" href="admin.css">
<link rel="stylesheet" href="/admin.css">
<style>
.error-page{display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:24px;text-align:center}
.error-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:48px 40px;max-width:400px;width:100%}
@@ -29,7 +29,7 @@
<p class="error-message" id="errorMessage">Запрашиваемая страница не существует или была перемещена.</p>
<div class="error-actions">
<a href="/" class="ghost">На главную</a>
<button onclick="history.back()">Назад</button>
<button type="button" id="backBtn">Назад</button>
</div>
<div class="error-details" id="errorDetails" style="display:none">
<strong>Детали:</strong>
@@ -37,21 +37,6 @@
</div>
</div>
</div>
<script>
(function(){
const params = new URLSearchParams(location.search);
const code = params.get('code') || '404';
const title = params.get('title') || '';
const message = params.get('message') || '';
const details = params.get('details') || '';
document.getElementById('errorCode').textContent = code;
document.getElementById('errorTitle').textContent = title || (code === '404' ? 'Страница не найдена' : 'Ошибка');
document.getElementById('errorMessage').textContent = message || (code === '404' ? 'Запрашиваемая страница не существует или была перемещена.' : 'Произошла ошибка на сервере.');
if (details) {
document.getElementById('errorDetails').style.display = 'block';
document.getElementById('errorStack').textContent = details;
}
})();
</script>
<script src="/js/error.js"></script>
</body>
</html>
+5 -164
View File
@@ -25,8 +25,8 @@
<div><label>По</label><input type="date" id="dateTo"></div>
<div><label>Тип</label>
<div class="view-toggle" id="tabToggle">
<button data-mode="attached" onclick="setMode('attached')">Прикреплённые</button>
<button data-mode="detached" onclick="setMode('detached')">Откреплённые</button>
<button data-mode="attached" type="button">Прикреплённые</button>
<button data-mode="detached" type="button" id="detachedTab">Откреплённые</button>
</div>
</div>
</div>
@@ -35,172 +35,13 @@
</div>
</div>
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
<div class="modal-overlay" id="imgModal">
<img id="imgModalSrc">
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script>
const PAGE_SIZE = 20;
let page = 1;
let totalFiles = 0;
let currentFiles = [];
let groupsLoaded = false;
let studentsLoaded = false;
let searchTimer = null;
let mode = 'attached';
function setMode(m) {
mode = m === 'detached' ? 'detached' : 'attached';
document.querySelectorAll('#tabToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
const show = mode === 'attached';
const keep = ['searchInput', 'studentFilter', 'groupFilter', 'dateFrom', 'dateTo'].map(id => document.getElementById(id));
if (!show) { keep.forEach(el => { el.value = ''; }); }
keep.forEach(el => { el.parentElement.style.display = show ? '' : 'none'; });
page = 1;
loadFiles();
}
function isImageFile(name) {
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function humanSize(b) {
if (!b) return '';
if (b < 1024) return b + ' Б';
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
return (b / 1048576).toFixed(1) + ' МБ';
}
function fileCardHTML(f) {
const url = `${API}/api/files/${f.token}`;
const thumb = isImageFile(f.name)
? `<img src="${url}" onclick="showImg('${url}')" alt="">`
: `<div class="file-icon">📄</div>`;
const target = isImageFile(f.name)
? `href="#" onclick="showImg('${url}');return false;"`
: `href="${url}" download`;
const meta = mode === 'detached'
? `<div class="group" style="color:#f59e0b">Откреплён от записи, не привязан</div>`
: `<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>`;
const action = mode === 'detached'
? `<button class="f-action f-del" onclick="deleteFile(${f.id})" title="Удалить файл навсегда">Удалить</button>`
: `<button class="f-action" onclick="detachFile(${f.id})" title="Открепить файл от записи">Открепить</button>`;
return `
<div class="card">
${thumb}
<div class="info">
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
${meta}
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
</div>
<div class="f-actions">${action}</div>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!currentFiles.length) {
grid.className = 'grid';
grid.innerHTML = mode === 'detached'
? '<div class="empty">Откреплённых файлов нет</div>'
: '<div class="empty">Файлов не найдено</div>';
return;
}
grid.className = 'grid';
grid.innerHTML = currentFiles.map(fileCardHTML).join('');
}
async function detachFile(id) {
if (!confirm('Открепить файл от записи? Он переместится во вкладку «Откреплённые».')) return;
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
if (res.ok) { showToast('Файл откреплён'); loadFiles(); }
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
}
async function deleteFile(id) {
if (!confirm('Удалить файл навсегда? Отменить это действие нельзя.')) return;
const res = await fetch(`${API}/api/files/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Файл удалён'); loadFiles(); }
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
}
async function loadGroupOptions() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
while (sel.options.length > 1) sel.remove(1);
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id; o.textContent = g.name;
sel.appendChild(o);
});
groupsLoaded = true;
}
async function loadStudentOptions() {
if (studentsLoaded) return;
const dl = document.getElementById('studentFilterList');
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
async function loadFiles() {
const p = new URLSearchParams();
if (mode === 'attached') {
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
}
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/files${mode === 'detached' ? '/detached' : ''}?${p}`, { headers: hdr() });
const data = await res.json();
currentFiles = data.files;
totalFiles = data.total;
renderGrid();
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalFiles / PAGE_SIZE)), goPage);
}
function goPage(p) {
page = p;
loadFiles();
}
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadFiles(); }, 300);
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
document.querySelectorAll('#tabToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
loadGroupOptions();
loadStudentOptions();
loadFiles();
}
})();
</script>
<script src="js/files.js"></script>
</body>
</html>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+41 -288
View File
@@ -11,316 +11,69 @@
<div class="sidebar" id="sidebar"></div>
<div class="main">
<h2>Группы</h2>
<div class="add-row">
<input type="text" id="newGroup" placeholder="Название группы">
<button onclick="addGroup()">Добавить</button>
<div class="add-row" style="align-items:center;gap:8px">
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
<select id="newGroupBranch" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:180px"><option value="">— без филиала —</option></select>
<select id="newGroupTutor" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:160px"><option value="">— не назначен —</option></select>
<button type="button" id="addGroupBtn">Добавить</button>
</div>
<div class="list" id="groupList"></div>
</div>
</div>
<div class="modal-overlay" id="photosModal" onclick="if(event.target===this)closePhotos()">
<div class="modal-overlay" id="photosModal">
<div class="gallery-modal">
<div class="gallery-head">
<h3 id="photosTitle">Фото</h3>
<button class="upload-btn" onclick="openAddPhoto()">Загрузить фото</button>
<button class="upload-btn" type="button" id="openPhotoBtn">Загрузить фото</button>
</div>
<div class="photo-grid" id="photoGrid"><div class="empty">Загрузка...</div></div>
<div class="pager" id="photosPager"></div>
<div class="gallery-foot">
<button class="cancel" onclick="closePhotos()">Закрыть</button>
<button class="cancel" type="button" id="closePhotosBtn">Закрыть</button>
</div>
</div>
</div>
<div class="modal-overlay" id="photoFormModal" onclick="if(event.target===this)closePhotoForm()">
<div class="edit-modal">
<div class="modal-overlay" id="photoFormModal">
<div class="edit-modal" style="max-width:480px">
<h3 id="photoFormTitle">Добавить фото</h3>
<div>
<label>Выбрать файл (jpg/png и т.п.)</label>
<input type="file" id="photoFile" accept="image/*">
</div>
<div id="photoFormPreview" class="photo-preview" style="display:none"><img id="photoPreviewImg" alt=""></div>
<div>
<label>Подпись</label>
<textarea id="photoCaption" placeholder="Например: выступление, тренировка..."></textarea>
</div>
<div>
<label>Дата съёмки</label>
<input type="date" id="photoTakenAt">
</div>
<div class="actions">
<button class="cancel" onclick="closePhotoForm()">Отмена</button>
<button class="save" onclick="savePhotoForm()">Сохранить</button>
</div>
<form id="photoForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Выбрать файл (jpg/png и т.п.) <span style="color:#ef4444">*</span></label>
<input type="file" id="photoFile" accept="image/*">
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<div id="photoFormPreview" class="photo-preview" style="display:none"><img id="photoPreviewImg" alt=""></div>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Подпись</label>
<textarea id="photoCaption" class="settings-input" placeholder="Например: выступление, тренировка..."></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Дата съёмки</label>
<input type="date" id="photoTakenAt" class="settings-input">
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="closePhotoFormBtn">Отмена</button>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script>
<script>
// --- Groups ---
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
async function loadGroups() {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
const list = document.getElementById('groupList');
if (!groups.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Нет групп</div>'; return; }
list.innerHTML = `<div class="group-grid">` + groups.map(g => {
const schedSet = g.day_of_week != null && g.time_start && g.time_end;
return `
<div class="group-card">
${g.cover_path
? `<img class="group-cover" src="${API}${g.cover_path}" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Фото группы (хронология)" alt="">`
: `<div class="group-cover empty-cover" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Фото группы (хронология)">📷</div>`}
<div class="group-head">
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
<button class="del" onclick="delGroup(${g.id})" title="Удалить">&times;</button>
</div>
<div class="group-schedule">
<div><label>День</label>
<select id="dow-${g.id}">
<option value="">—</option>
${DAYS.map((d, i) => `<option value="${i}" ${g.day_of_week === i ? 'selected' : ''}>${d}</option>`).join('')}
</select>
</div>
<div><label>С</label>
<input type="time" id="ts-${g.id}" value="${g.time_start ? g.time_start.slice(0,5) : ''}">
</div>
<div><label>По</label>
<input type="time" id="te-${g.id}" value="${g.time_end ? g.time_end.slice(0,5) : ''}">
</div>
</div>
<div class="group-foot">
<div style="display:flex;gap:8px">
<button class="save-sched" onclick="saveGroup(${g.id})">Сохранить</button>
<button class="photo-btn" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Фото группы (хронология)">Фото</button>
<button class="btn-link" onclick="exportGroup(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Экспортировать записи группы">CSV</button>
<button class="btn-link" onclick="exportGroupUrls(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Скачать .url файлы учеников группы">URL</button>
</div>
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
</div>
</div>`;
}).join('') + `</div>`;
}
async function saveGroup(id) {
const name = document.getElementById(`name-${id}`).value.trim();
if (!name) return;
const dow = document.getElementById(`dow-${id}`).value;
const ts = document.getElementById(`ts-${id}`).value;
const te = document.getElementById(`te-${id}`).value;
const res = await fetch(`${API}/api/groups/${id}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ name, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
});
if (res.ok) {
loadGroups();
const dayLabel = dow !== '' ? DAYS[dow] : 'без расписания';
const timeLabel = ts && te ? `, ${ts}–${te}` : '';
showToast(`Сохранено: «${name}» — ${dayLabel}${timeLabel}`);
} else { const e = await res.json(); alert(e.error); }
}
async function addGroup() {
const input = document.getElementById('newGroup');
const name = input.value.trim();
if (!name) return;
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
if (res.ok) { input.value = ''; loadGroups(); }
else { const e = await res.json(); alert(e.error); }
}
async function delGroup(id) {
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
loadGroups();
}
// --- Фото группы (хронология) ---
const PHOTO_PAGE_SIZE = 24;
let currentGroupId = null;
let currentGroupName = '';
let photos = [];
let photoPage = 1;
let totalPhotos = 0;
let editingPhotoId = null;
async function openPhotos(groupId, groupName) {
currentGroupId = groupId;
currentGroupName = groupName;
photoPage = 1;
editingPhotoId = null;
document.getElementById('photosTitle').textContent = `Фото — ${groupName}`;
document.getElementById('photosModal').classList.add('open');
await loadGroupPhotos();
}
function closePhotos() {
document.getElementById('photosModal').classList.remove('open');
currentGroupId = null;
}
async function loadGroupPhotos() {
const p = new URLSearchParams();
p.set('limit', PHOTO_PAGE_SIZE);
p.set('offset', (photoPage - 1) * PHOTO_PAGE_SIZE);
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos?${p}`, { headers: hdr() });
const data = await res.json();
photos = data.photos;
totalPhotos = data.total;
document.getElementById('photoGrid').className = 'photo-grid';
document.getElementById('photoGrid').innerHTML = photos.length
? photos.map(photoHTML).join('')
: '<div class="empty" style="grid-column:1/-1">Фото пока нет. Загрузите первое фото.</div>';
renderPager(document.getElementById('photosPager'), photoPage, Math.max(1, Math.ceil(totalPhotos / PHOTO_PAGE_SIZE)), goPhotoPage);
}
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; }
function photoHTML(p) {
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
return `
<div class="photo-item">
<img src="${API}${p.photo_path}" onclick="showImg('${API}${p.photo_path}')" alt="">
<div class="p-cap">${esc(p.caption || '')}</div>
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
<div class="p-actions">
<button class="p-edit" onclick="openEditPhoto(${p.id})" title="Редактировать">✎</button>
<button class="p-del" onclick="deletePhoto(${p.id})" title="Удалить">&times;</button>
</div>
</div>
`;
}
function openAddPhoto() {
editingPhotoId = null;
document.getElementById('photoFormTitle').textContent = `Добавить фото — ${currentGroupName}`;
document.getElementById('photoFile').value = '';
document.getElementById('photoCaption').value = '';
document.getElementById('photoTakenAt').value = '';
document.getElementById('photoFormPreview').style.display = 'none';
document.getElementById('photoFormModal').classList.add('open');
}
async function openEditPhoto(id) {
const p = photos.find(x => x.id === id);
if (!p) return;
editingPhotoId = id;
document.getElementById('photoFormTitle').textContent = 'Редактировать фото';
document.getElementById('photoFile').value = '';
document.getElementById('photoCaption').value = p.caption || '';
document.getElementById('photoTakenAt').value = p.taken_at || '';
const prev = document.getElementById('photoFormPreview');
const img = document.getElementById('photoPreviewImg');
img.src = `${API}${p.photo_path}`;
prev.style.display = 'block';
document.getElementById('photoFormModal').classList.add('open');
}
function closePhotoForm() {
document.getElementById('photoFormModal').classList.remove('open');
editingPhotoId = null;
}
function previewPhotoFile() {
const file = document.getElementById('photoFile').files[0];
const prev = document.getElementById('photoFormPreview');
if (!file) { prev.style.display = 'none'; return; }
const img = document.getElementById('photoPreviewImg');
img.src = URL.createObjectURL(file);
prev.style.display = 'block';
}
async function savePhotoForm() {
const caption = document.getElementById('photoCaption').value.trim();
const taken_at = document.getElementById('photoTakenAt').value;
if (editingPhotoId) {
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${editingPhotoId}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ caption: caption || null, taken_at: taken_at || null })
});
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
showToast('Фото обновлено');
} else {
const file = document.getElementById('photoFile').files[0];
if (!file) { alert('Выберите файл'); return; }
const fd = new FormData();
fd.append('photo', file);
fd.append('caption', caption);
fd.append('taken_at', taken_at);
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos`, {
method: 'POST',
headers: hdr(),
body: fd
});
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
showToast('Фото добавлено в хронологию');
}
closePhotoForm();
loadGroupPhotos();
loadGroups();
}
async function deletePhoto(id) {
if (!confirm('Удалить это фото безвозвратно?')) return;
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${id}`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
showToast('Фото удалено');
loadGroupPhotos();
loadGroups();
}
async function exportGroup(id, name) {
const res = await fetch(`${API}/api/entries?group_id=${id}`, { headers: hdr() });
const { entries } = await res.json();
if (!entries.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия Имя', 'Группа', 'Описание', 'Дата', 'Ссылка на страницу']];
entries.forEach(e => {
rows.push([
e.student_name,
e.group_name,
e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'),
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
]);
});
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `group_${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast(`Экспортировано записей: ${entries.length}`);
}
async function exportGroupUrls(groupId, groupName) {
const res = await fetch(`${API}/api/students`, { headers: hdr() });
const students = await res.json();
const list = students.filter(s => s.group_id === groupId);
if (!list.length) { alert('В группе нет учеников'); return; }
await exportStudentUrlZip(list, `group_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
showToast(`Экспортировано: ${list.length}`);
}
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadGroups();
}
})();
</script>
<script src="js/groups.js"></script>
</body>
</html>
+40 -202
View File
@@ -9,7 +9,10 @@
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding:24px 16px}
h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text-align:center}
.sub{color:var(--muted);font-size:.85rem;margin-bottom:32px;text-align:center}
.sub{color:var(--muted);font-size:.85rem;text-align:center}
.brand-head{display:flex;align-items:center;justify-content:center;gap:16px;margin-bottom:32px}
.brand-logo{height:76px;width:auto;max-width:min(46vw,340px);object-fit:contain;flex-shrink:0}
.brand-text{display:flex;flex-direction:column;align-items:flex-start}
.form{width:100%;max-width:900px;margin:0 auto;display:grid;grid-template-columns:320px 1fr;gap:32px;align-items:start}
@media(max-width:760px){.form{grid-template-columns:1fr;max-width:480px}}
@@ -63,6 +66,12 @@ textarea{resize:vertical;min-height:160px;font-family:inherit;line-height:1.5}
.toast.show{opacity:1}
.toast.error{background:#dc2626}
.sent-panel{width:100%;max-width:900px;margin:32px auto 0;background:var(--card);border:2px solid var(--accent);border-radius:var(--radius);padding:40px 32px;display:none;flex-direction:column;gap:20px;align-items:center;text-align:center;box-shadow:0 8px 24px rgba(37,99,235,.18)}
.sent-check{width:88px;height:88px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:2.6rem}
.sent-panel h2{font-size:1.6rem;font-weight:700;letter-spacing:-.02em;color:var(--text)}
.sent-panel p{font-size:1.05rem;color:var(--muted);max-width:520px}
.sent-timer{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:14px 20px;font-size:1rem;font-weight:600;color:var(--text)}
nav{position:fixed;top:16px;right:16px;display:flex;gap:8px}
nav a{color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 12px;border-radius:8px;transition:color .15s}
nav a:hover{color:var(--text)}
@@ -83,26 +92,31 @@ nav a:hover{color:var(--text)}
<nav>
<a href="admin.html">Админка</a>
</nav>
<h1 id="title">Что мы узнали на занятии</h1>
<p class="sub">Отметь, что ты делал(а) на занятии</p>
<div class="brand-head">
<img class="brand-logo" id="brandLogo" alt="" hidden>
<div class="brand-text">
<h1 id="title">Что мы узнали на занятии</h1>
<p class="sub">Отметь, что ты делал(а) на занятии</p>
</div>
</div>
<form class="form" id="form">
<div class="photo-col">
<div class="photo-card">
<div class="preview-wrap">
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
<button type="button" class="remove-photo" id="removePhotoBtn" onclick="removePhoto()">Убрать фото</button>
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
</div>
<div class="photo-btns">
<button type="button" class="cam" onclick="openCam()">Камера</button>
<button type="button" class="cam" id="openCamBtn">Камера</button>
</div>
</div>
<div class="files-card">
<div class="files-head">
<label>Файлы проекта</label>
<button type="button" class="files-clear" id="filesClearBtn" onclick="clearFiles()">Очистить</button>
<button type="button" class="files-clear" id="filesClearBtn">Очистить</button>
</div>
<button type="button" class="files-btn" onclick="document.getElementById('filesInput').click()">Выбрать файлы</button>
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
<input type="file" id="filesInput" multiple hidden>
<div class="files-list" id="filesList"></div>
</div>
@@ -111,9 +125,8 @@ nav a:hover{color:var(--text)}
<div class="fields-col">
<div class="field" id="nameField">
<label>Фамилия и имя</label>
<input type="text" id="nameInput" list="studentList" placeholder="Например, Иванов Иван" required>
<input type="text" id="nameInput" placeholder="Например, Иванов Иван" required autocomplete="off">
</div>
<datalist id="studentList"></datalist>
<div class="field">
<label>Группа</label>
@@ -122,15 +135,30 @@ nav a:hover{color:var(--text)}
</select>
</div>
<div class="field" id="moduleField" style="display:none">
<label>Тема модуля</label>
<input type="text" id="moduleInput" list="moduleInputList" placeholder="Начни вводить название темы..." autocomplete="off">
<datalist id="moduleInputList"></datalist>
</div>
<div class="field">
<label>Что сделал</label>
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
</div>
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
<button class="btn" type="submit" id="submitBtn">Отправить</button>
</div>
</form>
<div class="sent-panel" id="sentPanel">
<div class="sent-check">✓</div>
<h2 id="sentTitle">Запись отправлена!</h2>
<p id="sentText">Спасибо! Твоя запись уже передана преподавателю.</p>
<div class="sent-timer" id="sentTimer"></div>
</div>
<footer class="footer">
<div class="f-left" id="footerLeft"></div>
<div class="f-right" id="footerRight"></div>
@@ -141,204 +169,14 @@ nav a:hover{color:var(--text)}
<video id="camVideo" autoplay playsinline></video>
<canvas id="camCanvas"></canvas>
<div class="modal-actions">
<button class="cancel" onclick="closeCam()">Отмена</button>
<button class="capture" onclick="capture()">Снять</button>
<button type="button" class="cancel" id="cancelCamBtn">Отмена</button>
<button type="button" class="capture" id="captureBtn">Снять</button>
</div>
</div>
</div>
<div class="toast" id="toast">Готово!</div>
<script>
const API = location.origin;
const form = document.getElementById('form');
const preview = document.getElementById('preview');
const placeholderSrc = preview.src;
const removePhotoBtn = document.getElementById('removePhotoBtn');
const groupInput = document.getElementById('groupInput');
const toast = document.getElementById('toast');
const camVideo = document.getElementById('camVideo');
const camCanvas = document.getElementById('camCanvas');
const camModal = document.getElementById('camModal');
let capturedBlob = null;
let stream = null;
const descInput = document.getElementById('descInput');
const filesInput = document.getElementById('filesInput');
const filesList = document.getElementById('filesList');
const filesClearBtn = document.getElementById('filesClearBtn');
let selectedFiles = [];
function humanSize(b) {
if (b < 1024) return b + ' Б';
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
return (b / 1048576).toFixed(1) + ' МБ';
}
function renderFiles() {
filesList.innerHTML = '';
selectedFiles.forEach((f, i) => {
const row = document.createElement('div');
row.className = 'file-row';
const name = document.createElement('span');
name.className = 'f-name';
name.textContent = f.name;
name.title = f.name;
const size = document.createElement('span');
size.className = 'f-size';
size.textContent = humanSize(f.size);
const del = document.createElement('button');
del.type = 'button';
del.textContent = '✕';
del.onclick = () => removeFile(i);
row.append(name, size, del);
filesList.appendChild(row);
});
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
}
function removeFile(i) {
selectedFiles.splice(i, 1);
renderFiles();
}
function clearFiles() {
selectedFiles = [];
filesInput.value = '';
renderFiles();
}
filesInput.addEventListener('change', () => {
const keep = 10 - selectedFiles.length;
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
filesInput.value = '';
renderFiles();
});
const params = new URLSearchParams(location.search);
let studentName = '';
function escName(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
if (params.get('nameInput')) {
studentName = params.get('nameInput');
document.getElementById('nameInput').value = studentName;
const field = document.getElementById('nameField');
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
}
(async () => {
const res = await fetch(`${API}/api/students`);
const students = await res.json();
const dl = document.getElementById('studentList');
students.forEach(s => {
const o = document.createElement('option');
o.value = s.name;
dl.appendChild(o);
});
const me = studentName ? students.find(s => s.name === studentName) : null;
if (me && me.group_id) {
groupInput.innerHTML = `<option value="${me.group_id}" selected>${escName(me.group_name || `Группа ${me.group_id}`)}</option>`;
groupInput.value = String(me.group_id);
} else {
const groupsRes = await fetch(`${API}/api/groups/active`);
const groups = await groupsRes.json();
groups.forEach(g => {
const opt = document.createElement('option');
opt.value = g.id;
opt.textContent = g.name;
groupInput.appendChild(opt);
});
if (!groups.length) {
groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>';
}
}
try {
const fRes = await fetch(`${API}/api/public-settings`);
const f = await fRes.json();
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
} catch (e) { /* ignore */ }
})();
function showPreview(src) {
preview.src = src;
preview.classList.add('has-photo');
removePhotoBtn.classList.add('show');
}
function removePhoto() {
preview.src = placeholderSrc;
preview.classList.remove('has-photo');
removePhotoBtn.classList.remove('show');
capturedBlob = null;
}
async function openCam() {
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
camVideo.srcObject = stream;
camModal.classList.add('open');
} catch (e) {
alert('Не удалось открыть камеру: ' + e.message);
}
}
function closeCam() {
camModal.classList.remove('open');
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
}
function capture() {
camCanvas.width = camVideo.videoWidth;
camCanvas.height = camVideo.videoHeight;
const ctx = camCanvas.getContext('2d');
ctx.translate(camCanvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(camVideo, 0, 0);
camCanvas.toBlob(blob => {
capturedBlob = blob;
showPreview(URL.createObjectURL(blob));
closeCam();
}, 'image/jpeg', 0.85);
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = 'Отправка...';
const fd = new FormData();
if (capturedBlob) fd.append('photo', capturedBlob, 'photo.jpg');
selectedFiles.forEach(f => fd.append('files', f, f.name));
fd.append('student_name', studentName || document.getElementById('nameInput').value);
fd.append('group_id', groupInput.value);
fd.append('description', document.getElementById('descInput').value);
try {
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
if (res.status === 429) {
const e = await res.json().catch(() => ({}));
toast.textContent = e.error || 'Уже ответили: подождите';
toast.classList.add('show', 'error');
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
return;
}
if (!res.ok) throw new Error(await res.text());
form.reset();
removePhoto();
clearFiles();
toast.textContent = 'Готово!';
toast.classList.remove('error');
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
} catch (err) {
alert('Ошибка: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Отправить';
}
});
</script>
<script src="js/index.js"></script>
</body>
</html>
+196 -384
View File
@@ -5,6 +5,34 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Журнал — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
<link rel="stylesheet" href="admin.css">
<style>
.enhance-modal { max-width: 1100px; width: 94vw; }
.enhance-body { display: flex; gap: 16px; align-items: stretch; margin-top: 4px; }
.enhance-compare { position: relative; flex: 1 1 auto; min-width: 0; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
.enhance-compare.zoomed { cursor: grab; }
.enhance-compare.panning { cursor: grabbing; }
.enhance-stage { transform-origin: 0 0; will-change: transform; }
.enhance-stage > img, .enhance-stage > canvas { display: block; width: 100%; height: 100%; }
.enhance-stage > img { height: auto; }
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
.enhance-top .enhance-stage { position: absolute; inset: 0; }
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); pointer-events: auto; cursor: ew-resize; }
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
.enhance-badge.left { left: 10px; }
.enhance-badge.right { right: 10px; }
.enhance-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: .85rem; background: var(--bg); }
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
.enhance-sliders .settings-field input[type=range] { width: 100%; }
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
@media (max-width: 800px) {
.enhance-body { flex-direction: column; }
.enhance-sliders { flex: 1 1 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
}
</style>
</head>
<body data-page="journal">
<div class="layout">
@@ -15,14 +43,15 @@
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
<div><label>Студент</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
<div><label>Модуль</label><input type="text" id="moduleFilter" list="moduleFilterList" placeholder="Все модули" autocomplete="off"><datalist id="moduleFilterList"></datalist></div>
<div><label>С</label><input type="date" id="dateFrom"></div>
<div><label>По</label><input type="date" id="dateTo"></div>
<div><label>&nbsp;</label><button onclick="exportCSV()" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
<div><label>&nbsp;</label><button onclick="openLinkCreate()" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
<div><label>&nbsp;</label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
<div><label>&nbsp;</label><button type="button" id="createLinkBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
<div><label>&nbsp;</label>
<div class="view-toggle" id="viewToggle">
<button data-view="list" onclick="setView('list')">Список</button>
<button data-view="cards" onclick="setView('cards')">Карточки</button>
<button data-view="list" type="button">Список</button>
<button data-view="cards" type="button">Карточки</button>
</div>
</div>
</div>
@@ -31,408 +60,191 @@
</div>
</div>
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
<div class="modal-overlay" id="imgModal">
<img id="imgModalSrc">
</div>
<div class="modal-overlay" id="editModal" onclick="if(event.target===this)closeEdit()">
<div class="edit-modal">
<div class="modal-overlay" id="editModal">
<div class="edit-modal" style="max-width:720px">
<h3>Редактирование записи</h3>
<div>
<label>Фамилия и имя</label>
<input type="text" id="editName" list="studentEditList">
<datalist id="studentEditList"></datalist>
</div>
<div>
<label>Группа</label>
<select id="editGroup"></select>
</div>
<div>
<label>Что делала(а)</label>
<textarea id="editDesc"></textarea>
</div>
<div class="actions">
<button class="cancel" onclick="closeEdit()">Отмена</button>
<button class="save" onclick="saveEdit()">Сохранить</button>
<form id="editForm" class="link-fields" style="gap:12px">
<div class="settings-field">
<label>Фамилия и имя <span style="color:#ef4444">*</span></label>
<input type="text" id="editName" class="settings-input" list="studentEditList" required autocomplete="off">
<datalist id="studentEditList"></datalist>
</div>
<div class="settings-field">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="editGroup" class="settings-input" required></select>
</div>
<div class="settings-field">
<label>Тема модуля</label>
<input type="text" id="editModule" class="settings-input" list="editModuleList" placeholder="Начните вводить название..." autocomplete="off">
<datalist id="editModuleList"></datalist>
</div>
<div class="settings-field full">
<label>Что делала(а) <span style="color:#ef4444">*</span>
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
</label>
<textarea id="editDesc" class="settings-input" required></textarea>
<div id="editAiStatus" class="ai-status" style="display:none"></div>
</div>
<div class="settings-field">
<label>Фотография</label>
<input type="file" id="editPhoto" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.avif,.ico,.heic,.heif,.jfif">
<div class="files-preview" id="editPhotoPreview"></div>
</div>
<div class="settings-field">
<label>Прикрепить файлы</label>
<input type="file" id="editFiles" multiple accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.zip,.rar,.7z,.jpg,.jpeg,.png,.gif,.webp,.bmp,.avif,.ico,.heic,.heif,.jfif">
<div class="files-preview" id="editFilesPreview"></div>
</div>
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="editCancelBtn">Отмена</button>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="aiCorrectModal">
<div class="edit-modal" style="max-width:560px">
<h3>✨ Исправление текста</h3>
<div style="font-size:.85rem;color:var(--muted);margin-bottom:12px">ИИ предлагает вариант:</div>
<div id="aiSuggestion" style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;min-height:80px;max-height:300px;overflow:auto;white-space:pre-wrap;font-family:inherit"></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:12px">
<button type="button" class="btn-primary ghost" id="aiRejectBtn">Отклонить</button>
<button type="button" class="btn-primary" id="aiAcceptBtn">Применить</button>
</div>
</div>
</div>
<div class="modal-overlay" id="linkModal" onclick="if(event.target===this)closeLinkCreate()">
<div class="edit-modal">
<div class="modal-overlay" id="linkModal">
<div class="edit-modal" style="max-width:640px">
<div id="linkCreateView">
<h3>Создать ссылку</h3>
<div style="font-size:.85rem;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;line-height:1.5" id="linkPreview"></div>
<div>
<label>Название ссылки</label>
<input type="text" id="linkNameInput">
</div>
<div>
<label>Группа (если выбрана)</label>
<input type="text" id="linkGroupPreview" readonly style="background:var(--bg);color:var(--muted);cursor:not-allowed">
</div>
<div>
<label>Ученик (необязательно)</label>
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
</div>
<div class="actions">
<button class="cancel" onclick="closeLinkCreate()">Отмена</button>
<button class="save" onclick="createLink()">Создать</button>
<div class="link-fields">
<div class="settings-field">
<label>Название ссылки <span style="color:#ef4444">*</span></label>
<input type="text" id="linkNameInput" class="settings-input" required placeholder="Например: Апрельский журнал">
</div>
<div class="settings-field">
<label>Группа (если выбрана)</label>
<input type="text" id="linkGroupPreview" class="settings-input" readonly style="background:var(--bg);color:var(--muted);cursor:not-allowed">
</div>
<div class="settings-field">
<label>Ученик (необязательно)</label>
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
</div>
<div class="settings-field">
<label>Ссылка</label>
<input type="url" id="linkUrl" class="settings-input" placeholder="https://...">
</div>
<div class="settings-field full">
<label>Сообщение</label>
<textarea id="linkMessage" class="settings-input" rows="3" placeholder="Текст, который увидят на публичной странице"></textarea>
</div>
<div class="settings-field">
<label>Истекает через (дни)</label>
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
</div>
<div class="settings-field">
<label>Пароль для доступа (необязательно)</label>
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="linkShowNames">
Показывать фамилии и имена учеников
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="linkShowMessage">
Показать сообщение ученика
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="linkShowDate">
Показать дату в карточке записи
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="linkShowPhotos">
Показывать фото группы
</label>
</div>
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="linkCreateCloseBtn">Отмена</button>
<button type="button" class="btn-primary" id="linkCreateSaveBtn">Создать</button>
</div>
</div>
</div>
<div id="linkResultView" style="display:none">
<h3>Ссылка готова</h3>
<div style="font-size:.85rem;color:var(--muted);margin-bottom:8px">Открывается публичная страница с карточками — без входа и выбора параметров. Ссылку можно изменить или удалить в разделе «Ссылки».</div>
<input type="text" id="linkUrlResult" readonly style="font-size:.8rem;color:var(--accent)">
<div class="actions">
<a class="cancel-btn" href="links.html">К списку ссылок</a>
<button class="save" onclick="copyLink()">Копировать</button>
<input type="text" id="linkUrlResult" class="settings-input" readonly style="font-size:.8rem;color:var(--accent)">
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<a class="btn-primary ghost" href="links.html" style="text-decoration:none">К списку ссылок</a>
<button class="btn-primary" type="button" id="linkCopyBtn">Копировать</button>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="enhanceModal">
<div class="edit-modal enhance-modal">
<h3>Улучшение фотографии</h3>
<div class="enhance-body">
<div class="enhance-compare" id="enhanceCompare">
<div class="enhance-stage" id="enhanceStage">
<img id="enhanceOrig" alt="Оригинал">
</div>
<div class="enhance-top" id="enhanceTop" style="clip-path:inset(0 0 0 50%)">
<div class="enhance-stage" id="enhanceInner">
<canvas id="enhanceCanvas"></canvas>
</div>
</div>
<div class="enhance-divider" id="enhanceDivider"><div class="enhance-handle">⇔</div></div>
<div class="enhance-badge left">Оригинал</div>
<div class="enhance-badge right">Результат</div>
<div class="enhance-loading" id="enhanceLoading">Загрузка...</div>
</div>
<div class="enhance-sliders">
<div class="settings-field"><label>Яркость <span id="enhanceBrightnessVal">100%</span></label><input type="range" id="enhanceBrightness" min="50" max="150" value="100"></div>
<div class="settings-field"><label>Контраст <span id="enhanceContrastVal">100%</span></label><input type="range" id="enhanceContrast" min="50" max="150" value="100"></div>
<div class="settings-field"><label>Насыщенность <span id="enhanceSaturateVal">100%</span></label><input type="range" id="enhanceSaturate" min="0" max="200" value="100"></div>
<div class="settings-field"><label>Шумодав <span id="enhanceDenoiseVal">0%</span></label><input type="range" id="enhanceDenoise" min="0" max="100" value="0"></div>
<div class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
</div>
</div>
<div class="card-foot enhance-foot">
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
<button type="button" class="btn-primary" id="enhanceSaveBtn">Применить</button>
</div>
</div>
</div>
<div class="modal-overlay" id="photoHistoryModal">
<div class="edit-modal enhance-modal">
<h3 id="photoHistoryTitle">История изменений фотографии</h3>
<div id="photoHistoryList" style="display:flex;flex-direction:column;gap:12px;max-height:60vh;overflow:auto"></div>
<div class="card-foot enhance-foot">
<button type="button" class="btn-primary ghost" id="photoHistoryCloseBtn">Закрыть</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script>
// --- Journal ---
let currentEntries = [];
let editId = null;
let editGroups = [];
let groupsLoaded = false;
let studentsLoaded = false;
const PAGE_SIZE = 20;
let page = 1;
let totalEntries = 0;
let searchTimer = null;
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
function isImageFile(name) {
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function filesHTML(files) {
if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f => {
const link = isImageFile(f.name)
? `<a class="f-link" href="#" onclick="showImg('${API}/api/files/${f.token}');return false;" title="Открыть">${esc(f.name)}</a>`
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`;
return `<span class="f-item">${link}<button class="f-detach" onclick="detachFile(${f.id})" title="Открепить файл">✕</button></span>`;
}).join('')}</div>`;
}
function findFile(id) {
for (const e of currentEntries) {
const f = (e.files || []).find(x => x.id === id);
if (f) return f;
}
return null;
}
async function detachFile(id) {
const f = findFile(id);
if (!f) return;
if (!confirm(`Открепить файл «${f.name}» от записи? Он переместится в «Файлы → Откреплённые».`)) return;
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
if (res.ok) {
showToast('Файл откреплён');
loadEntries();
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка');
}
}
function gridEntryHTML(e) {
return `
<div class="card">
${e.photo_path ? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
</div>
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
<button class="del" onclick="delEntry(${e.id})" title="Удалить">&times;</button>
</div>
`;
}
function cardEntryHTML(e) {
return `
<div class="journal-card">
${e.photo_path
? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
<div class="body">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
</div>
<div class="foot">
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
<span>
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
<button class="del" onclick="delEntry(${e.id})" title="Удалить">&times;</button>
</span>
</div>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!currentEntries.length) {
grid.className = 'grid';
grid.innerHTML = '<div class="empty">Ничего не найдено</div>';
return;
}
if (view === 'cards') {
grid.className = 'journal-cards';
grid.innerHTML = currentEntries.map(cardEntryHTML).join('');
} else {
grid.className = 'grid';
grid.innerHTML = currentEntries.map(gridEntryHTML).join('');
}
}
function setView(v) {
view = v === 'cards' ? 'cards' : 'list';
localStorage.setItem('journalView', view);
document.querySelectorAll('#viewToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.view === view);
});
renderGrid();
}
function setDefaultDates() {
const today = new Date().toISOString().slice(0, 10);
document.getElementById('dateFrom').value = today;
document.getElementById('dateTo').value = today;
}
async function loadGroupOptions() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
while (sel.options.length > 1) sel.remove(1);
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id; o.textContent = g.name;
sel.appendChild(o);
});
groupsLoaded = true;
}
async function loadStudentOptions() {
if (studentsLoaded) return;
const dl = document.getElementById('studentFilterList');
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
async function loadEntries() {
const p = new URLSearchParams();
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
const data = await res.json();
currentEntries = data.entries;
totalEntries = data.total;
renderGrid();
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
}
function goPage(p) {
page = p;
loadEntries();
}
async function openEdit(id) {
const e = currentEntries.find(x => x.id === id);
if (!e) return;
editId = id;
fillAutocomplete();
document.getElementById('editName').value = e.student_name;
document.getElementById('editDesc').value = e.description;
const sel = document.getElementById('editGroup');
if (!editGroups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
editGroups = await res.json();
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
sel.value = e.group_id;
document.getElementById('editModal').classList.add('open');
}
function closeEdit() {
document.getElementById('editModal').classList.remove('open');
editId = null;
}
async function saveEdit() {
if (!editId) return;
const name = document.getElementById('editName').value.trim();
const group_id = document.getElementById('editGroup').value;
const description = document.getElementById('editDesc').value.trim();
if (!name || !group_id || !description) { alert('Заполните все поля'); return; }
const res = await fetch(`${API}/api/entries/${editId}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ student_name: name, group_id, description })
});
if (res.ok) {
closeEdit();
loadEntries();
showToast('Запись обновлена');
} else { const err = await res.json(); alert(err.error); }
}
async function delEntry(id) {
if (!confirm('Переместить запись в корзину? Файлы и фото сохранятся.')) return;
await fetch(`${API}/api/entries/${id}`, { method: 'DELETE', headers: hdr() });
showToast('Запись перемещена в корзину');
loadEntries();
}
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadEntries(); }, 300);
});
async function exportCSV() {
const p = new URLSearchParams();
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
const entries = (await res.json()).entries;
if (!entries.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия и имя', 'Группа', 'Описание', 'Дата', 'Фото']];
entries.forEach(e => {
rows.push([
e.student_name,
e.group_name,
e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'),
e.photo_path ? `${API}${e.photo_path}` : ''
]);
});
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
const bom = '\uFEFF';
const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const dateLabel = df && dt ? `${df}_${dt}` : 'all';
a.download = `journal_${dateLabel}.csv`;
a.click();
URL.revokeObjectURL(url);
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
document.querySelectorAll('#viewToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.view === view);
});
setDefaultDates();
loadGroupOptions();
loadStudentOptions();
loadEntries();
}
})();
// --- Ссылки ---
let linkGroups = [];
let linkStudentsLoaded = false;
let createdLinkUrl = '';
function fmtD(d) { return d ? d.split('-').reverse().join('.') : 'весь период'; }
async function openLinkCreate() {
const gid = document.getElementById('groupFilter').value;
if (!gid) { alert('Сначала выберите группу в фильтре'); return; }
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (!linkGroups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
linkGroups = await res.json();
}
if (!linkStudentsLoaded) {
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
const dl = document.getElementById('linkStudentList');
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
linkStudentsLoaded = true;
}
// Capture current student filter
const currentStudent = document.getElementById('studentFilter').value;
const g = linkGroups.find(x => String(x.id) === String(gid));
document.getElementById('linkPreview').innerHTML =
`Будут показаны записи: <b>${esc(g ? g.name : '')}</b><br>Период: <b>${fmtD(df)}</b> — <b>${fmtD(dt)}</b>`;
document.getElementById('linkGroupPreview').value = g ? g.name : '';
const dfl = df.split('-').reverse().join('.');
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
document.getElementById('linkStudent').value = currentStudent;
document.getElementById('linkCreateView').style.display = '';
document.getElementById('linkResultView').style.display = 'none';
document.getElementById('linkModal').classList.add('open');
}
function closeLinkCreate() {
document.getElementById('linkModal').classList.remove('open');
}
async function createLink() {
const name = document.getElementById('linkNameInput').value.trim();
const group_id = document.getElementById('groupFilter').value;
const student_name = document.getElementById('linkStudent').value;
const date_from = document.getElementById('dateFrom').value;
const date_to = document.getElementById('dateTo').value;
if (!name) { alert('Введите название'); return; }
const res = await fetch(`${API}/api/links`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null })
});
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
const link = await res.json();
createdLinkUrl = `${location.origin}/s/${link.token}`;
document.getElementById('linkUrlResult').value = createdLinkUrl;
document.getElementById('linkCreateView').style.display = 'none';
document.getElementById('linkResultView').style.display = '';
showToast('Ссылка создана');
}
function copyLink() { copyText(createdLinkUrl); }
</script>
<script src="js/journal.js"></script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
const ACTION_LABELS = {
'auth.login': 'Вход в систему',
'settings.update': 'Изменены настройки',
'user.create': 'Создан пользователь',
'user.update': 'Изменён пользователь',
'user.delete': 'Удалён пользователь',
'ip.ban': 'Заблокирован IP-адрес',
'ip.unban': 'Разблокирован IP-адрес',
'group.create': 'Создана группа',
'group.update': 'Изменена группа',
'group.delete': 'Удалена группа',
'group.photo.create': 'Загружена фото группы',
'group.photo.update': 'Изменено фото группы',
'group.photo.reorder': 'Изменён порядок фото группы',
'group.photo.set_cover': 'Обновлена обложка группы',
'group.photo.delete': 'Удалено фото группы',
'branch.create': 'Создан филиал',
'branch.update': 'Изменён филиал',
'branch.delete': 'Удалён филиал',
'student.create': 'Добавлен ученик',
'student.update': 'Изменён ученик',
'student.delete': 'Удалён ученик',
'student.batch-group': 'Ученики перенесены в группу',
'module.create': 'Создан модуль',
'module.update': 'Изменён модуль',
'module.delete': 'Модуль отключён',
'module.restore': 'Модуль включён',
'entry.update': 'Изменена запись',
'entry.photo.update': 'Изменено фото записи',
'entry.photo.set_main': 'Главное фото записи',
'entry.photo.delete': 'Удалено фото записи',
'entry.photo.enhance': 'Фото улучшено',
'entry.photo.enhance-ai.queue': 'ИИ-обработка фото поставлена в очередь',
'entry.photo.apply': 'Применён результат обработки фото',
'entry.photo.reject': 'Отклонён результат обработки фото',
'entry.photo.rollback': 'Фото отката к версии',
'entry.photo.restore_original': 'Возвращён оригинал фото',
'photo.job.preview': 'Создан предпросмотр обработки фото',
'photo.job.retry': 'Повтор обработки фото',
'photo.job.error': 'Ошибка обработки фото',
'photo-jobs.wake': 'Фото-воркер разбужен',
'photo-jobs.enabled': 'Переключена обработка фото',
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
'entry.soft-delete': 'Запись в корзину',
'entry.restore': 'Запись восстановлена',
'entry.permanent-delete': 'Запись удалена безвозвратно',
'link.create': 'Создана ссылка',
'link.update': 'Изменена ссылка',
'link.delete': 'Удалена ссылка',
'backup.download': 'Скачан бэкап',
'backup.restore': 'Выполнено восстановление',
'entry.ai.auto-check': 'ИИ-автопроверка записи',
'entry.ai.retry': 'Повтор ИИ-проверки записи',
'entry.ai.recheck': 'Запись отправлена на ИИ-проверку',
'entry.ai.revert': 'Возвращён оригинал текста записи',
'entry.ai.error': 'Ошибка ИИ-проверки записи',
'ai.wake': 'Воркер ИИ разбужен вручную',
'ai.enabled': 'Переключена автопроверка ИИ',
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
'ai.profile.create': 'Создан профиль ИИ',
'ai.profile.update': 'Изменён профиль ИИ',
'ai.profile.delete': 'Удалён профиль ИИ',
'ai.profile.activate': 'Выбран активный профиль ИИ',
'export.student': 'Экспорт данных ученика',
};
const AUDIT_PAGE_SIZE = 50;
let auditPage = 1;
let auditTotal = 0;
let auditRows = [];
function fmtTime(t) {
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
}
function dt(t) {
if (!t) return '';
if (typeof t === 'object') {
return esc(JSON.stringify(t).slice(0, 120));
}
try {
const o = JSON.parse(t);
return esc(JSON.stringify(o).slice(0, 120));
} catch { return esc(String(t).slice(0, 120)); }
}
function fullTarget(t) {
if (!t) return '';
if (typeof t === 'object') return JSON.stringify(t, null, 2);
try {
return JSON.stringify(JSON.parse(t), null, 2);
} catch { return String(t); }
}
function renderPager(container, page, totalPages, onPageChange) {
if (totalPages <= 1) { container.innerHTML = ''; return; }
const pages = [];
const maxVisible = 5;
let start = Math.max(1, page - Math.floor(maxVisible / 2));
let end = Math.min(totalPages, start + maxVisible - 1);
if (end - start + 1 < maxVisible) start = Math.max(1, end - maxVisible + 1);
for (let i = start; i <= end; i++) pages.push(i);
let html = '';
if (page > 1) html += `<button data-page="${page - 1}">&laquo;</button>`;
if (start > 1) { html += `<button data-page="1">1</button>`; if (start > 2) html += `<span class="pager-ellipsis">…</span>`; }
for (const p of pages) html += `<button data-page="${p}"${p === page ? ' class="active"' : ''}>${p}</button>`;
if (end < totalPages) { if (end < totalPages - 1) html += `<span class="pager-ellipsis">…</span>`; html += `<button data-page="${totalPages}">${totalPages}</button>`; }
if (page < totalPages) html += `<button data-page="${page + 1}">&raquo;</button>`;
container.innerHTML = html;
container.querySelectorAll('button').forEach(b => b.addEventListener('click', () => onPageChange(parseInt(b.dataset.page, 10))));
}
async function loadAudit() {
const el = document.getElementById('auditTable').querySelector('tbody');
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
const p = new URLSearchParams();
p.set('limit', AUDIT_PAGE_SIZE);
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
const action = document.getElementById('auditActionFilter').value;
if (action) p.set('action', action);
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
const rows = await res.json();
// Total count for pagination (approximate: if we got less than page size, we're on last page)
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
auditTotal = totalPages;
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
if (!filtered.length) {
document.getElementById('auditEmpty').style.display = 'block';
el.innerHTML = '';
} else {
document.getElementById('auditEmpty').style.display = 'none';
auditRows = filtered;
el.innerHTML = filtered.map((r, i) => `
<tr>
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
<td><button type="button" class="audit-details" data-audit-idx="${i}">${dt(r.target)}</button></td>
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
</tr>`).join('');
}
renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
}
function goAuditPage(p) {
auditPage = p;
loadAudit();
}
function fillActionFilter() {
const sel = document.getElementById('auditActionFilter');
const current = sel.value;
const opts = Object.entries(ACTION_LABELS)
.sort((a, b) => a[1].localeCompare(b[1], 'ru'))
.map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`)
.join('');
sel.innerHTML = '<option value="">Все действия</option>' + opts;
sel.value = current;
}
document.getElementById('auditActionFilter').addEventListener('change', () => { auditPage = 1; loadAudit(); });
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
document.getElementById('auditTable').addEventListener('click', e => {
const btn = e.target.closest('.audit-details');
if (!btn) return;
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
if (!r) return;
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target);
document.getElementById('auditDetailsModal').classList.add('open');
});
document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => {
document.getElementById('auditDetailsModal').classList.remove('open');
});
document.getElementById('auditDetailsModal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
fillActionFilter();
loadAudit();
}
})();
+109
View File
@@ -0,0 +1,109 @@
const PAGE_SIZE = 15;
const REASONS = {
'honeypot': 'Антиспам-поле заполнено',
'login-bruteforce': 'Подбор пароля (10 неудачных входов)',
'share-password-bruteforce': 'Подбор пароля ссылки',
'manual': 'Вручную',
};
let allBans = [];
let page = 1;
function reasonLabel(r) {
return REASONS[r] || r || '—';
}
async function loadBans() {
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
const tbody = document.getElementById('bansBody');
if (!res.ok) {
tbody.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки (${res.status})</td></tr>`;
allBans = [];
renderPager(document.getElementById('pager'), 1, 1, () => {});
return;
}
allBans = await res.json();
const maxPage = Math.max(1, Math.ceil(allBans.length / PAGE_SIZE));
if (page > maxPage) page = maxPage;
renderPage();
}
function renderPage() {
const tbody = document.getElementById('bansBody');
const empty = document.getElementById('bansEmpty');
const slice = allBans.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
empty.style.display = allBans.length ? 'none' : 'block';
tbody.innerHTML = slice.map(b => {
const reason = reasonLabel(b.reason);
const rcls = ['honeypot', 'login-bruteforce', 'share-password-bruteforce', 'manual'].includes(b.reason)
? `reason-${b.reason}` : 'reason-manual';
return `
<tr>
<td><b>${esc(b.ip)}</b></td>
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
<td>${new Date(b.created_at).toLocaleString('ru')}</td>
<td>${new Date(b.banned_until).toLocaleString('ru')}</td>
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
</tr>`;
}).join('');
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(allBans.length / PAGE_SIZE)), p => { page = p; renderPage(); });
}
async function doUnban(ip) {
const ban = allBans.find(b => b.ip === ip) || {};
if (!confirm(`Удалить IP ${ip} из блокировки (${reasonLabel(ban.reason)})?`)) return;
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
if (res.ok) showToast('IP удалён из блокировки: ' + ip);
else showToast('Ошибка разблокировки');
loadBans();
}
document.getElementById('bansBody').addEventListener('click', e => {
const ip = e.target.closest('[data-unban]')?.dataset.unban;
if (ip) doUnban(ip);
});
function openBanModal() {
document.getElementById('bIp').value = '';
document.getElementById('bReason').value = '';
document.getElementById('bHours').value = 24;
document.getElementById('banModal').classList.add('open');
}
function closeBanModal() {
document.getElementById('banModal').classList.remove('open');
}
async function saveBan() {
const ip = document.getElementById('bIp').value.trim();
const reason = document.getElementById('bReason').value.trim();
const hours = parseInt(document.getElementById('bHours').value, 10) || 24;
if (!ip) { showToast('Укажите IP-адрес'); return; }
const res = await fetch(`${API}/api/bans`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ ip, reason: reason || 'manual', hours }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
showToast(`IP ${ip} заблокирован на ${hours} ч`);
closeBanModal();
loadBans();
} else {
showToast(data.error || 'Ошибка блокировки');
}
}
document.getElementById('addBanBtn').addEventListener('click', openBanModal);
document.getElementById('bCancel').addEventListener('click', closeBanModal);
document.getElementById('bSave').addEventListener('click', saveBan);
document.getElementById('banModal').addEventListener('click', e => { if (e.target.id === 'banModal') closeBanModal(); });
document.getElementById('bIp').addEventListener('keydown', e => { if (e.key === 'Enter') saveBan(); });
document.getElementById('bHours').addEventListener('keydown', e => { if (e.key === 'Enter') saveBan(); });
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
loadBans();
}
})();
+117
View File
@@ -0,0 +1,117 @@
let branches = [];
async function loadBranches() {
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
if (!res.ok) return;
branches = await res.json();
renderBranches();
}
function renderBranches() {
const el = document.getElementById('branchesGrid');
if (!branches.length) {
el.innerHTML = '<div class="empty" style="grid-column:1/-1">Филиалов пока нет. Нажмите «Добавить филиал».</div>';
return;
}
el.innerHTML = branches.map(b => `
<div class="branch-card" data-id="${b.id}">
<div class="branch-header">
<span class="branch-name">${esc(b.name)}</span>
<span class="branch-badge">${b.groups_count} ${plural(b.groups_count, 'группа', 'группы', 'групп')}</span>
</div>
${b.address ? `<div class="branch-meta">📍 ${esc(b.address)}</div>` : ''}
${b.phone ? `<div class="branch-meta">📞 ${esc(b.phone)}</div>` : ''}
<div class="branch-actions">
<button class="branch-edit" data-edit-branch="${b.id}" title="Редактировать">✎</button>
${b.groups_count === 0 ? `<button class="branch-delete" data-del-branch="${b.id}" title="Удалить">&times;</button>` : ''}
</div>
</div>
`).join('');
}
function plural(n, one, few, many) {
n = Math.abs(n) % 100; const n1 = n % 10;
if (n > 10 && n < 20) return many;
if (n1 > 1 && n1 < 5) return few;
if (n1 === 1) return one;
return many;
}
function openBranchModal(id = null) {
const modal = document.getElementById('branchModal');
const form = document.getElementById('branchForm');
const title = document.getElementById('branchModalTitle');
const submitBtn = document.getElementById('branchSubmitBtn');
form.reset();
document.getElementById('branchId').value = '';
if (id) {
const b = branches.find(x => x.id === id);
if (!b) return;
title.textContent = 'Редактировать филиал';
submitBtn.textContent = 'Сохранить';
form.name.value = b.name;
form.address.value = b.address || '';
form.phone.value = b.phone || '';
document.getElementById('branchId').value = b.id;
} else {
title.textContent = 'Создать филиал';
submitBtn.textContent = 'Создать';
}
modal.classList.add('open');
setTimeout(() => form.name.focus(), 100);
}
function closeBranchModal() {
document.getElementById('branchModal').classList.remove('open');
}
document.getElementById('branchForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const id = fd.get('id');
const name = fd.get('name').trim();
const address = fd.get('address').trim();
const phone = fd.get('phone').trim();
if (!name) { alert('Название обязательно'); return; }
const url = id ? `${API}/api/branches/${id}` : `${API}/api/branches`;
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: hdrJson(),
body: JSON.stringify({ name, address: address || null, phone: phone || null })
});
if (res.ok) {
showToast(id ? 'Филиал обновлён' : 'Филиал создан');
closeBranchModal();
loadBranches();
} else { const err = await res.json(); alert(err.error || 'Ошибка'); }
});
async function deleteBranch(id) {
if (!confirm('Удалить этот филиал?')) return;
const res = await fetch(`${API}/api/branches/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
showToast('Филиал удалён');
loadBranches();
} else { const err = await res.json(); alert(err.error || 'Ошибка'); }
}
document.getElementById('addBranchBtn').addEventListener('click', () => openBranchModal());
document.getElementById('branchModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBranchModal(); });
document.getElementById('branchCancelBtn').addEventListener('click', closeBranchModal);
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
document.getElementById('branchesGrid').addEventListener('click', e => {
const ed = e.target.closest('[data-edit-branch]');
if (ed) { openBranchModal(parseInt(ed.dataset.editBranch, 10)); return; }
const dl = e.target.closest('[data-del-branch]');
if (dl) { deleteBranch(parseInt(dl.dataset.delBranch, 10)); }
});
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
loadBranches();
}
})();
+123
View File
@@ -0,0 +1,123 @@
function plural(n, one, few, many) {
n = Math.abs(n) % 100; const n1 = n % 10;
if (n > 10 && n < 20) return many;
if (n1 > 1 && n1 < 5) return few;
if (n1 === 1) return one;
return many;
}
function renderStats(s, disk) {
let diskHtml = '';
if (disk) {
const pct = disk.used_pct;
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
diskHtml = `
<div class="stat">
<div>${disk.used} <span class="stat-sub">/ ${disk.total}</span></div>
<div class="label">Диск: свободно ${disk.free} (${100 - pct}%)</div>
<div class="stat-bar"><i style="width:${pct}%;background:${color}"></i></div>
</div>`;
}
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="num">${s.entries}</div><div class="label">Всего записей</div></div>
<div class="stat"><div class="num">${s.students}</div><div class="label">Учеников</div></div>
<div class="stat"><div class="num">${s.groups}</div><div class="label">Групп</div></div>
<div class="stat"><div class="num">${s.today}</div><div class="label">Сегодня</div></div>
<div class="stat"><div class="num" style="color:${s.trash ? '#ef4444' : 'var(--muted)'}">${s.trash}</div><div class="label"><a href="trash.html">В корзине →</a></div></div>
${diskHtml}
`;
}
function last14days() {
const out = [];
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
return out;
}
function renderActivity(activity) {
const map = {};
activity.forEach(a => { map[a.d] = a.n; });
const days = last14days().map(d => {
const iso = d.toISOString().slice(0, 10);
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
});
const max = Math.max(1, ...days.map(x => x.n));
const todayIso = new Date().toISOString().slice(0, 10);
document.getElementById('activityChart').innerHTML = days.map(x => `
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
<div class="bar ${x.iso === todayIso ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
<div class="bar-day">${x.label}</div>
</div>`).join('');
}
function renderRecent(rows) {
const el = document.getElementById('recentList');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Записей пока нет</div>'; return; }
el.innerHTML = rows.map(e => `
<div class="recent-item">
${e.photo_path
? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">`
: `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`}
<div class="r-info">
<div class="r-name">${esc(e.student_name)}</div>
<div class="r-meta">${esc(e.group_name)} · ${new Date(e.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
</div>
<a class="r-link" href="journal.html" title="В журнал">→</a>
</div>`).join('');
}
function renderActive(rows) {
const el = document.getElementById('activeList');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Сейчас нет идущих групп</div>'; return; }
el.innerHTML = rows.map(g => `
<div class="active-item">
<span class="a-name">${esc(g.name)}</span>
<span class="a-right">
<span class="a-time">${g.day_label} ${g.time_start}–${g.time_end}</span>
<span class="a-now">сейчас</span>
</span>
</div>`).join('');
}
function renderTop(rows) {
const el = document.getElementById('topList');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Пока нет данных</div>'; return; }
el.innerHTML = rows.map((x, i) => `
<div class="top-item">
<span class="top-rank ${i > 2 ? 'alt' : ''}">${i + 1}</span>
<span class="top-name">${esc(x.student_name)}</span>
<span class="top-count">${x.n}</span>
</div>`).join('');
}
function renderPhotos(rows) {
const el = document.getElementById('dashPhotos');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Фото групп пока нет</div>'; return; }
el.innerHTML = rows.map(p => `
<div class="dash-photo" title="${esc(p.group_name)}">
<img src="${thumbSrc(p.photo_path)}" data-img="${API}${p.photo_path}" loading="lazy" decoding="async" alt="">
${p.caption ? `<div class="pcap">${esc(p.caption)}</div>` : ''}
</div>`).join('');
}
async function loadDashboard() {
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
const d = await res.json();
renderStats(d.stats, d.disk);
renderActivity(d.activity);
renderRecent(d.recent_entries);
renderActive(d.active_groups);
renderTop(d.top_students);
renderPhotos(d.photos);
renderIcons();
}
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadDashboard();
}
})();
+20
View File
@@ -0,0 +1,20 @@
(function(){
const params = new URLSearchParams(location.search);
const code = params.get('code') || '404';
const title = params.get('title') || '';
const message = params.get('message') || '';
const details = params.get('details') || '';
document.getElementById('errorCode').textContent = code;
document.getElementById('errorTitle').textContent = title || (code === '404' ? 'Страница не найдена' : 'Ошибка');
document.getElementById('errorMessage').textContent = message || (code === '404' ? 'Запрашиваемая страница не существует или была перемещена.' : 'Произошла ошибка на сервере.');
if (details) {
document.getElementById('errorDetails').style.display = 'block';
document.getElementById('errorStack').textContent = details;
}
})();
document.querySelector('.error-actions button').addEventListener('click', () => history.back());
fetch('/api/public-settings').then(r => r.ok ? r.json() : null).then(s => {
if (s && (s.system_name || '').trim()) document.title = `Ошибка — ${s.system_name}`;
}).catch(() => {});
+172
View File
@@ -0,0 +1,172 @@
const PAGE_SIZE = 20;
let page = 1;
let totalFiles = 0;
let currentFiles = [];
let groupsLoaded = false;
let studentsLoaded = false;
let searchTimer = null;
let mode = 'attached';
function setMode(m) {
mode = m === 'detached' ? 'detached' : 'attached';
document.querySelectorAll('#tabToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
const show = mode === 'attached';
const keep = ['searchInput', 'studentFilter', 'groupFilter', 'dateFrom', 'dateTo'].map(id => document.getElementById(id));
if (!show) { keep.forEach(el => { el.value = ''; }); }
keep.forEach(el => { el.parentElement.style.display = show ? '' : 'none'; });
page = 1;
loadFiles();
}
function isImageFile(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function humanSize(b) {
if (!b) return '';
if (b < 1024) return b + ' Б';
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
return (b / 1048576).toFixed(1) + ' МБ';
}
function fileCardHTML(f) {
const url = `${API}/api/files/${f.token}`;
const thumb = isImageFile(f.name)
? `<img src="${thumbSrc(url)}" data-img="${esc(url)}" loading="lazy" decoding="async" alt="">`
: `<div class="file-icon">📄</div>`;
const target = isImageFile(f.name)
? `href="#" data-img="${esc(url)}"`
: `href="${url}" download`;
const meta = mode === 'detached'
? `<div class="group" style="color:#f59e0b">Откреплён от записи, не привязан</div>`
: `<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>`;
const action = mode === 'detached'
? `<button class="f-action f-del" data-delete="${f.id}" title="Удалить файл навсегда">Удалить</button>`
: `<button class="f-action" data-detach="${f.id}" title="Открепить файл от записи">Открепить</button>`;
return `
<div class="card">
${thumb}
<div class="info">
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
${meta}
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
</div>
<div class="f-actions">${action}</div>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!currentFiles.length) {
grid.className = 'grid';
grid.innerHTML = mode === 'detached'
? '<div class="empty">Откреплённых файлов нет</div>'
: '<div class="empty">Файлов не найдено</div>';
return;
}
grid.className = 'grid';
grid.innerHTML = currentFiles.map(fileCardHTML).join('');
}
async function detachFile(id) {
if (!confirm('Открепить файл от записи? Он переместится во вкладку «Откреплённые».')) return;
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
if (res.ok) { showToast('Файл откреплён'); loadFiles(); }
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
}
async function deleteFile(id) {
if (!confirm('Удалить файл навсегда? Отменить это действие нельзя.')) return;
const res = await fetch(`${API}/api/files/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Файл удалён'); loadFiles(); }
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
}
async function loadGroupOptions() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
while (sel.options.length > 1) sel.remove(1);
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id; o.textContent = g.name;
sel.appendChild(o);
});
groupsLoaded = true;
}
async function loadStudentOptions() {
if (studentsLoaded) return;
const dl = document.getElementById('studentFilterList');
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
async function loadFiles() {
const p = new URLSearchParams();
if (mode === 'attached') {
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
}
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/files${mode === 'detached' ? '/detached' : ''}?${p}`, { headers: hdr() });
const data = await res.json();
currentFiles = data.files;
totalFiles = data.total;
renderGrid();
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalFiles / PAGE_SIZE)), goPage);
}
function goPage(p) {
page = p;
loadFiles();
}
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadFiles(); }, 300);
});
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
document.querySelectorAll('#tabToggle button').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
document.getElementById('grid').addEventListener('click', e => {
const dt = e.target.closest('[data-detach]');
if (dt) { detachFile(parseInt(dt.dataset.detach, 10)); return; }
const del = e.target.closest('[data-delete]');
if (del) { deleteFile(parseInt(del.dataset.delete, 10)); }
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
const detachedTab = document.getElementById('detachedTab');
if (detachedTab) detachedTab.style.display = isAdmin() ? '' : 'none';
if (mode === 'detached' && !isAdmin()) { mode = 'attached'; }
document.querySelectorAll('#tabToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
loadGroupOptions();
loadStudentOptions();
loadFiles();
}
})();
+449
View File
@@ -0,0 +1,449 @@
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
let branchesCache = [];
let tutorsCache = [];
let allGroups = [];
let currentCover = null;
async function loadBranchesCache() {
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
if (res.ok) branchesCache = await res.json();
}
async function loadTutorsCache() {
const res = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
if (res.ok) tutorsCache = await res.json();
}
function tutorSelectHTML(selectedId) {
const opts = tutorsCache.map(t => `<option value="${t.id}" ${t.id === selectedId ? 'selected' : ''}>${esc(t.name || t.username)}</option>`).join('');
return `<div><label>Тутор</label><select class="tutor-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— не назначен —</option>${opts}</select></div>`;
}
function branchSelectHTML(selectedId) {
const opts = branchesCache.map(b => `<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
return `<div><label>Филиал</label><select class="branch-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— без филиала —</option>${opts}</select></div>`;
}
async function loadGroups() {
await loadBranchesCache();
await loadTutorsCache();
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
allGroups = groups;
const list = document.getElementById('groupList');
if (!groups.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Нет групп</div>'; return; }
list.innerHTML = `<div class="group-grid">` + groups.map(g => {
const schedSet = g.day_of_week != null && g.time_start && g.time_end;
return `
<div class="group-card" data-group-id="${g.id}">
${g.cover_path
? `<img class="group-cover" src="${thumbSrc(g.cover_path)}" loading="lazy" decoding="async" alt="">`
: `<div class="group-cover empty-cover">📷</div>`}
<div class="group-head">
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
<button class="del" data-del-group="${g.id}" title="Удалить">&times;</button>
</div>
<div class="group-schedule">
${branchSelectHTML(g.branch_id)}
${tutorSelectHTML(g.tutor_id)}
<div><label>День</label>
<select id="dow-${g.id}">
<option value="">—</option>
${DAYS.map((d, i) => `<option value="${i}" ${g.day_of_week === i ? 'selected' : ''}>${d}</option>`).join('')}
</select>
</div>
<div><label>С</label>
<input type="time" id="ts-${g.id}" value="${g.time_start ? g.time_start.slice(0,5) : ''}">
</div>
<div><label>По</label>
<input type="time" id="te-${g.id}" value="${g.time_end ? g.time_end.slice(0,5) : ''}">
</div>
</div>
<div class="group-foot">
<div style="display:flex;gap:8px">
<button class="save-sched" data-save-group="${g.id}">Сохранить</button>
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">Фото</button>
<button class="btn-link" data-export-group="${g.id}" data-group-name="${esc(g.name)}" title="Экспортировать записи группы">CSV</button>
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
</div>
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
</div>
</div>`;
}).join('') + `</div>`;
}
async function saveGroup(id) {
const name = document.getElementById(`name-${id}`).value.trim();
if (!name) return;
const nameInput = document.getElementById(`name-${id}`);
const groupCard = nameInput.closest('.group-card');
const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null;
const branch_id = branchSelect ? branchSelect.value : '';
const tutorSelect = groupCard ? groupCard.querySelector('.tutor-select') : null;
const tutor_id = tutorSelect ? (tutorSelect.value || null) : undefined;
const dow = document.getElementById(`dow-${id}`).value;
const ts = document.getElementById(`ts-${id}`).value;
const te = document.getElementById(`te-${id}`).value;
const res = await fetch(`${API}/api/groups/${id}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
});
if (res.ok) {
loadGroups();
const dayLabel = dow !== '' ? DAYS[dow] : 'без расписания';
const timeLabel = ts && te ? `, ${ts}–${te}` : '';
showToast(`Сохранено: «${name}» — ${dayLabel}${timeLabel}`);
} else { const e = await res.json(); alert(e.error); }
}
async function addGroup() {
const input = document.getElementById('newGroup');
const name = input.value.trim();
if (!name) return;
await loadBranchesCache();
await loadTutorsCache();
const branchSelect = document.getElementById('newGroupBranch');
const branch_id = branchSelect ? branchSelect.value : '';
const tutorSelect = document.getElementById('newGroupTutor');
const tutor_id = tutorSelect && tutorSelect.value ? tutorSelect.value : null;
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id }) });
if (res.ok) { input.value = ''; loadGroups(); }
else { const e = await res.json(); alert(e.error); }
}
async function delGroup(id) {
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
loadGroups();
}
const PHOTO_PAGE_SIZE = 24;
let currentGroupId = null;
let currentGroupName = '';
let photos = [];
let photoPage = 1;
let totalPhotos = 0;
let editingPhotoId = null;
async function openPhotos(groupId, groupName) {
currentGroupId = groupId;
currentGroupName = groupName;
const g = allGroups.find(x => String(x.id) === String(groupId));
currentCover = g ? g.cover_path : null;
photoPage = 1;
editingPhotoId = null;
document.getElementById('photosTitle').textContent = `Фото — ${groupName}`;
document.getElementById('photosModal').classList.add('open');
await loadGroupPhotos();
}
function closePhotos() {
document.getElementById('photosModal').classList.remove('open');
currentGroupId = null;
}
async function loadGroupPhotos() {
const p = new URLSearchParams();
p.set('limit', PHOTO_PAGE_SIZE);
p.set('offset', (photoPage - 1) * PHOTO_PAGE_SIZE);
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos?${p}`, { headers: hdr() });
const data = await res.json();
photos = data.photos;
totalPhotos = data.total;
document.getElementById('photoGrid').className = 'photo-grid';
document.getElementById('photoGrid').innerHTML = photos.length
? photos.map(photoHTML).join('')
: '<div class="empty" style="grid-column:1/-1">Фото пока нет. Загрузите первое фото.</div>';
renderPager(document.getElementById('photosPager'), photoPage, Math.max(1, Math.ceil(totalPhotos / PHOTO_PAGE_SIZE)), goPhotoPage);
}
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; }
function photoHTML(p) {
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
const isCover = currentCover && p.photo_path === currentCover;
return `
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
<div class="drag-hint" title="Перетащите, чтобы изменить порядок">⠿</div>
<img src="${thumbSrc(p.photo_path)}" data-img="${API}${p.photo_path}" loading="lazy" decoding="async" alt="">
<div class="p-cap">${esc(p.caption || '')}</div>
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
<div class="p-actions">
<button class="p-cover${isCover ? ' active' : ''}" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" title="${isCover ? 'Текущая обложка' : 'Сделать обложкой'}" ${isCover ? 'disabled' : ''}>🖼️</button>
<button class="p-edit" data-edit-photo="${p.id}" title="Редактировать">✎</button>
<button class="p-del" data-del-photo="${p.id}" title="Удалить">&times;</button>
</div>
</div>
`;
}
function openAddPhoto() {
editingPhotoId = null;
document.getElementById('photoFormTitle').textContent = `Добавить фото — ${currentGroupName}`;
document.getElementById('photoFile').value = '';
document.getElementById('photoCaption').value = '';
document.getElementById('photoTakenAt').value = '';
document.getElementById('photoFormPreview').style.display = 'none';
document.getElementById('photoFormModal').classList.add('open');
}
async function openEditPhoto(id) {
const p = photos.find(x => x.id === id);
if (!p) return;
editingPhotoId = id;
document.getElementById('photoFormTitle').textContent = 'Редактировать фото';
document.getElementById('photoFile').value = '';
document.getElementById('photoCaption').value = p.caption || '';
document.getElementById('photoTakenAt').value = p.taken_at || '';
const prev = document.getElementById('photoFormPreview');
const img = document.getElementById('photoPreviewImg');
img.src = `${API}${p.photo_path}`;
prev.style.display = 'block';
document.getElementById('photoFormModal').classList.add('open');
}
function closePhotoForm() {
document.getElementById('photoFormModal').classList.remove('open');
editingPhotoId = null;
}
function previewPhotoFile() {
const file = document.getElementById('photoFile').files[0];
const prev = document.getElementById('photoFormPreview');
if (!file) { prev.style.display = 'none'; return; }
const img = document.getElementById('photoPreviewImg');
img.src = URL.createObjectURL(file);
prev.style.display = 'block';
}
async function savePhotoForm() {
const caption = document.getElementById('photoCaption').value.trim();
const taken_at = document.getElementById('photoTakenAt').value;
if (editingPhotoId) {
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${editingPhotoId}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ caption: caption || null, taken_at: taken_at || null })
});
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
showToast('Фото обновлено');
} else {
const file = document.getElementById('photoFile').files[0];
if (!file) { alert('Выберите файл'); return; }
const fd = new FormData();
fd.append('photo', file);
fd.append('caption', caption);
fd.append('taken_at', taken_at);
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos`, {
method: 'POST',
headers: hdr(),
body: fd
});
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
showToast('Фото добавлено в хронологию');
}
closePhotoForm();
loadGroupPhotos();
loadGroups();
}
async function deletePhoto(id) {
if (!confirm('Удалить это фото безвозвратно?')) return;
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${id}`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
const p = photos.find(x => x.id === id);
if (p && currentCover === p.photo_path) currentCover = null;
showToast('Фото удалено');
loadGroupPhotos();
loadGroups();
}
async function exportGroup(id, name) {
const res = await fetch(`${API}/api/entries?group_id=${id}`, { headers: hdr() });
const { entries } = await res.json();
if (!entries.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия Имя', 'Группа', 'Описание', 'Дата', 'Ссылка на страницу']];
entries.forEach(e => {
rows.push([
e.student_name,
e.group_name,
e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'),
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
]);
});
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `group_${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast(`Экспортировано записей: ${entries.length}`);
}
async function exportGroupUrls(groupId, groupName) {
const res = await fetch(`${API}/api/students`, { headers: hdr() });
const students = await res.json();
const list = students.filter(s => s.group_id === groupId);
if (!list.length) { alert('В группе нет учеников'); return; }
await exportStudentUrlZip(list, `group_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
showToast(`Экспортировано: ${list.length}`);
}
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
document.getElementById('addGroupBtn').addEventListener('click', addGroup);
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
document.getElementById('photoForm').addEventListener('submit', e => {
e.preventDefault();
savePhotoForm();
});
document.getElementById('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); });
document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); });
document.getElementById('openPhotoBtn').addEventListener('click', openAddPhoto);
document.getElementById('closePhotosBtn').addEventListener('click', closePhotos);
document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm);
document.getElementById('groupList').addEventListener('click', e => {
const sg = e.target.closest('[data-save-group]');
if (sg) { saveGroup(parseInt(sg.dataset.saveGroup, 10)); return; }
const dg = e.target.closest('[data-del-group]');
if (dg) { delGroup(parseInt(dg.dataset.delGroup, 10)); return; }
const eg = e.target.closest('[data-export-group]');
if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; }
const eu = e.target.closest('[data-export-urls]');
if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; }
const photoBtn = e.target.closest('.photo-btn');
if (photoBtn) {
const id = parseInt(photoBtn.dataset.groupId, 10);
const name = photoBtn.dataset.groupName;
if (!isNaN(id)) openPhotos(id, name);
}
});
function updateGroupCoverUI(coverPath) {
const card = document.querySelector(`.group-card[data-group-id="${currentGroupId}"]`);
if (!card) return;
const g = allGroups.find(x => String(x.id) === String(currentGroupId));
const existing = card.querySelector('.group-cover');
if (existing && existing.tagName === 'IMG') { existing.src = API + coverPath; return; }
const el = document.createElement('img');
el.className = 'group-cover';
el.src = API + coverPath;
el.alt = '';
if (existing) existing.replaceWith(el);
else card.insertBefore(el, card.firstChild);
}
let dragPhotoId = null;
async function persistPhotoOrder() {
const items = [...document.querySelectorAll('#photoGrid .photo-item')];
const order = items.map(el => parseInt(el.dataset.photoId, 10));
if (JSON.stringify(order) === JSON.stringify(photos.map(p => p.id))) return;
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/reorder`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ order })
});
if (res.ok) showToast('Порядок фото сохранён');
else {
try { const err = await res.json(); alert(err.error || 'Ошибка'); } catch { alert('Ошибка'); }
loadGroupPhotos();
}
}
document.getElementById('photoGrid').addEventListener('dragstart', e => {
const item = e.target.closest('.photo-item');
if (!item) return;
e.stopPropagation();
dragPhotoId = parseInt(item.dataset.photoId, 10);
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(dragPhotoId));
});
document.getElementById('photoGrid').addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest('.photo-item');
const dragged = dragPhotoId != null ? document.querySelector(`.photo-item[data-photo-id="${dragPhotoId}"]`) : null;
if (!target || !dragged || target === dragged) return;
if (target.contains(dragged)) return;
const rect = target.getBoundingClientRect();
const grid = document.getElementById('photoGrid');
grid.insertBefore(dragged, e.clientX > rect.left + rect.width / 2 ? target.nextSibling : target);
});
document.getElementById('photoGrid').addEventListener('dragend', e => {
const item = e.target.closest('.photo-item');
if (item) item.classList.remove('dragging');
if (dragPhotoId != null) persistPhotoOrder();
dragPhotoId = null;
});
document.getElementById('photoGrid').addEventListener('drop', e => e.preventDefault());
document.getElementById('photoGrid').addEventListener('click', async e => {
const coverBtn = e.target.closest('.p-cover');
if (coverBtn) {
const photoId = parseInt(coverBtn.dataset.photoId, 10);
const photoPath = coverBtn.dataset.photoPath;
if (!isNaN(photoId)) {
try {
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${photoId}/cover`, {
method: 'PUT',
headers: hdrJson(),
});
if (res.ok) {
const g = await res.json();
currentCover = g.cover_path || null;
const grp = allGroups.find(x => String(x.id) === String(currentGroupId));
if (grp) grp.cover_path = currentCover;
showToast('Обложка обновлена');
updateGroupCoverUI(currentCover);
loadGroupPhotos();
} else {
const err = await res.json();
alert(err.error || 'Ошибка');
}
} catch (err) {
alert('Ошибка сети: ' + err.message);
}
}
return;
}
const ed = e.target.closest('[data-edit-photo]');
if (ed) { openEditPhoto(parseInt(ed.dataset.editPhoto, 10)); return; }
const dl = e.target.closest('[data-del-photo]');
if (dl) { deletePhoto(parseInt(dl.dataset.delPhoto, 10)); }
});
async function populateNewGroupBranch() {
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
if (res.ok) {
const branches = await res.json();
const sel = document.getElementById('newGroupBranch');
sel.innerHTML = '<option value="">— без филиала —</option>' + branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
}
const tRes = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
if (tRes.ok) {
const tutors = await tRes.json();
const tSel = document.getElementById('newGroupTutor');
tSel.innerHTML = '<option value="">— не назначен —</option>' + tutors.map(t => `<option value="${t.id}">${esc(t.name || t.username)}</option>`).join('');
}
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
await populateNewGroupBranch();
loadGroups();
}
})();
+341
View File
@@ -0,0 +1,341 @@
const API = location.origin;
const form = document.getElementById('form');
const preview = document.getElementById('preview');
const placeholderSrc = preview.src;
const removePhotoBtn = document.getElementById('removePhotoBtn');
const groupInput = document.getElementById('groupInput');
const moduleInput = document.getElementById('moduleInput');
const moduleInputList = document.getElementById('moduleInputList');
const moduleField = document.getElementById('moduleField');
const moduleNames = new Map();
let moduleSearchTimer = null;
const toast = document.getElementById('toast');
const camVideo = document.getElementById('camVideo');
const camCanvas = document.getElementById('camCanvas');
const camModal = document.getElementById('camModal');
let capturedBlob = null;
let stream = null;
const descInput = document.getElementById('descInput');
const filesInput = document.getElementById('filesInput');
const filesList = document.getElementById('filesList');
const filesClearBtn = document.getElementById('filesClearBtn');
let selectedFiles = [];
const sentPanel = document.getElementById('sentPanel');
const sentTitle = document.getElementById('sentTitle');
const sentText = document.getElementById('sentText');
const sentTimer = document.getElementById('sentTimer');
const SENT_KEY = 'whatido_entry_sent_at';
let entryCooldownMin = 30;
let captureWidth = 640;
let captureHeight = 480;
let captureQuality = 0.92;
let sentTimerId = null;
function unlockForm() {
clearInterval(sentTimerId);
sentTimerId = null;
localStorage.removeItem(SENT_KEY);
sentPanel.style.display = 'none';
form.style.display = 'grid';
toast.textContent = 'Можно отправить новую запись';
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
}
function tickSentTimer(unlockTime) {
const remain = Math.max(0, unlockTime - Date.now());
if (remain <= 0) {
sentTimer.textContent = '';
unlockForm();
return;
}
const m = Math.floor(remain / 60000);
const s = Math.floor((remain % 60000) / 1000);
sentTimer.textContent = 'Новую запись можно отправить через ' + m + ' мин ' + s + ' сек';
}
function showSent(title, text, unlockTime) {
form.style.display = 'none';
sentPanel.style.display = 'flex';
sentTitle.textContent = title;
sentText.textContent = text;
tickSentTimer(unlockTime);
clearInterval(sentTimerId);
sentTimerId = setInterval(() => tickSentTimer(unlockTime), 1000);
}
function cooldownMs() { return entryCooldownMin * 60 * 1000; }
function checkPreviousSend() {
const raw = localStorage.getItem(SENT_KEY);
if (!raw) return;
const sentAt = parseInt(raw, 10);
if (!Number.isFinite(sentAt)) { localStorage.removeItem(SENT_KEY); return; }
const unlockTime = sentAt + cooldownMs();
if (Date.now() < unlockTime) {
showSent('Запись уже отправлена', 'Ты уже отправил(а) запись сегодня. Дождись окончания отсчёта, чтобы отправить новую.', unlockTime);
} else {
localStorage.removeItem(SENT_KEY);
}
}
function humanSize(b) {
if (b < 1024) return b + ' Б';
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
return (b / 1048576).toFixed(1) + ' МБ';
}
function renderFiles() {
filesList.innerHTML = '';
selectedFiles.forEach((f, i) => {
const row = document.createElement('div');
row.className = 'file-row';
const name = document.createElement('span');
name.className = 'f-name';
name.textContent = f.name;
name.title = f.name;
const size = document.createElement('span');
size.className = 'f-size';
size.textContent = humanSize(f.size);
const del = document.createElement('button');
del.type = 'button';
del.textContent = '✕';
del.addEventListener('click', () => removeFile(i));
row.append(name, size, del);
filesList.appendChild(row);
});
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
}
function removeFile(i) {
selectedFiles.splice(i, 1);
renderFiles();
}
function clearFiles() {
selectedFiles = [];
filesInput.value = '';
renderFiles();
}
filesInput.addEventListener('change', () => {
const keep = 10 - selectedFiles.length;
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
filesInput.value = '';
renderFiles();
});
const params = new URLSearchParams(location.search);
let studentName = '';
function escName(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
if (params.get('nameInput')) {
studentName = params.get('nameInput');
document.getElementById('nameInput').value = studentName;
const field = document.getElementById('nameField');
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
}
function renderModuleOptions(list) {
list.forEach(m => moduleNames.set(m.name, m.id));
moduleInputList.innerHTML = '';
list.forEach(m => {
const opt = document.createElement('option');
opt.value = m.name;
moduleInputList.appendChild(opt);
});
}
async function fetchModules(q, limit) {
const p = new URLSearchParams();
if (q) p.set('search', q);
p.set('limit', String(limit));
p.set('active', '1');
try {
const res = await fetch(`${API}/api/modules?${p}`);
if (!res.ok) return [];
const data = await res.json();
return data.modules || [];
} catch (e) { return []; }
}
async function resolveModuleId(name) {
const n = (name || '').trim();
if (!n) return null;
if (moduleNames.has(n)) return moduleNames.get(n);
const list = await fetchModules(n, 5);
renderModuleOptions(list);
const lower = n.toLowerCase();
const exact = list.find(m => m.name.toLowerCase() === lower);
return exact ? exact.id : null;
}
(async () => {
try {
const list = await fetchModules('', 50);
if (list.length) {
renderModuleOptions(list);
moduleInput.required = true;
moduleField.style.display = '';
}
} catch (e) { /* ignore */ }
const res = await fetch(`${API}/api/students`);
const students = await res.json();
const me = studentName ? students.find(s => s.name === studentName) : null;
if (me && me.group_id) {
groupInput.innerHTML = `<option value="${me.group_id}" selected>${escName(me.group_name || `Группа ${me.group_id}`)}</option>`;
groupInput.value = String(me.group_id);
} else {
const groupsRes = await fetch(`${API}/api/groups/active`);
const groups = await groupsRes.json();
groups.forEach(g => {
const opt = document.createElement('option');
opt.value = g.id;
opt.textContent = g.name;
groupInput.appendChild(opt);
});
if (!groups.length) {
groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>';
}
}
try {
const fRes = await fetch(`${API}/api/public-settings`);
const f = await fRes.json();
const title = document.getElementById('title');
if (title && (f.system_name || '').trim()) {
title.textContent = f.system_name;
document.title = f.system_name;
}
const brandLogo = document.getElementById('brandLogo');
if (brandLogo && f.system_logo) {
brandLogo.src = f.system_logo;
brandLogo.hidden = false;
brandLogo.alt = f.system_name || '';
}
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
const c = parseInt(f.spam_interval_min, 10);
if (Number.isFinite(c) && c >= 1) entryCooldownMin = c;
const w = parseInt(f.photo_capture_width, 10);
const h = parseInt(f.photo_capture_height, 10);
if (Number.isFinite(w) && w >= 160 && w <= 4096) captureWidth = w;
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
const q = parseFloat(f.photo_capture_quality);
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
} catch (e) { /* ignore */ }
checkPreviousSend();
})();
function showPreview(src) {
preview.src = src;
preview.classList.add('has-photo');
removePhotoBtn.classList.add('show');
}
function removePhoto() {
preview.src = placeholderSrc;
preview.classList.remove('has-photo');
removePhotoBtn.classList.remove('show');
capturedBlob = null;
}
async function openCam() {
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
camVideo.srcObject = stream;
camModal.classList.add('open');
} catch (e) {
alert('Не удалось открыть камеру: ' + e.message);
}
}
function closeCam() {
camModal.classList.remove('open');
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
}
function capture() {
camCanvas.width = camVideo.videoWidth;
camCanvas.height = camVideo.videoHeight;
const ctx = camCanvas.getContext('2d');
ctx.translate(camCanvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(camVideo, 0, 0);
camCanvas.toBlob(blob => {
capturedBlob = blob;
showPreview(URL.createObjectURL(blob));
closeCam();
}, 'image/jpeg', captureQuality);
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!capturedBlob) {
toast.textContent = 'Сначала сделайте фотографию';
toast.classList.add('show', 'error');
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
return;
}
let moduleId = '';
if (moduleField.style.display !== 'none') {
moduleId = await resolveModuleId(moduleInput.value);
if (!moduleId) {
toast.textContent = 'Выберите тему модуля из списка подсказок';
toast.classList.add('show', 'error');
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
return;
}
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = 'Отправка...';
const fd = new FormData();
fd.append('photo', capturedBlob, 'photo.jpg');
selectedFiles.forEach(f => fd.append('files', f, f.name));
fd.append('student_name', studentName || document.getElementById('nameInput').value);
fd.append('group_id', groupInput.value);
fd.append('module_id', moduleId || '');
fd.append('description', document.getElementById('descInput').value);
fd.append('website', document.getElementById('hpWebsite').value);
try {
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
if (res.status === 429) {
const e = await res.json().catch(() => ({}));
toast.textContent = e.error || 'Уже ответили: подождите';
toast.classList.add('show', 'error');
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
return;
}
if (!res.ok) throw new Error(await res.text());
form.reset();
removePhoto();
clearFiles();
localStorage.setItem(SENT_KEY, String(Date.now()));
showSent('Запись отправлена!', 'Спасибо! Твоя запись уже передана преподавателю.', Date.now() + cooldownMs());
} catch (err) {
alert('Ошибка: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Отправить';
}
});
removePhotoBtn.addEventListener('click', removePhoto);
document.getElementById('openCamBtn').addEventListener('click', openCam);
filesClearBtn.addEventListener('click', clearFiles);
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
document.getElementById('captureBtn').addEventListener('click', capture);
moduleInput.addEventListener('input', () => {
clearTimeout(moduleSearchTimer);
const q = moduleInput.value.trim();
moduleSearchTimer = setTimeout(async () => {
renderModuleOptions(await fetchModules(q, 20));
}, 250);
});
moduleInput.addEventListener('focus', async () => {
if (!moduleInput.value.trim()) renderModuleOptions(await fetchModules('', 50));
});
+1543
View File
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
let links = [];
let groups = [];
let editingId = null;
let studentsLoaded = false;
let linkSettings = null;
let linkPage = 1;
const LINK_PAGE_SIZE = 20;
async function loadLinkSettings() {
if (!linkSettings) {
const res = await fetch(`${API}/api/settings`, { headers: hdr() });
linkSettings = await res.json().catch(() => ({}));
}
return linkSettings;
}
function linkUrl(token) { return `${location.origin}/s/${token}`; }
async function loadLinks() {
await loadLinkSettings();
const p = new URLSearchParams();
p.set('limit', LINK_PAGE_SIZE);
p.set('offset', (linkPage - 1) * LINK_PAGE_SIZE);
const res = await fetch(`${API}/api/links?${p}`, { headers: hdr() });
const data = await res.json();
links = data.items;
const total = data.total;
const el = document.getElementById('linksList');
if (!links.length) { el.innerHTML = `<div class="empty">${linkPage > 1 ? 'На этой странице ссылок нет.' : 'Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».'}</div>`; renderPager(document.getElementById('pager'), 1, 1, goLinkPage); return; }
const now = Date.now();
el.innerHTML = links.map(l => {
const namesHidden = l.show_student_names != null ? !l.show_student_names : linkSettings.share_show_student_names === 'false';
const expired = l.expires_at && new Date(l.expires_at).getTime() < now;
const daysLeft = l.expires_at ? Math.ceil((new Date(l.expires_at).getTime() - now) / 86400000) : null;
const soon = !expired && daysLeft !== null && daysLeft <= 1;
return `
<div class="item${expired ? ' expired' : ''}">
<div style="min-width:0">
<div class="name">${esc(l.name)}${expired ? ' <span class="badge-expired">истекла</span>' : soon ? ' <span class="badge-soon">истекает</span>' : ''}</div>
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${new Date(l.expires_at).toLocaleDateString('ru')}</span>` : l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
· создана ${new Date(l.created_at).toLocaleString('ru')}
</div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
<a class="btn-link" style="text-decoration:none" href="/s/${l.token}" target="_blank" title="Открыть">Открыть</a>
<a class="btn-link" style="text-decoration:none" href="/r/${l.token}" target="_blank" title="Отчёт ученика">Отчёт</a>
<button class="btn-link" data-copy="${esc(linkUrl(l.token))}" title="Копировать ссылку">Копировать</button>
<button class="btn-link" data-edit-link="${l.id}" title="Редактировать">✎</button>
<button class="btn-link" style="color:var(--muted)" data-del-link="${l.id}" title="Удалить">&times;</button>
</div>
</div>
`;}).join('');
renderPager(document.getElementById('pager'), linkPage, Math.max(1, Math.ceil(total / LINK_PAGE_SIZE)), goLinkPage);
}
function goLinkPage(p) {
linkPage = p;
loadLinks();
}
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
async function openLinkModal() {
editingId = null;
document.getElementById('linkModalTitle').textContent = 'Новая ссылка';
document.getElementById('linkName').value = '';
document.getElementById('linkDateFrom').value = '';
document.getElementById('linkDateTo').value = '';
document.getElementById('linkExpiresDays').value = '7';
document.getElementById('linkShowNames').checked = false;
document.getElementById('linkPassword').value = '';
document.getElementById('linkMessage').value = '';
document.getElementById('linkUrl').value = '';
const s = await loadLinkSettings();
document.getElementById('linkShowNames').checked = s.share_show_student_names !== 'false';
document.getElementById('linkShowMessage').checked = s.share_show_student_message === 'true';
document.getElementById('linkShowDate').checked = s.share_show_entry_date === 'true';
document.getElementById('linkShowPhotos').checked = s.share_show_group_photos !== 'false';
if (!groups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
groups = await res.json();
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
if (!studentsLoaded) {
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
const dl = document.getElementById('linkStudentList');
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
document.getElementById('linkModal').classList.add('open');
}
async function openLinkEdit(id) {
const l = links.find(x => x.id === id);
if (!l) return;
editingId = id;
document.getElementById('linkModalTitle').textContent = 'Редактирование ссылки';
document.getElementById('linkName').value = l.name;
document.getElementById('linkDateFrom').value = l.date_from || '';
document.getElementById('linkDateTo').value = l.date_to || '';
document.getElementById('linkExpiresDays').value = l.expires_at ? Math.ceil((new Date(l.expires_at) - new Date()) / (1000 * 60 * 60 * 24)) : '7';
document.getElementById('linkShowNames').checked = l.show_student_names != null ? !!l.show_student_names : true;
document.getElementById('linkPassword').value = '';
document.getElementById('linkMessage').value = l.message || '';
document.getElementById('linkUrl').value = l.link_url || '';
const s = await loadLinkSettings();
document.getElementById('linkShowNames').checked = l.show_student_names != null ? !!l.show_student_names : s.share_show_student_names !== 'false';
document.getElementById('linkShowMessage').checked = l.show_student_message != null ? !!l.show_student_message : s.share_show_student_message === 'true';
document.getElementById('linkShowDate').checked = l.show_entry_date != null ? !!l.show_entry_date : s.share_show_entry_date === 'true';
document.getElementById('linkShowPhotos').checked = l.show_group_photos != null ? !!l.show_group_photos : s.share_show_group_photos !== 'false';
if (!groups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
groups = await res.json();
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
document.getElementById('linkGroup').value = l.group_id || '';
if (!studentsLoaded) {
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
const dl = document.getElementById('linkStudentList');
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
document.getElementById('linkStudent').value = l.student_name || '';
document.getElementById('linkModal').classList.add('open');
}
function closeLinkModal() {
document.getElementById('linkModal').classList.remove('open');
editingId = null;
}
async function saveLink() {
const name = document.getElementById('linkName').value.trim();
const group_id = document.getElementById('linkGroup').value;
const student_name = document.getElementById('linkStudent').value;
const date_from = document.getElementById('linkDateFrom').value;
const date_to = document.getElementById('linkDateTo').value;
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
const show_student_names = document.getElementById('linkShowNames').checked;
const access_password = document.getElementById('linkPassword').value;
const message = document.getElementById('linkMessage').value.trim();
const link_url = document.getElementById('linkUrl').value.trim();
if (!name) { alert('Введите название'); return; }
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, show_student_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null, show_student_message: document.getElementById('linkShowMessage').checked, show_entry_date: document.getElementById('linkShowDate').checked, show_group_photos: document.getElementById('linkShowPhotos').checked });
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
method: editingId ? 'PUT' : 'POST',
headers: hdrJson(),
body
});
if (res.ok) {
closeLinkModal();
loadLinks();
showToast(editingId ? 'Ссылка обновлена' : 'Ссылка создана');
} else {
const err = await res.json();
alert(err.error || 'Ошибка');
}
}
async function delLink(id) {
if (!confirm('Удалить ссылку?')) return;
await fetch(`${API}/api/links/${id}`, { method: 'DELETE', headers: hdr() });
loadLinks();
showToast('Ссылка удалена');
}
document.getElementById('linkModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeLinkModal(); });
document.getElementById('linkCancelBtn').addEventListener('click', closeLinkModal);
document.getElementById('linkSaveBtn').addEventListener('click', saveLink);
document.getElementById('linksList').addEventListener('click', e => {
const cp = e.target.closest('[data-copy]');
if (cp) { copyText(cp.dataset.copy); return; }
const ed = e.target.closest('[data-edit-link]');
if (ed) { openLinkEdit(parseInt(ed.dataset.editLink, 10)); return; }
const dl = e.target.closest('[data-del-link]');
if (dl) { delLink(parseInt(dl.dataset.delLink, 10)); }
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadLinks();
}
})();
+50
View File
@@ -0,0 +1,50 @@
const API = location.origin;
async function doLogin() {
const username = document.getElementById('userInput').value.trim();
const password = document.getElementById('pwdInput').value;
if (!username || !password) return;
const err = document.getElementById('loginErr');
err.style.display = 'none';
try {
const res = await fetch(`${API}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, website: document.getElementById('hpWebsite').value }),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.token) {
sessionStorage.setItem('authToken', data.token);
location.href = 'dashboard.html';
} else {
err.textContent = data.error || 'Неверный логин или пароль';
err.style.display = 'block';
}
} catch {
err.textContent = 'Ошибка соединения';
err.style.display = 'block';
}
}
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
document.getElementById('userInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
document.querySelector('.login button').addEventListener('click', doLogin);
(async () => {
try {
const res = await fetch(`${API}/api/public-settings`);
if (!res.ok) return;
const s = await res.json();
const title = document.getElementById('loginTitle');
if (title && (s.system_name || '').trim()) title.textContent = s.system_name;
const logo = document.getElementById('loginLogo');
if (logo && s.system_logo) {
const img = document.createElement('img');
img.src = s.system_logo;
img.alt = '';
logo.appendChild(img);
logo.style.display = 'flex';
}
document.title = title ? title.textContent + ' — Вход' : document.title;
} catch { /* ignore */ }
})();
+319
View File
@@ -0,0 +1,319 @@
let modulesList = [];
let mPage = 1;
const M_PAGE_SIZE = 25;
let mSearchTimer = null;
let modulePhotoDeleteFlag = false;
function mPlural(n, one, few, many) {
n = Math.abs(n) % 100; const n1 = n % 10;
if (n > 10 && n < 20) return many;
if (n1 > 1 && n1 < 5) return few;
if (n1 === 1) return one;
return many;
}
async function loadModules() {
const p = new URLSearchParams();
const q = document.getElementById('moduleSearch').value.trim();
if (q) p.set('search', q);
p.set('limit', M_PAGE_SIZE);
p.set('offset', (mPage - 1) * M_PAGE_SIZE);
const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
modulesList = data.modules || [];
const totalPages = Math.max(1, Math.ceil(data.total / M_PAGE_SIZE));
if (mPage > totalPages) { mPage = totalPages; return loadModules(); }
renderModules(data.total);
}
function renderModules(total) {
const list = document.getElementById('moduleList');
if (!modulesList.length) {
list.innerHTML = '<div class="empty">Модулей пока нет. Нажмите «Добавить модуль».</div>';
} else {
list.innerHTML = modulesList.map(m => `
<div class="item module-item${m.is_active ? '' : ' module-off'}" data-id="${m.id}">
${m.photo_path ? `<img class="module-thumb" src="${thumbSrc(m.photo_path)}" alt="" data-img="${esc(API + m.photo_path)}">` : ''}
<div class="module-info">
<div class="module-name">${esc(m.name)}${m.is_active ? '' : ' <span class="module-badge">Отключён</span>'}</div>
<div class="module-meta">${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}</div>
</div>
<div class="student-actions">
${m.is_active
? `<button class="btn-link" type="button" data-edit-module="${m.id}">Изменить</button>
<button class="btn-link danger" type="button" data-off-module="${m.id}">Отключить</button>`
: `<button class="btn-link" type="button" data-on-module="${m.id}">Включить</button>
<button class="btn-link" type="button" data-edit-module="${m.id}">Изменить</button>`}
</div>
</div>
`).join('');
}
renderPager(document.getElementById('pager'), mPage, Math.max(1, Math.ceil(total / M_PAGE_SIZE)), p => { mPage = p; loadModules(); });
}
function resetModulePhoto(m) {
modulePhotoDeleteFlag = false;
const fileInput = document.getElementById('modulePhotoFile');
const preview = document.getElementById('modulePhotoPreview');
const delBtn = document.getElementById('modulePhotoDeleteBtn');
const note = document.getElementById('modulePhotoNote');
fileInput.value = '';
if (m && m.photo_path) {
preview.src = thumbSrc(m.photo_path);
preview.style.display = 'block';
delBtn.style.display = 'inline-block';
note.textContent = 'Загрузка новой картинки заменит текущую';
} else {
preview.removeAttribute('src');
preview.style.display = 'none';
delBtn.style.display = 'none';
note.textContent = 'jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)';
}
}
function openModuleModal(id = null) {
const modal = document.getElementById('moduleModal');
const form = document.getElementById('moduleForm');
const title = document.getElementById('moduleModalTitle');
const submitBtn = document.getElementById('moduleSubmitBtn');
form.reset();
document.getElementById('moduleId').value = '';
document.getElementById('moduleLessons').value = '0';
if (id) {
const m = modulesList.find(x => x.id === id);
if (!m) return;
title.textContent = 'Редактировать модуль';
submitBtn.textContent = 'Сохранить';
form.name.value = m.name;
document.getElementById('moduleLessons').value = String(m.lessons_count);
document.getElementById('moduleId').value = m.id;
resetModulePhoto(m);
} else {
title.textContent = 'Создать модуль';
submitBtn.textContent = 'Создать';
resetModulePhoto(null);
}
modal.classList.add('open');
setTimeout(() => form.name.focus(), 100);
}
function previewModulePhotoFile() {
const file = document.getElementById('modulePhotoFile').files[0];
const preview = document.getElementById('modulePhotoPreview');
const delBtn = document.getElementById('modulePhotoDeleteBtn');
const note = document.getElementById('modulePhotoNote');
if (!file) return;
modulePhotoDeleteFlag = false;
preview.src = URL.createObjectURL(file);
preview.style.display = 'block';
delBtn.style.display = 'none';
note.textContent = 'Новая картинка будет сохранена после нажатия «Сохранить»';
}
function removeModulePhoto() {
const preview = document.getElementById('modulePhotoPreview');
const delBtn = document.getElementById('modulePhotoDeleteBtn');
const note = document.getElementById('modulePhotoNote');
if (!confirm('Удалить картинку модуля?')) return;
modulePhotoDeleteFlag = true;
document.getElementById('modulePhotoFile').value = '';
preview.removeAttribute('src');
preview.style.display = 'none';
delBtn.style.display = 'none';
note.textContent = 'Картинка будет удалена после нажатия «Сохранить»';
}
function pasteModulePhotoFile(e) {
const modal = document.getElementById('moduleModal');
if (!modal.classList.contains('open')) return;
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (const item of items) {
if (item.type && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (!file) continue;
e.preventDefault();
const dt = new DataTransfer();
dt.items.add(file);
const input = document.getElementById('modulePhotoFile');
input.files = dt.files;
previewModulePhotoFile();
return;
}
}
}
function closeModuleModal() {
document.getElementById('moduleModal').classList.remove('open');
}
async function disableModule(id) {
const m = modulesList.find(x => x.id === id);
const msg = m && m.entries_count > 0
? `Отключить модуль «${m.name}»? Он станет недоступен для выбора, у ${m.entries_count} связанных записей тема сохранится.`
: `Отключить модуль${m ? ` «${m.name}»` : ''}? Он станет недоступен для выбора.`;
if (!confirm(msg)) return;
const res = await fetch(`${API}/api/modules/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
showToast('Модуль отключён');
loadModules();
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка');
}
}
async function enableModule(id) {
const res = await fetch(`${API}/api/modules/${id}/restore`, { method: 'PUT', headers: hdr() });
if (res.ok) {
showToast('Модуль включён');
loadModules();
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка');
}
}
document.getElementById('moduleForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const id = document.getElementById('moduleId').value;
const name = form.name.value.trim();
const lessons_count = parseInt(form.lessons_count.value, 10);
if (!name) { alert('Название обязательно'); return; }
if (!Number.isInteger(lessons_count) || lessons_count < 0) { alert('Количество занятий — целое неотрицательное число'); return; }
const res = await fetch(id ? `${API}/api/modules/${id}` : `${API}/api/modules`, {
method: id ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify({ name, lessons_count })
});
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
const moduleRow = await res.json();
const mid = moduleRow.id;
const fileInput = document.getElementById('modulePhotoFile');
if (modulePhotoDeleteFlag) {
const delRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'DELETE', headers: hdr() });
if (!delRes.ok) {
const err = await delRes.json().catch(() => ({}));
alert(err.error || 'Ошибка удаления картинки');
}
}
const file = fileInput.files[0];
if (file) {
const fd = new FormData();
fd.append('photo', file);
const upRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'POST', headers: hdr(), body: fd });
if (!upRes.ok) {
const err = await upRes.json().catch(() => ({}));
alert(err.error || 'Ошибка загрузки картинки');
}
}
showToast(id ? 'Модуль обновлён' : 'Модуль создан');
closeModuleModal();
loadModules();
});
function parseBatchModules(raw) {
const items = [];
const errors = [];
const seen = new Set();
String(raw).split('\n').forEach((line, idx) => {
const text = line.trim();
if (!text) return;
const lineNo = idx + 1;
let name = '';
let lessonsRaw = '';
const sepIndex = text.search(/[|;=\t]/);
if (sepIndex !== -1) {
name = text.slice(0, sepIndex).trim();
lessonsRaw = text.slice(sepIndex + 1).trim();
} else {
const numMatch = text.match(/^(.*\S)\s+(\d+)$/);
if (numMatch) {
name = numMatch[1].trim();
lessonsRaw = numMatch[2];
}
}
if (!name && !lessonsRaw) { errors.push(`Строка ${lineNo}: «${text}» — укажите название и количество занятий, например: Основы Python | 10`); return; }
if (!name) { errors.push(`Строка ${lineNo}: «${text}» — не указано название`); return; }
if (!lessonsRaw) { errors.push(`Строка ${lineNo}: «${text}» — не указано количество занятий`); return; }
const lessons = Number(lessonsRaw);
if (!Number.isInteger(lessons) || lessons < 0 || lessons > 10000) {
errors.push(`Строка ${lineNo}: «${text}» — количество занятий должно быть целым числом от 0 до 10000`);
return;
}
const key = name.toLowerCase();
if (seen.has(key)) { errors.push(`Строка ${lineNo}: «${text}» — дубликат названия в списке`); return; }
seen.add(key);
items.push({ name, lessons_count: lessons });
});
return { items, errors };
}
function openBatchModuleModal() {
document.getElementById('batchModuleForm').reset();
document.getElementById('batchModuleModal').classList.add('open');
setTimeout(() => document.getElementById('batchModuleText').focus(), 100);
}
function closeBatchModuleModal() {
document.getElementById('batchModuleModal').classList.remove('open');
}
async function batchAddModules() {
const submitBtn = document.getElementById('batchModuleSubmitBtn');
const { items, errors } = parseBatchModules(document.getElementById('batchModuleText').value);
if (errors.length) {
alert('Исправьте строки:\n\n' + errors.join('\n'));
return;
}
if (!items.length) { alert('Добавьте хотя бы один модуль'); return; }
submitBtn.disabled = true;
let added = 0, dupes = 0, failed = 0;
for (const item of items) {
const res = await fetch(`${API}/api/modules`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(item) });
if (res.ok) added++;
else if (res.status === 409) dupes++;
else failed++;
}
submitBtn.disabled = false;
closeBatchModuleModal();
loadModules();
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}${failed ? `, ошибок: ${failed}` : ''}`);
}
document.getElementById('batchModuleForm').addEventListener('submit', e => {
e.preventDefault();
batchAddModules();
});
document.getElementById('batchModuleBtn').addEventListener('click', openBatchModuleModal);
document.getElementById('batchModuleCancelBtn').addEventListener('click', closeBatchModuleModal);
document.getElementById('batchModuleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchModuleModal(); });
document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal());
document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal);
document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); });
document.getElementById('modulePhotoFile').addEventListener('change', previewModulePhotoFile);
document.getElementById('modulePhotoDeleteBtn').addEventListener('click', removeModulePhoto);
document.addEventListener('paste', pasteModulePhotoFile);
document.getElementById('moduleSearch').addEventListener('input', () => {
clearTimeout(mSearchTimer);
mSearchTimer = setTimeout(() => { mPage = 1; loadModules(); }, 300);
});
document.getElementById('moduleList').addEventListener('click', e => {
const ed = e.target.closest('[data-edit-module]');
if (ed) { openModuleModal(parseInt(ed.dataset.editModule, 10)); return; }
const off = e.target.closest('[data-off-module]');
if (off) { disableModule(parseInt(off.dataset.offModule, 10)); return; }
const on = e.target.closest('[data-on-module]');
if (on) enableModule(parseInt(on.dataset.onModule, 10));
});
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
loadModules();
}
})();
+567
View File
@@ -0,0 +1,567 @@
const state={token:'',password:null,showNames:true,cdTarget:0,cdTimer:null,cdBound:false,systemName:'WhatIDo'};
function loadBrand(){
return fetch('/api/public-settings').then(function(res){
return res.ok?res.json():null;
}).then(function(s){
if(!s)return;
if((s.system_name||'').trim())state.systemName=s.system_name;
var logo=document.getElementById('brandLogo');
if(logo&&s.system_logo){
logo.src=s.system_logo;
logo.hidden=false;
var mark=document.getElementById('brandMark');
if(mark)mark.style.display='none';
}
var name=document.getElementById('brandName');
if(name&&(s.system_name||'').trim()){
name.textContent=s.system_name;
name.hidden=false;
}
}).catch(function(){});
}
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;')}
function pad(n){return String(n).padStart(2,'0')}
function toDate(v){if(!v)return null;const d=new Date(v);return isNaN(d.getTime())?null:d}
function fmtDate(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+d.getFullYear():''}
function fmtShort(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+String(d.getFullYear()).slice(2):''}
function fmtLong(v){const d=toDate(v);return d?d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'}):''}
function plural(n,one,few,many){const m10=n%10,m100=n%100;if(m100>=11&&m100<=14)return many;if(m10===1)return one;if(m10>=2&&m10<=4)return few;return many}
function words(n,one,few,many){return n+' '+plural(n,one,few,many)}
function isImageFile(name){return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
function extLabel(name){const m=String(name||'').match(/\.([a-z0-9]{1,5})$/i);return m?m[1].toUpperCase():'FILE'}
function baseName(name){return String(name||'файл').replace(/^.*[\\/]/,'').slice(0,80)}
function thumbSrc(p){if(!p)return '';if(p.indexOf('/uploads/')===0)return '/uploads/thumb/'+p.slice(9);if(p.indexOf('/api/files/')>=0)return p+(p.indexOf('?')>=0?'&':'?')+'thumb=1';return p}
function fileUrl(token){return '/api/share/'+encodeURIComponent(state.token)+'/files/'+encodeURIComponent(token)+(state.password?'?password='+encodeURIComponent(state.password):'')}
function initials(name){const parts=String(name||'').trim().split(/\s+/).filter(Boolean);if(!parts.length)return 'WI';const a=parts[0].charAt(0);const b=parts.length>1?parts[1].charAt(0):(parts[0].length>1?parts[0].charAt(1):'');return (a+b).toUpperCase()}
function setText(id,text){const el=document.getElementById(id);if(el)el.textContent=text==null?'':String(text)}
function setHTML(id,html){const el=document.getElementById(id);if(el)el.innerHTML=html}
function hideEl(el,on){if(el)el.classList.toggle('rep-hidden',!!on)}
function node(html){const t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstElementChild}
function periodLabel(from,to){
if(!from&&!to)return '';
const a=toDate(from),b=toDate(to);
if(a&&b)return fmtDate(a)+' — '+fmtDate(b);
return fmtDate(a)||fmtDate(b);
}
function yearLabel(from,to){
const a=toDate(from),b=toDate(to);
if(!a&&!b)return '';
const ay=a?a.getFullYear():null,by=b?b.getFullYear():null;
if(ay&&by)return ay===by?String(ay):ay+' — '+String(by).slice(2);
return String(ay||by);
}
function countsLabel(photos,files){
const out=[];
if(photos)out.push(words(photos,'фото','фото','фото'));
if(files)out.push(words(files,'файл','файла','файлов'));
return out.join(' · ');
}
async function fetchShare(token,password){
const headers={};
if(password)headers['x-share-password']=password;
const res=await fetch('/api/share/'+encodeURIComponent(token),{headers});
const data=await res.json().catch(function(){return {}});
return {ok:res.ok,status:res.status,data:data};
}
function showError(msg){
stopCountdown();
setText('errorText',msg);
const box=document.getElementById('errorBox');
if(box)box.style.display='flex';
const content=document.getElementById('content');
if(content)content.style.display='none';
}
function stopCountdown(){
if(state.cdTimer){clearInterval(state.cdTimer);state.cdTimer=null}
state.cdTarget=0;
const el=document.getElementById('countdown');
if(el)el.style.display='none';
}
function formatRemaining(ms){
const total=Math.floor(ms/1000);
const days=Math.floor(total/86400);
const hours=Math.floor((total%86400)/3600);
const minutes=Math.floor((total%3600)/60);
const seconds=total%60;
const parts=[];
if(days>0)parts.push(words(days,'день','дня','дней'));
if(hours>0)parts.push(words(hours,'час','часа','часов'));
if(minutes>0)parts.push(words(minutes,'минуту','минуты','минут'));
if(days===0)parts.push(words(seconds,'секунду','секунды','секунд'));
return parts.slice(0,2).join(' ');
}
function syncCountdown(){
const el=document.getElementById('countdown');
if(!el||!state.cdTarget)return;
if(state.cdTarget-Date.now()<=0){el.style.display='none';return}
const hero=document.getElementById('hero');
const passed=hero?window.scrollY>hero.offsetHeight*0.5:true;
el.style.display=passed?'inline-flex':'none';
}
function tickCountdown(){
const el=document.getElementById('countdown');
if(!el||!state.cdTarget)return;
const left=state.cdTarget-Date.now();
if(left<=0){stopCountdown();return}
el.classList.toggle('rep-urgent',left<3600000);
el.classList.toggle('rep-warn',left>=3600000&&left<86400000);
const txt=el.querySelector('.rep-cd-text');
if(txt)txt.innerHTML='Отчёт доступен ещё <strong>'+esc(formatRemaining(left))+'</strong>';
syncCountdown();
}
function startCountdown(expiresAt){
stopCountdown();
const t=toDate(expiresAt);
if(!t)return;
state.cdTarget=t.getTime();
if(state.cdTarget-Date.now()<=0){state.cdTarget=0;return}
if(!state.cdBound){state.cdBound=true;window.addEventListener('scroll',syncCountdown,{passive:true})}
tickCountdown();
state.cdTimer=setInterval(tickCountdown,1000);
}
function initSlider(slider,update){
if(!slider)return;
const track=slider.querySelector('.blocklist__list');
if(!track)return;
const prev=slider.querySelector('[data-slider-prev]');
const next=slider.querySelector('[data-slider-next]');
const step=function(){
const first=track.firstElementChild;
const w=first?first.getBoundingClientRect().width:track.clientWidth;
const visible=Math.max(1,Math.round(track.clientWidth/Math.max(1,w)));
return {width:Math.max(1,w),visible:visible};
};
const sync=function(){
const s=step();
const max=track.scrollWidth-track.clientWidth;
const one=track.scrollWidth<=track.clientWidth+4;
hideEl(prev,one||track.scrollLeft<=4);
hideEl(next,one||track.scrollLeft>=max-4);
};
const move=function(dir){
const s=step();
track.scrollBy({left:dir*s.width*s.visible,behavior:'smooth'});
};
if(prev)prev.addEventListener('click',function(){move(-1)});
if(next)next.addEventListener('click',function(){move(1)});
track.addEventListener('scroll',sync,{passive:true});
window.addEventListener('resize',sync);
sync();
if(update)update(sync);
}
function stepSlider(slider,dir){
const track=slider.querySelector('.blocklist__list');
if(!track)return;
const first=track.firstElementChild;
const w=first?first.getBoundingClientRect().width:track.clientWidth;
const visible=Math.max(1,Math.round(track.clientWidth/Math.max(1,w)));
track.scrollBy({left:dir*w*visible,behavior:'smooth'});
}
function scrollSliderTo(slider,index){
const track=slider.querySelector('.blocklist__list');
if(!track)return;
const child=track.children[index];
if(child)track.scrollTo({left:child.offsetLeft-track.offsetLeft,behavior:'smooth'});
}
function renderSlider(slider,items){
const track=slider.querySelector('.blocklist__list');
if(!track)return;
track.innerHTML=items.join('');
window.requestAnimationFrame(function(){initSlider(slider)});
}
function setNavVisible(name,on){
document.querySelectorAll('[data-nav="'+name+'"]').forEach(function(el){hideEl(el,!on)});
}
function makeNavLinks(){
document.querySelectorAll('.rep-nav .menu__link-lvl1').forEach(function(a){
a.addEventListener('click',function(){closeMenu()});
});
}
function openMenu(){
const panel=document.getElementById('navPanel');
const overlay=document.getElementById('navOverlay');
const close=document.getElementById('navClose');
if(panel)panel.classList.add('is-open');
if(overlay)overlay.classList.add('is-open');
if(close)close.classList.add('is-open');
document.documentElement.style.overflow='hidden';
}
function closeMenu(){
const panel=document.getElementById('navPanel');
const overlay=document.getElementById('navOverlay');
const close=document.getElementById('navClose');
if(panel)panel.classList.remove('is-open');
if(overlay)overlay.classList.remove('is-open');
if(close)close.classList.remove('is-open');
document.documentElement.style.overflow='';
}
function openModal(){const m=document.getElementById('passwordModal');if(m)m.classList.add('is-open');modalError('');const i=document.getElementById('passwordInput');if(i){i.value='';i.focus()}}
function closeModal(){const m=document.getElementById('passwordModal');if(m)m.classList.remove('is-open')}
function modalError(msg){const e=document.getElementById('passwordError');if(!e)return;e.textContent=msg||'';e.style.display=msg?'block':'none'}
function openLightbox(src){const lb=document.getElementById('lightbox');const img=document.getElementById('lightboxImg');if(!lb||!img)return;img.src=src;lb.classList.add('is-open')}
function photoPool(data){
const pool=[];
(data.entries||[]).forEach(function(e){if(e.photo_path)pool.push(e.photo_path)});
(data.photos||[]).forEach(function(p){if(p.photo_path)pool.push(p.photo_path)});
return pool;
}
function fileChips(files){
if(!files||!files.length)return '';
const items=files.map(function(f){
const url=fileUrl(f.token);
const label=baseName(f.name);
if(isImageFile(f.name))return '<a class="rep-chip" href="#" data-img="'+esc(url)+'" title="'+esc(label)+'"><span>'+esc(label)+'</span></a>';
return '<a class="rep-chip" href="'+esc(url)+'" download title="'+esc(label)+'"><span>'+esc(label)+'</span></a>';
}).join('');
return '<div class="rep-files">'+items+'</div>';
}
function hideSection(id){
const el=document.getElementById(id);
if(el)el.style.display='none';
}
function renderHero(data,heading,pool){
const entries=data.entries||[];
setText('heroName',heading);
setText('heroRole',data.group_name?'Ученик группы «'+data.group_name+'»':'Отчёт о проделанной работе');
setText('heroPeriod',yearLabel(data.date_from,data.date_to)||yearLabel(data.created_at));
const lead=(data.message&&String(data.message).trim())||('Здесь собраны все занятия за выбранный период: '+words(entries.length,'запись','записи','записей')+'.');
setText('heroLead',lead);
const box=document.getElementById('heroPhotoBox');
const img=document.getElementById('heroPhoto');
if(box&&img&&pool.length){
img.src=thumbSrc(pool[0]);
img.alt=heading;
box.setAttribute('data-img',pool[0]);
}else if(box){
if(img)img.remove();
const mono=document.createElement('span');
mono.className='rep-monogram';
mono.textContent=initials(heading);
box.appendChild(mono);
box.classList.add('rep-hero-photo');
}
}
function statItem(num,cap,variant){
const sets=[
{item:'list__item list__item--u-ix00fc47p',num:'text text--u-iif5jnrfz text-number-1-script',cap:'text t2 text--u-ih2l1ywk8'},
{item:'list__item list__item--u-ic8gt20s1',num:'text text--u-ipk87vesx text-number-1-script',cap:'text t2 text--u-i53xasxdu'},
{item:'list__item list__item--u-ici0pcuqz',num:'text text--u-ir07nprxn text-number-1-script',cap:'text t2 text--u-ise2s2948'},
{item:'list__item list__item--u-ici0pcuqz list__item--u-irn5njhv8',num:'text text--u-ir07nprxn text-number-1-script',cap:'text t2 text--u-ise2s2948'}
];
const s=sets[variant]||sets[0];
return '<div class="'+s.item+'">'+
'<div class="'+s.num+'"><span class="text-block-wrap-div">'+esc(num)+'</span></div>'+
'<div class="'+s.cap+'"><span class="text-block-wrap-div">'+esc(cap)+'</span></div>'+
'</div>';
}
function statSep(variant){
const extra=variant===1?' list__item--u-ip5wdmggw':(variant===2?' list__item--u-ixebr6v8q':'');
return '<div class="list__item list__item--u-isd3lg3iz'+extra+'"></div>';
}
function renderAbout(data,pool){
const entries=data.entries||[];
const photos=(data.photos||[]).length;
const files=entries.reduce(function(n,e){return n+((e.files||[]).length)},0);
const months={};
entries.forEach(function(e){const d=toDate(e.created_at);if(d)months[d.getFullYear()+'-'+d.getMonth()]=1});
const photosTotal=entries.filter(function(e){return e.photo_path}).length+photos;
setText('aboutTitle',(data.name&&String(data.name).trim())||'Отчёт');
setText('aboutLead',(data.message&&String(data.message).trim())||('Мы собрали '+words(entries.length,'запись','записи','записей')+' о работе за этот период — с фотографиями, файлами и описанием каждого занятия.'));
const parts=[];
if(periodLabel(data.date_from,data.date_to))parts.push('Период: '+periodLabel(data.date_from,data.date_to));
if(data.group_name)parts.push('Группа «'+data.group_name+'»');
setText('aboutNote',parts.join(' · '));
setHTML('statsList',
statItem(entries.length,'записей о проделанной работе',0)+statSep(0)+
statItem(photosTotal,'фото с занятий',1)+statSep(1)+
statItem(files,'проектных файлов',2)+statSep(2)+
statItem(Object.keys(months).length,'месяцев активных занятий',3)
);
const box=document.getElementById('aboutPhotoBox');
const img=document.getElementById('aboutPhoto');
const photo=pool[1]||pool[0];
if(box&&img&&photo){img.src=thumbSrc(photo);img.alt='';box.setAttribute('data-img',photo);}
else if(box){
if(img)img.remove();
const mono=document.createElement('span');
mono.className='rep-about-monogram';
mono.textContent=initials((data.name&&String(data.name).trim())||data.student_name||'WhatIDo');
box.appendChild(mono);
}
}
function renderTimeline(data){
const entries=data.entries||[];
const list=document.getElementById('timelineList');
if(!list)return;
if(!entries.length){hideSection('timeline');setNavVisible('timeline',false);return}
const showDate=data.show_entry_date!==false;
const showDesc=!!data.show_student_message;
list.innerHTML=entries.map(function(e,index){
const files=e.files||[];
const stamp=showDate?fmtShort(e.created_at):pad(index+1);
const aside=[];
if(data.group_name)aside.push(data.group_name);
const counts=countsLabel(e.photo_path?1:0,files.length);
if(counts)aside.push(counts);
return '<div class="list__item list__item--u-ipltrh05x">'+
'<div class="text text--u-ilncqhajz text-number-2"><span class="text-block-wrap-div">'+esc(stamp)+'</span></div>'+
'<div class="div div--u-i26e5rzsk"><div class="div div--u-it0l5f3ro">'+
'<div class="div div--u-izku4a3eb"></div><div class="div div--u-il2x5kjti"></div>'+
(e.photo_path?'<div class="image image--u-i2etuklnt" data-img="'+esc(e.photo_path)+'"><img class="image__img image__img--s2-iyzrya226" src="'+esc(thumbSrc(e.photo_path))+'" alt="" loading="lazy"></div>':'')+
'<div class="div div--u-iaj0y0mvl"></div><div class="div div--u-iu4k5l3n1"></div>'+
'</div></div>'+
'<div class="text text--u-ie3ure5d7 h3 rep-stack">'+
'<span class="text-block-wrap-div">'+esc(e.student_name||'Занятие')+'</span>'+
(showDesc&&e.description?'<div class="text t2 rep-tl-desc"><span class="text-block-wrap-div">'+esc(e.description)+'</span></div>':'')+
fileChips(files)+
'</div>'+
'<div class="div div--u-ib8lera1q rep-tl-aside">'+
aside.map(function(a){return '<div class="text text--u-i8vddz288 t2"><span class="text-block-wrap-div">'+esc(a)+'</span></div>'}).join('')+
'</div>'+
'</div>';
}).join('');
}
function workTitle(e){
const d=String(e.description||'').replace(/\s+/g,' ').trim();
if(d)return d.length>88?d.slice(0,88).trim()+'…':d;
return e.student_name||'Работа';
}
function renderWorks(data){
const slider=document.querySelector('.rep-slider[data-slider="works"]');
if(!slider)return;
const entries=(data.entries||[]).filter(function(e){return e.photo_path});
if(!entries.length){hideSection('works');setNavVisible('works',false);return}
const items=entries.slice(0,12).map(function(e,index){
return '<div class="blocklist__item-wrapper blocklist__item-wrapper--s2-in9zna1lp">'+
'<div class="blocklist__item blocklist__item--u-iqn443glf blocklist__item--s2-iqn443glf">'+
'<div class="div div--u-ifxd1g9rk">'+
'<div class="div div--u-ibren39lw"></div>'+
'<div class="div div--u-ixtm7p8ov"></div>'+
'<div class="text h3 text--u-ib8dkw227"><span class="text-block-wrap-div">'+esc(pad(index+1))+'</span></div>'+
'<div class="image image--u-iijk97g72 rep-work-photo" data-img="'+esc(e.photo_path)+'"><img class="image__img" src="'+esc(thumbSrc(e.photo_path))+'" alt="" loading="lazy"></div>'+
'<div class="div div--u-ilvcq6gdk">'+
'<div class="text text--u-i8ydj7eyx h3-script"><span class="text-block-wrap-div">'+esc(workTitle(e))+'</span></div>'+
'<div class="div div--u-ipp2467j8"><div class="text t2 text--u-iz3df5dls"><span class="text-block-wrap-div">'+esc(data.show_entry_date!==false?fmtDate(e.created_at):(e.student_name||''))+'</span></div></div>'+
'</div>'+
(data.show_student_message&&e.description&&workTitle(e)!==String(e.description).trim()?'<div class="text text--u-iz3df5dls l1 rep-work-desc"><span class="text-block-wrap-div">'+esc(e.description)+'</span></div>':'')+
fileChips(e.files)+
'<div class="div div--u-iidtqgnjo"></div>'+
'<div class="div div--u-iohjzr62g"></div>'+
'</div>'+
'</div>'+
'</div>';
});
renderSlider(slider,items);
}
function renderFiles(data){
const list=document.getElementById('filesList');
if(!list)return;
const rows=[];
(data.entries||[]).forEach(function(e){
(e.files||[]).forEach(function(f){rows.push({file:f,entry:e})});
});
if(!rows.length){hideSection('files');setNavVisible('files',false);return}
list.innerHTML=rows.map(function(row){
const f=row.file;
const url=fileUrl(f.token);
const label=baseName(f.name);
const isImg=isImageFile(f.name);
const meta=[];
if(data.show_entry_date!==false)meta.push(fmtDate(row.entry.created_at));
if(row.entry.student_name)meta.push(row.entry.student_name);
if(data.group_name)meta.push(data.group_name);
const icon=isImg
?'<div class="image image--u-i1bx3pcch"><img class="image__img" src="'+esc(thumbSrc(url))+'" alt="" loading="lazy"></div>'
:'<div class="image image--u-i1bx3pcch rep-file-icon">'+esc(extLabel(f.name))+'</div>';
const head=isImg
?'<a class="list__item list__item--u-iusd5amsk rep-file-item" href="#" data-img="'+esc(url)+'">'
:'<a class="list__item list__item--u-iusd5amsk rep-file-item" href="'+esc(url)+'" download>';
return head+
'<div class="div div--u-i65kawao9"><div class="text t2 text--u-inm34sjkb"><span class="text-block-wrap-div">'+esc(fmtDate(row.entry.created_at))+'</span></div></div>'+
'<div class="div div--u-iqdk5s4vv">'+
'<div class="div div--u-iq1qpzuod"></div>'+
'<div class="div div--u-itqr1x7ye"></div>'+
icon+
'<div class="div div--u-iauar4gpi"></div>'+
'<div class="div div--u-i78hvbbl0"></div>'+
'</div>'+
'<div class="text h3 text--u-ih08rddti rep-file-title"><span class="text-block-wrap-div">'+esc(label)+'</span></div>'+
'<div class="text l1 text--u-i0erozcli rep-stack rep-file-meta">'+
'<span class="text-block-wrap-div">'+esc(meta.join(' · '))+'</span>'+
'<span class="rep-file-cta">'+(isImg?'Посмотреть':'Скачать')+'</span>'+
'</div>'+
'</a>';
}).join('');
}
function renderGallery(data){
const slider=document.querySelector('.rep-slider[data-slider="gallery"]');
if(!slider)return;
const photos=data.photos||[];
if(!photos.length){hideSection('gallery');setNavVisible('gallery',false);return}
const items=photos.slice(0,12).map(function(p){
const caption=String(p.caption||'').trim();
return '<div class="blocklist__item-wrapper blocklist__item-wrapper--s2-iuntcnkpn">'+
'<div class="blocklist__item blocklist__item--u-iefuhgv1p blocklist__item--s2-iefuhgv1p">'+
'<div class="div div--u-iova3e6no">'+
'<div class="div div--u-iy3zasv7f"></div>'+
'<div class="div div--u-ish1rimi9"></div>'+
'<div class="image image--u-ipuomcrxx rep-gallery-photo" data-img="'+esc(p.photo_path)+'"><img class="image__img" src="'+esc(thumbSrc(p.photo_path))+'" alt="" loading="lazy"></div>'+
'<div class="div div--u-ij727gv6j"></div>'+
'<div class="div div--u-iymundgii"></div>'+
'</div>'+
'<div class="div div--u-iq00jif9z">'+
'<div class="text t2 text--u-i6vup9a8s rep-gallery-caption"><span class="text-block-wrap-div">'+esc(caption||(data.group_name?'Группа «'+data.group_name+'»':'Фото занятия'))+'</span></div>'+
'<div class="text t2 text--u-iwrgrsqzk rep-gallery-date"><span class="text-block-wrap-div">'+esc(fmtDate(p.taken_at||p.created_at))+'</span></div>'+
'<div class="text text--u-ig38djg6x l1 rep-gallery-text"><span class="text-block-wrap-div">'+esc(caption?'Фотография с занятий группы':'Фотография с занятий')+'</span></div>'+
'<div class="div div--u-i8lri9goh"></div>'+
'<div class="div div--u-i4kmjxmbi"></div>'+
'</div>'+
'</div>'+
'</div>';
});
renderSlider(slider,items);
}
function renderFooter(data,heading,pool){
setText('brandMark',initials(heading));
setText('brandMarkFooter',initials(heading));
setText('footGroup',data.group_name?'Группа «'+data.group_name+'»':'Группа не указана');
setText('footPeriod',periodLabel(data.date_from,data.date_to)?'Период: '+periodLabel(data.date_from,data.date_to):'Период: весь отчёт');
setText('footCreated','Обновлён: '+fmtDate(data.created_at));
const expires=toDate(data.expires_at);
setText('footExpires',expires?'Доступно до '+fmtDate(data.expires_at):'Срок не ограничен');
const link=document.getElementById('footLink');
if(link&&data.link_url&&/^https?:\/\//i.test(data.link_url)){link.href=data.link_url;link.style.display='inline-flex'}
else if(link){link.style.display='none'}
const note=document.getElementById('contactNote');
if(note)note.innerHTML='<span class="text-block-wrap-div">Если появятся вопросы по отчёту — напишите преподавателю<span style="color:rgba(68,68,68,1)"> — мы ответим в течение 24 часов.</span></span>';
setText('contactLead',(data.name&&String(data.name).trim())||'Отчёт о работе');
const sub=[];
if(periodLabel(data.date_from,data.date_to))sub.push('Период: '+periodLabel(data.date_from,data.date_to));
sub.push('Обновлён: '+fmtDate(data.created_at));
if(expires)sub.push('Доступен до: '+fmtDate(data.expires_at));
setText('contactSub',sub.join('\n'));
const btn=document.getElementById('contactBtn');
if(btn&&data.link_url&&/^https?:\/\//i.test(data.link_url)){
btn.href=data.link_url;
btn.target='_blank';
btn.rel='noopener noreferrer';
const label=btn.querySelector('.text-block-wrap-div');
if(label)label.textContent='Перейти по ссылке';
}
setText('footCopy',state.systemName+' © '+new Date().getFullYear());
setText('footBrand',heading);
const fbox=document.getElementById('footerPhotoBox');
const fimg=document.getElementById('footerPhoto');
const photo=pool[2]||pool[0];
if(fbox&&fimg&&photo){fimg.src=thumbSrc(photo);fimg.alt='';fbox.setAttribute('data-img',photo)}
else if(fbox){hideEl(fbox,true)}
}
function renderReport(data){
const heading=(data.name&&String(data.name).trim())||data.student_name||data.group_name||'Отчёт';
document.title=heading;
const pool=photoPool(data);
renderHero(data,heading,pool);
renderAbout(data,pool);
renderTimeline(data);
renderWorks(data);
renderFiles(data);
renderGallery(data);
renderFooter(data,heading,pool);
startCountdown(data.expires_at);
const content=document.getElementById('content');
if(content)content.style.display='block';
}
async function loadReport(){
const token=location.pathname.split('/').filter(Boolean).pop()||'';
state.token=token;
state.password=null;
const result=await fetchShare(token,null);
if(result.ok){
state.showNames=result.data.show_student_names!==false;
renderReport(result.data);
return;
}
if(result.status===401&&result.data.passwordRequired){
openModal();
return;
}
if(result.status===410)showError('Срок действия ссылки истёк.');
else showError('Ссылка не найдена или была удалена.');
setText('brandMark',initials('WhatIDo'));
setText('brandMarkFooter',initials('WhatIDo'));
}
function bindEvents(){
const burger=document.getElementById('navBurger');
const close=document.getElementById('navClose');
const overlay=document.getElementById('navOverlay');
if(burger)burger.addEventListener('click',openMenu);
if(close)close.addEventListener('click',closeMenu);
if(overlay)overlay.addEventListener('click',closeMenu);
document.querySelectorAll('.rep-nav .menu__link-lvl1, .rep-nav .link--u-i2ifdcrc4').forEach(function(a){
a.addEventListener('click',function(){closeMenu()});
});
document.addEventListener('click',function(ev){
const prev=ev.target.closest('[data-slider-prev]');
const next=ev.target.closest('[data-slider-next]');
if(prev||next){
const slider=(prev||next).closest('.rep-slider');
if(slider){ev.preventDefault();stepSlider(slider,prev?-1:1)}
return;
}
const imgEl=ev.target.closest('[data-img]');
if(imgEl){ev.preventDefault();openLightbox(imgEl.getAttribute('data-img'));return}
const lb=document.getElementById('lightbox');
if(lb&&lb.classList.contains('is-open'))closeLightbox();
});
const submit=document.getElementById('passwordSubmitBtn');
const input=document.getElementById('passwordInput');
if(submit)submit.addEventListener('click',async function(){
const value=input?input.value.trim():'';
if(!value)return;
submit.disabled=true;
const result=await fetchShare(state.token,value);
submit.disabled=false;
if(result.ok){
state.password=value;
closeModal();
state.showNames=result.data.show_student_names!==false;
renderReport(result.data);
}else{
modalError(result.status===410?'Срок действия ссылки истёк':'Неверный пароль');
if(input){input.value='';input.focus()}
}
});
if(input)input.addEventListener('keydown',function(ev){
if(ev.key==='Enter'&&submit)submit.click();
});
const modal=document.getElementById('passwordModal');
if(modal)modal.addEventListener('click',function(ev){if(ev.target===modal)closeModal()});
document.addEventListener('keydown',function(ev){
if(ev.key!=='Escape')return;
closeLightbox();
closeMenu();
});
window.addEventListener('resize',function(){
if(window.innerWidth>991)closeMenu();
});
}
bindEvents();
makeNavLinks();
loadBrand().then(function(){loadReport()});
function closeLightbox(){const lb=document.getElementById('lightbox');if(lb)lb.classList.remove('is-open')}
+752
View File
@@ -0,0 +1,752 @@
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine'];
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
'honeypot': 'Антиспам-поле',
'login-bruteforce': 'Подбор пароля входа',
'share-password-bruteforce': 'Подбор пароля ссылки',
'manual': 'Вручную',
};
let currentLogo = '';
let logoOriginal = '';
function renderLogoPreview() {
const img = document.getElementById('logoPreviewImg');
const fb = document.getElementById('logoPreviewFallback');
const rm = document.getElementById('logoRemoveBtn');
if (!img || !fb || !rm) return;
const hasLogo = !!currentLogo;
img.style.display = hasLogo ? 'block' : 'none';
fb.style.display = hasLogo ? 'none' : '';
if (hasLogo) img.src = currentLogo;
rm.hidden = !hasLogo;
}
function fieldValue(el) {
return el.type === 'checkbox' ? String(el.checked) : el.value;
}
function setBtnLoading(btn, on, spinnerClass) {
if (on) {
btn.dataset.label = btn.innerHTML;
btn.disabled = true;
const cls = spinnerClass || (btn.classList.contains('ghost') ? 'dark' : '');
btn.innerHTML = `<span class="spinner ${cls}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
} else {
btn.disabled = false;
btn.innerHTML = btn.dataset.label || btn.innerHTML;
}
}
function showError(msg) {
const t = document.getElementById('toast');
if (!t) return;
t.textContent = msg;
t.classList.add('show', 'error');
clearTimeout(t._errTimer);
t._errTimer = setTimeout(() => t.classList.remove('error'), 3400);
}
function showFieldError(id, msg) {
const el = document.getElementById(id);
const err = document.getElementById(`${id}Error`);
if (el) el.classList.add('invalid');
if (err) { err.textContent = msg; err.hidden = false; }
}
function clearFieldError(id) {
const el = document.getElementById(id);
const err = document.getElementById(`${id}Error`);
if (el) el.classList.remove('invalid');
if (err) err.hidden = true;
}
function plural(n, one, few, many) {
const a = n % 10, b = n % 100;
if (a === 1 && b !== 11) return one;
if (a >= 2 && a <= 4 && (b < 12 || b > 14)) return few;
return many;
}
function humanizeMinutes(min) {
const n = Number(min);
if (!Number.isFinite(n) || n < 1) return '';
if (n === 1440) return 'сутки';
if (n > 1440 && n % 1440 === 0) {
const d = n / 1440;
return `${d} ${plural(d, 'сутки', 'суток', 'суток')}`;
}
const h = Math.floor(n / 60);
const m = n % 60;
const parts = [];
if (h) parts.push(`${h} ${plural(h, 'час', 'часа', 'часов')}`);
if (m) parts.push(`${m} ${plural(m, 'минута', 'минуты', 'минут')}`);
if (!parts.length) parts.push(`${n} ${plural(n, 'минута', 'минуты', 'минут')}`);
return parts.join(' ');
}
function updateDirty() {
const dirtyCount = DIRTY_FIELDS.filter(id => {
const el = document.getElementById(id);
return String(el.dataset.original ?? '') !== fieldValue(el);
}).length;
document.getElementById('saveBar').classList.toggle('show', dirtyCount > 0);
}
function bindDirty() {
DIRTY_FIELDS.forEach(id => {
const el = document.getElementById(id);
el.addEventListener('input', updateDirty);
el.addEventListener('change', updateDirty);
});
}
function snapshotDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).dataset.original = fieldValue(document.getElementById(id));
});
updateDirty();
}
function revertChanges() {
DIRTY_FIELDS.forEach(id => {
const el = document.getElementById(id);
const original = el.dataset.original ?? '';
if (el.type === 'checkbox') el.checked = original === 'true';
else el.value = original;
});
currentLogo = logoOriginal;
renderLogoPreview();
clearFieldError('spamInterval');
updateSpamUi();
updateDirty();
showToast('Изменения отменены');
}
function initSectionNav() {
const nav = document.getElementById('settingsNav');
if (!nav) return;
const links = [...nav.querySelectorAll('a')];
const smooth = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
let ticking = false;
const setActive = () => {
ticking = false;
let current = SECTION_IDS[0];
for (const id of SECTION_IDS) {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top <= 170) current = id;
}
links.forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#${current}`));
};
window.addEventListener('scroll', () => {
if (!ticking) { ticking = true; requestAnimationFrame(setActive); }
}, { passive: true });
setActive();
nav.addEventListener('click', e => {
const a = e.target.closest('a');
if (!a) return;
e.preventDefault();
const target = document.getElementById(a.getAttribute('href').slice(1));
if (target) target.scrollIntoView({ behavior: smooth, block: 'start' });
history.replaceState(null, '', a.getAttribute('href'));
});
}
async function loadSettings() {
const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }),
fetch(`${API}/api/stats`, { headers: hdr() }),
]);
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
const s = await sRes.json();
document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
currentLogo = s.system_logo || '';
logoOriginal = currentLogo;
renderLogoPreview();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || '';
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
document.getElementById('shareShowMessage').checked = s.share_show_student_message === 'true';
document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true';
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false';
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
const pr = document.getElementById('photoResolution');
if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution;
if (!pr.value) pr.value = '640x480';
document.getElementById('photoQuality').value = s.photo_capture_quality !== undefined && s.photo_capture_quality !== '' ? s.photo_capture_quality : '0.92';
const pe = document.getElementById('photoEngine');
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
if (!pe.value) pe.value = 'auto';
snapshotDirty();
updateSpamUi();
loadAiQueue();
loadAiProfiles();
renderStats(stRes);
loadSystemInfo();
}
async function renderStats(res) {
const el = document.getElementById('dataStats');
if (!el) return;
if (!res || !res.ok) { el.innerHTML = '<span class="chip">Статистика недоступна</span>'; return; }
const st = await res.json();
el.innerHTML = `
<span class="chip">Записей: <b>${st.entries}</b></span>
<span class="chip">Учеников: <b>${st.students}</b></span>
<span class="chip">Групп: <b>${st.groups}</b></span>
<span class="chip">Сегодня: <b>${st.today}</b></span>
${st.trash ? `<span class="chip">В корзине: <b>${st.trash}</b> <a href="trash.html">→</a></span>` : ''}
`;
}
async function refreshStats() {
const el = document.getElementById('dataStats');
if (!el) return;
el.innerHTML = '<span class="chip">Обновление…</span>';
try {
await renderStats(await fetch(`${API}/api/stats`, { headers: hdr() }));
} catch {
el.innerHTML = '<span class="chip">Ошибка сети</span>';
}
}
async function loadSystemInfo() {
const el = document.getElementById('systemInfo');
if (!el) return;
try {
const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
if (res.ok) {
renderSystemInfo(await res.json());
const live = document.getElementById('systemLive');
if (live) live.textContent = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
return;
}
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
} catch {
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
}
}
function renderSystemInfo(si) {
const db = si.database || {};
const photos = si.photos || {};
const files = si.files || {};
const uploads = si.uploads || {};
const disk = si.disk;
let tablesHtml = '';
if (db.tables && db.tables.length) {
tablesHtml = '<details class="sys-details"><summary>Таблицы БД</summary><div class="sys-table-list">' +
db.tables.map(t => `<div class="sys-table-row"><span>${esc(t.name)}</span><span>${esc(t.size)}</span></div>`).join('') +
'</div></details>';
}
let diskHtml = '';
if (disk) {
const pct = disk.used_pct;
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
const cardCls = pct > 90 ? ' danger' : pct > 70 ? ' warn' : '';
diskHtml = `
<div class="sys-card${cardCls}">
<div class="sys-title">Диск</div>
<div class="sys-item"><span class="sys-label">Заполнено</span><span class="sys-value">${pct}%</span></div>
<div class="sys-item"><span class="sys-label">Использовано</span><span class="sys-value">${esc(disk.used)} / ${esc(disk.total)}</span></div>
<div class="sys-bar-wrap"><div class="sys-bar" style="width:${pct}%; background:${color}"></div></div>
<div class="sys-meta">Свободно: ${esc(disk.free)} (${100 - pct}%)</div>
</div>`;
}
document.getElementById('systemInfo').innerHTML = `
<div class="sys-card">
<div class="sys-title">База данных</div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(db.size || '—')}</span></div>
${tablesHtml}
</div>
<div class="sys-card">
<div class="sys-title">Фото</div>
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0} · ${esc(photos.group_photos?.size || '0 B')}</span></div>
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0} · ${esc(photos.entry_photos?.size || '0 B')}</span></div>
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b> · <b>${esc(photos.total_size || '0 B')}</b></span></div>
</div>
<div class="sys-card">
<div class="sys-title">Файлы</div>
<div class="sys-item"><span class="sys-label">Проектные файлы</span><span class="sys-value">${files.project_files?.count || 0}</span></div>
</div>
<div class="sys-card">
<div class="sys-title">Загрузки</div>
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(uploads.size || '0 B')}</span></div>
</div>
${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'}
`;
}
async function loadAiProfiles() {
const listEl = document.getElementById('aiProfilesList');
const select = document.getElementById('aiActiveProfile');
if (!listEl) return;
try {
const res = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
if (!res.ok) { listEl.innerHTML = '<span class="chip">Не удалось загрузить профили</span>'; return; }
const data = await res.json();
const profiles = data.profiles || [];
if (select) {
select.innerHTML = `<option value="native">Нативная — ${esc(data.native.model)}</option>` +
profiles.map(p => `<option value="${esc(p.id)}">${esc(p.name)} — ${esc(p.model)}</option>`).join('');
select.value = data.active;
}
const items = [{ id: 'native', name: 'Нативная (llama.cpp в Docker)', base_url: data.native.base_url, model: data.native.model, native: true }].concat(profiles);
listEl.innerHTML = items.map(p => `
<div class="ai-profile-item${data.active === p.id ? ' active' : ''}">
<div class="pi-info">
<div class="pi-name">${esc(p.name)}${data.active === p.id ? ' <span class="pi-badge">по умолчанию</span>' : ''}</div>
<div class="pi-meta">${esc(p.model)} · ${esc(p.base_url)}${p.max_tokens ? ` · max_tokens: ${esc(p.max_tokens)}` : ''}</div>
</div>
<div class="pi-actions">
${p.native ? '' : `<button type="button" class="pi-btn" data-edit-profile="${esc(p.id)}">Изменить</button>`}
${p.native ? '' : `<button type="button" class="pi-btn danger" data-del-profile="${esc(p.id)}" data-del-name="${esc(p.name)}">Удалить</button>`}
</div>
</div>
`).join('');
} catch { listEl.innerHTML = '<span class="chip">Ошибка загрузки</span>'; }
}
let aiProfileEditingId = null;
function openAiProfileForm(profile) {
aiProfileEditingId = profile ? profile.id : null;
document.getElementById('aiProfileModalTitle').textContent = profile ? 'Изменить профиль ИИ' : 'Добавить профиль ИИ';
document.getElementById('aiProfileModal').classList.add('open');
document.getElementById('aiProfileName').value = profile ? profile.name : '';
document.getElementById('aiProfileModel').value = profile ? profile.model : '';
document.getElementById('aiProfileBaseUrl').value = profile ? profile.base_url : '';
document.getElementById('aiProfileApiKey').value = '';
document.getElementById('aiProfileMaxTokens').value = profile && profile.max_tokens ? profile.max_tokens : '';
document.getElementById('aiProfileTestStatus').textContent = '';
document.getElementById('aiProfileName').focus();
}
function closeAiProfileForm() {
aiProfileEditingId = null;
document.getElementById('aiProfileModal').classList.remove('open');
}
function aiProfileFormData() {
return {
name: document.getElementById('aiProfileName').value,
model: document.getElementById('aiProfileModel').value,
base_url: document.getElementById('aiProfileBaseUrl').value,
api_key: document.getElementById('aiProfileApiKey').value,
max_tokens: document.getElementById('aiProfileMaxTokens').value.trim() === '' ? null : document.getElementById('aiProfileMaxTokens').value,
};
}
async function loadAiQueue() {
const el = document.getElementById('aiQueueInfo');
if (!el) return;
try {
const res = await fetch(`${API}/api/ai/queue`, { headers: hdr() });
if (!res.ok) { el.textContent = ''; return; }
const q = await res.json();
const c = q.counts || {};
el.innerHTML = `
<span class="chip">Автопроверка: <b>${q.enabled ? 'включена' : 'выключена'}</b></span>
<span class="chip">Модель: <b>${esc(q.model || '—')}</b></span>
<span class="chip">В очереди: <b>${c.pending || 0}</b></span>
<span class="chip">Проверено: <b>${c.done || 0}</b></span>
<span class="chip">Ошибок: <b>${c.error || 0}</b></span>
<span class="chip">Пропущено: <b>${c.skipped || 0}</b></span>
<a class="queue-link" href="worker.html">Монитор воркера →</a>`;
} catch { el.textContent = ''; }
}
async function saveAiProfile() {
const body = aiProfileFormData();
const st = document.getElementById('aiProfileTestStatus');
st.innerHTML = '<span class="spinner dark"></span> Сохранение...';
const res = await fetch(aiProfileEditingId ? `${API}/api/ai/profiles/${aiProfileEditingId}` : `${API}/api/ai/profiles`, {
method: aiProfileEditingId ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify(body),
});
if (res.ok) {
st.textContent = '';
showToast(aiProfileEditingId ? 'Профиль обновлён' : 'Профиль добавлен');
closeAiProfileForm();
loadAiProfiles();
} else {
const err = await res.json().catch(() => ({}));
st.innerHTML = `<span class="fail">${esc(err.error || 'Ошибка сохранения')}</span>`;
}
}
async function testAiProfile() {
const st = document.getElementById('aiProfileTestStatus');
st.textContent = 'Проверка…';
const res = await fetch(`${API}/api/ai/profiles/test`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(aiProfileFormData()) });
const d = await res.json().catch(() => ({ ok: false, error: 'Ошибка' }));
st.innerHTML = d.ok
? `<span class="ok">✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''}</span>`
: `<span class="fail">✗ ${esc(d.error || 'Не удалось подключиться')}</span>`;
}
async function activateAiProfile(id) {
const res = await fetch(`${API}/api/ai/profiles/activate`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ id }) });
if (res.ok) {
showToast('Модель по умолчанию обновлена');
loadAiProfiles();
loadAiQueue();
} else {
const err = await res.json().catch(() => ({}));
showError(err.error || 'Ошибка');
}
}
async function deleteAiProfile(id, name, btn) {
if (btn && !btn.dataset.confirming) {
btn.dataset.confirming = '1';
btn.classList.add('confirming');
btn.textContent = 'Точно?';
setTimeout(() => {
if (btn.isConnected) {
delete btn.dataset.confirming;
btn.classList.remove('confirming');
btn.textContent = 'Удалить';
}
}, 3000);
return;
}
const res = await fetch(`${API}/api/ai/profiles/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
showToast('Профиль удалён');
loadAiProfiles();
} else {
const err = await res.json().catch(() => ({}));
showError(err.error || 'Ошибка удаления');
}
}
const spamInput = document.getElementById('spamInterval');
function updateSpamUi() {
const raw = spamInput.value;
const n = Math.round(Number(raw));
const valid = raw !== '' && Number.isFinite(n) && n >= 1;
const hint = document.getElementById('spamIntervalHint');
if (hint) hint.textContent = valid ? `Следующая отправка будет доступна через ${humanizeMinutes(n)}` : 'Минимальное значение — 1 минута';
document.querySelectorAll('#spamPresets .preset-btn').forEach(b => b.classList.toggle('active', valid && Number(b.dataset.min) === n));
}
function clampSpam(v) {
let n = Math.round(Number(v));
if (!Number.isFinite(n) || n < 1) n = 1;
if (n > SPAM_MAX) n = SPAM_MAX;
return n;
}
spamInput.addEventListener('change', () => {
const n = Math.round(Number(spamInput.value));
if (spamInput.value === '' || !Number.isFinite(n) || n < 1) {
spamInput.value = clampSpam(spamInput.dataset.original ?? 30);
updateDirty();
}
updateSpamUi();
});
spamInput.addEventListener('input', updateSpamUi);
document.getElementById('spamPresets').addEventListener('click', e => {
const btn = e.target.closest('[data-min]');
if (!btn) return;
spamInput.value = btn.dataset.min;
spamInput.dispatchEvent(new Event('input', { bubbles: true }));
spamInput.dispatchEvent(new Event('change', { bubbles: true }));
});
document.getElementById('aiProfileAddBtn').addEventListener('click', () => openAiProfileForm(null));
document.getElementById('aiProfileSaveBtn').addEventListener('click', saveAiProfile);
document.getElementById('aiProfileTestBtn').addEventListener('click', testAiProfile);
document.getElementById('aiProfileCancelBtn').addEventListener('click', closeAiProfileForm);
document.getElementById('aiProfileModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAiProfileForm(); });
document.getElementById('aiActiveProfile').addEventListener('change', e => activateAiProfile(e.target.value));
document.getElementById('aiProfilesList').addEventListener('click', e => {
const del = e.target.closest('[data-del-profile]');
if (del) { deleteAiProfile(del.dataset.delProfile, del.dataset.delName, del); return; }
const edit = e.target.closest('[data-edit-profile]');
if (edit) {
fetch(`${API}/api/ai/profiles`, { headers: hdr() })
.then(r => r.json())
.then(data => {
const p = (data.profiles || []).find(x => x.id === edit.dataset.editProfile);
if (p) openAiProfileForm(p);
});
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
});
async function saveSettings() {
const v = spamInput.value;
const n = Number(v);
if (v === '' || !Number.isFinite(n) || Math.round(n) < 1) {
showFieldError('spamInterval', 'Укажите целое число минут (минимум 1)');
spamInput.focus();
showError('Проверьте поле «Интервал ожидания»');
return;
}
clearFieldError('spamInterval');
const btn = document.getElementById('saveBtn');
btn.dataset.loadingText = 'Сохранение...';
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/settings`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ settings: {
system_name: document.getElementById('systemName').value.trim(),
spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
ai_prompt: document.getElementById('aiPrompt').value.trim(),
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
share_show_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false',
share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false',
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false',
cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(),
photo_capture_resolution: document.getElementById('photoResolution').value,
photo_capture_quality: String(document.getElementById('photoQuality').value),
photo_enhance_engine: document.getElementById('photoEngine').value,
} })
});
if (res.ok) {
showToast('Настройки сохранены');
logoOriginal = currentLogo;
snapshotDirty();
updateSpamUi();
} else {
const e = await res.json().catch(() => ({}));
showError(e.error || 'Ошибка сохранения');
}
} catch {
showError('Ошибка сети при сохранении');
} finally {
setBtnLoading(btn, false);
}
}
async function downloadBackup() {
const btn = document.getElementById('backupBtn');
btn.dataset.loadingText = 'Формирование...';
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
if (!res.ok) { showError('Ошибка скачивания: ' + (await res.text())); return; }
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
showToast('Бэкап скачан');
} catch {
showError('Ошибка сети при формировании бэкапа');
} finally {
setBtnLoading(btn, false);
}
}
document.getElementById('restoreFile').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
const st = document.getElementById('restoreStatus');
const backupBtn = document.getElementById('backupBtn');
const restoreBtn = document.getElementById('restoreBtn');
st.classList.remove('err');
st.innerHTML = '<span class="spinner dark"></span> Восстановление...';
backupBtn.disabled = true;
restoreBtn.disabled = true;
const fd = new FormData();
fd.append('backup', file);
let res;
try {
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
} catch (err) {
st.textContent = '';
showError('Ошибка сети: ' + err.message);
e.target.value = '';
return;
} finally {
backupBtn.disabled = false;
restoreBtn.disabled = false;
}
if (res.ok) {
showToast('Данные восстановлены');
st.textContent = '';
loadSettings();
} else {
st.classList.add('err');
st.innerHTML = 'Ошибка восстановления';
showError(await res.text());
}
e.target.value = '';
});
async function loadBans() {
const el = document.getElementById('bansList');
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
if (res.status === 403) { document.getElementById('sec-bans').style.display = 'none'; return; }
if (!res.ok) { el.innerHTML = '<span class="chip">Не удалось загрузить</span>'; return; }
const bans = await res.json();
if (!bans.length) { el.innerHTML = '<span class="chip">Активных блокировок нет</span>'; return; }
el.innerHTML = bans.map(b => `
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
<b>${esc(b.ip)}</b>
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span>
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
</span>`).join('');
}
async function doUnban(ip) {
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('IP разблокирован: ' + ip); loadBans(); }
else showError('Ошибка разблокировки');
}
document.getElementById('bansList').addEventListener('click', e => {
const btn = e.target.closest('[data-unban]');
if (!btn) return;
const ip = btn.dataset.unban;
const chip = btn.closest('[data-chip]');
if (!chip || chip.dataset.confirming) return;
chip.dataset.confirming = '1';
chip.classList.add('confirming');
btn.outerHTML = `
<span class="chip-confirm">
Разблокировать?
<button type="button" class="chip-yes" data-unban-yes>Да</button>
<button type="button" class="chip-no" data-unban-no>Нет</button>
</span>`;
chip.querySelector('[data-unban-yes]').addEventListener('click', () => doUnban(ip));
chip.querySelector('[data-unban-no]').addEventListener('click', () => loadBans());
});
document.getElementById('backupBtn').addEventListener('click', downloadBackup);
document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click());
document.getElementById('saveBtn').addEventListener('click', saveSettings);
document.getElementById('resetBtn').addEventListener('click', revertChanges);
const systemRefreshBtn = document.getElementById('systemRefreshBtn');
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', async () => {
systemRefreshBtn.classList.add('spinning');
await loadSystemInfo();
systemRefreshBtn.classList.remove('spinning');
});
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
statsRefreshBtn.classList.add('spinning');
await refreshStats();
statsRefreshBtn.classList.remove('spinning');
});
function logoStatusText(html) {
const st = document.getElementById('logoUploadStatus');
if (st) st.innerHTML = html;
}
document.getElementById('logoUploadBtn').addEventListener('click', () => document.getElementById('logoInput').click());
document.getElementById('logoInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const btn = document.getElementById('logoUploadBtn');
if (file.size > 1024 * 1024) {
e.target.value = '';
showError('Логотип не должен превышать 1 МБ');
return;
}
btn.disabled = true;
logoStatusText('<span class="spinner dark"></span> Загрузка...');
try {
const fd = new FormData();
fd.append('logo', file);
const res = await fetch(`${API}/api/settings/logo`, { method: 'POST', headers: hdr(), body: fd });
const d = await res.json().catch(() => ({}));
if (res.ok) {
currentLogo = d.system_logo || '';
renderLogoPreview();
logoStatusText('');
showToast('Логотип обновлён');
} else {
logoStatusText(`<span class="fail">${esc(d.error || 'Ошибка загрузки')}</span>`);
}
} catch {
logoStatusText('<span class="fail">Ошибка сети</span>');
} finally {
btn.disabled = false;
e.target.value = '';
}
});
document.getElementById('logoRemoveBtn').addEventListener('click', async () => {
if (!confirm('Удалить логотип? Название системы сохранится.')) return;
logoStatusText('<span class="spinner dark"></span> Удаление...');
try {
const res = await fetch(`${API}/api/settings/logo`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
currentLogo = '';
logoOriginal = '';
renderLogoPreview();
logoStatusText('');
showToast('Логотип удалён');
} else {
logoStatusText('<span class="fail">Ошибка удаления</span>');
}
} catch {
logoStatusText('<span class="fail">Ошибка сети</span>');
}
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
const modal = document.getElementById('aiProfileModal');
const saveBtn = document.getElementById('saveBtn');
if (!modal.classList.contains('open') && document.getElementById('saveBar').classList.contains('show') && !saveBtn.disabled) saveSettings();
}
});
window.addEventListener('beforeunload', e => {
if (document.getElementById('saveBar').classList.contains('show')) {
e.preventDefault();
e.returnValue = '';
}
});
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
bindDirty();
initSectionNav();
updateSpamUi();
const authStatus = document.getElementById('authStatus');
try {
await loadSettings();
if (authStatus) authStatus.classList.add('loaded');
} catch {
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
}
loadBans();
}
})();
+335
View File
@@ -0,0 +1,335 @@
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
function isImageFile(name){return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
function thumbSrc(p){
if(!p)return p;
if(p.startsWith('/uploads/'))return '/uploads/thumb/'+p.slice('/uploads/'.length);
if(p.includes('/api/files/'))return p+(p.includes('?')?'&':'?')+'thumb=1';
return p;
}
let countdownInterval = null;
let countdownTarget = null;
let countdownScrollHandler = null;
function formatRemaining(ms) {
if (ms <= 0) return null;
const totalSec = Math.floor(ms / 1000);
const days = Math.floor(totalSec / 86400);
const hours = Math.floor((totalSec % 86400) / 3600);
const minutes = Math.floor((totalSec % 3600) / 60);
const seconds = totalSec % 60;
const parts = [];
if (days > 0) parts.push(`${days} ${declension(days, 'день', 'дня', 'дней')}`);
if (hours > 0) parts.push(`${hours} ${declension(hours, 'час', 'часа', 'часов')}`);
if (minutes > 0) parts.push(`${minutes} ${declension(minutes, 'минута', 'минуты', 'минут')}`);
if (days === 0) parts.push(`${seconds} ${declension(seconds, 'секунда', 'секунды', 'секунд')}`);
return parts.join(' ');
}
function declension(n, one, few, many) {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 19) return many;
if (mod10 === 1) return one;
if (mod10 >= 2 && mod10 <= 4) return few;
return many;
}
function shouldShowCountdown() {
const hero = document.querySelector('.hero');
if (!hero) return true;
return window.scrollY > hero.offsetHeight - 80;
}
function syncCountdownVisibility() {
const el = document.getElementById('countdown');
if (!countdownTarget) { el.classList.remove('visible'); return; }
if (countdownTarget - Date.now() <= 0) { el.classList.remove('visible'); return; }
if (shouldShowCountdown()) el.classList.add('visible');
else el.classList.remove('visible');
}
function updateCountdown() {
const el = document.getElementById('countdown');
if (!countdownTarget) { el.classList.remove('visible'); return; }
const now = Date.now();
const remaining = countdownTarget - now;
if (remaining <= 0) {
el.classList.remove('visible');
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
return;
}
const text = formatRemaining(remaining);
if (!text) { el.classList.remove('visible'); return; }
el.classList.remove('warn', 'urgent');
if (remaining < 3600000) el.classList.add('urgent');
else if (remaining < 86400000) el.classList.add('warn');
el.querySelector('.cd-text').innerHTML = `Ссылка доступна ещё <strong>${text}</strong>`;
syncCountdownVisibility();
}
function startCountdown(expiresAt) {
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
if (countdownScrollHandler) { window.removeEventListener('scroll', countdownScrollHandler); countdownScrollHandler = null; }
const el = document.getElementById('countdown');
if (!expiresAt) { el.classList.remove('visible'); return; }
countdownTarget = new Date(expiresAt).getTime();
countdownScrollHandler = syncCountdownVisibility;
window.addEventListener('scroll', countdownScrollHandler, { passive: true });
updateCountdown();
countdownInterval = setInterval(updateCountdown, 1000);
}
function fileType(name){
if(/\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image';
if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music';
if(/\.(pptx?|odp|key)$/i.test(name||'')) return 'presentation';
if(/\.(pdf|docx?|xlsx?|txt|rtf|odt|ods|csv|md)$/i.test(name||'')) return 'document';
return 'other';
}
function fileIcon(t){
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
return icons[t]||icons.other;
}
function filesHTML(files, password, shareToken){
if(!files||!files.length)return '';
const pw = password ? `?password=${encodeURIComponent(password)}` : '';
return `<div class="files-block">
<div class="files-label">Работы резидента</div>
<div class="files">${files.map(f=>{
const t = fileType(f.name);
const url = `/api/share/${shareToken}/files/${f.token}${pw}`;
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
?`href="#" data-img="${esc(url)}"`
:`href="${url}" download`}>
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
</a>`;
}).join('')}</div>
</div>`;
}
let currentToken = '';
let currentPassword = null;
async function fetchShareData(token, password) {
const headers = {};
if (password) headers['x-share-password'] = password;
const res = await fetch('/api/share/' + token, { headers });
const data = await res.json().catch(() => ({}));
return { ok: res.ok, status: res.status, data };
}
async function loadShare() {
const token = location.pathname.split('/').filter(Boolean).pop() || '';
currentToken = token;
currentPassword = null;
const tryLoad = async (pwd) => {
const result = await fetchShareData(token, pwd);
if (result.ok) {
renderShare(result.data, pwd);
return true;
}
if (result.status === 401 && result.data.passwordRequired) {
showPasswordModal();
return false;
}
if (result.status === 410) {
showError('Срок действия ссылки истёк');
return true;
}
showError('Ссылка не найдена или была удалена');
return true;
};
await tryLoad(null);
}
function showPasswordModal() {
document.getElementById('passwordInput').value = '';
document.getElementById('passwordModal').classList.add('open');
document.getElementById('passwordInput').focus();
}
function hidePasswordModal() {
document.getElementById('passwordModal').classList.remove('open');
}
async function submitPassword() {
const pwd = document.getElementById('passwordInput').value;
if (!pwd) return;
const result = await fetchShareData(currentToken, pwd);
if (result.ok) {
hidePasswordModal();
currentPassword = pwd;
renderShare(result.data, pwd);
} else {
alert('Неверный пароль');
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
}
function showError(msg) {
document.getElementById('title').textContent = msg;
document.getElementById('grid').innerHTML = `<div class="empty"><div class="big">🤔</div>${esc(msg)}</div>`;
document.getElementById('photosBlock').style.display = 'none';
document.getElementById('shareMessage').style.display = 'none';
document.getElementById('shareLinkBlock').style.display = 'none';
document.getElementById('countdown').classList.remove('visible');
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
if (countdownScrollHandler) { window.removeEventListener('scroll', countdownScrollHandler); countdownScrollHandler = null; }
}
function renderShare(data, password) {
const heading = (data.name && String(data.name).trim()) || data.group_name || 'Записи занятий';
document.title = heading;
document.getElementById('title').textContent = heading;
const chips = [];
if (data.group_name) chips.push(`Группа: <b>${esc(data.group_name)}</b>`);
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
startCountdown(data.expires_at);
const msgEl = document.getElementById('shareMessage');
if (data.message) {
msgEl.textContent = data.message;
msgEl.style.display = 'block';
} else {
msgEl.style.display = 'none';
}
const linkBlock = document.getElementById('shareLinkBlock');
linkBlock.innerHTML = '';
if (data.link_url && /^https?:\/\//i.test(data.link_url)) {
const a = document.createElement('a');
a.className = 'share-link-btn';
a.href = data.link_url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = '🔗 Перейти по ссылке';
linkBlock.appendChild(a);
linkBlock.style.display = 'block';
} else {
linkBlock.style.display = 'none';
}
const pb = document.getElementById('photosBlock');
if (data.show_group_photos && data.photos && data.photos.length) {
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
<a class="gphoto" href="#" data-img="${esc(p.photo_path)}">
<img src="${thumbSrc(p.photo_path)}" alt="${esc(p.caption || '')}" loading="lazy" decoding="async">
${p.caption ? `<div class="gcap">${esc(p.caption)}</div>` : ''}
</a>
`).join('');
pb.style.display = 'block';
} else {
pb.style.display = 'none';
}
const grid = document.getElementById('grid');
if (!data.entries.length) {
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
return;
}
const isAnonymized = !data.show_student_names;
const showStudentMessage = !!data.show_student_message;
const showEntryDate = !!data.show_entry_date;
const dayKey = e => {
const d = new Date(e.created_at);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
};
const dayLabel = k => {
const [y,m,d] = k.split('-').map(Number);
return new Date(y, m-1, d).toLocaleDateString('ru-RU', { day:'numeric', month:'long', year:'numeric' });
};
const cardHTML = e => {
const body = [
!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '',
!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : '',
showStudentMessage && e.description ? `<div class="desc">${esc(e.description)}</div>` : '',
filesHTML(e.files, password, currentToken)
].join('');
return `
<div class="card">
<div class="photo">
${e.photo_path
? `<img src="${thumbSrc(e.photo_path)}" data-img="${esc(e.photo_path)}" loading="lazy" decoding="async" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
</div>
${body ? `<div class="body">${body}</div>` : ''}
${showEntryDate ? `<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>` : ''}
</div>
`;
};
const groups = [];
let lastKey = null;
data.entries.forEach(e => {
const k = dayKey(e);
if (k !== lastKey) { groups.push({ key: k, entries: [] }); lastKey = k; }
groups[groups.length-1].entries.push(e);
});
grid.innerHTML = groups.map(g =>
`<div class="date-sep"><span class="date-chip">${esc(dayLabel(g.key))}</span><span class="date-line"></span></div>` +
g.entries.map(cardHTML).join('')
).join('');
}
document.getElementById('lightbox').addEventListener('click', () => document.getElementById('lightbox').classList.remove('open'));
document.getElementById('passwordModal').querySelector('button').addEventListener('click', submitPassword);
document.addEventListener('click', e => {
const imgEl = e.target.closest('[data-img]');
if (imgEl) { e.preventDefault(); showImg(imgEl.dataset.img); }
});
(async()=>{
await loadShare();
})();
(async function(){
const notice = document.getElementById('cookieNotice');
const acceptBtn = document.getElementById('cookieAcceptBtn');
const COOKIE_KEY = 'whatido_cookie_consent';
if (!notice || !acceptBtn || localStorage.getItem(COOKIE_KEY)) return;
try {
const res = await fetch('/api/public-settings');
const s = await res.json();
const text = (s.cookie_notice_text || '').trim();
if (!text) return;
notice.querySelector('p').textContent = text;
notice.classList.remove('hidden');
} catch (e) { return; }
acceptBtn.addEventListener('click', () => {
localStorage.setItem(COOKIE_KEY, '1');
notice.classList.add('hidden');
});
})();
(async function(){
try {
const res = await fetch('/api/public-settings');
if (!res.ok) return;
const s = await res.json();
const head = document.getElementById('brandHead');
if (!head) return;
const logo = document.getElementById('brandLogo');
const name = document.getElementById('brandName');
let any = false;
if (name && (s.system_name || '').trim()) {
name.textContent = s.system_name;
name.hidden = false;
any = true;
}
if (logo && s.system_logo) {
logo.src = s.system_logo;
logo.hidden = false;
any = true;
}
if (any) head.hidden = false;
} catch (e) {}
})();
+579
View File
@@ -0,0 +1,579 @@
let allStudents = [];
let allGroups = [];
let groupOptionsLoaded = false;
let sPage = 1;
const S_PAGE_SIZE = 25;
let sSearchTimer = null;
let aSearchTimer = null;
let attachGroupLoaded = false;
async function loadGroupOptions(sel, selected) {
if (!groupOptionsLoaded) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
allGroups = await res.json();
groupOptionsLoaded = true;
const filt = document.getElementById('studentGroupFilter');
if (filt && filt.options.length <= 1) {
filt.innerHTML = `<option value="">Все группы</option>` +
allGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
}
if (sel) {
sel.innerHTML = `<option value="">— не привязана —</option>` +
allGroups.map(g => `<option value="${g.id}" ${selected == g.id ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
}
}
async function loadStudents() {
const res = await fetch(`${API}/api/students`, { headers: hdr() });
allStudents = await res.json();
renderStudents();
}
function renderStudents() {
const q = document.getElementById('studentSearch').value.trim().toLowerCase();
const gid = document.getElementById('studentGroupFilter').value;
let filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
if (gid) filtered = filtered.filter(s => String(s.group_id) === gid);
const totalPages = Math.max(1, Math.ceil(filtered.length / S_PAGE_SIZE));
if (sPage > totalPages) sPage = 1;
const slice = filtered.slice((sPage - 1) * S_PAGE_SIZE, sPage * S_PAGE_SIZE);
const list = document.getElementById('studentList');
if (!slice.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Ничего не найдено</div>'; }
else {
list.innerHTML = slice.map(s => `
<div class="item">
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
<select class="group-select" id="sgroup-${s.id}"></select>
<div class="student-actions">
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
<button class="btn-link" data-profile-student="${s.id}" title="Скачать профиль ученика (ZIP: фото и работы)">Профиль</button>
<button class="btn-link" data-profile-data="${s.id}" title="Данные профиля: заполняют разделы страницы профиля в архиве">Данные</button>
<button class="btn-link" data-export-student="${esc(s.name)}" title="Сформировать ZIP-отчёт: фотографии и файлы">Отчёт</button>
<button class="btn-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
<button class="btn-del" data-del-student="${s.id}" title="Удалить">&times;</button>
</div>
</div>
`).join('');
slice.forEach(s => loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id));
}
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
}
function goStudentPage(p) {
sPage = p;
renderStudents();
}
async function addStudent() {
const input = document.getElementById('newStudent');
const name = input.value.trim();
if (!name) return;
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
if (res.ok) { input.value = ''; loadStudents(); fillAutocomplete(); showToast(`Добавлен: ${name}`); }
else { const e = await res.json(); alert(e.error); }
}
async function saveStudent(id) {
const name = document.getElementById(`sname-${id}`).value.trim();
if (!name) return;
const group_id = document.getElementById(`sgroup-${id}`).value;
const res = await fetch(`${API}/api/students/${id}`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ name, group_id }) });
if (res.ok) { loadStudents(); fillAutocomplete(); showToast(`Сохранено: ${name}`); }
else { const e = await res.json(); alert(e.error); }
}
let exportPendingName = null;
function openExportModal(name) {
exportPendingName = name;
document.getElementById('exportName').textContent = name;
document.getElementById('exportForm').reset();
document.getElementById('exportDateFrom').value = '';
document.getElementById('expEntries').checked = true;
document.getElementById('expPhotos').checked = true;
document.getElementById('expCaptions').checked = true;
document.getElementById('expFiles').checked = true;
document.getElementById('expDates').checked = true;
document.getElementById('exportModal').classList.add('open');
}
function closeExportModal() {
document.getElementById('exportModal').classList.remove('open');
exportPendingName = null;
}
function onExportPhotosToggle() {
const on = document.getElementById('expPhotos').checked;
document.getElementById('expCaptions').disabled = !on;
}
async function exportStudent(name, opts) {
showToast(`Формируем отчёт: ${name}...`);
try {
const q = new URLSearchParams({ name });
if (opts.dateFrom) q.set('date_from', opts.dateFrom);
if (opts.dateTo) q.set('date_to', opts.dateTo);
q.set('include_entries', opts.entries ? '1' : '0');
q.set('include_photos', opts.photos ? '1' : '0');
q.set('include_captions', opts.captions ? '1' : '0');
q.set('include_files', opts.files ? '1' : '0');
q.set('show_dates', opts.dates ? '1' : '0');
const res = await fetch(`${API}/api/export/student?${q}`, { headers: hdr() });
if (!res.ok) {
const e = await res.json();
alert(e.error || 'Не удалось сформировать отчёт');
return;
}
const blob = await res.blob();
downloadBlob(blob, `student_${safeWindowsName(name)}_report.zip`);
showToast(`Готово: ${name}`);
} catch (err) {
alert('Ошибка при формировании отчёта');
}
}
async function submitExport() {
const name = exportPendingName;
if (!name) return;
const captionsEl = document.getElementById('expCaptions');
const opts = {
entries: document.getElementById('expEntries').checked,
photos: document.getElementById('expPhotos').checked,
files: document.getElementById('expFiles').checked,
captions: captionsEl.checked && !captionsEl.disabled,
dates: document.getElementById('expDates').checked,
dateFrom: document.getElementById('exportDateFrom').value,
dateTo: document.getElementById('exportDateTo').value,
};
if (!opts.entries && !opts.photos && !opts.files) {
alert('Выберите хотя бы одну категорию: записи, фото или файлы');
return;
}
if (opts.dateFrom && opts.dateTo && opts.dateFrom > opts.dateTo) {
alert('Дата «С» позже даты «По»');
return;
}
closeExportModal();
await exportStudent(name, opts);
}
async function exportStudentProfile(id) {
const s = allStudents.find(x => x.id === id);
if (!s) return;
const btn = document.querySelector(`[data-profile-student="${id}"]`);
const label = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = 'Готовим...'; }
showToast(`Формируем профиль: ${s.name}...`);
try {
const res = await fetch(`${API}/api/export/student?name=${encodeURIComponent(s.name)}`, { headers: hdr() });
if (!res.ok) {
const e = await res.json().catch(() => ({}));
showToast(e.error || 'Не удалось сформировать профиль');
return;
}
const blob = await res.blob();
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${new Date().toISOString().slice(0, 10)}.zip`);
showToast(`Готово: ${s.name}`);
} catch (err) {
showToast('Ошибка при формировании профиля');
} finally {
if (btn) { btn.disabled = false; btn.textContent = label; }
}
}
let profileDataId = null;
let profilePhotos = [];
function openProfileData(id) {
profileDataId = id;
loadGroupOptions();
loadStudentPhotos(id);
fetch(`${API}/api/students/${id}/profile`, { headers: hdr() })
.then(r => (r.ok ? r.json() : Promise.reject(new Error('load'))))
.then(data => fillProfileForm(data.profile, data.name, data.photo_path))
.catch(() => fillProfileForm(null, '', ''));
document.getElementById('profileDataModal').classList.add('open');
}
function closeProfileData() {
document.getElementById('profileDataModal').classList.remove('open');
profileDataId = null;
}
async function loadStudentPhotos(id) {
try {
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
profilePhotos = data.photos || [];
document.getElementById('pfPhoto').value = data.photo_path || '';
renderStudentPhotos();
} catch (err) {
profilePhotos = [];
}
}
function renderStudentPhotos() {
const wrap = document.getElementById('pfPhotos');
if (!wrap) return;
const main = document.getElementById('pfPhoto').value.trim();
if (!profilePhotos.length) {
wrap.innerHTML = '<span class="hint" style="margin:0">Пока нет загруженных фото</span>';
return;
}
wrap.innerHTML = profilePhotos.map(p => `
<div class="pf-photo${p.photo_path === main ? ' main' : ''}" data-pf-set="${p.id}" title="Сделать главным">
<img src="${esc(p.photo_path)}" alt="" loading="lazy">
${p.photo_path === main ? '<span class="pf-badge">Главная</span>' : ''}
<button type="button" class="pf-del" data-pf-del="${p.id}" title="Удалить фото">✕</button>
</div>
`).join('');
}
async function uploadStudentPhoto(file) {
if (!profileDataId || !file) return;
if (!file.type.startsWith('image/')) {
alert('Можно загружать только изображения');
return;
}
showToast('Загружаем фото...');
const fd = new FormData();
fd.append('photo', file);
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
if (res.ok) {
await loadStudentPhotos(profileDataId);
showToast('Фото загружено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось загрузить фото');
}
} catch (err) {
alert('Ошибка при загрузке фото');
}
}
async function setMainStudentPhoto(pid) {
if (!profileDataId) return;
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}/main`, { method: 'PUT', headers: hdr() });
if (res.ok) {
const r = await res.json();
document.getElementById('pfPhoto').value = r.photo_path || '';
renderStudentPhotos();
showToast('Главное фото обновлено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось выбрать главное фото');
}
} catch (err) {
alert('Ошибка при выборе главного фото');
}
}
async function delStudentPhoto(pid) {
if (!profileDataId) return;
if (!confirm('Удалить это фото?')) return;
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
await loadStudentPhotos(profileDataId);
showToast('Фото удалено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось удалить фото');
}
} catch (err) {
alert('Ошибка при удалении фото');
}
}
function fillProfileForm(profile, name, photoPath) {
const p = profile || {};
const set = (id, v) => { document.getElementById(id).value = v === null || v === undefined ? '' : String(v); };
document.getElementById('profileDataTitle').textContent = name || 'Профиль ученика';
set('pfRole', p.role); set('pfStatus', p.status); set('pfStatusNote', p.status_note);
set('pfCity', p.city); set('pfMentor', p.mentor); set('pfJoined', p.joined);
set('pfBio', p.bio); set('pfQuote', p.quote);
set('pfTags', (p.tags || []).join(', '));
set('pfContacts', (p.contacts || []).map(c => [c.icon || 'link', c.label, c.href || ''].join(' | ')).join('\n'));
set('pfAchievements', (p.achievements || []).join('\n'));
set('pfSkills', (p.skills || []).map(s => [s.group || 'Навыки', s.name, s.level || '', s.value === null || s.value === undefined ? '' : s.value].join(' | ')).join('\n'));
set('pfExperience', (p.experience || []).map(e => [e.title, e.company || '', e.period || '', e.date || '', e.badge || '', e.description || '', (e.tags || []).join(',')].join(' | ')).join('\n'));
set('pfEducation', (p.education || []).map(m => [m.module, m.progress === null || m.progress === undefined ? '' : m.progress, m.grade || '', m.teacher || ''].join(' | ')).join('\n'));
set('pfPhoto', photoPath || '');
}
function collectProfile() {
const val = id => document.getElementById(id).value.trim();
const lines = id => val(id).split('\n').map(s => s.trim()).filter(Boolean);
const cols = line => line.split('|').map(s => s.trim());
const intOrNull = s => (s === '' || s === null || s === undefined ? null : Number(s));
const out = {
role: val('pfRole') || null,
status: val('pfStatus') || null,
status_note: val('pfStatusNote') || null,
city: val('pfCity') || null,
mentor: val('pfMentor') || null,
joined: val('pfJoined') || null,
bio: val('pfBio') || null,
quote: val('pfQuote') || null,
tags: val('pfTags') ? val('pfTags').split(',').map(s => s.trim()).filter(Boolean) : [],
contacts: lines('pfContacts').map(l => {
const c = cols(l);
return { icon: c[0] || 'link', label: c[1] || '', href: c[2] || '' };
}).filter(c => c.label),
achievements: lines('pfAchievements'),
skills: lines('pfSkills').map(l => {
const c = cols(l);
return { group: c[0] || 'Навыки', name: c[1] || '', level: c[2] || null, value: intOrNull(c[3]) };
}).filter(s => s.name),
experience: lines('pfExperience').map(l => {
const c = cols(l);
return {
title: c[0] || '', company: c[1] || null, period: c[2] || null, date: c[3] || null,
badge: c[4] || null, description: c[5] || null,
tags: c[6] ? c[6].split(',').map(s => s.trim()).filter(Boolean) : [],
};
}).filter(e => e.title),
education: lines('pfEducation').map(l => {
const c = cols(l);
return { module: c[0] || '', progress: intOrNull(c[1]), grade: c[2] || null, teacher: c[3] || null };
}).filter(m => m.module),
};
const hasData = Object.keys(out).some(k => (Array.isArray(out[k]) ? out[k].length : out[k]));
return hasData ? out : null;
}
async function saveProfileData(e) {
e.preventDefault();
if (!profileDataId) return;
const body = { profile: collectProfile() };
const photoPath = document.getElementById('pfPhoto').value.trim();
body.photo_path = photoPath || null;
const res = await fetch(`${API}/api/students/${profileDataId}/profile`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify(body),
});
if (res.ok) {
closeProfileData();
loadStudents();
showToast('Данные профиля сохранены');
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Не удалось сохранить данные профиля');
}
}
async function delStudent(id) {
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
loadStudents();
fillAutocomplete();
}
function openBatchAdd() {
document.getElementById('batchText').value = '';
loadGroupOptions(document.getElementById('batchGroup'), '');
document.getElementById('batchModal').classList.add('open');
}
function closeBatchAdd() {
document.getElementById('batchModal').classList.remove('open');
}
async function batchAdd() {
const raw = document.getElementById('batchText').value;
const names = [...new Set(raw.split('\n').map(s => s.trim()).filter(Boolean))];
if (!names.length) return;
const group_id = document.getElementById('batchGroup').value;
let added = 0, dupes = 0;
for (const name of names) {
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, group_id: group_id || null }) });
if (res.ok) added++;
else if (res.status === 409) dupes++;
}
closeBatchAdd();
loadStudents();
fillAutocomplete();
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}`);
}
document.getElementById('newStudent').addEventListener('keydown', e => { if (e.key === 'Enter') addStudent(); });
document.getElementById('batchForm').addEventListener('submit', e => {
e.preventDefault();
batchAdd();
});
document.getElementById('studentSearch').addEventListener('input', () => {
clearTimeout(sSearchTimer);
sSearchTimer = setTimeout(() => { sPage = 1; renderStudents(); }, 300);
});
document.getElementById('studentGroupFilter').addEventListener('change', () => { sPage = 1; renderStudents(); });
function exportStudentsCSV() {
const gid = document.getElementById('studentGroupFilter').value;
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
if (!list.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия Имя', 'Группа', 'Ссылка на страницу']];
list.forEach(s => {
rows.push([
s.name,
s.group_name || '',
`${location.origin}/?nameInput=${encodeURIComponent(s.name)}`
]);
});
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
a.download = `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast(`Экспортировано: ${list.length}`);
}
async function exportStudentsUrls() {
const gid = document.getElementById('studentGroupFilter').value;
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
if (!list.length) { alert('Нет данных для экспорта'); return; }
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
await exportStudentUrlZip(list, `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
showToast(`Экспортировано: ${list.length}`);
}
async function openBatchAttach() {
const sel = document.getElementById('attachGroup');
if (!attachGroupLoaded) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
sel.innerHTML = `<option value="">— выберите группу —</option>` +
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
attachGroupLoaded = true;
}
document.getElementById('attachSearch').value = '';
renderAttachList();
document.getElementById('attachModal').classList.add('open');
}
function closeBatchAttach() {
document.getElementById('attachModal').classList.remove('open');
}
function renderAttachList() {
const q = document.getElementById('attachSearch').value.trim().toLowerCase();
const filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
const list = document.getElementById('attachList');
if (!filtered.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:16px">Никого не найдено</div>'; return; }
list.innerHTML = filtered.map(s => `
<label>
<input type="checkbox" value="${s.id}">
<span>${esc(s.name)}</span>
<span class="cur">${s.group_name ? esc(s.group_name) : ''}</span>
</label>
`).join('');
}
async function batchAttach() {
const group_id = document.getElementById('attachGroup').value;
const ids = [...document.querySelectorAll('#attachList input[type=checkbox]:checked')].map(c => c.value);
if (!group_id) { alert('Выберите группу'); return; }
if (!ids.length) { alert('Выберите учеников'); return; }
const res = await fetch(`${API}/api/students/batch-group`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ group_id, student_ids: ids })
});
if (res.ok) {
const r = await res.json();
closeBatchAttach();
loadStudents();
showToast(`Прикреплено: ${r.updated}`);
} else { const e = await res.json(); alert(e.error); }
}
document.getElementById('attachForm').addEventListener('submit', e => {
e.preventDefault();
batchAttach();
});
document.getElementById('attachSearch').addEventListener('input', () => {
clearTimeout(aSearchTimer);
aSearchTimer = setTimeout(renderAttachList, 300);
});
document.getElementById('exportForm').addEventListener('submit', e => {
e.preventDefault();
submitExport();
});
document.getElementById('exportCancelBtn').addEventListener('click', closeExportModal);
document.getElementById('expPhotos').addEventListener('change', onExportPhotosToggle);
document.getElementById('exportModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeExportModal(); });
document.getElementById('addStudentBtn').addEventListener('click', addStudent);
document.getElementById('batchAddBtn').addEventListener('click', openBatchAdd);
document.getElementById('batchAttachBtn').addEventListener('click', openBatchAttach);
document.getElementById('exportStudentsCsvBtn').addEventListener('click', exportStudentsCSV);
document.getElementById('exportStudentsUrlsBtn').addEventListener('click', exportStudentsUrls);
document.getElementById('batchModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchAdd(); });
document.getElementById('attachModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchAttach(); });
document.getElementById('batchCancelBtn').addEventListener('click', closeBatchAdd);
document.getElementById('attachCancelBtn').addEventListener('click', closeBatchAttach);
document.getElementById('studentList').addEventListener('click', e => {
const sv = e.target.closest('[data-save-student]');
if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; }
const cp = e.target.closest('[data-copy-student]');
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
const ex = e.target.closest('[data-export-student]');
if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; }
const pf = e.target.closest('[data-profile-student]');
if (pf) { exportStudentProfile(parseInt(pf.dataset.profileStudent, 10)); return; }
const pd = e.target.closest('[data-profile-data]');
if (pd) { openProfileData(parseInt(pd.dataset.profileData, 10)); return; }
const dl = e.target.closest('[data-del-student]');
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
});
document.getElementById('profileDataForm').addEventListener('submit', saveProfileData);
document.getElementById('profileDataCancelBtn').addEventListener('click', closeProfileData);
document.getElementById('pfPhotoUploadBtn').addEventListener('click', () => document.getElementById('pfPhotoFile').click());
document.getElementById('pfPhotoFile').addEventListener('change', e => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (f) uploadStudentPhoto(f);
});
document.getElementById('pfPhotos').addEventListener('click', e => {
const del = e.target.closest('[data-pf-del]');
if (del) { delStudentPhoto(parseInt(del.dataset.pfDel, 10)); return; }
const set = e.target.closest('[data-pf-set]');
if (set) setMainStudentPhoto(parseInt(set.dataset.pfSet, 10));
});
document.addEventListener('paste', e => {
if (!profileDataId) return;
if (!document.getElementById('profileDataModal').classList.contains('open')) return;
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (const it of items) {
if (it.type && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) {
e.preventDefault();
uploadStudentPhoto(f);
}
return;
}
}
});
document.getElementById('profileDataModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeProfileData(); });
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
fillAutocomplete();
loadGroupOptions();
loadStudents();
}
})();
+99
View File
@@ -0,0 +1,99 @@
const PAGE_SIZE = 20;
let currentEntries = [];
let page = 1;
let totalEntries = 0;
function isImageFile(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function filesHTML(files) {
if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f =>
isImageFile(f.name)
? `<a class="f-link" href="#" data-img="${API}/api/files/${f.token}" title="Открыть">${esc(f.name)}</a>`
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`
).join('')}</div>`;
}
function entryHTML(e) {
return `
<div class="card">
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
<button class="del-btn" data-hard="${e.id}" title="Удалить навсегда (вместе с файлами)">&times;</button>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
grid.className = 'grid';
grid.innerHTML = currentEntries.length
? currentEntries.map(entryHTML).join('')
: '<div class="empty">Корзина пуста</div>';
document.getElementById('trashInfo').textContent =
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
document.getElementById('emptyBtn').disabled = totalEntries === 0;
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
}
async function loadTrash() {
const p = new URLSearchParams();
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
const data = await res.json();
currentEntries = data.entries;
totalEntries = data.total;
renderGrid();
}
function goPage(p) { page = p; loadTrash(); }
async function restoreEntry(id) {
const res = await fetch(`${API}/api/entries/${id}/restore`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Запись восстановлена'); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); }
}
async function hardDelete(id) {
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
showToast('Запись удалена навсегда');
loadTrash();
}
async function emptyTrash() {
if (totalEntries === 0) return;
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
}
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
document.getElementById('grid').addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card) return;
const rs = card.querySelector('[data-restore]');
if (rs && (e.target === rs || rs.contains(e.target))) { restoreEntry(parseInt(rs.dataset.restore, 10)); return; }
const hd = card.querySelector('[data-hard]');
if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); }
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadTrash();
}
})();
+127
View File
@@ -0,0 +1,127 @@
async function init() {
if (!(await checkAuth())) return;
if (!isAdmin()) { location.href = 'journal.html'; return; }
buildSidebar('users');
document.getElementById('addUserBtn').addEventListener('click', () => openModal(null));
document.getElementById('uCancel').addEventListener('click', closeModal);
document.getElementById('uSave').addEventListener('click', saveUser);
document.getElementById('uRole').addEventListener('change', () => {
document.getElementById('uBranchesField').style.display = document.getElementById('uRole').value === 'admin' ? 'none' : '';
});
await loadUsers();
}
let branches = [];
let editingId = null;
async function loadUsers() {
const [uRes, bRes] = await Promise.all([
fetch(`${API}/api/users`, { headers: hdr() }),
fetch(`${API}/api/branches`, { headers: hdr() }),
]);
if (!uRes.ok) { showToast('Ошибка загрузки пользователей'); return; }
branches = await bRes.json();
renderUsers(await uRes.json());
}
function renderUsers(users) {
const tbody = document.querySelector('#usersTable tbody');
const empty = document.getElementById('usersEmpty');
tbody.innerHTML = '';
empty.style.display = users.length ? 'none' : 'block';
const branchById = {};
branches.forEach(b => { branchById[b.id] = b.name; });
users.forEach(u => {
const tr = document.createElement('tr');
const branchNames = (u.branch_ids || []).map(id => branchById[id] || `#${id}`).join(', ') || '—';
const roleCls = u.role === 'admin' ? 'badge-admin' : 'badge-tutor';
const roleTxt = u.role === 'admin' ? 'Администратор' : 'Тутор';
const status = u.is_active ? '<span class="status-ok">Активен</span>' : '<span class="status-off">Заблокирован</span>';
const delBtn = u.id === currentUser.id
? ''
: `<button class="row-btn del" data-del="${u.id}" title="Удалить">&times;</button>`;
tr.innerHTML = `
<td>${u.id}</td>
<td>${esc(u.username)}</td>
<td>${esc(u.name || '')}</td>
<td><span class="${roleCls}">${roleTxt}</span></td>
<td class="user-branches">${esc(branchNames)}</td>
<td>${status}</td>
<td>
<button class="row-btn" data-edit="${u.id}" title="Редактировать">✎</button>
${delBtn}
</td>`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('[data-edit]').forEach(b => {
const userId = Number(b.dataset.edit);
const user = users.find(u => u.id === userId);
b.addEventListener('click', () => openModal(user));
});
tbody.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => delUser(Number(b.dataset.del))));
}
function openModal(user) {
editingId = user ? user.id : null;
document.getElementById('userModalTitle').textContent = user ? `Пользователь: ${user.username}` : 'Новый пользователь';
document.getElementById('uUsername').value = user ? user.username : '';
document.getElementById('uName').value = user ? (user.name || '') : '';
document.getElementById('uRole').value = user ? user.role : 'tutor';
document.getElementById('uPassword').value = '';
document.getElementById('uActive').checked = user ? user.is_active : true;
document.getElementById('pwdHint').textContent = user ? '(оставьте пустым, чтобы не менять)' : '(мин. 6 символов)';
document.getElementById('uRole').disabled = !!user && user.id === currentUser.id;
const sel = document.getElementById('uBranches');
sel.innerHTML = '';
branches.forEach(b => {
const o = document.createElement('option');
o.value = b.id;
o.textContent = b.name;
if (user && (user.branch_ids || []).includes(b.id)) o.selected = true;
sel.appendChild(o);
});
document.getElementById('uBranchesField').style.display = document.getElementById('uRole').value === 'admin' ? 'none' : '';
document.getElementById('userModal').classList.add('open');
}
function closeModal() {
document.getElementById('userModal').classList.remove('open');
}
async function saveUser() {
const username = document.getElementById('uUsername').value.trim();
const name = document.getElementById('uName').value.trim() || null;
const role = document.getElementById('uRole').value;
const password = document.getElementById('uPassword').value;
const is_active = document.getElementById('uActive').checked;
const branchSel = document.getElementById('uBranches');
const branch_ids = role === 'admin' ? [] : [...branchSel.selectedOptions].map(o => Number(o.value));
if (!username) { showToast('Укажите логин'); return; }
if (!editingId && password.length < 6) { showToast('Пароль должен быть не короче 6 символов'); return; }
const payload = { username, name, role, is_active, branch_ids };
if (password) payload.password = password;
const res = await fetch(editingId ? `${API}/api/users/${editingId}` : `${API}/api/users`, {
method: editingId ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Ошибка сохранения'); return; }
showToast('Сохранено');
closeModal();
loadUsers();
}
async function delUser(id) {
if (!confirm('Удалить пользователя?')) return;
const res = await fetch(`${API}/api/users/${id}`, { method: 'DELETE', headers: hdr() });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
showToast(data.error || 'Ошибка удаления');
return;
}
showToast('Пользователь удалён');
loadUsers();
}
init();
+474
View File
@@ -0,0 +1,474 @@
const AUTO_REFRESH_MS = 5000;
const RECENT_PAGE_SIZE = 15;
const PHOTO_PAGE_SIZE = 15;
const STATUS_LABELS = {
pending: 'В очереди',
processing: 'Проверяется',
done: 'Проверено',
skipped: 'Пропущено',
error: 'Ошибка',
reverted: 'Оригинал',
};
const STATUS_BADGE = {
done: 'ai-badge-done',
error: 'ai-badge-error',
skipped: 'ai-badge-skipped',
reverted: 'ai-badge-reverted',
};
let state = null;
let photoState = null;
let photoJobRow = null;
let refreshTimer = null;
let busy = false;
let photoBusy = false;
let recentRows = [];
let recentPage = 1;
let photoPage = 1;
let photoTotal = 0;
const PHOTO_ACTION_LABELS = {
ai: 'ИИ-обработка',
enhance: 'Улучшение',
restore: 'Возврат оригинала',
rollback: 'Откат',
};
const PHOTO_STATUS_LABELS = {
pending: 'В очереди',
processing: 'Обрабатывается',
done: 'Готово',
error: 'Ошибка',
rejected: 'Отклонено',
};
const PHOTO_STATUS_BADGE = {
done: 'ai-badge-done',
error: 'ai-badge-error',
processing: 'ai-badge-skipped',
};
function thumbUrl(p) {
if (!p) return '';
if (p.startsWith('/uploads/.originals/')) return `${API}${p}`;
return `${API}/uploads/thumb/${p.split('/').pop()}`;
}
function fmtDur(ms) {
if (ms === null || ms === undefined || ms < 0) return '—';
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d) return `${d} д ${h} ч`;
if (h) return `${h} ч ${m} мин`;
if (m) return `${m} мин ${sec} с`;
return `${sec} с`;
}
function fmtTime(t) {
if (!t) return '—';
try { return new Date(t).toLocaleString('ru'); } catch { return String(t); }
}
function ago(t) {
if (!t) return '—';
const diff = Date.now() - new Date(t).getTime();
if (Number.isNaN(diff)) return '—';
return fmtDur(diff) + ' назад';
}
function statusBadge(s) {
return `<span class="ai-badge ${STATUS_BADGE[s] || ''}">${esc(STATUS_LABELS[s] || s)}</span>`;
}
function renderState() {
const w = state.worker || {};
const svc = state.service || {};
const enabled = state.enabled;
const processing = !!w.processing;
const dotClass = !enabled ? '' : (svc.reachable ? (processing ? 'busy' : 'on') : 'err');
const label = !enabled
? 'Автопроверка выключена'
: (!svc.reachable ? 'Сервис модели недоступен' : (processing ? `Обрабатывается запись #${w.current_id ?? ''}` : 'Ожидание очереди'));
document.getElementById('stateLine').innerHTML = `<span class="worker-dot ${dotClass}"></span> ${esc(label)}`;
document.getElementById('toggleBtn').textContent = enabled ? 'Выключить автопроверку' : 'Включить автопроверку';
}
function renderCards() {
const w = state.worker || {};
const svc = state.service || {};
const cfg = w.config || {};
const pct = w.checks ? Math.round(((w.corrected || 0) / w.checks) * 100) : 0;
document.getElementById('workerCards').innerHTML = `
<div class="sys-card">
<div class="sys-title">Состояние воркера</div>
<div class="sys-item"><span class="sys-label">Режим</span><span class="sys-value">${w.processing ? 'Обработка' : 'Ожидание'}</span></div>
<div class="sys-item"><span class="sys-label">Текущая запись</span><span class="sys-value">${w.current_id ? '#' + w.current_id : '—'}</span></div>
<div class="sys-item"><span class="sys-label">Работает</span><span class="sys-value">${fmtDur(w.uptime_ms)}</span></div>
<div class="sys-item"><span class="sys-label">Запущен</span><span class="sys-value">${fmtTime(w.started_at)}</span></div>
</div>
<div class="sys-card">
<div class="sys-title">Модель</div>
<div class="sys-item"><span class="sys-label">Модель</span><span class="sys-value">${esc(state.model)}</span></div>
<div class="sys-item"><span class="sys-label">Сервис</span><span class="sys-value">${svc.reachable ? '<span class="lucide-inline" style="color:#16a34a"><i data-lucide="check"></i></span> доступен' : '<span class="lucide-inline" style="color:#ef4444"><i data-lucide="x"></i></span> недоступен'}</span></div>
<div class="sys-item"><span class="sys-label">Отклик</span><span class="sys-value">${svc.latency_ms !== undefined ? svc.latency_ms + ' мс' : '—'}</span></div>
<div class="sys-item"><span class="sys-label">Адрес</span><span class="sys-value" style="font-size:.75rem">${esc(state.ai_url || '')}</span></div>
${svc.error ? `<div class="sys-meta" style="color:#ef4444">${esc(svc.error)}</div>` : ''}
</div>
<div class="sys-card">
<div class="sys-title">Итоги работы</div>
<div class="sys-item"><span class="sys-label">Проверок</span><span class="sys-value">${w.checks || 0}</span></div>
<div class="sys-item"><span class="sys-label">Исправлено</span><span class="sys-value">${w.corrected || 0} (${pct}%)</span></div>
<div class="sys-item"><span class="sys-label">Без изменений</span><span class="sys-value">${w.unchanged || 0}</span></div>
<div class="sys-item"><span class="sys-label">Ошибок</span><span class="sys-value">${w.errors || 0}</span></div>
<div class="sys-item"><span class="sys-label">Активность</span><span class="sys-value">${ago(w.last_at)}</span></div>
${w.last_error ? `<div class="sys-meta" style="color:#ef4444">${esc(w.last_error)}</div>` : ''}
</div>
<div class="sys-card">
<div class="sys-title">Параметры (мин. нагрузка)</div>
<div class="sys-item"><span class="sys-label">Пауза простоя</span><span class="sys-value">${fmtDur(cfg.idle_min_ms)} → ${fmtDur(cfg.idle_max_ms)}</span></div>
<div class="sys-item"><span class="sys-label">Таймаут запроса</span><span class="sys-value">${fmtDur(cfg.request_timeout_ms)}</span></div>
<div class="sys-item"><span class="sys-label">Макс. текст</span><span class="sys-value">${cfg.max_input_chars || 0} симв.</span></div>
<div class="sys-item"><span class="sys-label">Мин. текст</span><span class="sys-value">${cfg.min_text_chars || 0} симв.</span></div>
<div class="sys-item"><span class="sys-label">Повторы при сбое</span><span class="sys-value">${cfg.max_attempts || 0}</span></div>
</div>
`;
}
function renderCounts() {
const c = state.counts || {};
const chips = [
['pending', 'В очереди', c.pending || 0],
['processing', 'Проверяется', c.processing || 0],
['done', 'Проверено', c.done || 0],
['skipped', 'Пропущено', c.skipped || 0],
['error', 'Ошибка', c.error || 0],
['reverted', 'Оригинал', c.reverted || 0],
];
document.getElementById('workerCounts').innerHTML = chips
.map(([cls, label, n]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`)
.join('');
}
function renderPending() {
const rows = state.pending || [];
document.getElementById('pendingCount').textContent = rows.length;
const tbody = document.querySelector('#pendingTable tbody');
const empty = document.getElementById('pendingEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
<tr>
<td>#${r.id}</td>
<td>${esc(r.student_name)}</td>
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
</tr>`).join('');
}
function renderRecent() {
const filter = document.getElementById('recentFilter').value;
const all = state.recent || [];
const rows = filter ? all.filter(r => r.ai_status === filter) : all;
document.getElementById('recentCount').textContent = `${rows.length} из ${all.length}`;
const tbody = document.querySelector('#recentTable tbody');
const empty = document.getElementById('recentEmpty');
const maxPage = Math.max(1, Math.ceil(rows.length / RECENT_PAGE_SIZE));
if (recentPage > maxPage) recentPage = maxPage;
if (!rows.length) {
recentRows = [];
tbody.innerHTML = '';
empty.style.display = 'block';
renderPager(document.getElementById('recentPager'), 1, 1, () => {});
return;
}
empty.style.display = 'none';
const slice = rows.slice((recentPage - 1) * RECENT_PAGE_SIZE, recentPage * RECENT_PAGE_SIZE);
recentRows = slice;
tbody.innerHTML = slice.map((r, i) => `
<tr>
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
<td>${esc(r.student_name)}</td>
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
<td>${statusBadge(r.ai_status)}</td>
<td>${r.changed ? '<span class="lucide-inline" style="color:#f59e0b"><i data-lucide="pencil"></i></span> исправлен' : '—'}</td>
<td><button type="button" class="ai-details" data-ai-idx="${i}">Подробнее</button></td>
</tr>`).join('');
renderPager(document.getElementById('recentPager'), recentPage, maxPage, p => { recentPage = p; renderRecent(); });
}
function renderErrors() {
const rows = state.errors || [];
document.getElementById('errorsCount').textContent = rows.length;
document.getElementById('errorsCard').style.display = rows.length ? 'flex' : 'none';
document.querySelector('#errorsTable tbody').innerHTML = rows.map(r => `
<tr>
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
<td>#${r.id}</td>
<td>${esc(r.student_name)}</td>
<td style="color:#ef4444">${esc(r.ai_error || '')}</td>
</tr>`).join('');
}
function render() {
if (!state) return;
renderState();
renderCards();
renderCounts();
renderPending();
renderRecent();
renderErrors();
renderIcons();
}
function renderPhoto() {
if (!photoState) return;
const w = photoState.worker || {};
document.getElementById('photoStateHint').textContent = !photoState.enabled
? 'Обработка фотографий выключена'
: (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди'));
document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер';
const c = photoState.counts || {};
document.getElementById('photoCounts').innerHTML = [
['pending', 'В очереди', c.pending || 0],
['processing', 'Обрабатывается', c.processing || 0],
['done', 'Готово', c.done || 0],
['error', 'Ошибка', c.error || 0],
].map(([cls, label, n]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`).join('');
const rows = photoState.recent || [];
renderPhotoPending();
const tbody = document.querySelector('#photoTable tbody');
const empty = document.getElementById('photoEmpty');
const pager = document.getElementById('photoRecentPager');
const maxPage = Math.max(1, Math.ceil(photoTotal / PHOTO_PAGE_SIZE));
if (photoPage > maxPage) photoPage = maxPage;
if (!photoTotal) { tbody.innerHTML = ''; empty.style.display = 'block'; renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); }); return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => {
const before = r.before_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
const after = r.after_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.after_path))}" alt="стало">` : '<span style="color:var(--muted)">—</span>';
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback';
const badge = `<span class="ai-badge ${PHOTO_STATUS_BADGE[r.status] || ''}">${esc(PHOTO_STATUS_LABELS[r.status] || r.status)}</span>`;
return `<tr>
<td style="white-space:nowrap;color:var(--muted)">${fmtTime(r.finished_at)}</td>
<td>#${r.entry_id} ${esc(r.student_name || '')}</td>
<td>${esc(PHOTO_ACTION_LABELS[r.action] || r.action)}</td>
<td>${badge}</td>
<td>${before}</td>
<td>${after}</td>
<td style="color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.error || '')}">${r.error ? esc(r.error) : ''}</td>
<td style="white-space:nowrap">
<button type="button" class="btn-link" data-photo-view="${r.id}">Смотреть</button>
${canRollback ? `<button type="button" class="btn-link" data-photo-rollback="${r.id}" data-photo-entry="${r.entry_id}">↩ Откатить</button>` : ''}
</td>
</tr>`;
}).join('');
renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); });
renderIcons();
}
function renderPhotoPending() {
const rows = photoState.pending || [];
document.getElementById('photoPendingCount').textContent = rows.length;
const tbody = document.querySelector('#photoPendingTable tbody');
const empty = document.getElementById('photoPendingEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => {
const processing = r.status === 'processing';
const action = esc(PHOTO_ACTION_LABELS[r.action] || r.action);
const badge = `<span class="ai-badge ${processing ? 'ai-badge-skipped' : ''}">${processing ? '⏳ Обрабатывается' : 'В очереди'}</span>`;
return `<tr${processing ? ' style="background:rgba(59,130,246,.08)"' : ''}>
<td>#${r.id}</td>
<td>#${r.entry_id} ${esc(r.student_name || '')} <span style="color:var(--muted)">(${esc(r.group_name || '')})</span></td>
<td>${action} ${badge}</td>
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
</tr>`;
}).join('');
}
function findPhotoJob(id) {
return (photoState && photoState.recent || []).find(r => r.id === id) || null;
}
function setPhotoJobClip(p) {
const v = Math.min(100, Math.max(0, p));
document.getElementById('photoJobTop').style.clipPath = `inset(0 0 0 ${v}%)`;
document.getElementById('photoJobDivider').style.left = v + '%';
}
function openPhotoJob(r) {
photoJobRow = r;
document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`;
document.getElementById('photoJobBefore').src = thumbUrl(r.before_path);
document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : '';
document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none';
setPhotoJobClip(50);
document.getElementById('photoJobModal').classList.add('open');
}
async function loadPhoto() {
if (photoBusy) return;
photoBusy = true;
try {
const res = await fetch(`${API}/api/photo-jobs/status?recent_limit=${PHOTO_PAGE_SIZE}&recent_offset=${(photoPage - 1) * PHOTO_PAGE_SIZE}`, { headers: hdr() });
if (!res.ok) return;
photoState = await res.json();
photoTotal = photoState.recent_total || 0;
renderPhoto();
} catch {
} finally {
photoBusy = false;
}
}
async function postPhoto(path_, body) {
const opts = { method: 'POST', headers: body ? hdrJson() : hdr() };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${API}${path_}`, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Ошибка'); return null; }
return data;
}
async function load() {
if (busy) return;
busy = true;
try {
const res = await fetch(`${API}/api/ai/status`, { headers: hdr() });
if (!res.ok) return;
state = await res.json();
render();
} catch {
} finally {
busy = false;
}
}
async function post(path, body) {
const opts = { method: 'POST', headers: body ? hdrJson() : hdr() };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${API}${path}`, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Ошибка'); return null; }
return data;
}
async function wake() {
if (await post('/api/ai/wake')) { showToast('Воркер разбужен'); load(); }
}
async function toggleEnabled() {
const next = !(state && state.enabled);
const data = await post('/api/ai/enabled', { enabled: next });
if (data) { showToast(next ? 'Автопроверка включена' : 'Автопроверка выключена'); load(); }
}
async function requeueFailed() {
if (!confirm('Вернуть все записи с ошибкой в очередь на повторную проверку?')) return;
const data = await post('/api/ai/requeue-failed');
if (data) { showToast(`В очередь возвращено: ${data.count}`); load(); }
}
function setAutoRefresh(on) {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
if (on) refreshTimer = setInterval(() => { load(); loadPhoto(); }, AUTO_REFRESH_MS);
}
document.getElementById('refreshBtn').addEventListener('click', () => { load(); loadPhoto(); });
document.getElementById('wakeBtn').addEventListener('click', wake);
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
document.getElementById('recentFilter').addEventListener('change', () => { recentPage = 1; renderRecent(); });
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked));
document.getElementById('photoWakeBtn').addEventListener('click', async () => {
if (await postPhoto('/api/photo-jobs/wake')) { showToast('Фото-воркер разбужен'); loadPhoto(); }
});
document.getElementById('photoToggleBtn').addEventListener('click', async () => {
const next = !(photoState && photoState.enabled);
const data = await postPhoto('/api/photo-jobs/enabled', { enabled: next });
if (data) { showToast(next ? 'Обработка фото включена' : 'Обработка фото выключена'); loadPhoto(); }
});
document.getElementById('photoRequeueBtn').addEventListener('click', async () => {
if (!confirm('Вернуть все задания с ошибкой в очередь на повторную обработку?')) return;
const data = await postPhoto('/api/photo-jobs/requeue-failed');
if (data) { showToast(`В очередь возвращено: ${data.count}`); loadPhoto(); }
});
document.getElementById('recentTable').addEventListener('click', e => {
const btn = e.target.closest('.ai-details');
if (!btn) return;
const r = recentRows[parseInt(btn.dataset.aiIdx, 10)];
if (!r) return;
document.getElementById('aiDetailTitle').textContent = `ИИ-проверка #${r.id} — ${r.student_name}`;
document.getElementById('aiDetailInput').textContent = r.description_original || '—';
document.getElementById('aiDetailOutput').textContent = r.description_ai || '—';
document.getElementById('aiDetailModal').classList.add('open');
});
document.getElementById('aiDetailCloseBtn').addEventListener('click', () => {
document.getElementById('aiDetailModal').classList.remove('open');
});
document.getElementById('aiDetailModal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
document.getElementById('photoTable').addEventListener('click', async e => {
const viewBtn = e.target.closest('[data-photo-view]');
if (viewBtn) {
const r = findPhotoJob(parseInt(viewBtn.dataset.photoView, 10));
if (r) openPhotoJob(r);
return;
}
const rb = e.target.closest('[data-photo-rollback]');
if (rb) {
const entryId = rb.dataset.photoEntry;
const jobId = rb.dataset.photoRollback;
if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return;
const data = await postPhoto(`/api/entries/${entryId}/photo/jobs/${jobId}/rollback`);
if (data) { showToast('Фотография отката'); loadPhoto(); }
}
});
document.getElementById('photoJobCloseBtn').addEventListener('click', () => {
document.getElementById('photoJobModal').classList.remove('open');
});
document.getElementById('photoJobModal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
document.getElementById('photoJobRollbackBtn').addEventListener('click', async () => {
const r = photoJobRow;
if (!r) return;
if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return;
const data = await postPhoto(`/api/entries/${r.entry_id}/photo/jobs/${r.id}/rollback`);
if (data) {
showToast('Фотография отката');
document.getElementById('photoJobModal').classList.remove('open');
loadPhoto();
}
});
(function initPhotoJobDrag() {
const area = document.getElementById('photoJobCompare');
let dragging = false;
const move = ev => {
const rect = area.getBoundingClientRect();
setPhotoJobClip(((ev.clientX - rect.left) / rect.width) * 100);
};
area.addEventListener('pointerdown', ev => {
dragging = true;
area.setPointerCapture(ev.pointerId);
move(ev);
});
area.addEventListener('pointermove', ev => { if (dragging) move(ev); });
area.addEventListener('pointerup', () => { dragging = false; });
area.addEventListener('pointercancel', () => { dragging = false; });
})();
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
load();
loadPhoto();
}
})();
+66 -149
View File
@@ -13,168 +13,85 @@
<h2>Ссылки</h2>
<p style="color:var(--muted);font-size:.85rem;margin-bottom:20px;max-width:680px">Каждая ссылка открывает публичную страницу с карточками записей выбранной группы за указанный период — без входа и выбора параметров.</p>
<div class="list" id="linksList"><div class="empty">Загрузка...</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
<div class="modal-overlay" id="linkModal" onclick="if(event.target===this)closeLinkModal()">
<div class="edit-modal">
<div class="modal-overlay" id="linkModal">
<div class="edit-modal" style="max-width:640px">
<h3 id="linkModalTitle">Новая ссылка</h3>
<div>
<label>Название</label>
<input type="text" id="linkName" placeholder="Например: СБ1400 05.09">
</div>
<div>
<label>Группа</label>
<select id="linkGroup"><option value="">— без группы —</option></select>
</div>
<div>
<label>Ученик (необязательно)</label>
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
</div>
<div style="display:flex;gap:12px">
<div style="flex:1">
<div class="link-fields">
<div class="settings-field">
<label>Название <span style="color:#ef4444">*</span></label>
<input type="text" id="linkName" class="settings-input" required placeholder="Например: СБ1400 05.09">
</div>
<div class="settings-field">
<label>Группа</label>
<select id="linkGroup" class="settings-input"><option value="">— без группы —</option></select>
</div>
<div class="settings-field">
<label>Ученик (необязательно)</label>
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
</div>
<div class="settings-field">
<label>Ссылка</label>
<input type="url" id="linkUrl" class="settings-input" placeholder="https://...">
</div>
<div class="settings-field full">
<label>Сообщение</label>
<textarea id="linkMessage" class="settings-input" rows="3" placeholder="Текст, который увидят на публичной странице"></textarea>
</div>
<div class="settings-field">
<label>С</label>
<input type="date" id="linkDateFrom">
<input type="date" id="linkDateFrom" class="settings-input">
</div>
<div style="flex:1">
<div class="settings-field">
<label>По</label>
<input type="date" id="linkDateTo">
<input type="date" id="linkDateTo" class="settings-input">
</div>
<div class="settings-field">
<label>Истекает через (дни)</label>
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
</div>
<div class="settings-field">
<label>Пароль для доступа (необязательно)</label>
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="linkShowNames">
Показывать фамилии и имена учеников
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="linkShowMessage">
Показать сообщение ученика
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="linkShowDate">
Показать дату в карточке записи
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="linkShowPhotos">
Показывать фото группы
</label>
</div>
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="linkCancelBtn">Отмена</button>
<button type="button" class="btn-primary" id="linkSaveBtn">Сохранить</button>
</div>
</div>
<div class="actions">
<button class="cancel" onclick="closeLinkModal()">Отмена</button>
<button class="save" onclick="saveLink()">Сохранить</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script>
let links = [];
let groups = [];
let editingId = null;
let studentsLoaded = false;
function linkUrl(token) { return `${location.origin}/s/${token}`; }
async function loadLinks() {
const res = await fetch(`${API}/api/links`, { headers: hdr() });
links = await res.json();
const el = document.getElementById('linksList');
if (!links.length) { el.innerHTML = '<div class="empty">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
el.innerHTML = links.map(l => `
<div class="item">
<div style="min-width:0">
<div class="name">${esc(l.name)}</div>
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
· создана ${new Date(l.created_at).toLocaleString('ru')}
</div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
<a class="btn-link" style="text-decoration:none" href="/s/${l.token}" target="_blank" title="Открыть">Открыть</a>
<button class="btn-link" onclick="copyText('${linkUrl(l.token)}')" title="Копировать ссылку">Копировать</button>
<button class="btn-link" onclick="openLinkEdit(${l.id})" title="Редактировать">✎</button>
<button class="btn-link" style="color:var(--muted)" onclick="delLink(${l.id})" title="Удалить">&times;</button>
</div>
</div>
`).join('');
}
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
async function openLinkModal() {
editingId = null;
document.getElementById('linkModalTitle').textContent = 'Новая ссылка';
document.getElementById('linkName').value = '';
document.getElementById('linkDateFrom').value = '';
document.getElementById('linkDateTo').value = '';
if (!groups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
groups = await res.json();
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
if (!studentsLoaded) {
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
const dl = document.getElementById('linkStudentList');
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
document.getElementById('linkModal').classList.add('open');
}
async function openLinkEdit(id) {
const l = links.find(x => x.id === id);
if (!l) return;
editingId = id;
document.getElementById('linkModalTitle').textContent = 'Редактирование ссылки';
document.getElementById('linkName').value = l.name;
document.getElementById('linkDateFrom').value = l.date_from || '';
document.getElementById('linkDateTo').value = l.date_to || '';
if (!groups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
groups = await res.json();
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
document.getElementById('linkGroup').value = l.group_id || '';
if (!studentsLoaded) {
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
const dl = document.getElementById('linkStudentList');
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
document.getElementById('linkStudent').value = l.student_name || '';
document.getElementById('linkModal').classList.add('open');
}
function closeLinkModal() {
document.getElementById('linkModal').classList.remove('open');
editingId = null;
}
async function saveLink() {
const name = document.getElementById('linkName').value.trim();
const group_id = document.getElementById('linkGroup').value;
const student_name = document.getElementById('linkStudent').value;
const date_from = document.getElementById('linkDateFrom').value;
const date_to = document.getElementById('linkDateTo').value;
if (!name) { alert('Введите название'); return; }
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null });
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
method: editingId ? 'PUT' : 'POST',
headers: hdrJson(),
body
});
if (res.ok) {
closeLinkModal();
loadLinks();
showToast(editingId ? 'Ссылка обновлена' : 'Ссылка создана');
} else {
const err = await res.json();
alert(err.error || 'Ошибка');
}
}
async function delLink(id) {
if (!confirm('Удалить ссылку?')) return;
await fetch(`${API}/api/links/${id}`, { method: 'DELETE', headers: hdr() });
loadLinks();
showToast('Ссылка удалена');
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadLinks();
}
})();
</script>
<script src="js/links.js"></script>
</body>
</html>
+14 -25
View File
@@ -9,7 +9,10 @@
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;display:flex;align-items:center;justify-content:center}
.login{max-width:320px;width:100%;text-align:center;background:var(--card);border:1px solid var(--border);border-radius:16px;padding:40px 32px}
.login h1{font-size:1.3rem;font-weight:600;margin-bottom:24px}
.login .brand{display:flex;flex-direction:column;align-items:center;gap:14px;margin-bottom:24px}
.login .brand .logo{height:48px;width:auto;min-width:48px;background:var(--bg);border:1px solid var(--border);display:none;align-items:center;justify-content:center;overflow:hidden}
.login .brand .logo img{max-width:100%;max-height:100%;object-fit:contain}
.login h1{font-size:1.3rem;font-weight:600;margin:0}
.login input{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:14px 16px;font-size:.95rem;outline:none;margin-bottom:12px;text-align:center}
.login input:focus{border-color:var(--accent)}
.login button{width:100%;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:14px;font-size:1rem;font-weight:600;cursor:pointer}
@@ -22,31 +25,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
</head>
<body>
<div class="login">
<h1>Админ-панель</h1>
<input type="password" id="pwdInput" placeholder="Пароль" autofocus>
<button onclick="doLogin()">Войти</button>
<div class="err" id="loginErr">Неверный пароль</div>
<div class="brand">
<div class="logo" id="loginLogo"></div>
<h1 id="loginTitle">Админ-панель</h1>
</div>
<input type="text" id="userInput" placeholder="Логин" autocomplete="username" autofocus>
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
<input type="text" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
<button type="button" id="loginBtn">Войти</button>
<div class="err" id="loginErr">Неверный логин или пароль</div>
<div class="back"><a href="/">На главную</a></div>
</div>
<script>
const API = location.origin;
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
async function doLogin() {
const pwd = document.getElementById('pwdInput').value;
if (!pwd) return;
try {
const res = await fetch(`${API}/api/stats`, { headers: { 'X-Admin-Token': pwd } });
if (res.ok) {
sessionStorage.setItem('adminToken', pwd);
location.href = 'dashboard.html';
} else {
document.getElementById('loginErr').style.display = 'block';
}
} catch {
document.getElementById('loginErr').style.display = 'block';
}
}
</script>
<script src="js/login.js"></script>
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Темы модулей — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="modules">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Темы модулей</h2>
<p class="page-sub">Ученик выбирает тему модуля при заполнении формы</p>
</div>
<div class="actions-row" style="margin-bottom:16px">
<button class="btn-primary" id="addModuleBtn" type="button">
<span style="font-size:1.2rem">+</span> Добавить модуль
</button>
<button class="btn-primary ghost" id="batchModuleBtn" type="button">
Пакетное добавление
</button>
<input type="text" id="moduleSearch" class="search-input" placeholder="Поиск по названию..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
</div>
<div class="list" id="moduleList"><div class="empty">Загрузка…</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
<div class="modal-overlay" id="moduleModal">
<div class="edit-modal" style="max-width:480px">
<h3 id="moduleModalTitle">Создать модуль</h3>
<form id="moduleForm" class="settings-stack" style="gap:12px">
<input type="hidden" id="moduleId">
<div class="settings-field">
<label>Название <span style="color:#ef4444">*</span></label>
<input type="text" name="name" class="settings-input" required placeholder="Например: Основы Python" autocomplete="off">
</div>
<div class="settings-field">
<label>Количество занятий</label>
<input type="number" name="lessons_count" id="moduleLessons" class="settings-input" min="0" max="10000" step="1" value="0">
</div>
<div class="settings-field">
<label>Картинка модуля</label>
<div class="module-photo-controls">
<img id="modulePhotoPreview" class="module-photo-preview" alt="" style="display:none">
<div class="module-photo-actions">
<label class="btn-primary ghost module-photo-pick" for="modulePhotoFile">Выбрать файл</label>
<input type="file" id="modulePhotoFile" accept="image/*" style="display:none">
<button type="button" id="modulePhotoDeleteBtn" class="btn-link danger" style="display:none">Удалить</button>
</div>
<span id="modulePhotoNote" class="hint">jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)</span>
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="moduleCancelBtn">Отмена</button>
<button type="submit" class="btn-primary" id="moduleSubmitBtn">Создать</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="batchModuleModal">
<div class="edit-modal" style="max-width:520px">
<h3>Пакетное добавление модулей</h3>
<form id="batchModuleForm" class="settings-stack" style="gap:12px">
<div class="settings-field">
<label>Модули — каждый с новой строки <span style="color:#ef4444">*</span></label>
<textarea id="batchModuleText" class="settings-input" style="min-height:160px;font-family:monospace" required
placeholder="Основы Python | 10&#10;Введение в базы данных | 8&#10;Разработка интерфейсов 12"></textarea>
<span class="hint">Название может состоять из нескольких слов. Количество занятий отделяйте символом <b>|</b>, <b>;</b>, <b>=</b> или табуляцией; если не указать разделитель — последнее число в строке считается количеством занятий. Количество занятий обязательно для каждой строки.</span>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="batchModuleCancelBtn">Отмена</button>
<button type="submit" class="btn-primary" id="batchModuleSubmitBtn">Добавить</button>
</div>
</form>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/modules.js"></script>
<style>
.page-sub{color:var(--muted);font-size:.85rem;margin:0}
.module-item{display:flex;justify-content:space-between;align-items:center;gap:12px}
.module-info{min-width:0;display:flex;flex-direction:column;gap:4px}
.module-name{font-weight:600;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.module-thumb{width:48px;height:48px;border-radius:8px;object-fit:cover;flex-shrink:0}
.module-meta{font-size:.78rem;color:var(--muted)}
.module-item.module-off .module-name{color:var(--muted)}
.module-badge{display:inline-block;margin-left:8px;padding:1px 8px;border-radius:999px;font-size:.7rem;font-weight:600;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle}
.module-item .btn-link.danger{color:#ef4444}
.module-item .btn-link.danger:hover{border-color:#ef4444}
#batchModuleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block}
#batchModuleModal .hint b{color:var(--text)}
.module-photo-controls{display:flex;flex-direction:column;gap:10px;align-items:flex-start;width:100%}
.module-photo-preview{width:100%;max-height:180px;object-fit:contain;border:1px solid var(--border);border-radius:10px;background:var(--bg)}
.module-photo-actions{display:flex;align-items:center;gap:8px}
.module-photo-pick{text-align:center;cursor:pointer;margin:0}
.module-photo-actions .btn-link.danger{color:#ef4444;border-color:rgba(239,68,68,.35)}
.module-photo-actions .btn-link.danger:hover{border-color:#ef4444}
#moduleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block}
@media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}}
</style>
</body>
</html>
File diff suppressed because one or more lines are too long
+188
View File
@@ -0,0 +1,188 @@
/* WhatIDo - report page layer on top of report.css (design system from the reference layout). */
@font-face{
font-family:"Inter";
font-style:normal;
font-weight:400;
font-display:swap;
src:url("/fonts/inter-400.woff2") format("woff2");
}
@font-face{
font-family:"Inter";
font-style:normal;
font-weight:500;
font-display:swap;
src:url("/fonts/inter-500-cyr.woff2") format("woff2");
unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face{
font-family:"Inter";
font-style:normal;
font-weight:500;
font-display:swap;
src:url("/fonts/inter-500-lat.woff2") format("woff2");
unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
:root{
--rep-accent:rgb(118,132,255);
--rep-accent-hover:rgb(89,99,185);
--rep-bg:rgb(21,21,21);
--rep-line:rgba(255,255,255,.1);
--rep-line-soft:rgba(255,255,255,.16);
--rep-muted:rgba(174,176,176,1);
--sf-img-0:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjUnIGhlaWdodD0nMjUnIHZpZXdCb3g9JzAgMCAyNSAyNScgZmlsbD0nbm9uZScgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz48cGF0aCBkPSdNMTUuNSA2LjVMOS41IDEyLjVMMTUuNSAxOC41JyBzdHJva2U9JyM5QzlDOUMnIHN0cm9rZS1saW5lY2FwPSdyb3VuZCcgc3Ryb2tlLWxpbmVqb2luPSdyb3VuZCcvPjwvc3ZnPgo=");
--sf-img-1:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjUnIGhlaWdodD0nMjUnIHZpZXdCb3g9JzAgMCAyNSAyNScgZmlsbD0nbm9uZScgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz48cGF0aCBkPSdNOS41IDE4LjVMMTUuNSAxMi41TDkuNSA2LjUnIHN0cm9rZT0nIzlDOUM5Qycgc3Ryb2tlLWxpbmVjYXA9J3JvdW5kJyBzdHJva2UtbGluZWpvaW49J3JvdW5kJy8+PC9zdmc+Cg==");
--sf-img-7:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNS45OTg0IDUuMzkzNDFMMjUuMTkwNiAxNC41ODU2TDI1LjE5MDYgMTQuNTg1NkwyNi42MDQ4IDE1Ljk5OThMMjYuNjA0OCAxNS45OTk4TDI2LjYwNSAxNkwyNS4xOTA3IDE3LjQxNDJMMjUuMTkwNiAxNy40MTRMMTUuOTk4MiAyNi42MDY0TDE0LjU4NCAyNS4xOTIyTDIyLjc4NSAxNi45OTEyTDYuNDk1MjMgMTYuOTkxMkw2LjQ5NTIzIDE1LjAwODRMMjIuNzg0OSAxNS4wMDg0TDE0LjU4NDEgNi44MDc2MkwxNS45OTg0IDUuMzkzNDFaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K");
--sf-img-8:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNi4wMDE2IDI2LjYwNjZMNi44MDk0NCAxNy40MTQ0TDYuODA5NDQgMTcuNDE0NEw1LjM5NTIyIDE2LjAwMDJMNS4zOTUyMyAxNi4wMDAyTDUuMzk1MDQgMTZMNi44MDkyNiAxNC41ODU4TDYuODA5NDQgMTQuNTg2TDE2LjAwMTggNS4zOTM1OUwxNy40MTYgNi44MDc4TDkuMjE0OTkgMTUuMDA4OUwyNS41MDQ4IDE1LjAwODhMMjUuNTA0OCAxNi45OTE2TDkuMjE1MDggMTYuOTkxNkwxNy40MTU5IDI1LjE5MjRMMTYuMDAxNiAyNi42MDY2WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==");
--sf-img-9:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTEiIHZpZXdCb3g9IjAgMCAxMiAxMSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEuMTk5MjIgNC44NTYzNkw0Ljc5OTIyIDkuNzMyNTVMMTAuNzk5MiAxLjE5OTIyIiBzdHJva2U9IndoaXRlIi8+Cjwvc3ZnPgo=");
}
html{scroll-behavior:smooth}
html,body{background-color:var(--rep-bg)}
body{overflow-x:hidden}
::selection{background-color:var(--rep-accent);color:#fff}
.container--u-i80idakgi{opacity:1!important;transform:none!important}
.rep-hidden{display:none!important}
.rep-static{cursor:default;color:var(--rep-muted)}
[data-img]{cursor:zoom-in}
button,a{-webkit-tap-highlight-color:transparent}
.rep-logo-box{width:auto;min-width:44px;height:40px;align-items:center;justify-content:center;overflow:visible}
.rep-logo{font-size:20px;font-weight:500;letter-spacing:-.05em;line-height:1;color:rgb(21,21,21);white-space:nowrap}
.rep-brand-logo{height:40px;width:auto;max-width:min(30vw,200px);object-fit:contain;display:block}
.rep-brand-name{font-size:18px;font-weight:500;letter-spacing:-.05em;line-height:1;color:rgb(21,21,21);white-space:nowrap;max-width:min(26vw,180px);overflow:hidden;text-overflow:ellipsis}
.rep-nav .link-block--u-ij7yf5mm2{column-gap:12px}
.rep-logo-box--footer{height:20px;min-width:0}
.rep-logo--white{font-size:18px;color:#fff}
.rep-overlay{display:none}
.rep-overlay.is-open{display:block}
@media (min-width:992px){
.rep-burger,.rep-menu-close{display:none!important}
}
@media (max-width:991px){
.rep-menu-panel.is-open{display:flex!important;position:fixed;top:0;right:0;bottom:0;left:0;z-index:85;flex-direction:column;align-items:flex-start;background-color:rgb(255,255,255);padding:20px;overflow-y:auto}
.rep-menu-panel.is-open .menu__scroll-wrapper{display:block!important;width:100%;min-width:0;height:auto}
.rep-menu-panel.is-open .menu__wrapper-lvl1{display:block!important;position:static;background-image:none;background-color:transparent;padding:0}
.rep-menu-panel.is-open .menu__list-lvl1{display:flex!important;flex-direction:column;align-items:flex-start;row-gap:16px;padding:50px 10px 0}
.rep-menu-panel.is-open .menu__link-lvl1{font-size:26px;line-height:110%;padding:0}
.rep-menu-close.is-open{display:block!important;position:absolute;top:22px;right:22px}
.rep-burger{display:block}
}
.rep-hero-photo{position:relative;display:flex;align-items:center;justify-content:center}
.rep-monogram{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:clamp(56px,9vw,140px);font-weight:600;letter-spacing:-.06em;color:rgba(255,255,255,.06)}
.rep-hero-tag{color:rgba(255,255,255,.35)}
.rep-hero-lead{white-space:pre-line;word-break:break-word}
.rep-slider{width:100%;max-width:100%;min-width:0}
.rep-slider .blocklist__list{display:flex;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;scroll-snap-type:x mandatory;scrollbar-width:none;-ms-overflow-style:none}
.rep-slider .blocklist__list::-webkit-scrollbar{display:none;width:0;height:0}
.rep-slider .blocklist__item-wrapper{flex:0 0 100%;max-width:100%;scroll-snap-align:start}
@media (min-width:992px){
.rep-slider .blocklist__item-wrapper{flex:0 0 50%}
}
.rep-slider [data-slider-prev],.rep-slider [data-slider-next]{cursor:pointer}
.rep-slider .rep-arrow-off{opacity:.06!important;pointer-events:none}
.rep-work-photo{width:100%;max-width:280px;height:auto;aspect-ratio:1/1;border-radius:55px}
@media (max-width:991px){
.rep-work-photo{max-width:200px}
}
.rep-work-photo .image__img{width:100%;height:100%;object-fit:cover}
.rep-gallery-photo{width:180px;height:180px;max-width:180px;border-radius:50px}
@media (max-width:991px){
.rep-gallery-photo{width:140px;height:140px;max-width:140px;border-radius:40px}
}
.rep-gallery-photo .image__img{width:100%;height:100%;object-fit:cover}
.rep-gallery-caption{color:#fff}
.rep-gallery-date{color:rgba(56,56,56,1);margin-bottom:auto}
.rep-gallery-text{color:#fff}
.rep-stack{flex-direction:column;align-items:flex-start;row-gap:12px}
.rep-tl-desc{color:rgba(255,255,255,.7);max-width:350px;line-height:130%}
.rep-tl-aside{row-gap:12px}
.rep-tl-count{color:rgba(56,56,56,1)}
.rep-files{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;max-width:350px}
.rep-chip{display:inline-flex;align-items:center;gap:8px;max-width:280px;padding:9px 18px;border:1px solid var(--rep-line-soft);border-radius:100px;font-size:13px;font-weight:500;letter-spacing:-.05em;line-height:100%;color:rgba(255,255,255,.7);text-decoration:none;transition:border-color .1s linear,color .1s linear}
.rep-chip:hover{border-color:rgba(255,255,255,.65);color:#fff}
.rep-chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rep-chip--static{cursor:default}
.rep-chip--static:hover{border-color:var(--rep-line-soft);color:rgba(255,255,255,.7)}
.rep-empty{color:rgba(174,176,176,1);font-size:15px;font-weight:500;letter-spacing:-.05em;padding:20px 0}
.rep-file-item{text-decoration:none;color:inherit;cursor:pointer;transition:background-color .15s linear}
.rep-file-item:hover{background-color:rgb(255,255,255)}
.rep-file-icon{display:flex;align-items:center;justify-content:center;width:180px;height:180px;max-width:180px;border-radius:50px;background-color:rgb(255,255,255);border:1px solid rgba(0,0,0,.06);font-size:20px;font-weight:500;letter-spacing:-.05em;color:rgba(21,21,21,.5);text-transform:uppercase}
@media (max-width:991px){
.rep-file-icon{width:140px;height:140px;max-width:140px;border-radius:40px;font-size:16px}
}
.rep-file-title{color:rgb(21,21,21);overflow-wrap:anywhere}
.rep-file-meta{color:var(--rep-muted)}
.rep-file-cta{display:inline-flex;margin-top:12px;padding:9px 18px;border:1px solid rgba(187,189,190,1);border-radius:100px;font-size:13px;font-weight:500;letter-spacing:-.05em;color:rgba(21,21,21,.75);text-decoration:none}
.rep-file-item:hover .rep-file-cta{border-color:rgb(21,21,21);color:rgb(21,21,21)}
.rep-contact{width:auto;max-width:420px;align-items:flex-start;row-gap:16px}
.rep-contact-title{color:#fff;font-size:24px;font-weight:500;letter-spacing:-.05em;line-height:115%}
.rep-contact-note{color:var(--rep-muted);font-size:15px;font-weight:500;letter-spacing:-.05em;line-height:140%}
.rep-contact-btn{width:100%;height:60px;background-color:var(--rep-accent);border-radius:10px;color:#fff}
.rep-contact-btn:hover{background-color:var(--rep-accent-hover)}
.rep-footer-photo{width:180px;height:180px;max-width:180px;border-radius:55px}
@media (max-width:991px){
.rep-footer-photo{width:140px;height:140px;max-width:140px}
}
.rep-footer-photo .image__img{width:100%;height:100%;object-fit:cover}
.rep-countdown{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:60;display:inline-flex;align-items:center;gap:8px;max-width:calc(100% - 32px);padding:12px 22px;border:1px solid var(--rep-line-soft);border-radius:100px;background-color:rgba(21,21,21,.94);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);color:rgba(255,255,255,.8);font-size:14px;font-weight:500;letter-spacing:-.05em;line-height:120%;white-space:nowrap;overflow:hidden}
.rep-countdown strong{color:#fff;font-weight:500}
.rep-countdown.rep-warn{border-color:rgba(250,192,0,.45)}
.rep-countdown.rep-urgent{border-color:rgba(255,79,82,.55)}
.rep-error{display:flex;align-items:center;justify-content:center;min-height:70vh;padding:80px 30px}
.rep-error-inner{display:flex;flex-direction:column;align-items:center;row-gap:20px;max-width:540px;text-align:center}
.rep-error-title{color:#fff;font-size:clamp(32px,4vw,50px);font-weight:500;letter-spacing:-.05em;line-height:100%}
.rep-error-text{color:var(--rep-muted);font-size:15px;font-weight:500;letter-spacing:-.05em;line-height:140%}
.rep-error-btn{width:auto;height:60px;padding:0 30px;background-color:var(--rep-accent);border-radius:10px;color:#fff}
.rep-error-btn:hover{background-color:var(--rep-accent-hover)}
.rep-lightbox{position:fixed;top:0;right:0;bottom:0;left:0;z-index:300;display:none;align-items:center;justify-content:center;padding:30px;background-color:rgba(0,0,0,.9);cursor:zoom-out}
.rep-lightbox.is-open{display:flex}
.rep-lightbox img{max-width:100%;max-height:100%;object-fit:contain;border-radius:10px}
.rep-modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:400;display:none;align-items:center;justify-content:center;padding:20px;background-color:rgba(0,0,0,.72);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}
.rep-modal.is-open{display:flex}
.rep-modal-box{display:flex;flex-direction:column;row-gap:16px;width:100%;max-width:420px;padding:35px;border:1px solid rgba(44,44,44,1);border-radius:20px;background-color:var(--rep-bg)}
.rep-modal-title{color:#fff;font-size:24px;font-weight:500;letter-spacing:-.05em;line-height:115%}
.rep-modal-text{color:var(--rep-muted);font-size:15px;font-weight:500;letter-spacing:-.05em;line-height:140%}
.rep-modal-input{width:100%;height:55px;padding:0 18px;border:1px solid var(--rep-line-soft);border-radius:10px;background-color:transparent;color:#fff;font-size:15px;font-weight:500;letter-spacing:-.05em;outline:none}
.rep-modal-input::placeholder{color:rgba(255,255,255,.35)}
.rep-modal-input:focus{border-color:rgba(255,255,255,.5)}
.rep-modal-btn{width:100%;height:55px;background-color:var(--rep-accent);border-radius:10px;color:#fff}
.rep-modal-btn:hover{background-color:var(--rep-accent-hover)}
.rep-modal-error{color:rgb(255,79,82);font-size:14px;font-weight:500;letter-spacing:-.05em}
@media (max-width:640px){
.rep-countdown{bottom:14px;padding:10px 16px;font-size:13px}
.rep-modal-box{padding:25px}
}
.rep-work-desc{color:rgba(255,255,255,.7);max-width:350px;line-height:130%}
.rep-stack>.text-block-wrap-div{flex-grow:0}
.rep-file-meta .text-block-wrap-div{flex-grow:0}
.rep-about-photo{display:block;width:100%;height:100%}
.rep-about-photo .image__img{width:100%;height:100%;object-fit:cover}
.rep-about-monogram{display:flex;align-items:center;justify-content:center;width:100%;height:100%;font-size:64px;font-weight:600;letter-spacing:-.06em;color:rgba(21,21,21,.12)}
#works.section--u-ibq7nxfzo{background-color:rgb(255,255,255)}
#works .text--u-ihb2xbsz6{color:rgba(21,21,21,1)}
#works .blocklist__item--u-iqn443glf{background-color:rgb(255,255,255)}
#works .div--u-ifxd1g9rk{border-top-color:rgba(228,231,232,1);border-right-color:rgba(228,231,232,1);border-bottom-color:rgba(228,231,232,1);border-left-color:rgba(228,231,232,1)}
#works .text--u-ib8dkw227{color:rgba(220,220,220,1)}
#works .text--u-i8ydj7eyx{color:rgba(21,21,21,1)}
#works .div--u-ipp2467j8{border-top-color:rgba(187,189,190,1);border-right-color:rgba(187,189,190,1);border-bottom-color:rgba(187,189,190,1);border-left-color:rgba(187,189,190,1)}
#works .text--u-iz3df5dls{color:rgba(21,21,21,1)}
#works .div--u-ibren39lw,#works .div--u-ixtm7p8ov,#works .div--u-iidtqgnjo,#works .div--u-iohjzr62g{background-color:rgb(21,21,21)}
#works .rep-work-desc{color:rgba(21,21,21,.7)}
#works .rep-chip{border-color:rgb(187,189,190);color:rgba(21,21,21,.75)}
#works .rep-chip:hover{border-color:rgb(21,21,21);color:rgb(21,21,21)}
+285
View File
@@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#151515">
<meta name="robots" content="noindex,nofollow">
<title>Отчёт</title>
<link rel="stylesheet" href="/report.css">
<link rel="stylesheet" href="/report.custom.css">
</head>
<body>
<div class="mosaic-wrap">
<div class="root root--u-i8z5xo3zc" id="top">
<section class="section paddings--left-right section--u-i7l3q2nvu rep-nav">
<div class="container container--u-i80idakgi">
<a href="#top" class="link-block link-block--u-ij7yf5mm2">
<div class="image image--u-irhpgss26 rep-logo-box">
<img id="brandLogo" class="rep-brand-logo" alt="" hidden>
<span class="rep-logo" id="brandMark">—</span>
</div>
<span class="rep-brand-name" id="brandName" hidden></span>
</a>
<div class="menu menu--u-i9yrdei10 menu--s2-i9yrdei10">
<div class="menu__fixed-wrapper menu__fixed-wrapper--s2-iek5sxvy1 rep-menu-panel" id="navPanel">
<div class="menu__scroll-wrapper menu__scroll-wrapper--u-ihg46fvlv menu__scroll-wrapper--s2-ihg46fvlv">
<div class="menu__wrapper-lvl1 menu__wrapper-lvl1--u-ic1ljomjg menu__wrapper-lvl1--s2-ic1ljomjg">
<div class="menu__list-lvl1 menu__list-lvl1--u-ioyaxt4m1 menu__list-lvl1--s2-ioyaxt4m1">
<div class="menu__item-lvl1 menu__item-lvl1--u-imeieea6o" data-nav="about">
<a href="#about" class="menu__link-lvl1 menu__link-lvl1--u-i6278jyvl">
<div class="menu__link-text menu__link-text--u-i2qgb1ij1"><span class="text-block-wrap-div">Обо мне</span></div>
</a>
</div>
<div class="menu__item-lvl1 menu__item-lvl1--u-imeieea6o" data-nav="timeline">
<a href="#timeline" class="menu__link-lvl1 menu__link-lvl1--u-i6278jyvl">
<div class="menu__link-text menu__link-text--u-i2qgb1ij1"><span class="text-block-wrap-div">Хроника</span></div>
</a>
</div>
<div class="menu__item-lvl1 menu__item-lvl1--u-imeieea6o" data-nav="works">
<a href="#works" class="menu__link-lvl1 menu__link-lvl1--u-i6278jyvl">
<div class="menu__link-text menu__link-text--u-i2qgb1ij1"><span class="text-block-wrap-div">Работы</span></div>
</a>
</div>
<div class="menu__item-lvl1 menu__item-lvl1--u-imeieea6o" data-nav="gallery">
<a href="#gallery" class="menu__link-lvl1 menu__link-lvl1--u-i6278jyvl">
<div class="menu__link-text menu__link-text--u-i2qgb1ij1"><span class="text-block-wrap-div">Фото</span></div>
</a>
</div>
<div class="menu__item-lvl1 menu__item-lvl1--u-imeieea6o" data-nav="files">
<a href="#files" class="menu__link-lvl1 menu__link-lvl1--u-i6278jyvl">
<div class="menu__link-text menu__link-text--u-i2qgb1ij1"><span class="text-block-wrap-div">Файлы</span></div>
</a>
</div>
</div>
</div>
</div>
<div role="button" class="menu__close-button menu__close-button--u-ib386a3r1 menu__close-button--s2-ib386a3r1 rep-menu-close" id="navClose"></div>
</div>
<div role="button" class="menu__burger-button menu__burger-button--u-ibf8kzqjr menu__burger-button--s2-ibf8kzqjr rep-burger" id="navBurger"></div>
<div class="menu__overlay menu__overlay--u-io05iogd9 rep-overlay" id="navOverlay"></div>
</div>
<a href="#contact" class="link text--link-1 link--u-i2ifdcrc4"><span class="text-block-wrap-div">Контакты</span></a>
</div>
</section>
<div id="errorBox" class="rep-error" style="display:none">
<div class="rep-error-inner">
<div class="rep-error-title">Отчёт недоступен</div>
<div class="rep-error-text" id="errorText"></div>
<a href="/" class="button button--u-i4a1jl1f8 text--button rep-error-btn">
<span class="text-button text-button--u-i4t8wl9m2"><span class="text-block-wrap-div">На главную</span></span>
</a>
</div>
</div>
<main id="content" style="display:none">
<section class="section section--u-imrochvid" id="hero">
<div class="text text--u-ihro87s5g h1" id="heroName"><span class="text-block-wrap-div">—</span></div>
<div class="div div--u-iypw3r3hy"></div>
<div class="div div--u-iac2fpjoy paddings--left-right">
<div class="text text--u-if3tqh9j6 t2" id="heroRole"><span class="text-block-wrap-div">—</span></div>
<div class="text text--u-il0q6mzel t2" id="heroPeriod"><span class="text-block-wrap-div">—</span></div>
</div>
<div class="div div--u-ii07pa6dy">
<div class="image image--u-ivhonc1br">
<img class="image__img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDExIiBoZWlnaHQ9IjQxMiIgdmlld0JveD0iMCAwIDQxMSA0MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjIwNSIgY3k9IjIwNiIgcj0iMjA0LjUiIHN0cm9rZT0iIzIxMjEyMSIvPgo8cmVjdCB3aWR0aD0iNTgxIiBoZWlnaHQ9IjEiIHRyYW5zZm9ybT0icm90YXRlKDQ1KSIgZmlsbD0iIzIxMjEyMSIvPgo8L3N2Zz4KCg==" alt="">
</div>
<div class="div div--u-ivyxhpbts">
<div class="div div--u-inb3arto0">
<div class="div div--u-iloks34xh">
<div class="image image--u-id09xtq63">
<img class="image__img image__img--s2-ipfbguwr1" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCA0MCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAgMEwyMCAyMEgwVjBaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjAgMjBMNDAgLTguNzQyMjhlLTA3TDQwIDIwTDIwIDIwWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cgo=" alt="">
</div>
<div class="text text--u-is1fxv2no l1 rep-hero-lead" id="heroLead"><span class="text-block-wrap-div"></span></div>
<div class="text text--u-ig2k8b0st l1" id="heroTag"><span class="text-block-wrap-div">[Отчёт]</span></div>
<div class="image image--u-i5hwy71ca">
<img class="image__img image__img--s2-icfu2drw3" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCA0MCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQwIDIwTDIwIC0xLjc0ODQ2ZS0wNkw0MCAwTDQwIDIwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTIwIDBMLTguNzQyMjhlLTA3IDIwTDAgLTguNzQyMjhlLTA3TDIwIDBaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4KCg==" alt="">
</div>
<div class="div div--u-iasmwwdzw"></div>
<div class="div div--u-i9jct2f9m"></div>
<div class="div div--u-is7e9z5nq"></div>
<div class="div div--u-i0k4yxpc8"></div>
</div>
</div>
<div class="image image--u-is0urbrfg rep-hero-photo" id="heroPhotoBox">
<img class="image__img" id="heroPhoto" alt="">
</div>
</div>
<div class="image image--u-i8jhhfeik">
<img class="image__img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDEwIiBoZWlnaHQ9IjQxMiIgdmlld0JveD0iMCAwIDQxMCA0MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjIwNSIgY3k9IjIwNSIgcj0iMjA0LjUiIHN0cm9rZT0iIzIxMjEyMSIvPgo8cmVjdCB4PSI0MTEiIHk9IjEiIHdpZHRoPSI1ODAiIGhlaWdodD0iMSIgdHJhbnNmb3JtPSJyb3RhdGUoMTM1IDQxMSAxKSIgZmlsbD0iIzIxMjEyMSIvPgo8L3N2Zz4KCg==" alt="">
</div>
</div>
<div class="div div--u-ivqnx6ncb"></div>
<div class="div div--u-ivysvnhdd"></div>
</section>
<section class="section section--u-ije008ww5 paddings--left-right" id="about">
<div class="div div--u-ikil9thol"></div>
<div class="text text--u-ivqsnwblc h2" id="aboutTitle"><span class="text-block-wrap-div">Обо мне</span></div>
<div class="div div--u-isbf1g6de"></div>
<div class="div div--u-iayi3al5a">
<div class="div div--u-i7arpju57"></div>
<div class="div div--u-i6wqm8o03">
<div class="text text--u-iaopidyhq h3" id="aboutLead"><span class="text-block-wrap-div"></span></div>
</div>
<div class="div div--u-isrs3fsi8">
<div class="div div--u-ih80fbo71"></div>
<div class="div div--u-inkx4saax">
<div class="image image--u-iidjge3zd rep-about-photo" id="aboutPhotoBox">
<img class="image__img" id="aboutPhoto" alt="">
</div>
<div class="div div--u-irqublwm9"></div>
<div class="div div--u-ikxc1qjen"></div>
<div class="div div--u-i88rv34ah"></div>
<div class="div div--u-i8l8bxdx6"></div>
</div>
<div class="div div--u-iryjyivpv">
<div class="text text--u-igq5dogo6 t2" id="aboutNote"><span class="text-block-wrap-div"></span></div>
</div>
</div>
<div class="list list--u-i59hjh2ej" id="statsList"></div>
</div>
<a href="#contact" class="button button--u-i4a1jl1f8 text--button">
<span class="text-button text-button--u-i4t8wl9m2"><span class="text-block-wrap-div">Связаться</span></span>
</a>
</section>
<section class="section paddings--left-right section--u-iw83bdcw9" id="timeline">
<div class="text text--u-i6dz2a56i h2"><span class="text-block-wrap-div">Хроника занятий</span></div>
<div class="list list--u-iaxl58kyi" id="timelineList"></div>
</section>
<section class="section section--u-ibq7nxfzo paddings--left-right" id="works">
<div class="text h2 text--u-ihb2xbsz6"><span class="text-block-wrap-div">Работы</span></div>
<div class="div rep-slider" data-slider="works">
<div class="blocklist blocklist--u-ivlioq6ab blocklist--s2-ivlioq6ab">
<div class="blocklist__list-wrapper blocklist__list-wrapper--s2-i4t2ufz23">
<div class="blocklist__list blocklist__list--s2-iou286xss" id="worksTrack"></div>
</div>
<div class="blocklist__controls-wrapper blocklist__controls-wrapper--s2-igcvqnkng">
<div class="blocklist__controls blocklist__controls--u-igz87d7pf">
<div class="blocklist__control-arrows blocklist__control-arrows--u-ihwh9bgqr">
<div role="button" class="blocklist__arrow-prev2 blocklist__arrow-prev2--u-i72uloioe" data-slider-prev></div>
<div role="button" class="blocklist__arrow-next2 blocklist__arrow-next2--u-ivlw85q4f" data-slider-next></div>
</div>
<div class="blocklist__controls-pagination-wrapper"></div>
</div>
</div>
</div>
</div>
</section>
<section class="section paddings--left-right section--u-ip0mjje82" id="files">
<div class="text text--u-i24jt3mcb h2"><span class="text-block-wrap-div">Проектные файлы</span></div>
<div class="list list--u-iix2uh3ie" id="filesList"></div>
</section>
<section class="section section--u-i0y44hvpw paddings--left-right" id="gallery">
<div class="text h2 text--u-ikcqharhc"><span class="text-block-wrap-div">Фото группы</span></div>
<div class="div div--u-ig1bsrybs"></div>
<div class="div div--u-i0kioh3ih"></div>
<div class="div rep-slider" data-slider="gallery">
<div class="blocklist blocklist--u-iohyxn81v blocklist--s2-iohyxn81v">
<div class="blocklist__list-wrapper blocklist__list-wrapper--s2-ijirftpw5">
<div class="blocklist__list blocklist__list--s2-iak70wit9" id="galleryTrack"></div>
</div>
<div class="blocklist__controls-wrapper blocklist__controls-wrapper--s2-ig787iibk">
<div class="blocklist__controls blocklist__controls--u-iau8c9wem">
<div class="blocklist__control-arrows blocklist__control-arrows--u-ihw55qli4">
<div role="button" class="blocklist__arrow-prev2 blocklist__arrow-prev2--u-i8t1smxl7" data-slider-prev></div>
<div role="button" class="blocklist__arrow-next2 blocklist__arrow-next2--u-ixzxe7qta" data-slider-next></div>
</div>
<div class="blocklist__controls-pagination-wrapper"></div>
</div>
</div>
</div>
</div>
</section>
<section class="section section--u-ihbjvn71x" id="contact">
<div class="text text--u-ifdrgto5v h2"><span class="text-block-wrap-div">Связь с нами</span></div>
<div class="div div--u-ivyhwi7sr"></div>
<div class="div div--u-iq5a3c8eo">
<div class="image image--u-ipv2zlpi2">
<img class="image__img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDExIiBoZWlnaHQ9IjQxMiIgdmlld0JveD0iMCAwIDQxMSA0MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjIwNSIgY3k9IjIwNiIgcj0iMjA0LjUiIHN0cm9rZT0iIzIxMjEyMSIvPgo8cmVjdCB3aWR0aD0iNTgxIiBoZWlnaHQ9IjEiIHRyYW5zZm9ybT0icm90YXRlKDQ1KSIgZmlsbD0iIzIxMjEyMSIvPgo8L3N2Zz4KCg==" alt="">
</div>
<div class="text text--u-ieyliov62 h3" id="contactNote"><span class="text-block-wrap-div"></span></div>
<div class="image image--u-im8xge6of">
<img class="image__img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDEwIiBoZWlnaHQ9IjQxMiIgdmlld0JveD0iMCAwIDQxMCA0MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjIwNSIgY3k9IjIwNSIgcj0iMjA0LjUiIHN0cm9rZT0iIzIxMjEyMSIvPgo8cmVjdCB4PSI0MTEiIHk9IjEiIHdpZHRoPSI1ODAiIGhlaWdodD0iMSIgdHJhbnNmb3JtPSJyb3RhdGUoMTM1IDQxMSAxKSIgZmlsbD0iIzIxMjEyMSIvPgo8L3N2Zz4KCg==" alt="">
</div>
</div>
<div class="div div--u-ifj6asx15">
<div class="div div--u-i95yztn00"></div>
<div class="div div--u-iqswnal3i"></div>
<div class="image image--u-i9ff2hgvg rep-footer-photo" id="footerPhotoBox">
<img class="image__img" id="footerPhoto" alt="">
</div>
<div class="image image--u-ishqnsp3y">
<img class="image__img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCA0MCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAgMEwyMCAyMEgwVjBaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjAgMjBMNDAgLTguNzQyMjhlLTA3TDQwIDIwTDIwIDIwWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cgo=" alt="">
</div>
<div class="div div--u-iosm3syjx"></div>
<div class="div div--u-imsajm4rq"></div>
</div>
<div class="div div--u-i0phithov">
<div class="div div--u-i82ybieml">
<a href="#top" class="link-block link-block--u-ic5ntfotz">
<div class="image image--u-iwz9z9ztv rep-logo-box rep-logo-box--footer"><span class="rep-logo rep-logo--white" id="brandMarkFooter">—</span></div>
</a>
<div class="div div--u-iqpqkl8er">
<div class="div div--u-i230b7fin">
<div class="text t2 text--u-iukrw4zbo"><span class="text-block-wrap-div">Меню</span></div>
<a href="#about" class="link text--link-1 link--u-ihn1tumex"><span class="text-block-wrap-div">Обо мне</span></a>
<a href="#timeline" class="link text--link-1 link--u-iw5fg3iez"><span class="text-block-wrap-div">Хроника занятий</span></a>
<a href="#works" class="link text--link-1 link--u-i6sruiw9p" data-nav="works"><span class="text-block-wrap-div">Работы</span></a>
<a href="#files" class="link text--link-1 link--u-ivy8mkxoi" data-nav="files"><span class="text-block-wrap-div">Файлы</span></a>
<a href="#gallery" class="link text--link-1 link--u-i17jh0lsx" data-nav="gallery"><span class="text-block-wrap-div">Фото группы</span></a>
</div>
<div class="div div--u-i3z8k7bsc">
<div class="text t2 text--u-isp07chjo"><span class="text-block-wrap-div">Отчёт</span></div>
<div class="link text--link-1 rep-static"><span class="text-block-wrap-div" id="footGroup"></span></div>
<div class="link text--link-1 rep-static"><span class="text-block-wrap-div" id="footPeriod"></span></div>
<div class="link text--link-1 rep-static"><span class="text-block-wrap-div" id="footCreated"></span></div>
</div>
<div class="div div--u-i94yzqcsn">
<div class="text t2 text--u-ibz851gxs"><span class="text-block-wrap-div">Ссылка</span></div>
<a href="#" class="link text--link-1 link--u-ioxiekbkh" id="footLink" target="_blank" rel="noopener noreferrer" style="display:none"><span class="text-block-wrap-div">Перейти по ссылке</span></a>
<div class="link text--link-1 rep-static"><span class="text-block-wrap-div" id="footExpires"></span></div>
</div>
</div>
</div>
<div class="form form--u-i3ge2e3aw rep-contact">
<div class="rep-contact-title" id="contactLead"></div>
<div class="rep-contact-note" id="contactSub"></div>
<a href="#top" class="button button--u-i4a1jl1f8 text--button rep-contact-btn" id="contactBtn">
<span class="text-button text-button--u-i4t8wl9m2"><span class="text-block-wrap-div">В начало отчёта</span></span>
</a>
</div>
</div>
<div class="div div--u-inush9rz9">
<div class="text t2 text--u-irub9snx4"><span class="text-block-wrap-div" id="footCopy"></span></div>
<div class="link text--link-1 rep-static"><span class="text-block-wrap-div" id="footBrand">WhatIDo</span></div>
</div>
</section>
</main>
</div>
</div>
<div class="rep-countdown" id="countdown" style="display:none"><span class="rep-cd-text"></span></div>
<div class="rep-lightbox" id="lightbox"><img id="lightboxImg" alt=""></div>
<div class="rep-modal" id="passwordModal">
<div class="rep-modal-box">
<div class="rep-modal-title">Ссылка защищена паролем</div>
<div class="rep-modal-text">Введите пароль, чтобы открыть отчёт</div>
<input type="password" id="passwordInput" class="rep-modal-input" placeholder="Пароль" autocomplete="current-password">
<button type="button" id="passwordSubmitBtn" class="button button--u-i4a1jl1f8 text--button rep-modal-btn">
<span class="text-button text-button--u-i4t8wl9m2"><span class="text-block-wrap-div">Войти</span></span>
</button>
<div class="rep-modal-error" id="passwordError" style="display:none">Неверный пароль</div>
</div>
</div>
<script src="/js/report.js"></script>
</body>
</html>
+245 -178
View File
@@ -12,30 +12,180 @@
<div class="main">
<div class="page-head">
<h2>Настройки</h2>
<p class="page-sub">Параметры формы и данные системы</p>
<p class="page-sub">Параметры формы, публичных страниц и данные системы</p>
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
<button class="head-btn" id="reloadBtn" type="button" title="Перезагрузить настройки"><i data-lucide="rotate-ccw"></i> Сбросить</button>
</div>
<div class="settings-stack">
<div class="settings-card">
<div class="settings-layout">
<aside class="settings-nav" id="settingsNav">
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
</aside>
<div class="settings-stack settings-stack-grid">
<div class="settings-card wide" id="sec-system">
<div class="card-head">
<div class="ic">🛡️</div>
<h3>Защита от повторов</h3>
<div class="ic"><i data-lucide="bar-chart-3"></i></div>
<h3>Системная информация</h3>
<button class="icon-btn" id="systemRefreshBtn" type="button" title="Обновить" aria-label="Обновить"><i data-lucide="refresh-cw"></i></button>
<span class="head-live" id="systemLive" title="Данные кэшируются на сервере"></span>
</div>
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. 0 — отключить.</p>
<div class="settings-row">
<div class="settings-field">
<label for="spamInterval">Интервал ожидания</label>
<div class="unit-row">
<input type="number" id="spamInterval" class="settings-input" min="0" max="1440" step="1">
<span class="unit">минут</span>
<div id="systemInfo" class="sys-grid">
<div class="sys-card loading">Загрузка…</div>
</div>
</div>
<div class="settings-card" id="sec-brand">
<div class="card-head">
<div class="ic"><i data-lucide="palette"></i></div>
<h3>Брендинг</h3>
</div>
<p class="hint">Название и логотип отображаются в админ-панели, на форме ответа ученика, странице входа и в отчёте.</p>
<div class="settings-field">
<label for="systemName">Название системы</label>
<input type="text" id="systemName" class="settings-input" maxlength="60" placeholder="WhatIDo">
</div>
<div class="settings-field">
<label>Логотип</label>
<div class="logo-picker">
<div class="logo-preview" id="logoPreview">
<span id="logoPreviewFallback">—</span>
<img id="logoPreviewImg" alt="Логотип">
</div>
<div class="logo-picker-actions">
<div class="logo-picker-btns">
<button class="btn-primary ghost" id="logoUploadBtn" type="button"><i data-lucide="upload"></i> Загрузить логотип</button>
<button class="btn-primary danger-ghost" id="logoRemoveBtn" type="button" hidden><i data-lucide="trash-2"></i> Удалить</button>
</div>
<input type="file" id="logoInput" accept="image/*" style="display:none">
<div class="logo-upload-status" id="logoUploadStatus"></div>
<span class="hint">Рекомендуется широкое изображение с прозрачным фоном (PNG/WebP), не более 1 МБ</span>
</div>
</div>
</div>
</div>
<div class="settings-card" id="sec-antispam">
<div class="card-head">
<div class="ic"><i data-lucide="shield"></i></div>
<h3>Защита от повторов</h3>
</div>
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
<div class="settings-field">
<label for="spamInterval">Интервал ожидания</label>
<div class="unit-row">
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
<span class="unit">минут</span>
</div>
<span class="field-error" id="spamIntervalError"></span>
<div class="preset-btns" id="spamPresets">
<button class="preset-btn" type="button" data-min="1">1 мин</button>
<button class="preset-btn" type="button" data-min="15">15 мин</button>
<button class="preset-btn" type="button" data-min="30">30 мин</button>
<button class="preset-btn" type="button" data-min="60">1 час</button>
<button class="preset-btn" type="button" data-min="1440">Сутки</button>
</div>
<span class="hint" id="spamIntervalHint"></span>
</div>
</div>
<div class="settings-card" id="sec-share">
<div class="card-head">
<div class="ic"><i data-lucide="link"></i></div>
<h3>Страница публичной ссылки /s/&lt;hash&gt;</h3>
</div>
<p class="hint">Управление видимостью элементов на публичных страницах записей.</p>
<label class="toggle">
<input type="checkbox" id="shareShowMessage">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Сообщение ученика</b><small>Текст «Что делала(а)» на странице по ссылке</small></span>
</label>
<label class="toggle">
<input type="checkbox" id="shareShowDate">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Дата записи</b><small>Дата в карточке записи</small></span>
</label>
<label class="toggle">
<input type="checkbox" id="shareShowNames">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Фамилии и имена</b><small>Имена учеников в галерее работ</small></span>
</label>
<label class="toggle">
<input type="checkbox" id="shareShowPhotos">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Фото группы</b><small>Групповое фото в шапке страницы</small></span>
</label>
<div class="settings-field">
<label for="cookieNoticeText">Уведомление о cookie</label>
<textarea id="cookieNoticeText" class="settings-input" rows="3" style="min-height:80px"></textarea>
<span class="hint">Пусто — баннер не показывается</span>
</div>
</div>
<div class="settings-card" id="sec-ai">
<div class="card-head">
<div class="ic"><i data-lucide="bot"></i></div>
<h3>Промпт ИИ для исправления текста</h3>
</div>
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
<label class="toggle">
<input type="checkbox" id="aiAutoCheck">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Автопроверка новых записей</b><small>Без участия человека — воркер сам обрабатывает очередь</small></span>
</label>
<div class="settings-field">
<label for="aiPrompt">Промпт</label>
<textarea id="aiPrompt" class="settings-input mono" rows="5" spellcheck="false"></textarea>
</div>
<div class="ai-queue" id="aiQueueInfo"></div>
</div>
<div class="settings-card">
<div class="card-head">
<div class="ic">📄</div>
<div class="ic"><i data-lucide="boxes"></i></div>
<h3>Профили моделей ИИ</h3>
</div>
<p class="hint">Модель, выбранная по умолчанию, обрабатывает записи учеников. «Нативная» — llama.cpp в Docker. Можно добавить любого провайдера с OpenAI-совместимым API (OpenRouter, OpenAI, Ollama и др.).</p>
<div class="ai-queue" id="aiProfileActive"></div>
<div class="card-foot" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;min-width:0;max-width:100%">
Модель по умолчанию:
<select id="aiActiveProfile" class="settings-input" style="width:auto;min-width:0;max-width:100%;flex:1 1 200px">
<option value="native">Нативная (llama.cpp)</option>
</select>
</label>
<button class="btn-primary ghost" id="aiProfileAddBtn" type="button"><i data-lucide="plus"></i> Добавить профиль</button>
</div>
<div id="aiProfilesList" class="ai-profiles-list"></div>
</div>
<div class="settings-card" id="sec-backup">
<div class="card-head">
<div class="ic"><i data-lucide="database"></i></div>
<h3>Резервное копирование</h3>
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
</div>
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
<div class="card-foot">
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
<button class="btn-primary ghost danger" id="restoreBtn" type="button"><i data-lucide="upload"></i> Восстановить из файла</button>
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
</div>
<div class="restore-status" id="restoreStatus"></div>
</div>
<div class="settings-card" id="sec-form">
<div class="card-head">
<div class="ic"><i data-lucide="file-text"></i></div>
<h3>Футер на форме</h3>
</div>
<p class="hint">Тексты показываются внизу формы ответа ученика.</p>
@@ -51,182 +201,99 @@
</div>
</div>
<div class="settings-card">
<div class="settings-card" id="sec-photo">
<div class="card-head">
<div class="ic">🗄️</div>
<h3>Резервное копирование</h3>
<div class="ic"><i data-lucide="camera"></i></div>
<h3>Фотография с веб-камеры</h3>
</div>
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
<div class="card-foot">
<button class="btn-primary ghost" id="backupBtn" onclick="downloadBackup()">⬇️ Скачать бэкап</button>
<button class="btn-primary ghost" id="restoreBtn" onclick="document.getElementById('restoreFile').click()">⬆️ Восстановить из файла</button>
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
<div class="settings-row">
<div class="settings-field">
<label for="photoResolution">Разрешение</label>
<select id="photoResolution" class="settings-input">
<option value="640x480">640 × 480 (по умолчанию)</option>
<option value="1280x720">1280 × 720 (HD)</option>
<option value="1920x1080">1920 × 1080 (Full HD)</option>
<option value="2560x1440">2560 × 1440 (QHD)</option>
</select>
</div>
<div class="settings-field">
<label for="photoQuality">Качество JPEG</label>
<div class="unit-row">
<input type="number" id="photoQuality" class="settings-input" min="0.5" max="1" step="0.01" value="0.92">
<span class="unit">0.5–1.0</span>
</div>
<span class="hint">Больше значение — лучше качество и крупнее файл</span>
</div>
<div class="settings-field">
<label for="photoEngine">Обработка улучшения фото</label>
<select id="photoEngine" class="settings-input">
<option value="auto">Авто (сервер, иначе клиент)</option>
<option value="server">Сервер (sharp)</option>
<option value="client">Клиент (браузер)</option>
</select>
<span class="hint">Серверный режим обрабатывает оригинал без пережатия и быстрее на больших фото</span>
</div>
</div>
<div class="restore-status" id="restoreStatus"></div>
</div>
<div class="settings-card" id="sec-bans">
<div class="card-head">
<div class="ic"><i data-lucide="shield-off"></i></div>
<h3>Блокировки IP</h3>
</div>
<p class="hint">IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке.</p>
<div class="data-stats" id="bansList"><span class="chip">Загрузка…</span></div>
</div>
</div>
</div>
</div>
</div>
<div class="save-bar" id="saveBar">
<span class="hint" id="saveHint">⚠️ Есть несохранённые изменения</span>
<button class="btn-primary" id="saveBtn" onclick="saveSettings()">Сохранить изменения</button>
<span class="hint" id="saveHint"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
<span class="save-bar-actions">
<button class="btn-primary ghost" id="resetBtn" type="button"><i data-lucide="rotate-ccw"></i> Отменить</button>
<button class="btn-primary" id="saveBtn" type="button"><i data-lucide="check"></i> Сохранить</button>
</span>
</div>
<div class="modal-overlay" id="aiProfileModal">
<div class="edit-modal" style="max-width:560px">
<h3 id="aiProfileModalTitle">Добавить профиль ИИ</h3>
<div class="settings-field">
<label for="aiProfileName">Название профиля <span style="color:#ef4444">*</span></label>
<input type="text" id="aiProfileName" class="settings-input" placeholder="Например: OpenRouter GPT-4o-mini">
</div>
<div class="settings-field">
<label for="aiProfileModel">Модель <span style="color:#ef4444">*</span></label>
<input type="text" id="aiProfileModel" class="settings-input" placeholder="openai/gpt-4o-mini">
</div>
<div class="settings-field">
<label for="aiProfileBaseUrl">Base URL (OpenAI-совместимый API) <span style="color:#ef4444">*</span></label>
<input type="text" id="aiProfileBaseUrl" class="settings-input" placeholder="https://openrouter.ai/api/v1">
</div>
<div class="settings-field">
<label for="aiProfileApiKey">API-ключ (если требуется)</label>
<input type="password" id="aiProfileApiKey" class="settings-input" placeholder="sk-...">
</div>
<div class="settings-field">
<label for="aiProfileMaxTokens">Max tokens (лимит ответа модели)</label>
<input type="number" id="aiProfileMaxTokens" class="settings-input" min="16" max="32768" placeholder="Авто (1024–4096)">
<span class="hint">Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 1024+ — иначе рассуждения не уместятся и ответ придёт пустым.</span>
</div>
<div class="ai-test-status" id="aiProfileTestStatus"></div>
<div class="actions" style="flex-wrap:wrap">
<button class="btn-primary" id="aiProfileSaveBtn" type="button">Сохранить</button>
<button class="btn-primary ghost" id="aiProfileTestBtn" type="button"><i data-lucide="plug-zap"></i> Проверить</button>
<button class="btn-primary ghost" id="aiProfileCancelBtn" type="button">Отмена</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script>
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight'];
function setBtnLoading(btn, on, spinnerClass) {
if (on) {
btn.dataset.label = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
} else {
btn.disabled = false;
btn.innerHTML = btn.dataset.label || btn.innerHTML;
}
}
function updateDirty() {
const dirty = DIRTY_FIELDS.some(id => {
const el = document.getElementById(id);
return String(el.dataset.original ?? '') !== el.value;
});
document.getElementById('saveBar').classList.toggle('show', dirty);
}
function bindDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).addEventListener('input', updateDirty);
});
}
function snapshotDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).dataset.original = document.getElementById(id).value;
});
updateDirty();
}
async function loadSettings() {
const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }),
fetch(`${API}/api/stats`, { headers: hdr() }),
]);
const s = await sRes.json();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || '';
snapshotDirty();
if (stRes.ok) {
const st = await stRes.json();
document.getElementById('dataStats').innerHTML = `
<span class="chip">Записей: <b>${st.entries}</b></span>
<span class="chip">Учеников: <b>${st.students}</b></span>
<span class="chip">Групп: <b>${st.groups}</b></span>
<span class="chip">Сегодня: <b>${st.today}</b></span>
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
`;
} else {
document.getElementById('dataStats').innerHTML = '';
}
}
async function saveSettings() {
const v = document.getElementById('spamInterval').value;
if (v === '' || Number(v) < 0) { alert('Введите число от 0'); return; }
const btn = document.getElementById('saveBtn');
btn.dataset.loadingText = 'Сохранение...';
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/settings`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ settings: {
spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
} })
});
if (res.ok) {
showToast('Настройки сохранены');
snapshotDirty();
} else { const e = await res.json(); alert(e.error); }
} finally {
setBtnLoading(btn, false);
}
}
async function downloadBackup() {
const btn = document.getElementById('backupBtn');
btn.dataset.loadingText = 'Формирование...';
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
showToast('Бэкап скачан');
} finally {
setBtnLoading(btn, false);
}
}
document.getElementById('restoreFile').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
const st = document.getElementById('restoreStatus');
const backupBtn = document.getElementById('backupBtn');
const restoreBtn = document.getElementById('restoreBtn');
st.classList.remove('err');
st.innerHTML = '<span class="spinner dark"></span> Восстановление...';
backupBtn.disabled = true;
restoreBtn.disabled = true;
const fd = new FormData();
fd.append('backup', file);
let res;
try {
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
} catch (err) {
st.textContent = '';
alert('Ошибка сети: ' + err.message);
e.target.value = '';
return;
} finally {
backupBtn.disabled = false;
restoreBtn.disabled = false;
}
if (res.ok) {
showToast('Данные восстановлены');
st.textContent = '';
loadSettings();
} else {
st.classList.add('err');
st.innerHTML = 'Ошибка восстановления';
const t = await res.text();
alert('Ошибка: ' + t);
}
e.target.value = '';
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
bindDirty();
loadSettings();
}
})();
</script>
<script src="js/settings.js"></script>
</body>
</html>
+55 -85
View File
@@ -10,6 +10,10 @@
:root{--yellow:#F7C90E;--dark:#211E22;--gray:#9C9C9C;--page:#f6f6f6;--card-bg:#fff;--radius:30px}
body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background:var(--page);color:var(--dark);min-height:100dvh}
.hero{background:var(--yellow);padding:44px 20px 52px;text-align:center;position:relative;overflow:hidden}
.hero .brand{display:flex;align-items:center;justify-content:center;gap:14px;margin-bottom:16px}
.hero .brand[hidden]{display:none}
.hero .brand .brand-logo{height:52px;width:auto;max-width:min(42vw,300px);object-fit:contain;display:block}
.hero .brand .brand-name{font-size:1.05rem;font-weight:800;letter-spacing:-.02em;color:var(--dark);opacity:.8}
.hero h1{font-size:clamp(1.6rem,4.5vw,2.9rem);font-weight:800;line-height:1.05;letter-spacing:-.02em;color:var(--dark);max-width:900px;margin:0 auto}
.hero .chips{margin-top:20px;display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
.hero .chip{background:#fff;color:var(--dark);border-radius:999px;padding:8px 18px;font-size:.88rem;font-weight:600;box-shadow:5px 5px 10px rgba(216,173,0,.25)}
@@ -38,6 +42,9 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
.card .files a.ftype-presentation .ficon{background:#F6E4E8}
.card .files a.ftype-other .ficon{background:#EFEFEF}
.card .foot{margin-top:auto;padding:8px 6px 2px;font-size:.78rem;color:var(--gray)}
.date-sep{grid-column:1/-1;display:flex;align-items:center;gap:14px;margin:8px 0 -8px}
.date-sep .date-chip{background:var(--dark);color:#fff;font-size:.82rem;font-weight:700;padding:7px 16px;border-radius:999px;letter-spacing:.01em}
.date-sep .date-line{flex:1;height:1px;background:#E3E3E3}
.empty{grid-column:1/-1;background:#fff;border-radius:var(--radius);padding:48px 24px;text-align:center;color:var(--gray);box-shadow:0 1px 3px rgba(20,30,40,.06)}
.empty .big{font-size:2rem;margin-bottom:10px}
.loading{grid-column:1/-1;text-align:center;color:var(--gray);padding:40px}
@@ -54,106 +61,69 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
@media(max-width:1000px){.grid{grid-template-columns:repeat(3,1fr)}.photos-grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:760px){.grid{grid-template-columns:repeat(2,1fr)}.photos-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:480px){.grid{grid-template-columns:1fr}.photos-grid{grid-template-columns:1fr}}
.password-modal{position:fixed;inset:0;background:rgba(12,14,20,.85);display:none;align-items:center;justify-content:center;z-index:200;padding:24px}
.password-modal.open{display:flex}
.password-box{background:var(--card-bg);border-radius:16px;padding:24px;max-width:360px;width:100%;text-align:center}
.password-box h3{margin-bottom:8px}
.password-box p{color:var(--gray);font-size:.9rem;margin-bottom:16px}
.password-box input{width:100%;padding:14px 16px;border:1px solid #EBEBEB;border-radius:12px;font-size:1rem;margin-bottom:16px}
.password-box button{width:100%;padding:14px;background:var(--yellow);color:var(--dark);border:none;border-radius:12px;font-size:1rem;font-weight:700;cursor:pointer}
.password-box button:hover{opacity:.9}
.share-message{background:var(--card-bg);border-radius:20px;padding:20px 24px;margin-bottom:32px;box-shadow:0 1px 3px rgba(20,30,40,.06);font-size:.98rem;line-height:1.55;color:#3a3f4a;white-space:pre-wrap;word-break:break-word}
.share-link-block{margin-bottom:32px}
.share-link-btn{display:inline-flex;align-items:center;gap:8px;background:var(--dark);color:#fff;text-decoration:none;border-radius:999px;padding:13px 24px;font-size:.92rem;font-weight:700;transition:opacity .15s,transform .15s}
.share-link-btn:hover{opacity:.9;transform:translateY(-2px)}
.countdown{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(150%);z-index:50;display:flex;align-items:center;gap:10px;padding:14px 24px;font-size:.92rem;color:var(--dark);background:#fff;border-radius:16px;box-shadow:0 8px 30px rgba(20,30,40,.16);max-width:calc(100vw - 32px);opacity:0;transition:transform .3s ease,opacity .3s ease;pointer-events:none}
.countdown.visible{transform:translateX(-50%) translateY(0);opacity:1;pointer-events:auto}
.countdown .cd-icon{font-size:1.2rem}
.countdown .cd-text{color:#4a4f5a;line-height:1.4;text-align:center}
.countdown .cd-text strong{color:var(--dark);font-weight:700}
.countdown.warn{background:#FFF3E0}
.countdown.warn .cd-text strong{color:#E65100}
.countdown.urgent{background:#FFEBEE}
.countdown.urgent .cd-text strong{color:#C62828}
.cookie-notice{position:fixed;bottom:24px;right:24px;z-index:150;max-width:340px;background:var(--card-bg);border-radius:16px;padding:18px 20px;box-shadow:0 8px 30px rgba(20,30,40,.18);font-size:.9rem;line-height:1.5;color:#3a3f4a}
.cookie-notice.hidden{display:none}
.cookie-notice p{margin-bottom:14px}
.cookie-notice button{width:100%;padding:10px 14px;background:var(--yellow);color:var(--dark);border:none;border-radius:12px;font-size:.9rem;font-weight:700;cursor:pointer}
.cookie-notice button:hover{opacity:.9}
@media(max-width:480px){.cookie-notice{left:24px;right:24px;max-width:none}}
</style>
</head>
<body>
<section class="hero">
<div class="brand" id="brandHead" hidden>
<img class="brand-logo" id="brandLogo" alt="" hidden>
<span class="brand-name" id="brandName" hidden></span>
</div>
<h1 id="title">Загрузка...</h1>
<div class="chips" id="chips"></div>
</section>
<main class="wrap">
<div id="shareMessage" class="share-message" style="display:none"></div>
<div id="shareLinkBlock" class="share-link-block" style="display:none"></div>
<div id="photosBlock">
<div class="photos-label">Фото группы</div>
<div class="photos-grid" id="photosGrid"></div>
</div>
<div class="grid" id="grid"><div class="loading">Карточки загружаются...</div></div>
</main>
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')"><img id="lightboxImg" alt=""></div>
<div class="lightbox" id="lightbox"><img id="lightboxImg" alt=""></div>
<div id="countdown" class="countdown"><span class="cd-icon">⏳</span><span class="cd-text"></span></div>
<div class="cookie-notice hidden" id="cookieNotice">
<p>Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.</p>
<button type="button" id="cookieAcceptBtn">Принять</button>
</div>
<script>
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
function isImageFile(name){return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
function fileType(name){
if(/\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image';
if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music';
if(/\.(pptx?|odp|key)$/i.test(name||'')) return 'presentation';
if(/\.(pdf|docx?|xlsx?|txt|rtf|odt|ods|csv|md)$/i.test(name||'')) return 'document';
return 'other';
}
function fileIcon(t){
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
return icons[t]||icons.other;
}
function filesHTML(files){
if(!files||!files.length)return '';
return `<div class="files-block">
<div class="files-label">Работы резидента</div>
<div class="files">${files.map(f=>{
const t = fileType(f.name);
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
?`href="#" onclick="showImg('/api/files/${f.token}');return false;"`
:`href="/api/files/${f.token}" download`}>
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
</a>`;
}).join('')}</div>
</div>`;
}
<div class="password-modal" id="passwordModal">
<div class="password-box">
<h3>🔒 Ссылка защищена паролем</h3>
<p>Введите пароль для доступа к записям</p>
<input type="password" id="passwordInput" placeholder="Пароль" autocomplete="current-password">
<button type="button" id="passwordSubmitBtn">Войти</button>
</div>
</div>
(async()=>{
const token = location.pathname.split('/').filter(Boolean).pop() || '';
try {
const res = await fetch('/api/share/' + token);
if (!res.ok) throw new Error();
const data = await res.json();
const heading = data.group_name || data.name || 'Записи занятий';
document.title = heading;
document.getElementById('title').textContent = heading;
const chips = [];
if (data.group_name) chips.push(`Группа: <b>${esc(data.group_name)}</b>`);
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
const pb = document.getElementById('photosBlock');
if (data.photos && data.photos.length) {
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
<a class="gphoto" href="#" onclick="showImg('${p.photo_path}');return false;">
<img src="${p.photo_path}" alt="${esc(p.caption || '')}" loading="lazy">
${p.caption ? `<div class="gcap">${esc(p.caption)}</div>` : ''}
</a>
`).join('');
pb.style.display = 'block';
} else {
pb.style.display = 'none';
}
const grid = document.getElementById('grid');
if (!data.entries.length) {
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
return;
}
grid.innerHTML = data.entries.map(e => `
<div class="card">
<div class="photo">
${e.photo_path
? `<img src="${e.photo_path}" onclick="showImg('${e.photo_path}')" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
</div>
<div class="body">
<div class="name">${esc(e.student_name)}</div>
<div>${e.group_name ? `<span class="group">${esc(e.group_name)}</span>` : ''}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
</div>
<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>
</div>
`).join('');
} catch {
document.getElementById('title').textContent = 'Ссылка не найдена';
document.getElementById('grid').innerHTML = '<div class="empty"><div class="big">🤔</div>Эта ссылка не существует или была удалена</div>';
}
})();
</script>
<script src="/js/share.js"></script>
</body>
</html>
+179 -257
View File
@@ -14,283 +14,205 @@
<div class="add-row">
<input type="text" id="newStudent" placeholder="Фамилия Имя" list="studentAddList">
<datalist id="studentAddList"></datalist>
<button onclick="addStudent()">Добавить</button>
<button class="btn-ghost" onclick="openBatchAdd()">Пакетное добавление</button>
<button class="btn-ghost" onclick="openBatchAttach()">Прикрепить к группе</button>
<button type="button" id="addStudentBtn">Добавить</button>
<button class="btn-ghost" type="button" id="batchAddBtn">Пакетное добавление</button>
<button class="btn-ghost" type="button" id="batchAttachBtn">Прикрепить к группе</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<input type="text" id="studentSearch" class="search-input" placeholder="Поиск по имени..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
<select id="studentGroupFilter" class="search-input" style="padding:10px 14px"><option value="">Все группы</option></select>
<button class="btn-link" onclick="exportStudentsCSV()">Экспорт CSV</button>
<button class="btn-link" onclick="exportStudentsUrls()">Экспорт URL</button>
<button class="btn-link" type="button" id="exportStudentsCsvBtn">Экспорт CSV</button>
<button class="btn-link" type="button" id="exportStudentsUrlsBtn">Экспорт URL</button>
</div>
<div class="list" id="studentList"></div>
<div class="pager" id="pager"></div>
</div>
</div>
<div class="modal-overlay" id="batchModal" onclick="if(event.target===this)closeBatchAdd()">
<div class="edit-modal">
<div class="modal-overlay" id="batchModal">
<div class="edit-modal" style="max-width:480px">
<h3>Пакетное добавление учеников</h3>
<div>
<label>Имена — каждое с новой строки</label>
<textarea id="batchText" placeholder="Иванов Иван&#10;Петров Пётр&#10;Сидоров Сидор"></textarea>
</div>
<div>
<label>Группа (необязательно)</label>
<select id="batchGroup"><option value="">— без группы —</option></select>
</div>
<div class="actions">
<button class="cancel" onclick="closeBatchAdd()">Отмена</button>
<button class="save" onclick="batchAdd()">Добавить</button>
</div>
<form id="batchForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Имена — каждое с новой строки <span style="color:#ef4444">*</span></label>
<textarea id="batchText" class="settings-input" style="min-height:120px" required placeholder="Иванов Иван&#10;Петров Пётр&#10;Сидоров Сидор"></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Группа (необязательно)</label>
<select id="batchGroup" class="settings-input"><option value="">— без группы —</option></select>
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="batchCancelBtn">Отмена</button>
<button type="submit" class="btn-primary">Добавить</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="attachModal" onclick="if(event.target===this)closeBatchAttach()">
<div class="edit-modal">
<div class="modal-overlay" id="exportModal">
<div class="edit-modal" style="max-width:480px">
<h3>Экспорт отчёта: <span id="exportName" style="color:var(--accent)"></span></h3>
<form id="exportForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Период (необязательно)</label>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input type="date" id="exportDateFrom" class="settings-input" style="min-width:0;flex:1">
<input type="date" id="exportDateTo" class="settings-input" style="min-width:0;flex:1">
</div>
<span class="hint">Ограничить записи/фото/файлы датами</span>
</div>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expEntries" checked>
Записи занятий (текст)
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expPhotos" checked>
Фотографии
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expCaptions" checked>
Подписи к фотографиям
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expFiles" checked>
Файлы
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expDates" checked>
Показывать даты
</label>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="exportCancelBtn">Отмена</button>
<button type="submit" class="btn-primary">Скачать ZIP</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="attachModal">
<div class="edit-modal" style="max-width:480px">
<h3>Прикрепить к группе</h3>
<div>
<label>Группа</label>
<select id="attachGroup"><option value="">— выберите группу —</option></select>
</div>
<div>
<label>Ученики</label>
<input type="text" id="attachSearch" class="search-input" placeholder="Поиск по имени..." style="width:100%;margin-bottom:8px">
<div class="attach-list" id="attachList"></div>
</div>
<div class="actions">
<button class="cancel" onclick="closeBatchAttach()">Отмена</button>
<button class="save" onclick="batchAttach()">Прикрепить</button>
</div>
<form id="attachForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="attachGroup" class="settings-input" required><option value="">— выберите группу —</option></select>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Ученики</label>
<input type="text" id="attachSearch" class="settings-input" placeholder="Поиск по имени..." style="margin-bottom:8px">
<div class="attach-list" id="attachList"></div>
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="attachCancelBtn">Отмена</button>
<button type="submit" class="btn-primary">Прикрепить</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="profileDataModal">
<div class="edit-modal" style="max-width:900px;width:96%;max-height:92dvh;overflow-y:auto">
<h3>Данные профиля: <span id="profileDataTitle" style="color:var(--accent)"></span></h3>
<form id="profileDataForm" style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
<div class="settings-field">
<label>Роль / направление</label>
<input type="text" id="pfRole" class="settings-input" placeholder="Например: Ученик направления «Frontend-разработка»">
</div>
<div class="settings-field">
<label>Статус</label>
<input type="text" id="pfStatus" class="settings-input" placeholder="Активно учится">
</div>
<div class="settings-field">
<label>Примечание к статусу</label>
<input type="text" id="pfStatusNote" class="settings-input" placeholder="учится с сентября 2022">
</div>
<div class="settings-field">
<label>Город</label>
<input type="text" id="pfCity" class="settings-input">
</div>
<div class="settings-field">
<label>Наставник</label>
<input type="text" id="pfMentor" class="settings-input">
</div>
<div class="settings-field">
<label>В академии с</label>
<input type="text" id="pfJoined" class="settings-input" placeholder="В академии с сентября 2022">
</div>
<div class="settings-field" style="grid-column:1/-1">
<label>О себе</label>
<textarea id="pfBio" class="settings-input" style="min-height:70px" placeholder="Короткий рассказ ученика о себе"></textarea>
</div>
<div class="settings-field">
<label>Цитата</label>
<input type="text" id="pfQuote" class="settings-input" placeholder="«Мой принцип: сначала понять, потом написать»">
</div>
<div class="settings-field">
<label>Теги (через запятую)</label>
<input type="text" id="pfTags" class="settings-input" placeholder="HTML, CSS, JavaScript">
</div>
<div class="settings-field">
<label>Контакты — строка: Иконка | Подпись | Ссылка</label>
<textarea id="pfContacts" class="settings-input" style="min-height:64px" placeholder="mail | maria@example.com | mailto:maria@example.com&#10;send | @nickname | https://t.me/nickname"></textarea>
<span class="hint">Иконки: mail, send, phone, github, globe, link, at-sign</span>
</div>
<div class="settings-field">
<label>Достижения — по одному в строке</label>
<textarea id="pfAchievements" class="settings-input" style="min-height:64px"></textarea>
</div>
<div class="settings-field">
<label>Навыки — строка: Группа | Навык | Уровень | %</label>
<textarea id="pfSkills" class="settings-input" style="min-height:64px" placeholder="Вёрстка | HTML5 · семантика | Изучен | 95"></textarea>
</div>
<div class="settings-field">
<label>Опыт работы — строка: Заголовок | Организация | Период | Дата | Бейдж | Описание | Теги (через запятую)</label>
<textarea id="pfExperience" class="settings-input" style="min-height:64px" placeholder="Хакатон Digital City | Академия | Март 2024 | 03.2024 | Хакатон | Прототип карты заявок, 2 место | Vue 3, Leaflet"></textarea>
</div>
<div class="settings-field">
<label>Прогресс по модулям — строка: Тема модуля | Прогресс % | Оценка | Преподаватель</label>
<textarea id="pfEducation" class="settings-input" style="min-height:64px" placeholder="JavaScript: основы языка | 100 | Оценка 4,7 | Игорь Дёмин"></textarea>
</div>
<div class="settings-field" style="grid-column:1/-1">
<label>Фотографии ученика</label>
<input type="hidden" id="pfPhoto">
<div class="pf-photos" id="pfPhotos"></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px">
<input type="file" id="pfPhotoFile" accept="image/*" hidden>
<button type="button" class="btn-primary ghost" id="pfPhotoUploadBtn">Загрузить фото</button>
<span class="hint">Клик по фото — сделать главным. Можно вставить из буфера обмена (Ctrl+V)</span>
</div>
</div>
<div style="grid-column:1/-1;display:flex;justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="profileDataCancelBtn">Отмена</button>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script>
<script>
// --- Students ---
let allStudents = [];
let allGroups = [];
let groupOptionsLoaded = false;
let sPage = 1;
const S_PAGE_SIZE = 25;
let sSearchTimer = null;
let aSearchTimer = null;
let attachGroupLoaded = false;
async function loadGroupOptions(sel, selected) {
if (!groupOptionsLoaded) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
allGroups = await res.json();
groupOptionsLoaded = true;
const filt = document.getElementById('studentGroupFilter');
if (filt && filt.options.length <= 1) {
filt.innerHTML = `<option value="">Все группы</option>` +
allGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
}
if (sel) {
sel.innerHTML = `<option value="">— не привязана —</option>` +
allGroups.map(g => `<option value="${g.id}" ${selected == g.id ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
}
}
async function loadStudents() {
const res = await fetch(`${API}/api/students`, { headers: hdr() });
allStudents = await res.json();
renderStudents();
}
function renderStudents() {
const q = document.getElementById('studentSearch').value.trim().toLowerCase();
const gid = document.getElementById('studentGroupFilter').value;
let filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
if (gid) filtered = filtered.filter(s => String(s.group_id) === gid);
const totalPages = Math.max(1, Math.ceil(filtered.length / S_PAGE_SIZE));
if (sPage > totalPages) sPage = 1;
const slice = filtered.slice((sPage - 1) * S_PAGE_SIZE, sPage * S_PAGE_SIZE);
const list = document.getElementById('studentList');
if (!slice.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Ничего не найдено</div>'; }
else {
list.innerHTML = slice.map(s => `
<div class="item">
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
<select class="group-select" id="sgroup-${s.id}"></select>
<div class="student-actions">
<button class="btn-save" onclick="saveStudent(${s.id})">Сохранить</button>
<button class="btn-link" onclick="copyStudentLink('${esc(s.name).replace(/'/g, '&#39;')}')" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
<button class="btn-del" onclick="delStudent(${s.id})" title="Удалить">&times;</button>
</div>
</div>
`).join('');
slice.forEach(s => loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id));
}
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
}
function goStudentPage(p) {
sPage = p;
renderStudents();
}
async function addStudent() {
const input = document.getElementById('newStudent');
const name = input.value.trim();
if (!name) return;
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
if (res.ok) { input.value = ''; loadStudents(); fillAutocomplete(); showToast(`Добавлен: ${name}`); }
else { const e = await res.json(); alert(e.error); }
}
async function saveStudent(id) {
const name = document.getElementById(`sname-${id}`).value.trim();
if (!name) return;
const group_id = document.getElementById(`sgroup-${id}`).value;
const res = await fetch(`${API}/api/students/${id}`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ name, group_id }) });
if (res.ok) { loadStudents(); fillAutocomplete(); showToast(`Сохранено: ${name}`); }
else { const e = await res.json(); alert(e.error); }
}
async function delStudent(id) {
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
loadStudents();
fillAutocomplete();
}
function openBatchAdd() {
document.getElementById('batchText').value = '';
loadGroupOptions(document.getElementById('batchGroup'), '');
document.getElementById('batchModal').classList.add('open');
}
function closeBatchAdd() {
document.getElementById('batchModal').classList.remove('open');
}
async function batchAdd() {
const raw = document.getElementById('batchText').value;
const names = [...new Set(raw.split('\n').map(s => s.trim()).filter(Boolean))];
if (!names.length) return;
const group_id = document.getElementById('batchGroup').value;
let added = 0, dupes = 0;
for (const name of names) {
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, group_id: group_id || null }) });
if (res.ok) added++;
else if (res.status === 409) dupes++;
}
closeBatchAdd();
loadStudents();
fillAutocomplete();
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}`);
}
document.getElementById('newStudent').addEventListener('keydown', e => { if (e.key === 'Enter') addStudent(); });
document.getElementById('studentSearch').addEventListener('input', () => {
clearTimeout(sSearchTimer);
sSearchTimer = setTimeout(() => { sPage = 1; renderStudents(); }, 300);
});
document.getElementById('studentGroupFilter').addEventListener('change', () => { sPage = 1; renderStudents(); });
function exportStudentsCSV() {
const gid = document.getElementById('studentGroupFilter').value;
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
if (!list.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия Имя', 'Группа', 'Ссылка на страницу']];
list.forEach(s => {
rows.push([
s.name,
s.group_name || '',
`${location.origin}/?nameInput=${encodeURIComponent(s.name)}`
]);
});
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
a.download = `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast(`Экспортировано: ${list.length}`);
}
async function exportStudentsUrls() {
const gid = document.getElementById('studentGroupFilter').value;
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
if (!list.length) { alert('Нет данных для экспорта'); return; }
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
await exportStudentUrlZip(list, `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
showToast(`Экспортировано: ${list.length}`);
}
// --- Batch attach ---
async function openBatchAttach() {
const sel = document.getElementById('attachGroup');
if (!attachGroupLoaded) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
sel.innerHTML = `<option value="">— выберите группу —</option>` +
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
attachGroupLoaded = true;
}
document.getElementById('attachSearch').value = '';
renderAttachList();
document.getElementById('attachModal').classList.add('open');
}
function closeBatchAttach() {
document.getElementById('attachModal').classList.remove('open');
}
function renderAttachList() {
const q = document.getElementById('attachSearch').value.trim().toLowerCase();
const filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
const list = document.getElementById('attachList');
if (!filtered.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:16px">Никого не найдено</div>'; return; }
list.innerHTML = filtered.map(s => `
<label>
<input type="checkbox" value="${s.id}">
<span>${esc(s.name)}</span>
<span class="cur">${s.group_name ? esc(s.group_name) : ''}</span>
</label>
`).join('');
}
async function batchAttach() {
const group_id = document.getElementById('attachGroup').value;
const ids = [...document.querySelectorAll('#attachList input[type=checkbox]:checked')].map(c => c.value);
if (!group_id) { alert('Выберите группу'); return; }
if (!ids.length) { alert('Выберите учеников'); return; }
const res = await fetch(`${API}/api/students/batch-group`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ group_id, student_ids: ids })
});
if (res.ok) {
const r = await res.json();
closeBatchAttach();
loadStudents();
showToast(`Прикреплено: ${r.updated}`);
} else { const e = await res.json(); alert(e.error); }
}
document.getElementById('attachSearch').addEventListener('input', () => {
clearTimeout(aSearchTimer);
aSearchTimer = setTimeout(renderAttachList, 300);
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
fillAutocomplete();
loadGroupOptions();
loadStudents();
}
})();
</script>
<script src="js/students.js"></script>
</body>
</html>
+4 -91
View File
@@ -13,107 +13,20 @@
<h2>Корзина</h2>
<div class="trash-head">
<span class="trash-muted" id="trashInfo"></span>
<button class="danger-btn" id="emptyBtn" onclick="emptyTrash()">Очистить корзину</button>
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
</div>
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
<div class="modal-overlay" id="imgModal">
<img id="imgModalSrc">
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script>
const PAGE_SIZE = 20;
let currentEntries = [];
let page = 1;
let totalEntries = 0;
function isImageFile(name) {
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function filesHTML(files) {
if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f =>
isImageFile(f.name)
? `<a class="f-link" href="#" onclick="showImg('${API}/api/files/${f.token}');return false;" title="Открыть">${esc(f.name)}</a>`
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`
).join('')}</div>`;
}
function entryHTML(e) {
return `
<div class="card">
${e.photo_path ? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" onclick="restoreEntry(${e.id})" title="Восстановить">Восстановить</button>
<button class="del-btn" onclick="hardDelete(${e.id})" title="Удалить навсегда (вместе с файлами)">&times;</button>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
grid.className = 'grid';
grid.innerHTML = currentEntries.length
? currentEntries.map(entryHTML).join('')
: '<div class="empty">Корзина пуста</div>';
document.getElementById('trashInfo').textContent =
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
document.getElementById('emptyBtn').disabled = totalEntries === 0;
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
}
async function loadTrash() {
const p = new URLSearchParams();
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
const data = await res.json();
currentEntries = data.entries;
totalEntries = data.total;
renderGrid();
}
function goPage(p) { page = p; loadTrash(); }
async function restoreEntry(id) {
const res = await fetch(`${API}/api/entries/${id}/restore`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Запись восстановлена'); loadTrash(); }
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
}
async function hardDelete(id) {
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
showToast('Запись удалена навсегда');
loadTrash();
}
async function emptyTrash() {
if (totalEntries === 0) return;
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadTrash();
}
})();
</script>
<script src="js/trash.js"></script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Пользователи — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="users">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Пользователи</h2>
<p class="page-sub">Управление доступом и ролями</p>
</div>
<div class="actions-row" style="margin-bottom:16px">
<button class="btn-primary" id="addUserBtn" type="button">
<span style="font-size:1.2rem">+</span> Добавить пользователя
</button>
</div>
<div class="audit-wrap">
<table class="audit-table" id="usersTable">
<thead>
<tr>
<th>ID</th>
<th>Логин</th>
<th>Имя</th>
<th>Роль</th>
<th>Филиалы</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="usersEmpty" style="display:none">Пользователи не найдены</div>
</div>
</div>
</div>
<div class="modal-overlay" id="userModal">
<div class="edit-modal" style="max-width:480px">
<h3 id="userModalTitle">Новый пользователь</h3>
<div class="settings-stack" style="gap:12px;max-width:none">
<div class="settings-field">
<label>Логин <span style="color:#ef4444">*</span></label>
<input type="text" id="uUsername" class="settings-input" placeholder="login" autocomplete="off">
</div>
<div class="settings-field">
<label>Имя</label>
<input type="text" id="uName" class="settings-input" placeholder="Имя Фамилия">
</div>
<div class="settings-field">
<label>Роль</label>
<select id="uRole" class="settings-input">
<option value="admin">Администратор</option>
<option value="tutor">Тутор</option>
</select>
</div>
<div class="settings-field" id="uBranchesField">
<label>Филиалы (мультивыбор)</label>
<select id="uBranches" class="settings-input" multiple size="4"></select>
<span class="hint" style="font-size:.75rem">Тутор видит только свои филиалы. Выбор: Ctrl/Cmd+клик.</span>
</div>
<div class="settings-field">
<label>Пароль <small id="pwdHint" style="font-weight:400;color:var(--muted)"></small></label>
<input type="password" id="uPassword" class="settings-input" placeholder="••••••••">
</div>
<div class="settings-field" style="flex-direction:row;align-items:center;gap:8px">
<input type="checkbox" id="uActive" checked>
<label style="margin:0">Активен</label>
</div>
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
<button type="button" class="btn-primary ghost" id="uCancel">Отмена</button>
<button type="button" class="btn-primary" id="uSave">Сохранить</button>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/users.js"></script>
<style>
.badge-admin,.badge-tutor{font-size:.7rem;font-weight:600;padding:2px 10px;border-radius:999px;white-space:nowrap}
.badge-admin{color:#7c3aed;background:rgba(124,58,237,.12)}
.badge-tutor{color:var(--accent);background:rgba(37,99,235,.1)}
.status-ok{font-size:.72rem;font-weight:600;color:#16a34a;background:rgba(22,163,74,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
.status-off{font-size:.72rem;font-weight:600;color:#ef4444;background:rgba(239,68,68,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
.row-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px 8px;border-radius:6px}
.row-btn:hover{background:var(--bg);color:var(--accent)}
.row-btn.del:hover{color:#ef4444;background:rgba(239,68,68,.1)}
.user-branches{max-width:220px;color:var(--muted);font-size:.78rem}
</style>
</body>
</html>
+12
View File
File diff suppressed because one or more lines are too long
+192
View File
@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Воркер ИИ — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
<style>
.enhance-compare { position: relative; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
.enhance-stage { transform-origin: 0 0; }
.enhance-stage > img { display: block; width: 100%; height: auto; }
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
.enhance-top .enhance-stage { position: absolute; inset: 0; }
.enhance-top .enhance-stage > img { height: 100%; object-fit: fill; }
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); cursor: ew-resize; }
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
.enhance-badge.left { left: 10px; }
.enhance-badge.right { right: 10px; }
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
</style>
</head>
<body data-page="worker">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Воркер ИИ</h2>
<p class="page-sub">Отслеживание автоматической проверки сообщений записей</p>
</div>
<div class="actions-row">
<span class="worker-state" id="stateLine"><span class="worker-dot"></span> Загрузка…</span>
<span style="flex:1"></span>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:.85rem">
<input type="checkbox" id="autoRefresh"> Автообновление
</label>
<button class="btn-link" id="refreshBtn" type="button">Обновить</button>
<button class="btn-link" id="wakeBtn" type="button" title="Запустить обработку очереди немедленно">Разбудить</button>
<button class="btn-link" id="requeueBtn" type="button" title="Вернуть все записи с ошибкой в очередь">Перепроверить ошибки</button>
<button class="btn-link" id="photoWakeBtn" type="button" title="Разбудить фото-воркер">📷 Разбудить фото</button>
<button class="btn-link" id="photoToggleBtn" type="button">📷 …</button>
<button class="btn-link" id="toggleBtn" type="button">…</button>
</div>
<div class="sys-grid" id="workerCards" style="margin-top:16px">
<div class="sys-card loading">Загрузка…</div>
</div>
<div class="worker-counts" id="workerCounts"></div>
<div class="settings-stack settings-stack-grid" style="margin-top:20px">
<div class="settings-card wide">
<div class="card-head">
<div class="ic"><i data-lucide="hourglass"></i></div>
<h3>Очередь (<span id="pendingCount">0</span>)</h3>
</div>
<p class="hint">Записи, ожидающие проверки моделью. Обновляются автоматически.</p>
<div class="audit-wrap">
<table class="audit-table" id="pendingTable">
<thead>
<tr><th>ID</th><th>Ученик</th><th>Группа</th><th>Создана</th></tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="pendingEmpty" style="display:none">Очередь пуста.</div>
</div>
</div>
<div class="settings-card wide">
<div class="card-head">
<div class="ic"><i data-lucide="history"></i></div>
<h3>Последние проверки</h3>
</div>
<div class="actions-row" style="margin-bottom:10px">
<select id="recentFilter" class="settings-input" style="max-width:220px">
<option value="">Все статусы</option>
<option value="done">Проверено</option>
<option value="skipped">Пропущено</option>
<option value="error">Ошибка</option>
<option value="reverted">Возвращён оригинал</option>
</select>
<span class="hint" id="recentCount"></span>
</div>
<div class="audit-wrap">
<table class="audit-table" id="recentTable">
<thead>
<tr><th>Проверено</th><th>Ученик</th><th>Группа</th><th>Статус</th><th>Изменения</th><th>Детали</th></tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="recentEmpty" style="display:none">Проверок пока нет.</div>
</div>
<div class="pager" id="recentPager"></div>
</div>
<div class="settings-card wide">
<div class="card-head">
<div class="ic"><i data-lucide="image"></i></div>
<h3>Обработка фотографий</h3>
</div>
<div class="actions-row" style="margin-bottom:10px">
<span class="hint" id="photoStateHint"></span>
<span style="flex:1"></span>
<button class="btn-link" id="photoRequeueBtn" type="button" title="Вернуть задания с ошибкой в очередь">Повторить ошибки фото</button>
</div>
<div class="worker-counts" id="photoCounts" style="margin-bottom:10px"></div>
<p class="hint">В очереди (<span id="photoPendingCount">0</span>)</p>
<div class="audit-wrap" style="margin-bottom:14px">
<table class="audit-table" id="photoPendingTable">
<thead>
<tr><th>Задание</th><th>Запись</th><th>Действие</th><th>Поставлено</th></tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="photoPendingEmpty" style="display:none">Очередь пуста.</div>
</div>
<div class="audit-wrap">
<table class="audit-table" id="photoTable">
<thead>
<tr><th>Время</th><th>Запись</th><th>Действие</th><th>Статус</th><th>Было</th><th>Стало</th><th>Ошибка</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="photoEmpty" style="display:none">Обработок фотографий пока нет.</div>
</div>
<div class="pager" id="photoRecentPager"></div>
</div>
<div class="settings-card wide" id="errorsCard" style="display:none">
<div class="card-head">
<div class="ic"><i data-lucide="triangle-alert"></i></div>
<h3>Ошибки (<span id="errorsCount">0</span>)</h3>
</div>
<p class="hint">Записи, которые не удалось обработать после всех попыток. Нажмите «Перепроверить ошибки», чтобы запустить их снова.</p>
<div class="audit-wrap">
<table class="audit-table" id="errorsTable">
<thead>
<tr><th>Время</th><th>ID</th><th>Ученик</th><th>Ошибка</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="aiDetailModal">
<div class="edit-modal" style="max-width:680px">
<h3 id="aiDetailTitle">Детали ИИ-проверки</h3>
<div class="ai-detail-block">
<div class="ai-detail-label">Отправлено в модель</div>
<pre id="aiDetailInput"></pre>
</div>
<div class="ai-detail-block">
<div class="ai-detail-label">Ответ модели</div>
<pre id="aiDetailOutput"></pre>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="cancel" id="aiDetailCloseBtn">Закрыть</button>
</div>
</div>
</div>
<div class="modal-overlay" id="photoJobModal">
<div class="edit-modal" style="max-width:900px">
<h3 id="photoJobTitle">Обработка фотографии</h3>
<div class="enhance-compare" id="photoJobCompare">
<div class="enhance-stage">
<img id="photoJobBefore" alt="Было">
</div>
<div class="enhance-top" id="photoJobTop" style="clip-path:inset(0 0 0 50%)">
<div class="enhance-stage enhance-stage-fill">
<img id="photoJobAfter" alt="Стало">
</div>
</div>
<div class="enhance-divider" id="photoJobDivider"><div class="enhance-handle">⇔</div></div>
<div class="enhance-badge left">Было</div>
<div class="enhance-badge right">Стало</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:14px">
<button type="button" class="btn-primary ghost" id="photoJobCloseBtn">Закрыть</button>
<button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/worker.js"></script>
</body>
</html>
+1473
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

+1
View File
@@ -14,6 +14,7 @@ echo ">> Копирование фотографий"
rm -rf backups/_uploads
mkdir -p backups/_uploads
docker cp "whatido-app-1:/app/uploads/." "backups/_uploads/"
rm -rf backups/_uploads/.thumbs
echo ">> Сборка архива"
tar -czf "backups/whatido-backup-$STAMP.tar.gz" -C backups db.sql.gz _uploads
+18
View File
@@ -0,0 +1,18 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
function sh(cmd) {
try { return execSync(cmd, { encoding: 'utf8' }).trim(); } catch { return ''; }
}
const out = {
full: sh('git rev-parse HEAD'),
short: sh('git rev-parse --short HEAD'),
date: sh('git log -1 --format=%cI')
};
fs.writeFileSync(
path.join(__dirname, '..', 'public', 'version.json'),
JSON.stringify(out, null, 2)
);
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
ROOT="$(git rev-parse --show-toplevel)"
node "$ROOT/scripts/gen-version.js"
+4233 -235
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
set -e
# Опциональный WireGuard: если в wg/wg0.conf лежит конфиг — поднимаем VPN
# и только после установки handshake запускаем туннель. Без конфига —
# cloudflared стартует сразу, как в базовой схеме Quick Tunnel.
if [ -f /etc/wireguard/wg0.conf ]; then
echo "WireGuard config found, bringing up wg0..."
# Папка wg/ смонтирована :ro, поэтому копируем в /tmp и ставим 600, чтобы
# wg-quick не ругался на world-accessible файл приватного ключа.
cp /etc/wireguard/wg0.conf /tmp/wg0.conf
chmod 600 /tmp/wg0.conf
cp /etc/resolv.conf /tmp/resolv.conf.default
wg-quick up /tmp/wg0.conf
# wg-quick/resolvconf перезаписывает resolv.conf (убирает docker-DNS 127.0.0.11),
# из-за чего внутреннее имя app не резолвится. Восстанавливаем прежний конфиг.
cat /tmp/resolv.conf.default > /etc/resolv.conf
echo "Waiting for WireGuard handshake (timeout: ${WG_HANDSHAKE_TIMEOUT:-60}s)..."
# Формат `wg show wg0 latest-handshakes`: "<pubkey> <epoch-ts>"; 0 = handshake ещё не было.
waited=0; handshake=0
while [ "$handshake" -eq 0 ] && [ "$waited" -lt "${WG_HANDSHAKE_TIMEOUT:-60}" ]; do
if wg show wg0 latest-handshakes | awk '{ if ($2 != 0) found=1 } END { exit !found }'; then
handshake=1
else
sleep 2
waited=$((waited + 2))
fi
done
if [ "$handshake" -eq 1 ]; then
echo "WireGuard is up, starting Cloudflare tunnel through VPN..."
else
echo "WARNING: WireGuard handshake not established within ${WG_HANDSHAKE_TIMEOUT:-60}s; starting Cloudflare tunnel directly (no VPN egress)."
fi
else
echo "No WireGuard config, starting Cloudflare tunnel directly..."
fi
exec cloudflared --no-autoupdate tunnel --url "$CLOUDFLARE_TUNNEL_URL"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
set -e
tailscaled &
echo "Waiting for tailscaled..."
sleep 5
until tailscale status > /dev/null 2>&1; do
sleep 1
done
tailscale up --hostname=whatido --accept-dns=false
echo "Tailscale is up, waiting for network to settle..."
sleep 5
tailscale funnel --bg --yes http://127.0.0.1:3003
echo "Funnel is ready!"
sleep infinity
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
set -e
MODEL_NAME="${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}"
MODEL_REPO="${AI_MODEL_REPO:-Qwen/Qwen2.5-1.5B-Instruct-GGUF}"
MODEL_PATH="/models/${MODEL_NAME}"
MODEL_URL="https://huggingface.co/${MODEL_REPO}/resolve/main/${MODEL_NAME}"
if [ ! -s "${MODEL_PATH}" ]; then
echo "[text-corrector] Model ${MODEL_PATH} not found, downloading from ${MODEL_URL}"
set -- -L --fail --retry 3 --retry-delay 5 --progress-bar -o "${MODEL_PATH}.part"
if [ -n "${HUGGINGFACE_TOKEN}" ]; then
echo "[text-corrector] Using Hugging Face token"
set -- "$@" -H "Authorization: Bearer ${HUGGINGFACE_TOKEN}"
fi
curl "$@" "${MODEL_URL}"
mv "${MODEL_PATH}.part" "${MODEL_PATH}"
echo "[text-corrector] Download complete"
fi
echo "[text-corrector] Starting llama-server with ${MODEL_PATH}"
exec /app/llama-server -m "${MODEL_PATH}" --host 0.0.0.0 --port 8080 -c 4096 -n 256 -t 8 --temp 0.1
+1395
View File
File diff suppressed because it is too large Load Diff
+1209
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
# TODO: профиль ученика + ZIP-архив
## Задача
1. На `/students.html` у каждого ученика — кнопка **«Профиль»**, которая одним нажатием
отдаёт ZIP-архив со всеми данными ученика (фото, работы/файлы, записи журнала).
2. Сгенерированная страница `index.html` внутри архива должна быть оформлена
**полностью по дизайну `student-profile.html`** (тёмная/светлая тема, токены,
топбар, hero, статистика, образование, опыт, навыки, проекты, уроки, «о себе», футер).
3. Если в БД нет данных для заполнения блоков этой страницы — страница не должна
ломаться или показывать выдуманные данные: блоки рендерятся из того, что есть,
пустые разделы получают аккуратный empty-state, а **данные можно добавить позже**
(без правок кода).
## План работ
- [x] Разведка: `server.js` (экспорт `/api/export/student`, `createZipWriter`,
`renderStudentReport`), `public/students.html` + `public/js/students.js`,
`db/init.sql` + `db/migration.sql`, дизайн-макет `student-profile.html`.
- [x] `todo.md` (этот файл).
- [x] БД: у ученика появляются поля для будущего заполнения профиля —
`students.profile JSONB` и `students.photo_path` (`db/init.sql`, `db/migration.sql`).
- [x] Бэкап/восстановление: новые поля сохраняются и восстанавливаются
(`normalizeRestoreData`, `INSERT INTO students`).
- [x] API: `GET/PUT /api/students/:id/profile` (requireAuth + проверка филиала,
валидация структуры профиля, аудит `student.profile.update`).
- [x] Новый модуль `student-report.js`: ZIP-writer (перенесён из `server.js`) +
`renderStudentReport()` — страница профиля ученика по дизайну
`student-profile.html` (самодостаточный HTML+CSS+JS, Lucide через CDN с
fallback-заглушками, лайтбокс, ленты, тема, печать, анимации).
- [x] `server.js`: вызов рендера через модуль, убрать старый рендер из `server.js`,
расширить выборки экспорта (модули, статус ИИ, филиал, `students.profile`).
- [x] `public/students.html` + `public/js/students.js`: кнопка **«Профиль»**
(прямая выгрузка ZIP) и кнопка **«Данные»** (модальное окно заполнения данных
профиля: роль, статус, город, наставник, о себе, цитата, теги, контакты,
достижения, навыки, опыт, прогресс по модулям).
- [x] Архив: `index.html` + `photos/` + `files/` + `data.json` (метаданные профиля).
- [x] Проверка: `node --check`, генерация тестового ZIP и разбор `zipfile`
(структура + наличие разделов профиля в `index.html`).
## Решения и допущения
- «index.html» из задачи — это `index.html` внутри ZIP-архива (страница профиля
ученика). Именно он теперь оформлен по `student-profile.html`. Публичный
`public/index.html` (форма отправки записи) не переделывался: это отдельная
страница, и в задаче про неё данных нет.
- Кнопка называется «Профиль» (в задаче опечатка «Профаил»), подпись в подсказке:
«Скачать профиль ученика (ZIP: фото и работы)».
- Дизайн-макет использует Lucide-иконки через CDN; в архиве оставлен тот же подход,
потому что он полностью офлайн-деградирует (класс `no-icons`), как и в макете.
- Данные, которых нет в БД (опыт, навыки, контакты, достижения, прогресс по модулям,
фото), рендерятся из полей `students.profile`; пока их нет — показывается
empty-state с подсказкой, а заполнить их можно в админке («Данные» у ученика).
- В `renderStudentReport` добавлен раздел «Фотографии» (лента в стиле макета) —
без него фото из архива негде было бы посмотреть; ленты и лайтбокс — в стиле
`student-profile.html`.
## Как применить на существующей базе
```bash
docker compose exec -T db psql -U app -d whereldo \
-c "ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);" \
-c "ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;"
docker compose up -d --build app
```
(`db/migration.sql` уже содержит эти же ALTER-команды; на чистой базе `init.sql`
создаёт поля сразу.)
## Как проверить
```bash
# 1. Админка → «Ученики»: у каждого ученика кнопки «Профиль» и «Данные»
# 2. «Данные» → заполнить роль/био/навыки/контакты/достижения/опыт → Сохранить
# 3. «Профиль» → скачивается profile_<имя>_<дата>.zip:
# index.html (страница профиля по student-profile.html), photos/, files/, data.json
```
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# dynv6 DDNS update script
# Environment variables expected:
# DYNDNS_TOKEN - dynv6 token
# DYNDNS_HOSTNAME - domain name (e.g., kiberone.dns.army)
set -e
TOKEN="${DYNDNS_TOKEN}"
HOSTNAME="${DYNDNS_HOSTNAME}"
if [ -z "$TOKEN" ] || [ -z "$HOSTNAME" ]; then
echo "ERROR: DYNDNS_TOKEN and DYNDNS_HOSTNAME must be set"
exit 1
fi
echo "[$(date)] Updating dynv6 for $HOSTNAME..."
# Update IPv4
IPV4=$(curl -s -4 https://api.ipify.org 2>/dev/null || echo "")
if [ -n "$IPV4" ]; then
echo "[$(date)] Detected IPv4: $IPV4"
RESPONSE=$(curl -s "https://dynv6.com/api/update?hostname=${HOSTNAME}&token=${TOKEN}&ipv4=${IPV4}")
echo "[$(date)] IPv4 update response: $RESPONSE"
else
echo "[$(date)] No IPv4 detected"
fi
# Update IPv6
IPV6=$(curl -s -6 https://api6.ipify.org 2>/dev/null || echo "")
if [ -n "$IPV6" ]; then
echo "[$(date)] Detected IPv6: $IPV6"
RESPONSE=$(curl -s "https://dynv6.com/api/update?hostname=${HOSTNAME}&token=${TOKEN}&ipv6=${IPV6}")
echo "[$(date)] IPv6 update response: $RESPONSE"
else
echo "[$(date)] No IPv6 detected"
fi
echo "[$(date)] Update complete"
+507
View File
@@ -0,0 +1,507 @@
const IDLE_MIN_MS = 2000;
const IDLE_MAX_MS = 60000;
const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10);
const MAX_INPUT_CHARS = 2000;
const MIN_TEXT_CHARS = 4;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const PHOTO_MAX_ATTEMPTS = 3;
const PHOTO_AI_TIMEOUT_MS = 300000;
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, originalsDir }) {
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
const IDLE_MIN = 2000;
const IDLE_MAX = 30000;
let started = false;
let stopped = false;
let idleMs = IDLE_MIN;
let wake = null;
let processing = false;
let currentId = null;
let startedAt = null;
const stats = { jobs: 0, done: 0, errors: 0, last_at: null, last_error: null };
const CONFIG = {
idle_min_ms: IDLE_MIN,
idle_max_ms: IDLE_MAX,
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS,
max_attempts: PHOTO_MAX_ATTEMPTS,
ai_url: AI_URL,
};
function sleep(ms) {
return new Promise((resolve) => {
const t = setTimeout(() => { wake = null; resolve(); }, ms);
wake = () => { clearTimeout(t); wake = null; resolve(); };
});
}
function notify() {
if (wake) wake();
}
async function isEnabled() {
const v = await getSetting('photo_worker_enabled', 'true');
return String(v) !== 'false';
}
async function claimNext() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`SELECT j.id, j.entry_id, j.action, j.params
FROM photo_jobs j
WHERE j.status = 'pending'
ORDER BY j.id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`
);
if (!rows.length) {
await client.query('COMMIT');
return null;
}
await client.query(`UPDATE photo_jobs SET status = 'processing' WHERE id = $1`, [rows[0].id]);
await client.query('COMMIT');
return rows[0];
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally {
client.release();
}
}
function safeUnlinkPath(p) {
try {
if (!p) return;
const abs = path.resolve(p);
const root = path.resolve(uploadsDir);
if (abs !== root && abs.startsWith(root + path.sep)) fs.unlinkSync(abs);
} catch (e) { console.error('photo worker unlink:', e.message); }
}
function backupOldFile(oldRelPath) {
if (!oldRelPath) return null;
const oldAbs = path.join(uploadsDir, String(oldRelPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
if (!fs.existsSync(oldAbs)) return null;
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
const backupPath = path.join(originalsDir, backupName);
try {
fs.renameSync(oldAbs, backupPath);
return `/uploads/.originals/${backupName}`;
} catch (e) {
console.error('photo worker backup failed:', e.message);
return null;
}
}
async function enhanceWithSharp(srcAbs, params) {
if (!sharp) throw new Error('sharp недоступен на сервере');
const p = params || {};
const clamp = (v, min, max, def) => {
const n = parseFloat(v);
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
};
const brightness = clamp(p.brightness, 10, 300, 100);
const contrast = clamp(p.contrast, 10, 300, 100);
const saturate = clamp(p.saturate, 0, 300, 100);
const sharpAmt = clamp(p.sharp, 0, 100, 0);
const denoise = clamp(p.denoise, 0, 100, 0);
let pipeline = sharp(srcAbs).rotate();
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
if (contrast !== 100) {
const a = contrast / 100;
pipeline = pipeline.linear(a, 128 * (1 - a));
}
if (sharpAmt > 0) {
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
}
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(path.join(uploadsDir, newName));
return `/uploads/${newName}`;
}
async function runAiEnhance(srcAbs) {
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
const buf = fs.readFileSync(srcAbs);
const fd = new FormData();
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
fd.append('scale', '2');
const resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
method: 'POST',
body: fd,
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
});
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
const out = Buffer.from(await resp.arrayBuffer());
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
fs.writeFileSync(path.join(uploadsDir, newName), out);
return `/uploads/${newName}`;
}
async function applyResult(job, newPath) {
await pool.query(
`UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`,
[newPath, job.id]
);
if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath });
}
async function processOne(job) {
stats.jobs++;
const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entry_id]);
if (!rows.length || !rows[0].photo_path) {
await pool.query(
`UPDATE photo_jobs SET status = 'error', error = 'У записи нет фото', finished_at = now() WHERE id = $1`,
[job.id]
);
stats.errors++;
stats.last_at = new Date().toISOString();
stats.last_error = 'У записи нет фото';
return true;
}
const photoPath = rows[0].photo_path;
const srcAbs = path.join(uploadsDir, String(photoPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
try {
const newPath = job.action === 'ai' ? await runAiEnhance(srcAbs) : await enhanceWithSharp(srcAbs, job.params);
await applyResult(job, newPath);
stats.done++;
stats.last_at = new Date().toISOString();
stats.last_error = null;
return true;
} catch (e) {
const message = (e && e.message ? e.message : 'error').slice(0, 500);
stats.last_at = new Date().toISOString();
stats.last_error = message;
const { rows: cur } = await pool.query('SELECT attempts FROM photo_jobs WHERE id = $1', [job.id]);
const tries = ((cur[0] && cur[0].attempts) || 0) + 1;
if (tries < PHOTO_MAX_ATTEMPTS) {
await pool.query(`UPDATE photo_jobs SET status = 'pending', attempts = $1, error = $2 WHERE id = $3`, [tries, message, job.id]);
if (logAudit) await logAudit(null, 'photo.job.retry', { entry_id: job.entry_id, job_id: job.id, attempt: tries, error: message });
return false;
}
await pool.query(
`UPDATE photo_jobs SET status = 'error', attempts = $1, error = $2, finished_at = now() WHERE id = $3`,
[tries, message, job.id]
);
stats.errors++;
if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message });
return true;
}
}
async function loop() {
while (!stopped) {
let job = null;
try {
if (!(await isEnabled())) {
await sleep(IDLE_MAX);
continue;
}
job = await claimNext();
} catch (e) {
console.error('Photo worker claim error:', e);
await sleep(IDLE_MAX);
continue;
}
if (!job) {
await sleep(idleMs);
idleMs = Math.min(idleMs * 2, IDLE_MAX);
continue;
}
idleMs = IDLE_MIN;
processing = true;
currentId = job.id;
let ok = true;
try {
ok = await processOne(job);
} catch (e) {
console.error('Photo worker process error:', e);
} finally {
processing = false;
currentId = null;
}
if (!ok) await sleep(IDLE_MAX);
}
}
async function resetStale() {
await pool.query(`UPDATE photo_jobs SET status = 'pending' WHERE status = 'processing'`);
}
function start() {
if (started) return;
started = true;
startedAt = Date.now();
resetStale()
.catch((e) => console.error('Photo worker reset error:', e))
.finally(() => { loop().catch((e) => console.error('Photo worker loop error:', e)); });
}
function getInfo() {
return {
started_at: startedAt ? new Date(startedAt).toISOString() : null,
uptime_ms: startedAt ? Date.now() - startedAt : 0,
processing,
current_id: currentId,
config: CONFIG,
...stats,
};
}
return { start, notify, getInfo };
}
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
let started = false;
let stopped = false;
let idleMs = IDLE_MIN_MS;
let wake = null;
let processing = false;
let currentId = null;
let startedAt = null;
const stats = { checks: 0, corrected: 0, unchanged: 0, errors: 0, last_at: null, last_error: null };
const attempts = new Map();
const MAX_ATTEMPTS = 3;
const CONFIG = {
idle_min_ms: IDLE_MIN_MS,
idle_max_ms: IDLE_MAX_MS,
request_timeout_ms: REQUEST_TIMEOUT_MS,
max_input_chars: MAX_INPUT_CHARS,
min_text_chars: MIN_TEXT_CHARS,
max_attempts: MAX_ATTEMPTS,
model: MODEL,
ai_url: AI_URL,
};
function sleep(ms) {
return new Promise((resolve) => {
const t = setTimeout(() => { wake = null; resolve(); }, ms);
wake = () => { clearTimeout(t); wake = null; resolve(); };
});
}
function notify() {
if (wake) wake();
}
async function isEnabled() {
const v = await getSetting('ai_autocheck_enabled', 'true');
return String(v) !== 'false';
}
async function claimNext() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`SELECT id, description, description_original FROM entries
WHERE ai_status = 'pending' AND deleted_at IS NULL
ORDER BY id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`
);
if (!rows.length) {
await client.query('COMMIT');
return null;
}
await client.query(`UPDATE entries SET ai_status = 'processing' WHERE id = $1`, [rows[0].id]);
await client.query('COMMIT');
return rows[0];
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally {
client.release();
}
}
async function resolveActiveProfile() {
try {
const active = await getSetting('ai_active_profile', 'native');
if (active && active !== 'native') {
const raw = await getSetting('ai_profiles', '[]');
const list = JSON.parse(raw || '[]');
if (Array.isArray(list)) {
return list.find(p => p && p.id === active && p.base_url && p.model) || null;
}
}
} catch (e) {}
return null;
}
function normalizeOpenAiBase(base) {
let b = String(base || '').trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(b)) return null;
if (!/\/v1$/i.test(b)) b += '/v1';
return b;
}
async function callModel(text, systemPrompt) {
const profile = await resolveActiveProfile();
const headers = { 'Content-Type': 'application/json' };
let url;
let model;
let maxTokens;
if (profile) {
const base = normalizeOpenAiBase(profile.base_url);
if (!base) throw new Error('Некорректный base_url профиля ИИ');
url = `${base}/chat/completions`;
model = profile.model;
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, text.length * 2 + 512));
} else {
url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
model = MODEL;
maxTokens = Math.min(256, Math.max(128, text.length + 64));
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text },
],
temperature: 0.1,
max_tokens: maxTokens,
}),
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
const data = await res.json();
const choice = data.choices && data.choices[0];
const content = (choice && choice.message && choice.message.content || '').trim();
if (!content) {
throw new Error(`Модель вернула пустой ответ (finish_reason=${choice && choice.finish_reason || 'unknown'})`);
}
return content;
} finally {
clearTimeout(timer);
}
}
function sanitize(original, out) {
if (!out) return original;
const o = out.trim();
if (!o) return original;
if (o.length > original.length * 4 + 80) return original;
return o;
}
async function processOne(row) {
const original = String(row.description_original ?? row.description ?? '').trim();
if (original.length < MIN_TEXT_CHARS) {
await pool.query(`UPDATE entries SET ai_status = 'skipped', ai_checked_at = now() WHERE id = $1`, [row.id]);
return true;
}
const prompt = (await getSetting('ai_prompt', DEFAULT_PROMPT)) || DEFAULT_PROMPT;
const input = original.length > MAX_INPUT_CHARS ? original.slice(0, MAX_INPUT_CHARS) : original;
try {
const out = await callModel(input, prompt);
const checked = sanitize(input, out);
const changed = checked !== input;
await pool.query(
`UPDATE entries SET description_ai = $1, description = $1, ai_status = 'done', ai_error = NULL, ai_checked_at = now() WHERE id = $2`,
[changed ? checked : original, row.id]
);
attempts.delete(row.id);
stats.checks++;
if (changed) stats.corrected++; else stats.unchanged++;
stats.last_at = new Date().toISOString();
stats.last_error = null;
if (logAudit) await logAudit(null, 'entry.ai.auto-check', { id: row.id, changed });
return true;
} catch (e) {
const message = (e && e.message ? e.message : 'error').slice(0, 500);
const tries = (attempts.get(row.id) || 0) + 1;
stats.last_at = new Date().toISOString();
stats.last_error = message;
if (tries < MAX_ATTEMPTS) {
attempts.set(row.id, tries);
await pool.query(`UPDATE entries SET ai_status = 'pending', ai_error = $1 WHERE id = $2`, [message, row.id]);
if (logAudit) await logAudit(null, 'entry.ai.retry', { id: row.id, attempt: tries, error: message });
return false;
}
attempts.delete(row.id);
stats.errors++;
await pool.query(
`UPDATE entries SET ai_status = 'error', ai_error = $1, ai_checked_at = now() WHERE id = $2`,
[message, row.id]
);
if (logAudit) await logAudit(null, 'entry.ai.error', { id: row.id, error: message });
return true;
}
}
async function loop() {
while (!stopped) {
let row = null;
try {
if (!(await isEnabled())) {
await sleep(IDLE_MAX_MS);
continue;
}
row = await claimNext();
} catch (e) {
console.error('AI auto-check claim error:', e);
await sleep(IDLE_MAX_MS);
continue;
}
if (!row) {
await sleep(idleMs);
idleMs = Math.min(idleMs * 2, IDLE_MAX_MS);
continue;
}
idleMs = IDLE_MIN_MS;
processing = true;
currentId = row.id;
let ok = true;
try {
ok = await processOne(row);
} catch (e) {
console.error('AI auto-check process error:', e);
} finally {
processing = false;
currentId = null;
}
if (!ok) await sleep(IDLE_MAX_MS);
}
}
async function resetStale() {
await pool.query(`UPDATE entries SET ai_status = 'pending' WHERE ai_status = 'processing'`);
}
function start() {
if (started) return;
started = true;
startedAt = Date.now();
resetStale()
.catch((e) => console.error('AI auto-check reset error:', e))
.finally(() => { loop().catch((e) => console.error('AI auto-check loop error:', e)); });
}
function getStats() {
return { ...stats, processing };
}
function getInfo() {
return {
started_at: startedAt ? new Date(startedAt).toISOString() : null,
uptime_ms: startedAt ? Date.now() - startedAt : 0,
processing,
current_id: currentId,
config: CONFIG,
...stats,
};
}
return { start, notify, getStats, getInfo };
}
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker };