36 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
50 changed files with 10782 additions and 720 deletions
+4
View File
@@ -27,3 +27,7 @@ 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
+3
View File
@@ -27,5 +27,8 @@ Thumbs.db
# --- Playwright test artifacts ---
.playwright-mcp/
# --- Generated version data ---
public/version.json
# --- Internal audit (not for commit) ---
SECURITY_AUDIT.md
+1 -1
View File
@@ -10,7 +10,7 @@ RUN set -e; \
try_repo() { \
printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \
echo "Trying apk mirror: $1"; \
apk add --no-cache openssl; \
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" && \
+68
View File
@@ -24,17 +24,44 @@ CREATE TABLE IF NOT EXISTS groups (
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,
@@ -42,6 +69,7 @@ CREATE TABLE IF NOT EXISTS entries (
ai_checked_at TIMESTAMPTZ,
ai_error TEXT,
photo_path VARCHAR(255),
photo_original_path VARCHAR(255),
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
@@ -51,8 +79,25 @@ 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,
@@ -146,6 +191,8 @@ CREATE TABLE IF NOT EXISTS users (
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,
@@ -163,6 +210,27 @@ CREATE TABLE IF NOT EXISTS sessions (
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,
+65
View File
@@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS banned_ips (
);
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,
@@ -152,6 +154,8 @@ CREATE TABLE IF NOT EXISTS user_branches (
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,
@@ -184,3 +188,64 @@ FROM (
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);
+32 -16
View File
@@ -21,6 +21,8 @@ services:
build:
context: .
network: host
extra_hosts:
- "host.docker.internal:host-gateway"
expose:
- "3003"
- "3443"
@@ -35,6 +37,7 @@ services:
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:
@@ -72,22 +75,22 @@ services:
# если в 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
# 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
@@ -111,7 +114,20 @@ services:
- "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:
+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')
+130 -7
View File
@@ -4,7 +4,10 @@ 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}
@@ -29,6 +32,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.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 .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}
@@ -49,8 +53,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.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}
@@ -127,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}
@@ -158,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}
@@ -192,6 +200,13 @@ 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;display:block;background:var(--bg)}
@@ -247,12 +262,13 @@ 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(auto-fit,minmax(320px,1fr));gap:20px;max-width:1200px}
.settings-stack-grid > .settings-card{max-width:none;height:100%}
.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}
@@ -287,6 +303,14 @@ 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}
@@ -418,4 +442,103 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.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}}
+41 -1
View File
@@ -56,6 +56,7 @@ function buildSidebar(active) {
{ 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' },
@@ -67,7 +68,10 @@ function buildSidebar(active) {
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>
@@ -76,10 +80,46 @@ function buildSidebar(active) {
${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">
<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 ---
+3
View File
@@ -16,6 +16,9 @@
</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>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -14,6 +14,7 @@
<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>
+28 -1
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,8 +92,13 @@ nav a:hover{color:var(--text)}
<nav>
<a href="admin.html">Админка</a>
</nav>
<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">
@@ -121,6 +135,12 @@ 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>
@@ -132,6 +152,13 @@ nav a:hover{color:var(--text)}
</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>
+81
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,6 +43,7 @@
<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 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>
@@ -48,6 +77,11 @@
<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>
@@ -161,6 +195,53 @@
</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>
+36
View File
@@ -21,10 +21,26 @@ const ACTION_LABELS = {
'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': 'Запись удалена безвозвратно',
@@ -41,6 +57,11 @@ const ACTION_LABELS = {
'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;
@@ -95,6 +116,8 @@ async function loadAudit() {
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();
@@ -126,6 +149,18 @@ function goAuditPage(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);
@@ -148,6 +183,7 @@ document.getElementById('auditDetailsModal').addEventListener('click', e => {
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
fillActionFilter();
loadAudit();
}
})();
+4
View File
@@ -14,3 +14,7 @@ if (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(() => {});
+26 -2
View File
@@ -1,5 +1,6 @@
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
let branchesCache = [];
let tutorsCache = [];
let allGroups = [];
let currentCover = null;
@@ -8,6 +9,16 @@ async function loadBranchesCache() {
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>`;
@@ -15,6 +26,7 @@ function branchSelectHTML(selectedId) {
async function loadGroups() {
await loadBranchesCache();
await loadTutorsCache();
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
allGroups = groups;
@@ -33,6 +45,7 @@ async function loadGroups() {
</div>
<div class="group-schedule">
${branchSelectHTML(g.branch_id)}
${tutorSelectHTML(g.tutor_id)}
<div><label>День</label>
<select id="dow-${g.id}">
<option value="">—</option>
@@ -66,13 +79,15 @@ async function saveGroup(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, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
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();
@@ -87,9 +102,12 @@ async function addGroup() {
const name = input.value.trim();
if (!name) return;
await loadBranchesCache();
await loadTutorsCache();
const branchSelect = document.getElementById('newGroupBranch');
const branch_id = branchSelect ? branchSelect.value : '';
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null }) });
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); }
}
@@ -414,6 +432,12 @@ async function populateNewGroupBranch() {
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 () => {
+150 -6
View File
@@ -4,6 +4,11 @@ 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');
@@ -15,6 +20,64 @@ 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 + ' Б';
@@ -74,7 +137,49 @@ if (params.get('nameInput')) {
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;
@@ -97,9 +202,29 @@ if (params.get('nameInput')) {
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) {
@@ -117,7 +242,7 @@ function removePhoto() {
async function openCam() {
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
camVideo.srcObject = stream;
camModal.classList.add('open');
} catch (e) {
@@ -141,7 +266,7 @@ function capture() {
capturedBlob = blob;
showPreview(URL.createObjectURL(blob));
closeCam();
}, 'image/jpeg', 0.85);
}, 'image/jpeg', captureQuality);
}
form.addEventListener('submit', async (e) => {
@@ -152,6 +277,16 @@ form.addEventListener('submit', async (e) => {
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 = 'Отправка...';
@@ -161,6 +296,7 @@ form.addEventListener('submit', async (e) => {
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);
@@ -177,10 +313,8 @@ form.addEventListener('submit', async (e) => {
form.reset();
removePhoto();
clearFiles();
toast.textContent = 'Готово!';
toast.classList.remove('error');
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
localStorage.setItem(SENT_KEY, String(Date.now()));
showSent('Запись отправлена!', 'Спасибо! Твоя запись уже передана преподавателю.', Date.now() + cooldownMs());
} catch (err) {
alert('Ошибка: ' + err.message);
} finally {
@@ -195,3 +329,13 @@ 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));
});
+880 -11
View File
File diff suppressed because it is too large Load Diff
+24 -6
View File
@@ -3,6 +3,8 @@ let groups = [];
let editingId = null;
let studentsLoaded = false;
let linkSettings = null;
let linkPage = 1;
const LINK_PAGE_SIZE = 20;
async function loadLinkSettings() {
if (!linkSettings) {
@@ -16,21 +18,30 @@ function linkUrl(token) { return `${location.origin}/s/${token}`; }
async function loadLinks() {
await loadLinkSettings();
const res = await fetch(`${API}/api/links`, { headers: hdr() });
links = await res.json();
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">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
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">
<div class="item${expired ? ' expired' : ''}">
<div style="min-width:0">
<div class="name">${esc(l.name)}</div>
<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>` : ' · весь период'}
${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
${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')}
@@ -38,12 +49,19 @@ async function loadLinks() {
</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}` : ''; }
+19
View File
@@ -29,3 +29,22 @@ async function doLogin() {
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')}
+523 -62
View File
@@ -1,4 +1,27 @@
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText'];
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;
@@ -8,24 +31,74 @@ 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 || 'Обработка...'}`;
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 dirty = DIRTY_FIELDS.some(id => {
const dirtyCount = DIRTY_FIELDS.filter(id => {
const el = document.getElementById(id);
return String(el.dataset.original ?? '') !== fieldValue(el);
});
document.getElementById('saveBar').classList.toggle('show', dirty);
}).length;
document.getElementById('saveBar').classList.toggle('show', dirtyCount > 0);
}
function bindDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).addEventListener('input', updateDirty);
const el = document.getElementById(id);
el.addEventListener('input', updateDirty);
el.addEventListener('change', updateDirty);
});
}
@@ -36,12 +109,61 @@ function snapshotDirty() {
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 || '';
@@ -52,27 +174,60 @@ async function loadSettings() {
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();
if (stRes.ok) {
const st = await stRes.json();
document.getElementById('dataStats').innerHTML = `
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>
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
${st.trash ? `<span class="chip">В корзине: <b>${st.trash}</b> <a href="trash.html">→</a></span>` : ''}
`;
} else {
document.getElementById('dataStats').innerHTML = '';
}
const siRes = await fetch(`${API}/api/system-info`, { headers: hdr() });
if (siRes.ok) {
const si = await siRes.json();
renderSystemInfo(si);
} else {
document.getElementById('systemInfo').innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
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>';
}
}
@@ -86,7 +241,7 @@ function renderSystemInfo(si) {
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>${t.name}</span><span>${t.size}</span></div>`).join('') +
db.tables.map(t => `<div class="sys-table-row"><span>${esc(t.name)}</span><span>${esc(t.size)}</span></div>`).join('') +
'</div></details>';
}
@@ -94,29 +249,28 @@ function renderSystemInfo(si) {
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-item">
<span class="sys-label">Диск</span>
<span class="sys-value">${disk.used} / ${disk.total}</span>
</div>
<div class="sys-bar-wrap">
<div class="sys-bar" style="width:${pct}%; background:${color}"></div>
</div>
<div class="sys-meta">Свободно: ${disk.free} (${100 - pct}%)</div>
`;
<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">${db.size || '—'}</span></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} · ${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} · ${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>${photos.total_size || '0 B'}</b></span></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>
@@ -125,15 +279,72 @@ function renderSystemInfo(si) {
<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">${uploads.size || '0 B'}</span></div>
</div>
<div class="sys-card">
<div class="sys-title">Диск</div>
${diskHtml || '<div class="sys-meta">Недоступно</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;
@@ -142,14 +353,153 @@ async function loadAiQueue() {
if (!res.ok) { el.textContent = ''; return; }
const q = await res.json();
const c = q.counts || {};
const state = q.enabled ? 'включена' : 'выключена';
el.innerHTML = `Автопроверка: <b>${state}</b> · Модель: <b>${esc(q.model)}</b> · В очереди: <b>${c.pending || 0}</b> · Проверено: <b>${c.done || 0}</b> · Ошибок: <b>${c.error || 0}</b> · Пропущено: <b>${c.skipped || 0}</b> · <a href="worker.html">Монитор воркера →</a>`;
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 = document.getElementById('spamInterval').value;
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
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);
@@ -158,6 +508,7 @@ async function saveSettings() {
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(),
@@ -168,12 +519,22 @@ async function saveSettings() {
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();
} else { const e = await res.json(); alert(e.error); }
updateSpamUi();
} else {
const e = await res.json().catch(() => ({}));
showError(e.error || 'Ошибка сохранения');
}
} catch {
showError('Ошибка сети при сохранении');
} finally {
setBtnLoading(btn, false);
}
@@ -185,7 +546,7 @@ async function downloadBackup() {
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
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="([^"]+)"/);
@@ -197,6 +558,8 @@ async function downloadBackup() {
a.click();
URL.revokeObjectURL(url);
showToast('Бэкап скачан');
} catch {
showError('Ошибка сети при формировании бэкапа');
} finally {
setBtnLoading(btn, false);
}
@@ -220,7 +583,7 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
} catch (err) {
st.textContent = '';
alert('Ошибка сети: ' + err.message);
showError('Ошибка сети: ' + err.message);
e.target.value = '';
return;
} finally {
@@ -234,8 +597,7 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
} else {
st.classList.add('err');
st.innerHTML = 'Ошибка восстановления';
const t = await res.text();
alert('Ошибка: ' + t);
showError(await res.text());
}
e.target.value = '';
});
@@ -243,35 +605,37 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
async function loadBans() {
const el = document.getElementById('bansList');
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
if (res.status === 403) { document.getElementById('bansCard').style.display = 'none'; return; }
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" data-chip="${esc(b.ip)}">
<b>${esc(b.ip)}</b> — ${esc(b.reason)} до ${new Date(b.banned_until).toLocaleString('ru')}
<button type="button" data-unban="${esc(b.ip)}" title="Разблокировать" style="background:none;border:none;color:var(--primary);cursor:pointer;font-weight:700">✕</button>
<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 showToast('Ошибка разблокировки');
else showError('Ошибка разблокировки');
}
document.getElementById('bansList').addEventListener('click', e => {
const ip = e.target.closest('[data-unban]')?.dataset.unban;
if (!ip) return;
const chip = e.target.closest('[data-chip]');
if (chip.dataset.confirming) return;
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.style.background = 'rgba(239,68,68,.08)';
e.target.outerHTML = `
<span style="white-space:nowrap">
Убрать из блокировки?
<button type="button" data-unban-yes style="background:none;border:none;color:#16a34a;cursor:pointer;font-weight:700">Да</button>
<button type="button" data-unban-no style="background:none;border:none;color:var(--muted);cursor:pointer;font-weight:700">Нет</button>
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());
@@ -280,12 +644,109 @@ document.getElementById('bansList').addEventListener('click', e => {
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();
loadSettings();
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();
}
})();
+45 -2
View File
@@ -239,7 +239,15 @@ function renderShare(data, password) {
const isAnonymized = !data.show_student_names;
const showStudentMessage = !!data.show_student_message;
const showEntryDate = !!data.show_entry_date;
grid.innerHTML = data.entries.map(e => {
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>` : '',
@@ -257,7 +265,18 @@ function renderShare(data, password) {
${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>
`;
}).join('');
};
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'));
@@ -290,3 +309,27 @@ document.addEventListener('click', e => {
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) {}
})();
+247
View File
@@ -47,6 +47,8 @@ function renderStudents() {
<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>
@@ -156,6 +158,215 @@ async function submitExport() {
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() });
@@ -318,10 +529,46 @@ document.getElementById('studentList').addEventListener('click', e => {
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);
+209 -2
View File
@@ -1,5 +1,6 @@
const AUTO_REFRESH_MS = 5000;
const RECENT_PAGE_SIZE = 15;
const PHOTO_PAGE_SIZE = 15;
const STATUS_LABELS = {
pending: 'В очереди',
processing: 'Проверяется',
@@ -16,10 +17,40 @@ const STATUS_BADGE = {
};
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 '—';
@@ -190,6 +221,116 @@ function render() {
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;
@@ -231,15 +372,28 @@ async function requeueFailed() {
function setAutoRefresh(on) {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
if (on) refreshTimer = setInterval(load, AUTO_REFRESH_MS);
if (on) refreshTimer = setInterval(() => { load(); loadPhoto(); }, AUTO_REFRESH_MS);
}
document.getElementById('refreshBtn').addEventListener('click', load);
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');
@@ -258,10 +412,63 @@ 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();
}
})();
+1
View File
@@ -13,6 +13,7 @@
<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>
+8 -2
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,7 +25,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
</head>
<body>
<div class="login">
<h1>Админ-панель</h1>
<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="">
+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>
+196 -66
View File
@@ -12,84 +12,178 @@
<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-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">
<div class="settings-card wide" id="sec-system">
<div class="card-head">
<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>
<div id="systemInfo" class="sys-grid">
<div class="sys-card loading">Загрузка…</div>
</div>
</div>
<div class="settings-card">
<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-row">
<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">
<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>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<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>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<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>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<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>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<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>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<div class="settings-field">
<label for="cookieNoticeText">Уведомление о cookie</label>
<textarea id="cookieNoticeText" class="settings-input" rows="3" style="min-height:80px"></textarea>
</div>
<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"><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>
@@ -107,45 +201,43 @@
</div>
</div>
<div class="settings-card">
<div class="settings-card" id="sec-photo">
<div class="card-head">
<div class="ic"><i data-lucide="bot"></i></div>
<h3>Промпт ИИ для исправления текста</h3>
<div class="ic"><i data-lucide="camera"></i></div>
<h3>Фотография с веб-камеры</h3>
</div>
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label for="aiPrompt">Промпт</label>
<textarea id="aiPrompt" class="settings-input" rows="4" style="min-height:100px;font-family:monospace;font-size:.85rem"></textarea>
<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="settings-row">
<div class="settings-field" style="flex:1">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="aiAutoCheck">
Автоматически проверять новые записи (без участия человека)
</label>
</div>
</div>
<div class="ai-queue" id="aiQueueInfo"></div>
</div>
<div class="settings-card">
<div class="card-head">
<div class="ic"><i data-lucide="database"></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" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
<button class="btn-primary ghost" 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="bansCard">
<div class="settings-card" id="sec-bans">
<div class="card-head">
<div class="ic"><i data-lucide="shield-off"></i></div>
<h3>Блокировки IP</h3>
@@ -156,11 +248,49 @@
</div>
</div>
</div>
</div>
<div class="save-bar" id="saveBar">
<span class="hint" id="saveHint" style="display:inline-flex;align-items:center;gap:6px"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
<button class="btn-primary" id="saveBtn" type="button">Сохранить изменения</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>
+11
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}
@@ -85,6 +92,10 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
</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>
+79
View File
@@ -130,6 +130,85 @@
</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>
+72
View File
@@ -5,6 +5,21 @@
<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">
@@ -24,6 +39,8 @@
<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>
@@ -78,6 +95,39 @@
<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>
@@ -112,6 +162,28 @@
</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>
+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

+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"
+1709 -494
View File
File diff suppressed because it is too large Load Diff
+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
```
+295 -6
View File
@@ -3,6 +3,252 @@ 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';
@@ -70,27 +316,70 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
}
}
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(`${AI_URL}/v1/chat/completions`, {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
signal: controller.signal,
body: JSON.stringify({
model: MODEL,
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text },
],
temperature: 0.1,
max_tokens: Math.min(512, Math.max(64, text.length + 32)),
max_tokens: maxTokens,
}),
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
const data = await res.json();
return (data.choices?.[0]?.message?.content || '').trim();
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);
}
@@ -215,4 +504,4 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
return { start, notify, getStats, getInfo };
}
module.exports = { createEntryAutoChecker };
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker };