Compare commits
94
Commits
b15abc34aa
...
16aba3efb0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16aba3efb0 | ||
|
|
cb3f010cd9 | ||
|
|
30cf04b0cf | ||
|
|
f2d465e0c4 | ||
|
|
e435b4ad43 | ||
|
|
218c3f825d | ||
|
|
4623358f21 | ||
|
|
5e2533b876 | ||
|
|
1ef81f9d1c | ||
|
|
72eeb5cf9b | ||
|
|
d7d4cc1133 | ||
|
|
d3dd922e32 | ||
|
|
12a527b3ee | ||
|
|
e6291a0235 | ||
|
|
d434732f41 | ||
|
|
12c612eccd | ||
|
|
5874e2b619 | ||
|
|
881b5b4c67 | ||
|
|
1145be02f3 | ||
|
|
27a2238082 | ||
|
|
dec8790a3a | ||
|
|
3a345cbefd | ||
|
|
c4fd31cd53 | ||
|
|
049df61e55 | ||
|
|
57e48a9d4f | ||
|
|
a93e37a6b1 | ||
|
|
0b4243fe91 | ||
|
|
66f7fdc1a8 | ||
|
|
d7c793c67a | ||
|
|
efe2a42d1d | ||
|
|
bbfde902a5 | ||
|
|
cc9a9e6c2d | ||
|
|
2784084c68 | ||
|
|
e8337c2845 | ||
|
|
6cfb13310d | ||
|
|
2c8beedc4b | ||
|
|
0b763e5738 | ||
|
|
1fc17225b0 | ||
|
|
a7692b1c35 | ||
|
|
fac668567e | ||
|
|
3c40e94a57 | ||
|
|
dd4d306a4e | ||
|
|
9203eee5d2 | ||
|
|
b263a735ac | ||
|
|
854f2d4650 | ||
|
|
779270fb73 | ||
|
|
1d706f1356 | ||
|
|
a8e1aa743d | ||
|
|
1e38419f07 | ||
|
|
21b00a8e09 | ||
|
|
db684efe9e | ||
|
|
fd753c1318 | ||
|
|
192de5e690 | ||
|
|
809b978c4b | ||
|
|
c54a5b180c | ||
|
|
e69426882a | ||
|
|
5aee2ce3f5 | ||
|
|
7dd816cfce | ||
|
|
19b0c855a9 | ||
|
|
d5359dd31f | ||
|
|
2c7ec17c8b | ||
|
|
2bebfc071c | ||
|
|
fd9b470c4c | ||
|
|
9798872f20 | ||
|
|
f9d310c8ea | ||
|
|
f8036fae79 | ||
|
|
4db02d75bc | ||
|
|
ee19da7fa0 | ||
|
|
7044505915 | ||
|
|
ad81adfe71 | ||
|
|
275ed46dfb | ||
|
|
b7e798b867 | ||
|
|
3850fe35e4 | ||
|
|
7ccc9199e0 | ||
|
|
0d6d059f5b | ||
|
|
63494f322b | ||
|
|
89152295ea | ||
|
|
5ae476551d | ||
|
|
0681831aaf | ||
|
|
6197f57c43 | ||
|
|
0f267ca6a9 | ||
|
|
bc3487639d | ||
|
|
018c65adc5 | ||
|
|
d6e589d2f5 | ||
|
|
7a003e5df6 | ||
|
|
616dabb595 | ||
|
|
e0cec0f943 | ||
|
|
441bdfe0fe | ||
|
|
eef33e457f | ||
|
|
ea14fd654f | ||
|
|
8a50b46b7b | ||
|
|
57f2ea4f41 | ||
|
|
dd5a2ea288 | ||
|
|
6844d659fc |
+11
-1
@@ -1,4 +1,14 @@
|
|||||||
node_modules
|
node_modules
|
||||||
uploads
|
|
||||||
.git
|
.git
|
||||||
|
uploads
|
||||||
|
backups
|
||||||
|
certs
|
||||||
|
models
|
||||||
|
wg
|
||||||
|
*.log
|
||||||
|
*.tar.gz
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=сложный-пароль-админки
|
||||||
|
DB_PASSWORD=случайная-длинная-строка
|
||||||
|
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
|
||||||
|
BACKUP_UPLOAD_LIMIT_MB=500
|
||||||
|
|
||||||
|
# Hugging Face token (нужен для закрытых/приватных репозиториев моделей)
|
||||||
|
HUGGINGFACE_TOKEN=
|
||||||
|
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
|
||||||
|
AI_MODEL=qwen2.5-1.5b-instruct-q4_k_m.gguf
|
||||||
|
# Репозиторий Hugging Face, откуда скачивается модель
|
||||||
|
AI_MODEL_REPO=Qwen/Qwen2.5-1.5B-Instruct-GGUF
|
||||||
|
# Промпт по умолчанию для автоисправления текстов (переопределяет встроенный, если в настройках не сохранён свой)
|
||||||
|
AI_PROMPT=Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.
|
||||||
|
# Таймаут запроса к AI (мс), по умолчанию 120000 (2 минуты)
|
||||||
|
AI_REQUEST_TIMEOUT_MS=120000
|
||||||
|
|
||||||
|
# === Cloudflare Tunnel (контейнер cloudflared) ===
|
||||||
|
# По умолчанию — Quick Tunnel на trycloudflare.com: случайный публичный URL,
|
||||||
|
# адрес печатается в логах: docker compose logs -f cloudflared.
|
||||||
|
# Чтобы привязать свой домен, замените command сервиса cloudflared
|
||||||
|
# в docker-compose.yml на: command: ["tunnel", "run", "--token", "${CLOUDFLARE_TUNNEL_TOKEN}"]
|
||||||
|
# (токен вида "eyJ..." создаётся в Cloudflare Zero Trust -> Networks -> Tunnels).
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN=
|
||||||
|
# Внутренний адрес приложения, на который указывает Quick Tunnel.
|
||||||
|
CLOUDFLARE_TUNNEL_URL=http://app:3003
|
||||||
|
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
|
||||||
|
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
|
||||||
|
WG_HANDSHAKE_TIMEOUT=60
|
||||||
|
|
||||||
|
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
|
||||||
|
PHOTO_AI_URL=
|
||||||
|
PHOTO_AI_MAX_PIXELS=4000000
|
||||||
+10
@@ -6,6 +6,7 @@
|
|||||||
*.key
|
*.key
|
||||||
*.crt
|
*.crt
|
||||||
certs/
|
certs/
|
||||||
|
wg/
|
||||||
|
|
||||||
# --- Run-time data / uploads ---
|
# --- Run-time data / uploads ---
|
||||||
uploads/
|
uploads/
|
||||||
@@ -22,3 +23,12 @@ node_modules/
|
|||||||
tmp/
|
tmp/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# --- Playwright test artifacts ---
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# --- Generated version data ---
|
||||||
|
public/version.json
|
||||||
|
|
||||||
|
# --- Internal audit (not for commit) ---
|
||||||
|
SECURITY_AUDIT.md
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# AGENT.md — Developer Agent Guidelines for WhatIDo
|
||||||
|
|
||||||
|
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**WhatIDo** — Accounting system for an educational center: attendance journal, student project works, group gallery, detached files, and public showcase pages (share links).
|
||||||
|
|
||||||
|
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Docker Compose, Tailscale (Serve/Funnel)
|
||||||
|
- **Architecture**: Single Express server (`server.js`) + static frontend in `public/`
|
||||||
|
- **Deployment**: Docker Compose (app + db + tailscale), bind-mounted uploads, named volume for Postgres data
|
||||||
|
- **Auth**: Admin-only via `X-Admin-Token` header (value = `ADMIN_PASSWORD` env var). No user sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Rules
|
||||||
|
|
||||||
|
### 1. Code Style
|
||||||
|
- **No comments** unless explicitly requested
|
||||||
|
- **ES modules not used** — CommonJS (`require`) throughout
|
||||||
|
- **Error handling**: try/catch with explicit status codes, no global error handler
|
||||||
|
- **Validation**: Inline helper functions (`reqInt`, `reqStr`, `optInt`, etc.) — use them
|
||||||
|
- **Security first**: All uploads validated, path traversal blocked, rate limits on public routes
|
||||||
|
|
||||||
|
### 2. Database
|
||||||
|
- **Schema**: Defined in `db/init.sql` (runs on first container start)
|
||||||
|
- **Migrations**: `db/migration.sql` for existing DBs — update both when changing schema
|
||||||
|
- **Connection**: Single `Pool` from `pg`, `DATABASE_URL` from env
|
||||||
|
- **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation
|
||||||
|
- **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for multi-statement ops
|
||||||
|
|
||||||
|
### 3. File Uploads
|
||||||
|
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
||||||
|
- **Limits**: 10 MB/file, 30 MB total per entry
|
||||||
|
- **Storage**: `uploads/` bind-mounted to host, filenames = `timestamp-random.ext`
|
||||||
|
- **HEIC**: Auto-converted to JPEG via `heic-convert`
|
||||||
|
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/`
|
||||||
|
|
||||||
|
### 4. API Patterns
|
||||||
|
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
|
||||||
|
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min)
|
||||||
|
- **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success
|
||||||
|
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
|
||||||
|
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
|
||||||
|
|
||||||
|
### 5. Frontend (public/)
|
||||||
|
- Vanilla HTML/CSS/JS, no build step
|
||||||
|
- Each page = single HTML file + shared `admin.js` / `admin.css`
|
||||||
|
- API calls via `fetch` with `X-Admin-Token` from `localStorage`
|
||||||
|
- Share pages (`share.html`, `links.html`) work without auth
|
||||||
|
|
||||||
|
### 6. Docker / Compose
|
||||||
|
- **Dockerfile**: Node 20 Alpine, installs deps, generates self-signed TLS cert
|
||||||
|
- **docker-compose.yml**: 3 services (db, app, tailscale)
|
||||||
|
- `db`: postgres:16-alpine, healthcheck, init.sql mounted
|
||||||
|
- `app`: builds from Dockerfile, exposes 3003/3443, mounts uploads
|
||||||
|
- `tailscale`: host network, NET_ADMIN, runs `start-tailscale.sh` (funnel to 127.0.0.1:3443)
|
||||||
|
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`
|
||||||
|
- **Port 443 on host** must be free (tailscale listens directly)
|
||||||
|
|
||||||
|
### 7. Tailscale Publication
|
||||||
|
- No external IP / port forwarding needed
|
||||||
|
- Access: `https://whatido.<tailnet>.ts.net` (inside tailnet + internet via Funnel)
|
||||||
|
- First run: `docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido` → authorize in browser
|
||||||
|
- Enable Serve/Funnel in Tailscale admin console for the node
|
||||||
|
- Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container
|
||||||
|
|
||||||
|
### 8. Backup / Restore
|
||||||
|
- **Admin UI**: `/api/backup` (download tar.gz), `/api/restore` (upload tar.gz)
|
||||||
|
- **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level)
|
||||||
|
- Backup format: `data.json` (all tables) + `uploads/` directory
|
||||||
|
- Restore validates all data, resets sequences, sweeps orphans
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Add a new API endpoint
|
||||||
|
1. Add route in `server.js` (group with related routes)
|
||||||
|
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
|
||||||
|
3. Validate input with helper functions
|
||||||
|
4. Use parameterized queries, transactions if multi-table
|
||||||
|
5. Call `logAudit(req, 'action.name', { ... })` for mutations
|
||||||
|
6. Return JSON, handle errors with appropriate status codes
|
||||||
|
|
||||||
|
### Add a database column/table
|
||||||
|
1. Update `db/init.sql` (CREATE TABLE / ALTER TABLE)
|
||||||
|
2. Update `db/migration.sql` (idempotent ALTERs)
|
||||||
|
3. Update `server.js` queries that SELECT/INSERT the table
|
||||||
|
4. Test: `docker compose down && docker compose up -d --build`
|
||||||
|
|
||||||
|
### Add a frontend page
|
||||||
|
1. Create `public/newpage.html` (copy structure from existing)
|
||||||
|
2. Link in `public/admin.html` navigation if admin page
|
||||||
|
3. Use `admin.js` utilities: `api()`, `requireAuth()`, `formatDate()`, etc.
|
||||||
|
4. No build step — just refresh browser
|
||||||
|
|
||||||
|
### Modify file upload rules
|
||||||
|
- Edit `BLOCKED_EXT`, `ALLOWED_IMAGE_EXT`, `ADMIN_ALLOWED_EXT` constants
|
||||||
|
- Update Multer `fileFilter` functions
|
||||||
|
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Verification
|
||||||
|
|
||||||
|
No automated test suite exists. Verify manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start stack
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker compose logs -f app
|
||||||
|
|
||||||
|
# Test API (replace TOKEN)
|
||||||
|
curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
||||||
|
|
||||||
|
# Run backup/restore scripts
|
||||||
|
./scripts/backup.sh
|
||||||
|
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Checklist (before any change)
|
||||||
|
- [ ] No SQL interpolation — only `$1`, `$2`...
|
||||||
|
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
|
||||||
|
- [ ] Rate limiter on new public routes
|
||||||
|
- [ ] Admin routes behind `requireAdmin`
|
||||||
|
- [ ] No secrets in code — only via env vars
|
||||||
|
- [ ] Helmet headers present (already global)
|
||||||
|
- [ ] CORS disabled (no `cors` middleware)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map (key files)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
|
||||||
|
| `worker.js` | Background AI auto-check worker for entry messages |
|
||||||
|
| `db/init.sql` | Initial schema (runs on fresh DB) |
|
||||||
|
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
||||||
|
| `docker-compose.yml` | Service definitions (app, db, tailscale) |
|
||||||
|
| `Dockerfile` | App image build |
|
||||||
|
| `public/*.html` | Frontend pages |
|
||||||
|
| `public/admin.js` | Shared frontend logic |
|
||||||
|
| `scripts/backup.sh` | Host-level backup script |
|
||||||
|
| `scripts/restore.sh` | Host-level restore script |
|
||||||
|
| `start-tailscale.sh` | Tailscale container entrypoint |
|
||||||
|
| `.env.example` | Env var template |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Do Not
|
||||||
|
|
||||||
|
- ❌ Add dependencies without updating `package.json` and rebuilding
|
||||||
|
- ❌ Write files outside `uploads/` or `certs/`
|
||||||
|
- ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
|
||||||
|
- ❌ Expose DB port (5432) outside docker network
|
||||||
|
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
||||||
|
- ❌ Add comments to code (this file excepted)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full rebuild
|
||||||
|
docker compose down && docker compose up -d --build
|
||||||
|
|
||||||
|
# App logs
|
||||||
|
docker compose logs -f app
|
||||||
|
|
||||||
|
# DB shell
|
||||||
|
docker compose exec db psql -U app -d whereldo
|
||||||
|
|
||||||
|
# Tailscale status
|
||||||
|
docker exec -it whatido-tailscale-1 tailscale status
|
||||||
|
|
||||||
|
# Manual funnel restart
|
||||||
|
docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443
|
||||||
|
```
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Пример конфигурации Caddy для публичного развёртывания WhatIDo.
|
||||||
|
#
|
||||||
|
# Порядок включения:
|
||||||
|
# 1. Скопируйте этот файл в ./Caddyfile (cp Caddyfile.example Caddyfile)
|
||||||
|
# 2. Замените yourdomain.example на реальный домен/WWW, указывающий на сервер
|
||||||
|
# 3. В docker-compose.yml расскомментируйте сервис caddy (и тома caddy_data/caddy_config)
|
||||||
|
# и переведите блок ports сервиса app в expose (см. комментарии в compose)
|
||||||
|
# 4. docker compose up -d --build
|
||||||
|
#
|
||||||
|
# Caddy автоматически получит Let's Encrypt сертификат на 80/443 портах.
|
||||||
|
# Запрос к app идёт по HTTPS на внутренний порт 3443 (самоподписанный серт
|
||||||
|
# приложения, поэтому tls_insecure_skip_verify).
|
||||||
|
|
||||||
|
kiberone.dns.army {
|
||||||
|
reverse_proxy app:3443 {
|
||||||
|
transport http {
|
||||||
|
tls_insecure_skip_verify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Внутренний HTTP-листенер для Cloudflare Quick Tunnel (без редиректов).
|
||||||
|
:8080 {
|
||||||
|
reverse_proxy app:3443 {
|
||||||
|
transport http {
|
||||||
|
tls_insecure_skip_verify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Пример конфигурации Caddy для публичного развёртывания WhatIDo.
|
||||||
|
#
|
||||||
|
# Порядок включения:
|
||||||
|
# 1. Скопируйте этот файл в ./Caddyfile (cp Caddyfile.example Caddyfile)
|
||||||
|
# 2. Замените yourdomain.example на реальный домен/WWW, указывающий на сервер
|
||||||
|
# 3. В docker-compose.yml расскомментируйте сервис caddy (и тома caddy_data/caddy_config)
|
||||||
|
# и переведите блок ports сервиса app в expose (см. комментарии в compose)
|
||||||
|
# 4. docker compose up -d --build
|
||||||
|
#
|
||||||
|
# Caddy автоматически получит Let's Encrypt сертификат на 80/443 портах.
|
||||||
|
# Запрос к app идёт по HTTPS на внутренний порт 3443 (самоподписанный серт
|
||||||
|
# приложения, поэтому tls_insecure_skip_verify).
|
||||||
|
|
||||||
|
yourdomain.example {
|
||||||
|
reverse_proxy app:3443 {
|
||||||
|
transport http {
|
||||||
|
tls_insecure_skip_verify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-2
@@ -5,9 +5,26 @@ COPY package.json package-lock.json* ./
|
|||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN mkdir -p uploads certs
|
RUN mkdir -p uploads certs
|
||||||
RUN apk add --no-cache openssl && \
|
RUN set -e; \
|
||||||
|
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
|
||||||
|
try_repo() { \
|
||||||
|
printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \
|
||||||
|
echo "Trying apk mirror: $1"; \
|
||||||
|
timeout 45 apk add --no-cache --timeout 20 openssl; \
|
||||||
|
}; \
|
||||||
|
if ! try_repo "https://dl-cdn.alpinelinux.org/alpine" && \
|
||||||
|
! try_repo "https://mirrors.edge.kernel.org/alpine" && \
|
||||||
|
! try_repo "http://mirrors.edge.kernel.org/alpine"; then \
|
||||||
|
echo "apk failed on all mirrors (HTTPS and HTTP). Diagnostics:"; \
|
||||||
|
echo "-- date:"; date; \
|
||||||
|
echo "-- resolv.conf:"; cat /etc/resolv.conf; \
|
||||||
|
echo "-- proxy env:"; env | grep -i proxy || echo none; \
|
||||||
|
wget -qO- --timeout=15 https://mirrors.edge.kernel.org/alpine/$V/main/x86_64/APKINDEX.tar.gz >/dev/null && echo "https probe: ok" || echo "https probe: fail"; \
|
||||||
|
wget -qO- --timeout=15 http://mirrors.edge.kernel.org/alpine/$V/main/x86_64/APKINDEX.tar.gz >/dev/null && echo "http probe: ok" || echo "http probe: fail"; \
|
||||||
|
exit 1; \
|
||||||
|
fi && \
|
||||||
openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
|
openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
|
||||||
-keyout certs/key.pem -out certs/cert.pem \
|
-keyout certs/key.pem -out certs/cert.pem \
|
||||||
-subj "/CN=whatido.local" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
|
-subj "/CN=whatido.local" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
|
||||||
EXPOSE 3000 3443
|
EXPOSE 3003 3443
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Сборка контейнера cloudflared с опциональным WireGuard.
|
||||||
|
# Официальный образ cloudflare/cloudflared — distroless (без оболочки/apk), поэтому:
|
||||||
|
# 1) берём из него статичный бинарник cloudflared;
|
||||||
|
# 2) саму сборку делаем на Alpine, где ставим wireguard-tools.
|
||||||
|
# Если в wg/wg0.conf нет конфига — VPN не поднимается, туннель запускается как обычно.
|
||||||
|
FROM cloudflare/cloudflared:latest AS cf
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache wireguard-tools iproute2 iptables ip6tables
|
||||||
|
COPY --from=cf /usr/local/bin/cloudflared /usr/bin/cloudflared
|
||||||
|
COPY start-cloudflared.sh /start-cloudflared.sh
|
||||||
|
ENTRYPOINT ["/bin/sh", "/start-cloudflared.sh"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl bash
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY update-dynv6.sh /app/update-dynv6.sh
|
||||||
|
RUN chmod +x /app/update-dynv6.sh
|
||||||
|
|
||||||
|
# Run once on startup, then every 5 minutes in a loop
|
||||||
|
CMD /app/update-dynv6.sh && while true; do sleep 300; /app/update-dynv6.sh; done
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# План: Мультифилиальность и роли пользователей
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
Сейчас приложение однофилиальное и одно-user-овое. Нужно поддержать:
|
||||||
|
- 5-20 филиалов
|
||||||
|
- Несколько тьюторов/админов
|
||||||
|
- Филиал студента определяется автоматически через его группу
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. База данных — схема
|
||||||
|
|
||||||
|
### Новые таблицы
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Филиалы
|
||||||
|
CREATE TABLE branches (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Пользователи системы
|
||||||
|
CREATE TABLE users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(150) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'tutor', -- 'admin' или 'tutor'
|
||||||
|
branch_id INT REFERENCES branches(id), -- NULL для супер-админа
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Изменения в существующих таблицах
|
||||||
|
|
||||||
|
| Таблица | Новое поле | Тип | Связь |
|
||||||
|
|---------|-----------|-----|-------|
|
||||||
|
| `groups` | `branch_id` | `INT` | `REFERENCES branches(id)` |
|
||||||
|
| `students` | `branch_id` | `INT` | `REFERENCES branches(id)` |
|
||||||
|
| `entries` | `branch_id` | `INT` | `REFERENCES branches(id)` |
|
||||||
|
| `group_photos` | `branch_id` | `INT` | `REFERENCES branches(id)` |
|
||||||
|
|
||||||
|
### Логика определения филиала
|
||||||
|
- Студент → в группе → группа привязана к филиалу → филиал определён
|
||||||
|
- При отправке записи `branch_id` берётся из группы студента
|
||||||
|
- Студенту не нужно выбирать филиал — он определяется автоматически
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Аутентификация
|
||||||
|
|
||||||
|
- Вход по **имени + пароль** (без email)
|
||||||
|
- Пароли хранятся в `bcrypt` хеше
|
||||||
|
- Токен (JWT) выдаётся при логине, хранится в `sessionStorage`
|
||||||
|
- `requireAdmin` заменяется на `requireAuth` + проверку роли
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Роли
|
||||||
|
|
||||||
|
| Роль | Возможности |
|
||||||
|
|------|-------------|
|
||||||
|
| **admin** | Всё: управление пользователями, филиалами, настройками, бэкапами + все данные |
|
||||||
|
| **tutor** | Управление записями, группами, студентами, файлами, ссылками — видит все группы |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Публичная форма
|
||||||
|
|
||||||
|
- Ссылка на форму одна (как сейчас)
|
||||||
|
- Студент вводит имя → система находит студента → определяет группу → определяет филиал
|
||||||
|
- Если студент новый (нет в базе) — варианты на обсуждение:
|
||||||
|
- Не пускать (только зарегистрированные студенты)
|
||||||
|
- Автоматически создать в группе "Не распределён"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Фронтенд — изменения
|
||||||
|
|
||||||
|
### Страница логина
|
||||||
|
- Поле "Имя" + "Пароль" вместо одного пароля
|
||||||
|
|
||||||
|
### Админ-панель
|
||||||
|
- В хедере/сайдбаре выпадающий список филиалов для фильтрации
|
||||||
|
- Группы — отображение филиала у каждой группы
|
||||||
|
- Студенты — отображение филиала у каждого студента
|
||||||
|
- Журнал — фильтр по филиалу
|
||||||
|
- Настройки — управление пользователями и филиалами (только для admin)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API — новые эндпоинты
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|-------|------|----------|
|
||||||
|
| `POST` | `/api/auth/login` | Вход (имя + пароль → токен) |
|
||||||
|
| `GET` | `/api/branches` | Список филиалов |
|
||||||
|
| `POST` | `/api/branches` | Создать филиал (admin) |
|
||||||
|
| `DELETE` | `/api/branches/:id` | Удалить филиал (admin) |
|
||||||
|
| `GET` | `/api/users` | Список пользователей (admin) |
|
||||||
|
| `POST` | `/api/users` | Создать пользователя (admin) |
|
||||||
|
| `DELETE` | `/api/users/:id` | Удалить пользователя (admin) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Миграция данных
|
||||||
|
|
||||||
|
- Существующие группы/студенты/записи автоматически попадут в филиал "Основной" (или первый созданный)
|
||||||
|
- `ADMIN_PASSWORD` из `.env` конвертируется в запись `users` с ролью `admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Структура файлов (рефакторинг)
|
||||||
|
|
||||||
|
```
|
||||||
|
server.js → рефакторинг на модули:
|
||||||
|
├── routes/
|
||||||
|
│ ├── auth.js (логин/аутентификация)
|
||||||
|
│ ├── branches.js (CRUD филиалов)
|
||||||
|
│ ├── users.js (CRUD пользователей)
|
||||||
|
│ ├── groups.js (существующие + branch_id)
|
||||||
|
│ ├── students.js (существующие + branch_id)
|
||||||
|
│ ├── entries.js (существующие + branch_id)
|
||||||
|
│ └── ...
|
||||||
|
├── middleware/
|
||||||
|
│ ├── auth.js (requireAuth, requireRole)
|
||||||
|
│ └── branch.js (branch scoping)
|
||||||
|
└── db.js (подключение к БД)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок реализации
|
||||||
|
|
||||||
|
1. **Миграция БД** — создать таблицы `branches`, `users`, добавить `branch_id` к существующим
|
||||||
|
2. **Миграция данных** — создать филиал "Основной", перенести существующие данные
|
||||||
|
3. **Бэкенд: аутентификация** — `auth.js` (логин, JWT, middleware)
|
||||||
|
4. **Бэкенд: CRUD филиалов и пользователей** — новые роуты
|
||||||
|
5. **Бэкенд: branch scoping** — добавить `branch_id` ко всем существующим запросам
|
||||||
|
6. **Фронтенд: логин** — новая страница логина
|
||||||
|
7. **Фронтенд: admin panel** — добавить фильтр филиалов, страницы управления
|
||||||
|
8. **Тестирование** — проверить все сценарии
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# PRD.md — Product Requirements Document: WhatIDo
|
||||||
|
|
||||||
|
**Version**: 1.0
|
||||||
|
**Status**: Active
|
||||||
|
**Last Updated**: 2026-09-09
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Product Summary
|
||||||
|
|
||||||
|
**WhatIDo** is a self-hosted accounting system for an educational center (youth club, coding school, art studio). It tracks attendance, student project works, group photo chronicles, and publishes public showcase pages via share links. Designed for zero-public-IP deployment using Tailscale Funnel.
|
||||||
|
|
||||||
|
**Target Users**:
|
||||||
|
- **Administrators/Teachers** — manage groups, students, entries, files, settings
|
||||||
|
- **Parents/Students** — view public showcase pages (read-only, no auth)
|
||||||
|
|
||||||
|
**Core Value**: Simple, secure, zero-infrastructure publishing. Runs on any Linux box with Docker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Functional Requirements
|
||||||
|
|
||||||
|
### 2.1 Groups Management
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|----|-------------|----------|
|
||||||
|
| GRP-1 | CRUD groups: name, schedule (day of week, start/end time), branch assignment | Must |
|
||||||
|
| GRP-2 | Group cover photo (auto-set from latest group photo or manual) | Must |
|
||||||
|
| GRP-3 | Photo chronicle per group: upload, caption, date taken, pagination | Must |
|
||||||
|
| GRP-4 | Active groups endpoint (filters by current day/time in Europe/Moscow) | Must |
|
||||||
|
| GRP-5 | Branches (locations): CRUD with address, phone; groups link to branch | Should |
|
||||||
|
|
||||||
|
### 2.2 Students Management
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|----|-------------|----------|
|
||||||
|
| STU-1 | CRUD students: name, group assignment | Must |
|
||||||
|
| STU-2 | Batch assign students to group | Should |
|
||||||
|
| STU-3 | Unique name constraint per student | Must |
|
||||||
|
|
||||||
|
### 2.3 Journal Entries (Attendance + Project Works)
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|----|-------------|----------|
|
||||||
|
| ENT-1 | Create entry: student name (free text), group, description, photo, multiple files | Must |
|
||||||
|
| ENT-2 | List entries with filters: group, date range, student name, search (name/description), deleted flag | Must |
|
||||||
|
| ENT-3 | Update entry: description, photo, files | Must |
|
||||||
|
| ENT-4 | Soft delete / restore (deleted_at timestamp) | Must |
|
||||||
|
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must |
|
||||||
|
| ENT-6 | Pagination (limit/offset) + total count | Must |
|
||||||
|
| ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must |
|
||||||
|
| ENT-8 | Files attached to entry: upload (max 10 files, 10 MB each, 30 MB total), download by token | Must |
|
||||||
|
|
||||||
|
### 2.4 Files Management (Centralized)
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|----|-------------|----------|
|
||||||
|
| FIL-1 | List all files with filters: search, student, group, date range | Must |
|
||||||
|
| FIL-2 | Detached files tab: files with `entry_id = NULL` | Must |
|
||||||
|
| FIL-3 | Detach file from entry (sets `detached_at`) | Must |
|
||||||
|
| FIL-4 | Delete file (removes from disk + DB) | Must |
|
||||||
|
| FIL-5 | Public file access by token (image inline, others download) | Must |
|
||||||
|
| FIL-6 | File size display in list | Should |
|
||||||
|
|
||||||
|
### 2.5 Share Links (Public Showcase Pages)
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|----|-------------|----------|
|
||||||
|
| SHR-1 | Create share link: name, optional group, student, date range, anonymize names, expiry (default 7 days), optional password | Must |
|
||||||
|
| SHR-2 | List/Edit/Delete share links (admin) | Must |
|
||||||
|
| SHR-3 | Public page (`/s/:token`): shows filtered entries + group photos | Must |
|
||||||
|
| SHR-4 | Password protection on share link (bcrypt) | Must |
|
||||||
|
| SHR-5 | Expiry enforcement (410 Gone after expires_at) | Must |
|
||||||
|
| SHR-6 | Anonymize student names on public page (Student 1, Student 2...) | Should |
|
||||||
|
| SHR-7 | Group photos on public page (latest 12) | Should |
|
||||||
|
| SHR-8 | File download from share page (validates link + password + filters) | Must |
|
||||||
|
|
||||||
|
### 2.6 Dashboard & Statistics
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|----|-------------|----------|
|
||||||
|
| DSH-1 | Stats cards: total entries, trash count, groups, unique students | Must |
|
||||||
|
| DSH-2 | Active groups right now (schedule match) | Must |
|
||||||
|
| DSH-3 | Activity chart: entries per day (last 14 days) | Should |
|
||||||
|
| DSH-4 | Top students by entry count | Should |
|
||||||
|
| DSH-5 | Recent entries list (last 10) | Should |
|
||||||
|
|
||||||
|
### 2.7 Settings
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|----|-------------|----------|
|
||||||
|
| SET-1 | Footer left/right text (displayed on public pages) | Must |
|
||||||
|
| SET-2 | Anti-spam interval (minutes) | Must |
|
||||||
|
| SET-3 | Admin-only access | Must |
|
||||||
|
|
||||||
|
### 2.8 Backup & Restore
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|----|-------------|----------|
|
||||||
|
| BAK-1 | Download full backup: tar.gz with data.json (all tables) + uploads/ | Must |
|
||||||
|
| BAK-2 | Restore from backup file: validates format, replaces all data, resets sequences | Must |
|
||||||
|
| BAK-3 | Host-level scripts: `backup.sh`, `restore.sh` | Should |
|
||||||
|
| BAK-4 | Audit log entry for backup download/restore | Must |
|
||||||
|
|
||||||
|
### 2.9 Audit Log
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|----|-------------|----------|
|
||||||
|
| AUD-1 | Log all mutating actions: action name, target JSON, IP, timestamp | Must |
|
||||||
|
| AUD-2 | Admin view: paginated list (default 100, max 1000) | Must |
|
||||||
|
|
||||||
|
### 2.10 Security & Infrastructure
|
||||||
|
| ID | Requirement | Priority |
|
||||||
|
|----|-------------|----------|
|
||||||
|
| SEC-1 | Admin auth via `X-Admin-Token` header (env `ADMIN_PASSWORD`, no default) | Must |
|
||||||
|
| SEC-2 | Rate limiting: entries 10/15min, files/share 300/15min | Must |
|
||||||
|
| SEC-3 | Upload validation: block dangerous extensions, MIME check, size limits | Must |
|
||||||
|
| SEC-4 | Path traversal protection on file delete/serve | Must |
|
||||||
|
| SEC-5 | Helmet headers (X-Frame-Options, nosniff, HSTS, Referrer-Policy) | Must |
|
||||||
|
| SEC-6 | No CORS (cross-origin blocked) | Must |
|
||||||
|
| SEC-7 | DB port not exposed publicly | Must |
|
||||||
|
| SEC-8 | TLS termination by Tailscale (Let's Encrypt), app uses self-signed cert internally | Must |
|
||||||
|
| SEC-9 | Tailscale Funnel publication (no public IP, no port forward) | Must |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Non-Functional Requirements
|
||||||
|
|
||||||
|
| Category | Requirement |
|
||||||
|
|----------|-------------|
|
||||||
|
| **Performance** | API responses < 500ms for typical queries; pagination for large lists |
|
||||||
|
| **Reliability** | DB healthcheck; app restarts on crash; uploads persisted on host |
|
||||||
|
| **Scalability** | Single-instance design; PostgreSQL connection pooling via `pg.Pool` |
|
||||||
|
| **Maintainability** | Single `server.js` file; vanilla frontend; no build step |
|
||||||
|
| **Portability** | Docker Compose; runs on any Linux/ARM64/AMD64 with Docker |
|
||||||
|
| **Backup/Recovery** | Full restore < 5 min for typical dataset (< 1 GB) |
|
||||||
|
| **Security** | No secrets in image; env vars only; regular dependency updates |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Model
|
||||||
|
|
||||||
|
```
|
||||||
|
groups
|
||||||
|
id PK, name UK, created_at, day_of_week (0-6), time_start, time_end, branch_id FK, cover_path
|
||||||
|
|
||||||
|
students
|
||||||
|
id PK, name UK, group_id FK, created_at
|
||||||
|
|
||||||
|
entries
|
||||||
|
id PK, student_name, group_id FK, description, photo_path, deleted_at, created_at
|
||||||
|
|
||||||
|
project_files
|
||||||
|
id PK, entry_id FK (nullable), token UK, path, name, created_at, detached_at
|
||||||
|
|
||||||
|
group_photos
|
||||||
|
id PK, group_id FK, photo_path, caption, taken_at, created_at
|
||||||
|
|
||||||
|
share_links
|
||||||
|
id PK, token UK, name, group_id FK, student_name, date_from, date_to,
|
||||||
|
anonymize_names, expires_at, access_password_hash, created_at
|
||||||
|
|
||||||
|
settings
|
||||||
|
key PK, value
|
||||||
|
|
||||||
|
audit_log
|
||||||
|
id PK, action, target JSONB, ip, created_at
|
||||||
|
|
||||||
|
branches
|
||||||
|
id PK, name UK, address, phone, created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API Surface (Key Endpoints)
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/api/entries` | Admin | List entries (filters, pagination) |
|
||||||
|
| POST | `/api/entries` | Admin | Create entry (photo + files) |
|
||||||
|
| PUT | `/api/entries/:id` | Admin | Update entry |
|
||||||
|
| DELETE | `/api/entries/:id` | Admin | Soft delete |
|
||||||
|
| POST | `/api/entries/:id/restore` | Admin | Restore from trash |
|
||||||
|
| GET | `/api/files` | Admin | All files (filters) |
|
||||||
|
| GET | `/api/files/detached` | Admin | Detached files |
|
||||||
|
| POST | `/api/files/:id/detach` | Admin | Detach file |
|
||||||
|
| GET | `/api/files/:token` | Public | Download file by token |
|
||||||
|
| GET | `/api/groups` | Public | List groups |
|
||||||
|
| POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups |
|
||||||
|
| GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle |
|
||||||
|
| GET | `/api/share/:token` | Public | Share page data |
|
||||||
|
| GET | `/api/links` | Admin | List share links |
|
||||||
|
| POST/PUT/DELETE | `/api/links` | Admin | CRUD share links |
|
||||||
|
| GET | `/api/backup` | Admin | Download backup |
|
||||||
|
| POST | `/api/restore` | Admin | Upload & restore backup |
|
||||||
|
| GET | `/api/dashboard` | Admin | Dashboard data |
|
||||||
|
| GET | `/api/stats` | Admin | Stats cards |
|
||||||
|
| GET | `/api/audit` | Admin | Audit log |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Flows
|
||||||
|
|
||||||
|
### 6.1 Teacher Creates Attendance Entry
|
||||||
|
1. Opens `/journal.html`
|
||||||
|
2. Fills form: student name (typeahead from existing), group, description
|
||||||
|
3. Adds photo (optional) + project files (optional)
|
||||||
|
4. Submits → entry appears in list, files accessible by token
|
||||||
|
|
||||||
|
### 6.2 Admin Publishes Showcase for Parents
|
||||||
|
1. Opens `/links.html`
|
||||||
|
2. Creates share link: selects group, date range, sets password
|
||||||
|
3. Copies link `https://whatido.tailnet.ts.net/s/abc123`
|
||||||
|
4. Sends to parents → they open, enter password, view entries + photos
|
||||||
|
|
||||||
|
### 6.3 Admin Restores from Backup
|
||||||
|
1. Opens `/settings.html` → Backups tab
|
||||||
|
2. Uploads `.tar.gz` backup file
|
||||||
|
3. Confirms → all data replaced, sequences reset, orphans cleaned
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deployment Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet / Tailnet
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ Tailscale │ (network_mode: host, port 443)
|
||||||
|
│ Funnel/Serve │ TLS: Let's Encrypt (*.ts.net)
|
||||||
|
└────────┬─────────┘
|
||||||
|
│ HTTPS (trusts app self-signed cert)
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ App (Node.js) │ 127.0.0.1:3443 (HTTPS), :3003 (HTTP→HTTPS redirect)
|
||||||
|
│ Express + pg │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ PostgreSQL 16 │ Internal docker network only
|
||||||
|
│ (named volume) │
|
||||||
|
└──────────────────┘
|
||||||
|
|
||||||
|
Host filesystem:
|
||||||
|
./uploads ──────► /app/uploads (bind mount)
|
||||||
|
./certs ──────► /etc/tailscale/app-certs (ro)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Configuration
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `ADMIN_PASSWORD` | Yes | Admin token value (no default, server refuses start) |
|
||||||
|
| `DB_PASSWORD` | Yes | Postgres `app` user password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Release Criteria
|
||||||
|
|
||||||
|
- [ ] All Must-have requirements implemented and tested
|
||||||
|
- [ ] Docker Compose starts cleanly on fresh host (`docker compose up -d --build`)
|
||||||
|
- [ ] Tailscale Funnel publishes successfully (manual verification)
|
||||||
|
- [ ] Backup/restore roundtrip works (data + files intact)
|
||||||
|
- [ ] No critical security findings (rate limits, upload validation, auth)
|
||||||
|
- [ ] README.md updated with accurate setup instructions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Future Considerations (Not in Scope v1)
|
||||||
|
|
||||||
|
- Multi-user auth (teachers with own logins)
|
||||||
|
- Email/push notifications
|
||||||
|
- Mobile app / PWA
|
||||||
|
- Rich text editor for descriptions
|
||||||
|
- Bulk import students (CSV)
|
||||||
|
- Webhooks for external integrations
|
||||||
|
- Automated scheduled backups to S3/remote
|
||||||
|
- Role-based access (read-only vs admin)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Acceptance Test Scenarios
|
||||||
|
|
||||||
|
| Scenario | Steps | Expected |
|
||||||
|
|----------|-------|----------|
|
||||||
|
| Fresh deploy | `cp .env.example .env` → edit → `docker compose up -d --build` | App healthy, DB migrated, HTTPS on 3443 |
|
||||||
|
| Create entry | POST `/api/entries` with photo + 2 files | Entry created, files downloadable by token |
|
||||||
|
| Share link | Create link with password → open `/s/token` → enter password | Entries filtered, photos shown, files download |
|
||||||
|
| Backup/restore | Download backup → delete entry → restore → verify entry back | Full state restored, sequences correct |
|
||||||
|
| Tailscale publish | `tailscale up` → enable Funnel → `tailscale funnel` | Public URL accessible via HTTPS |
|
||||||
|
| Upload rejection | POST `.html` file → 400 error | Dangerous extensions blocked |
|
||||||
|
| Rate limit | 11 rapid POST `/api/entries` → 429 on 11th | Entry limiter enforced |
|
||||||
|
|
||||||
|
---
|
||||||
@@ -4,60 +4,204 @@
|
|||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- **Журнал записей** — отметки о занятиях с фото и прикреплёнными файлами (проектные работы), мягкое удаление и корзина
|
- **Журнал записей** — отметки о занятиях с фото и прикреплёнными файлами (проектные работы), мягкое удаление и корзина; в режиме карточек у записи показывается бейдж группы поверх фото и иконка статуса AI-проверки
|
||||||
|
- **AI-проверка (воркер)** — фоновый авто-чек текста записей (`worker.js`); статус каждой записи (очередь / проверка / проверено / пропущено / ошибка) отображается компактной иконкой в журнале с всплывающей подсказкой
|
||||||
- **Файлы** — централизованный раздел со всеми загруженными файлами, фильтры (имя воспитанника, группа, даты, поиск) и вкладка «Откреплённые»
|
- **Файлы** — централизованный раздел со всеми загруженными файлами, фильтры (имя воспитанника, группа, даты, поиск) и вкладка «Откреплённые»
|
||||||
- **Группы** — учебные группы, расписание (день недели, время), фотохроника группы
|
- **Группы** — учебные группы, расписание (день недели, время), фотохроника группы
|
||||||
- **Воспитанники** — справочник с привязкой к группам
|
- **Воспитанники** — справочник с привязкой к группам
|
||||||
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
||||||
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
||||||
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
||||||
- **Настройки** — научные тексты футера, анти-спам интервал
|
- **Настройки** — тексты футера, анти-спам интервал
|
||||||
- **HTTPS** — самоподписанный TLS-сертификат, автогенерация при сборке
|
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
|
|
||||||
- Node.js + Express
|
- Node.js + Express
|
||||||
- PostgreSQL (pg)
|
- PostgreSQL (pg)
|
||||||
- Multer (загрузка файлов), Tar (бэкапы)
|
- Multer (загрузка файлов), Tar (бэкапы)
|
||||||
|
- Lucide (иконки UI)
|
||||||
- Docker / Docker Compose
|
- Docker / Docker Compose
|
||||||
|
- Tailscale (Serve / Funnel) — публикация по HTTPS
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
### Требования
|
### Требования
|
||||||
|
|
||||||
- Docker + Docker Compose
|
- Linux-хост с Docker и плагином `docker compose`
|
||||||
|
- Free порта `443` на хосте (его займёт tailscale для Funnel)
|
||||||
|
- Аккаунт Tailscale (для публикации по ссылке)
|
||||||
|
|
||||||
### Запуск
|
### Запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# создайте .env с паролем администратора (см. раздел «Конфигурация»)
|
# 1) создайте .env из примера и задайте свои пароли
|
||||||
|
cp .env.example .env
|
||||||
|
$EDITOR .env
|
||||||
|
|
||||||
|
# 2) соберите и поднимите стек
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
После старта:
|
После старта (без публикации через tailscale):
|
||||||
|
|
||||||
- **HTTP** `http://localhost:3000` — редирект на HTTPS
|
- **HTTP** `http://localhost:3003` — редирект на HTTPS
|
||||||
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, принимайте предупреждение браузера)
|
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
|
||||||
- **PostgreSQL** `localhost:5432` — `app:app`, база `whereldo`
|
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
|
||||||
|
|
||||||
Управление:
|
Управление:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps # статус
|
docker compose ps # статус
|
||||||
docker compose logs -f app
|
docker compose logs -f app # логи приложения
|
||||||
docker compose down # остановка (данные сохраняются)
|
docker compose down # остановка (данные сохраняются)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
|
||||||
|
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
Переменные окружения (`.env`):
|
Переменные окружения (`.env`):
|
||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
|------------------|--------------|-------------------------------------|
|
|------------------|--------------------|-------------------------------------|
|
||||||
| `ADMIN_PASSWORD` | `admin` | Пароль администратора (X-Admin-Token) |
|
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
|
||||||
| `DATABASE_URL` | см. compose | Строка подключения к PostgreSQL |
|
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
|
||||||
|
|
||||||
Внутри контейнера `db` также задаются `POSTGRES_DB=whereldo`, `POSTGRES_USER=app`, `POSTGRES_PASSWORD=app`, `TZ=Europe/Moscow`.
|
Пример `.env` (в репозитории — `.env.example`):
|
||||||
|
|
||||||
|
```
|
||||||
|
ADMIN_PASSWORD=сложный-пароль
|
||||||
|
DB_PASSWORD=случайная-длинная-строка
|
||||||
|
```
|
||||||
|
|
||||||
|
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
|
||||||
|
|
||||||
|
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
|
||||||
|
|
||||||
|
## Публичный доступ через Tailscale
|
||||||
|
|
||||||
|
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
|
||||||
|
|
||||||
|
Цепочка:
|
||||||
|
|
||||||
|
```
|
||||||
|
Интернет / tailnet → https://whatido.<tailnet>.ts.net (TLS от Tailscale)
|
||||||
|
→ localhost:443 контейнера tailscale
|
||||||
|
→ https://127.0.0.1:3443 (приложение, самоподписанный cert)
|
||||||
|
```
|
||||||
|
|
||||||
|
Контейнер доверяет самоподписанному сертификату приложения через `SSL_CERT_FILE=/etc/tailscale/app-certs/cert.pem` (файл монтируется из `./certs/cert.pem`).
|
||||||
|
|
||||||
|
### 1. Вход в tailnet при первом запуске
|
||||||
|
|
||||||
|
При первом старте контейнер автоматически выполняет `tailscale up` и печатает ссылку для авторизации — откройте её в браузере и добавьте устройство в аккаунт. Ввести устройство вручную там не нужно: `tailscale status` покажет статус:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it whatido-tailscale-1 tailscale status
|
||||||
|
```
|
||||||
|
|
||||||
|
Если авторизация по какой-то причине не прошла, выполните вход вручную:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido
|
||||||
|
# откроется ссылка вида https://login.tailscale.com/a/... — войдите в браузер
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Включите функции и найдите адрес
|
||||||
|
|
||||||
|
После авторизации узел получит имя вида `whatido` и адрес:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it whatido-tailscale-1 tailscale status
|
||||||
|
# что-то вроде: whatido.taile47725.ts.net 100.x.x.x receiver online
|
||||||
|
```
|
||||||
|
|
||||||
|
Чтобы публиковать сайт, для tailnet должны быть включены:
|
||||||
|
|
||||||
|
- **HTTPS Certificates** — автоматически выдается при первом Serve/Funnel
|
||||||
|
- **Serve** (доступ из tailnet) и **Funnel** (доступ из интернета) — включаются в админ-консоли Tailscale, например по прямой ссылке на узел:
|
||||||
|
`https://login.tailscale.com/f/serve?node=<node-id>` и `https://login.tailscale.com/funnel?node=<node-id>`
|
||||||
|
(id узла берётся из `tailscale status`)
|
||||||
|
|
||||||
|
### 3. Запуск Serve / Funnel
|
||||||
|
|
||||||
|
Контейнер при старте сам выполняет:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tailscale funnel --bg --yes https://127.0.0.1:3443
|
||||||
|
```
|
||||||
|
|
||||||
|
(`funnel` включает и serve-часть; флаг `--bg` — работа в фоне, `--yes` — не спрашивать подтверждения.)
|
||||||
|
|
||||||
|
Если сервис уже запущен и команды в compose не отработали (например, функции только что включили в консоли), выполните вручную:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверить состояние:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec whatido-tailscale-1 tailscale funnel status
|
||||||
|
# https://whatido.<tailnet>.ts.net/ (Funnel on)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Готово
|
||||||
|
|
||||||
|
- Из любого устройства вашей tailnet: `https://whatido.<tailnet>.ts.net/`
|
||||||
|
- Из интернета (при включённом Funnel): тот же адрес
|
||||||
|
- Админка: `https://whatido.<tailnet>.ts.net/admin`
|
||||||
|
|
||||||
|
### Важные замечания
|
||||||
|
|
||||||
|
- **Порт 443 на хосте должен быть свободен** — tailscale слушает его напрямую (поэтому у сервиса `network_mode: host`, а приложение опубликовано на `127.0.0.1:3003/3443`).
|
||||||
|
- **Сертификат приложения**: контейнер приложения генерирует self-signed `cert.pem` при сборке образа (это не секрет — публичный сертификат). Он монтируется в tailscale через `./certs/cert.pem`. Если образ приложения пересобирали впервые на новом хосте — скопируйте сертификат и перезапустите tailscale:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build app
|
||||||
|
docker cp $(docker compose ps -q app):/app/certs/cert.pem certs/cert.pem
|
||||||
|
docker compose up -d tailscale
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`/lib/modules`** монтируется в контейнер tailscale, чтобы на LC/дистрибутивах без авто-загрузки модулей корректно инициализировался netfilter (иначе `tailscaled` падает с `Table does not exist` и узел мигает online/offline).
|
||||||
|
- **Ограничения сети**: если у провайдера нет глобального IPv6 и соединения к ПК-ядрам Tailscale нестабильные (CGNAT), отвечать наружу узел может с перебоями — классический признак: `tailscale status` показывает `online`, а из интернета URL не открывается. Внутри tailnet сайт работает всегда.
|
||||||
|
|
||||||
|
## Альтернативная публикация (Caddy)
|
||||||
|
|
||||||
|
Исторически проект публиковался через reverse-proxy Caddy + Let's Encrypt (файлы `Caddyfile.example`, закомментированный сервис в старых версиях compose). Если нужно классическое публичное HTTPS на собственном домене с проброшенными портами 80/443 — этот вариант остаётся возможным: раскомментируйте/восстановите сервис `caddy`, укажите домен в `Caddyfile` и уберите сетевые блокировки. По умолчанию сейчас рекомендуется Tailscale-схема выше.
|
||||||
|
|
||||||
|
## Публикация наружу через Cloudflare (Quick Tunnel)
|
||||||
|
|
||||||
|
Помимо Tailscale, стек умеет публиковать приложение через контейнер `cloudflared` (сервис в `docker-compose.yml`). **Quick Tunnel** даёт случайный публичный адрес `*.trycloudflare.com` через Cloudflare — без своего домена, банковской карты и проброса портов (подойдёт, если Zero Trust требует привязать карту).
|
||||||
|
|
||||||
|
Узнать текущий адрес для доступа:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs cloudflared | grep trycloud
|
||||||
|
```
|
||||||
|
|
||||||
|
### Опциональный WireGuard для эгресса Cloudflare
|
||||||
|
|
||||||
|
`cloudflared` собирается из `Dockerfile.cloudflared`, в который добавлена поддержка **опционального WireGuard** — полезно, когда Cloudflare не работает с прямого IP хоста, и исход туннеля нужно пустить через VPN.
|
||||||
|
|
||||||
|
- Положите рабочий конфиг провайдера в `wg/wg0.conf` (папка `wg/` монтируется в контейнер как `/etc/wireguard`). Пример-заглушка уже лежит в `wg/wg0.conf` — замените значения на свои.
|
||||||
|
- Если `wg/wg0.conf` есть → контейнер **сначала поднимает WireGuard и ждёт handshake** (весь эгресс Cloudflare идёт через VPN). Если handshake не установился за `WG_HANDSHAKE_TIMEOUT` сек (по умолчанию 60) — **fallback: туннель стартует напрямую без VPN**, а в логах пишется предупреждение (сайт не лежит из-за недоступного VPN-провайдера).
|
||||||
|
- Если `wg0.conf` нет или папка пустая → VPN не включается, туннель стартует сразу, как в базовой схеме.
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f cloudflared # в идеале сначала "WireGuard is up", затем URL
|
||||||
|
docker compose exec cloudflared wg show # есть handshake — VPN поднят
|
||||||
|
```
|
||||||
|
|
||||||
|
Примечания:
|
||||||
|
- `wg/wg0.conf` содержит приватный ключ, поэтому папка `wg/` добавлена в `.gitignore` (в репозиторий не попадёт).
|
||||||
|
- При полном туннеле (`AllowedIPs = 0.0.0.0/0`) существующий маршрут docker-сети сохраняется, поэтому `cloudflared` по-прежнему достаёт `app` локально, а наружу уходит через wg0. Если вдруг `app` станет недоступен из-за VPN — переключите `CLOUDFLARE_TUNNEL_URL` в `.env` на docker-шлюз (`http://<gateway-ip>:3003`).
|
||||||
|
|
||||||
|
Адрес Quick Tunnel меняется при каждом перезапуске контейнера `cloudflared`. Для постоянного адреса используйте именованный туннель (для этого в `start-cloudflared.sh` замените последнюю команду на `exec cloudflared --no-autoupdate tunnel run --token "$CLOUDFLARE_TUNNEL_TOKEN"` и задайте токен; токен — из Cloudflare Zero Trust **Networks → Tunnels**) или схему через Tailscale выше.
|
||||||
|
|
||||||
## Структура данных
|
## Структура данных
|
||||||
|
|
||||||
@@ -89,6 +233,17 @@ docker compose down # остановка (данные сохраняютс
|
|||||||
./scripts/restore.sh # восстановление из архива
|
./scripts/restore.sh # восстановление из архива
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет.
|
||||||
|
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
||||||
|
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
||||||
|
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
||||||
|
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
|
||||||
|
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
|
||||||
|
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
|
||||||
|
- **TLS**: снаружи HTTPS терминируется Tailscale (сертификат Let's Encrypt для `*.ts.net`); между tailscale и приложением используется самоподписанный сертификат приложения.
|
||||||
|
|
||||||
## Основные API
|
## Основные API
|
||||||
|
|
||||||
| Метод | Путь | Назначение |
|
| Метод | Путь | Назначение |
|
||||||
@@ -113,9 +268,12 @@ docker compose down # остановка (данные сохраняютс
|
|||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
├── docker-compose.yml # сервисы app + db
|
├── docker-compose.yml # сервисы: app + db + tailscale
|
||||||
|
├── .env.example # шаблон переменных окружения
|
||||||
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
|
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
|
||||||
├── server.js # Express-приложение
|
├── server.js # Express-приложение
|
||||||
|
├── worker.js # фоновый worker AI-проверки записей
|
||||||
|
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
|
||||||
├── db/
|
├── db/
|
||||||
│ ├── init.sql # схема при первом запуске
|
│ ├── init.sql # схема при первом запуске
|
||||||
│ └── migration.sql # миграции существующей БД
|
│ └── migration.sql # миграции существующей БД
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# Аудит безопасности и антиспама — WhatIDo
|
||||||
|
|
||||||
|
> Приложение предназначено для публичного развёртывания. Ниже — результаты аудита
|
||||||
|
> по уровню важности: 🔴 критично (исправить обязательно), 🟠 высокий приоритет,
|
||||||
|
> 🟡 средний приоритет, 🔵 замечания по антиспаму.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 КРИТИЧНО (исправить обязательно перед публикацией)
|
||||||
|
|
||||||
|
### 1. Админ-токен в открытом виде + уязвимость по времени
|
||||||
|
**Файл:** `server.js:52-56`
|
||||||
|
```js
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
const token = req.headers['x-admin-token'];
|
||||||
|
if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Токен сравнивается через `===` — **уязвим к атакам по времени** (timing attack).
|
||||||
|
- Нет хеширования (bcrypt/argon2) — при утечке `.env` или логов токен сразу компрометирован.
|
||||||
|
- Реальный пароль лежит в `.env` на диске.
|
||||||
|
|
||||||
|
**Рекомендация:** использовать `crypto.timingSafeEqual` и хранить bcrypt-хеш.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Файлы доступны анонимно по токену
|
||||||
|
**Статус:** 🟢 Частично исправлено (share-файлы привязаны к ссылке)
|
||||||
|
- ✅ Публичные share-файлы теперь отдаются **только** через `GET /api/share/:shareToken/files/:fileToken`, где проверяется принадлежность файла к записям активной ссылки (expiry, пароль, фильтры группы/имени/периода) — `server.js:612-647`.
|
||||||
|
- ⚠️ `GET /api/files/:token` и статика `/uploads/*` по-прежнему доступны по токену (нужны для админ-панели). Токены криптостойкие (16 байт hex).
|
||||||
|
|
||||||
|
**Файлы:** `server.js:612-647`, `public/share.html:106-121`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Публичные share-ссылки раскрывают ПИД (персональные данные)
|
||||||
|
**Файл:** `server.js:480-531`
|
||||||
|
- `/api/share/:token` возвращает: имена учеников, фото, описания, файлы проектов.
|
||||||
|
- Нет срока действия ссылки (`expires_at`).
|
||||||
|
- Юридический риск (152-ФЗ, GDPR) — данные детей в открытую.
|
||||||
|
|
||||||
|
**Рекомендация:** добавить `expires_at` в `share_links`, опцию анонимизации имён, требование пароля к ссылке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟠 ВЫСОКИЙ ПРИОРИТЕТ
|
||||||
|
|
||||||
|
### 4. Нет IP-based rate limit на публичный POST
|
||||||
|
**Файл:** `server.js:969`
|
||||||
|
- Только интервал по имени студента (`spam_interval_min`, дефолт 30 мин).
|
||||||
|
- Бот может менять имена — ограничение обходится.
|
||||||
|
- Нет лимита по IP — легко завалить сервер или перебрать `/api/files/:token`.
|
||||||
|
|
||||||
|
**Рекомендация:** добавить `express-rate-limit` по IP на `POST /api/entries`, `GET /api/share/:token`, `GET /api/files/:token`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Нет honeypot / CAPTCHA
|
||||||
|
**Файл:** `public/index.html:305`
|
||||||
|
- Форма отправки полностью открыта для ботов.
|
||||||
|
- Скрытое поле-ловушка (honeypot) остановит 90% простых ботов.
|
||||||
|
|
||||||
|
**Рекомендация:** добавить `<input name="website" style="display:none" tabindex="-1" autocomplete="off">` и проверку на сервере.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Restore бэкапа загружает 300 МБ в память
|
||||||
|
**Файл:** `server.js:191-194`
|
||||||
|
```js
|
||||||
|
const uploadBackup = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 300 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- `memoryStorage()` — под нагрузкой DoS (OOM killer).
|
||||||
|
- Архив не проверяется на содержимое до распаковки.
|
||||||
|
|
||||||
|
**Рекомендация:** использовать `diskStorage` во временную директорию, лимит 50 МБ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. CSP отключён
|
||||||
|
**Файл:** `server.js:47`
|
||||||
|
```js
|
||||||
|
app.use(helmet({ contentSecurityPolicy: false }));
|
||||||
|
```
|
||||||
|
- Весь фронтенд на inline-скриптах и атрибутных обработчиках — строгий CSP их заблокирует.
|
||||||
|
- Без CSP — риск XSS через инъекции в ошибки/настройки.
|
||||||
|
|
||||||
|
**Рекомендация:** рефакторинг фронтенда на внешние JS-файлы → включить CSP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Сравнение токена без timing-safe
|
||||||
|
**Файл:** `server.js:54`
|
||||||
|
- `token !== ADMIN_PASSWORD` — уязвим к timing attack.
|
||||||
|
|
||||||
|
**Рекомендация:** `crypto.timingSafeEqual(Buffer.from(token), Buffer.from(ADMIN_PASSWORD))`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Поле `files` принимает любые расширения
|
||||||
|
**Файл:** `server.js:83-93`
|
||||||
|
- `fileFilter` проверяет только `photo` (image MIME).
|
||||||
|
- Поле `files` принимает **что угодно** — `.html`, `.js`, `.svg` (SVG может содержать JS).
|
||||||
|
- Имена генерируются случайно, но при угадывании токена — вредоносный файл отдаётся как есть.
|
||||||
|
|
||||||
|
**Рекомендация:** добавить тот же блок-лист расширений для `files`, отдавать как `download` (не inline).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Нет лимита на размер JSON-body
|
||||||
|
**Файл:** `server.js:48`
|
||||||
|
```js
|
||||||
|
app.use(express.json());
|
||||||
|
```
|
||||||
|
- Нет `limit` — можно слать огромные JSON, забивать память.
|
||||||
|
|
||||||
|
**Рекомендация:** `express.json({ limit: '1mb' })`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 СРЕДНИЙ ПРИОРИТЕТ
|
||||||
|
|
||||||
|
| # | Проблема | Файл/Место |
|
||||||
|
|---|----------|------------|
|
||||||
|
| 11 | Share-ссылки никогда не истекают | `db/init.sql:32-41` — нет `expires_at` |
|
||||||
|
| 12 | Нет аудит-лога админ-действий | — |
|
||||||
|
| 13 | Стектрейсы утекают в non-production | `server.js:1155-1157` |
|
||||||
|
| 14 | Нет HSTS / secure cookies / принудительного HTTPS | `server.js:1169-1180` |
|
||||||
|
| 15 | Имена учеников в URL параметрах share | `public/share.html:105` |
|
||||||
|
| 16 | Отсутствует валидация `spam_interval_min` ≥ 1 | `server.js:1004` — можно поставить 0 и отключить антиспам |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔵 АНТИСПАМ — ПРОБЕЛЫ
|
||||||
|
|
||||||
|
| Мера | Статус | Что нужно |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| IP-based rate limit | ❌ | `rateLimit` по IP на `/api/entries` |
|
||||||
|
| Honeypot поле | ❌ | Скрытый input в форме + проверка сервером |
|
||||||
|
| CAPTCHA / Turnstile | ❌ | Опционально: Cloudflare Turnstile |
|
||||||
|
| Мин. интервал спама | ⚠️ Можно 0 | Валидация: минимум 1 минута |
|
||||||
|
| Квота суммарного объёма на IP/день | ❌ | Добавить (напр. 100 МБ/день) |
|
||||||
|
| Лимит файлов на запрос | ✅ 10 файлов | Оставить |
|
||||||
|
| Суммарный размер на запрос | ✅ 30 МБ | Оставить |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ УЖЕ ИСПРАВЛЕНО (подтверждено в текущем коде)
|
||||||
|
|
||||||
|
- ❌ Убран фолбэк-пароль `'admin'` — старт невозможен без `ADMIN_PASSWORD`
|
||||||
|
- ❌ CORS полностью удалён
|
||||||
|
- ❌ Порт БД 5432 не опубликован, креды из `.env`
|
||||||
|
- ❌ `escapeHtml` исправлен (`&`)
|
||||||
|
- ❌ `express-rate-limit` на API роутах
|
||||||
|
- ❌ Валидация restore-данных + безопасный `safeUnlink` (path traversal защита)
|
||||||
|
- ❌ `helmet` + security-заголовки (кроме CSP)
|
||||||
|
- ❌ Блок-лист расширений загрузки + лимит 30 МБ/запись
|
||||||
|
- ❌ Параметризованные запросы (нет SQL-инъекций)
|
||||||
|
- ❌ Токены файлов криптостойкие (16 байт hex)
|
||||||
|
- ❌ Файлы не перезаписываются (случайные имена)
|
||||||
|
- ❌ Multer-лимиты на размеры есть
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 ПЛАН ДЕЙСТВИЙ (must-do перед публикацией)
|
||||||
|
|
||||||
|
### Фаза 1 — Критично (до публичного запуска)
|
||||||
|
1. **Хеш админ-токена** (bcrypt) + `crypto.timingSafeEqual` для сравнения
|
||||||
|
2. **Защита файлов** — отдача только в контексте валидной share-ссылки или подписанные URL
|
||||||
|
3. **IP rate limit** на `POST /api/entries` (10 req / 15 мин на IP)
|
||||||
|
4. **Honeypot** в публичной форме
|
||||||
|
5. **Backup restore на диск** (diskStorage, лимит 50 МБ)
|
||||||
|
6. **Мин. spam_interval_min = 1** (валидация в settings)
|
||||||
|
|
||||||
|
### Фаза 2 — Укрепление (высокий приоритет)
|
||||||
|
7. **CSP** — рефакторинг inline-скриптов → внешние файлы
|
||||||
|
8. **HSTS** через reverse-proxy (Caddy/nginx)
|
||||||
|
9. **Блок-лист расширений для `files`** + отдача как download
|
||||||
|
10. **JSON body limit** (`1mb`)
|
||||||
|
11. **`expires_at` для share_links** + опция анонимизации
|
||||||
|
12. **Аудит-лог** админ-действий (логин, CRUD, backup/restore)
|
||||||
|
|
||||||
|
### Фаза 3 — Приватность и соответствие
|
||||||
|
13. **Анонимизация share-ссылок** (опция скрыть имена)
|
||||||
|
14. **Политика хранения** — автоудаление старых записей
|
||||||
|
15. **Privacy notice** на публичной форме
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## БЫСТРЫЕ ПОБЕДЫ (можно внедрить сегодня)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 1. Timing-safe админ-проверка (server.js:52-56)
|
||||||
|
const crypto = require('crypto');
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
const token = req.headers['x-admin-token'];
|
||||||
|
const expected = Buffer.from(ADMIN_PASSWORD);
|
||||||
|
const provided = Buffer.from(token || '');
|
||||||
|
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. IP rate limit на публичную запись (server.js:969)
|
||||||
|
const entryIpLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
keyGenerator: req => req.ip,
|
||||||
|
message: { error: 'Too many submissions from this IP' }
|
||||||
|
});
|
||||||
|
app.post('/api/entries', entryIpLimiter, entryLimiter, ...);
|
||||||
|
|
||||||
|
// 3. Honeypot в форме (index.html) — скрытое поле
|
||||||
|
// <input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none">
|
||||||
|
// В хендлере: if (req.body.website) return res.status(400).json({ error: 'Spam detected' });
|
||||||
|
|
||||||
|
// 4. JSON body limit (server.js:48)
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
// 5. Блок-лист для project files (server.js:83-93)
|
||||||
|
const BLOCKED_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||||||
|
if (file.fieldname === 'files' && ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файлы для доработки (приоритет)
|
||||||
|
|
||||||
|
| Файл | Что править |
|
||||||
|
|------|-------------|
|
||||||
|
| `server.js:52-56` | timing-safe compare, bcrypt-хеш |
|
||||||
|
| `server.js:969` | IP rate limiter + honeypot проверка |
|
||||||
|
| `server.js:191-194` | diskStorage для backup restore |
|
||||||
|
| `server.js:83-93` | блок-лист для `files` |
|
||||||
|
| `server.js:48` | `express.json({ limit: '1mb' })` |
|
||||||
|
| `server.js:1004` | валидация `spam_interval_min >= 1` |
|
||||||
|
| `public/index.html` | honeypot input |
|
||||||
|
| `db/init.sql` | добавить `expires_at` в `share_links` |
|
||||||
|
| `docker-compose.yml` | раскомментировать Caddy для TLS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Аудит выполнен: 2026-09-07*
|
||||||
|
*Статус: готово к внедрению Фазы 1*
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# Аудит безопасности и антиспама — WhatIDo
|
||||||
|
|
||||||
|
> Приложение предназначено для публичного развёртывания. Ниже — результаты аудита
|
||||||
|
> по уровню важности: 🔴 критично (исправить обязательно), 🟠 высокий приоритет,
|
||||||
|
> 🟡 средний приоритет, 🔵 замечания по антиспаму.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 КРИТИЧНО (исправить обязательно перед публикацией)
|
||||||
|
|
||||||
|
### 1. Админ-токен в открытом виде + уязвимость по времени
|
||||||
|
**Файл:** `server.js:52-56`
|
||||||
|
```js
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
const token = req.headers['x-admin-token'];
|
||||||
|
if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Токен сравнивается через `===` — **уязвим к атакам по времени** (timing attack).
|
||||||
|
- Нет хеширования (bcrypt/argon2) — при утечке `.env` или логов токен сразу компрометирован.
|
||||||
|
- Реальный пароль лежит в `.env` на диске.
|
||||||
|
|
||||||
|
**Рекомендация:** использовать `crypto.timingSafeEqual` и хранить bcrypt-хеш.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Файлы доступны анонимно по токену
|
||||||
|
**Файлы:** `server.js:877-885`, `server.js:49`
|
||||||
|
- `GET /api/files/:token` и статика `/uploads/*` отдают любой файл любому, кто знает токен.
|
||||||
|
- Токены криптостойкие (32 hex), но **фото детей и учебные проекты не должны быть публично доступны по угадываемому ключу**.
|
||||||
|
- Share-ссылки (`/api/share/:token`) также выдают все файлы записи.
|
||||||
|
|
||||||
|
**Рекомендация:** отдавать файлы только в контексте действующей share-ссылки (проверка принадлежности entry к ссылке) либо подписанные URL с TTL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Публичные share-ссылки раскрывают ПИД (персональные данные)
|
||||||
|
**Файл:** `server.js:480-531`
|
||||||
|
- `/api/share/:token` возвращает: имена учеников, фото, описания, файлы проектов.
|
||||||
|
- Нет срока действия ссылки (`expires_at`).
|
||||||
|
- Юридический риск (152-ФЗ, GDPR) — данные детей в открытую.
|
||||||
|
|
||||||
|
**Рекомендация:** добавить `expires_at` в `share_links`, опцию анонимизации имён, требование пароля к ссылке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟠 ВЫСОКИЙ ПРИОРИТЕТ
|
||||||
|
|
||||||
|
### 4. Нет IP-based rate limit на публичный POST
|
||||||
|
**Файл:** `server.js:969`
|
||||||
|
- Только интервал по имени студента (`spam_interval_min`, дефолт 30 мин).
|
||||||
|
- Бот может менять имена — ограничение обходится.
|
||||||
|
- Нет лимита по IP — легко завалить сервер или перебрать `/api/files/:token`.
|
||||||
|
|
||||||
|
**Рекомендация:** добавить `express-rate-limit` по IP на `POST /api/entries`, `GET /api/share/:token`, `GET /api/files/:token`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Нет honeypot / CAPTCHA
|
||||||
|
**Файл:** `public/index.html:305`
|
||||||
|
- Форма отправки полностью открыта для ботов.
|
||||||
|
- Скрытое поле-ловушка (honeypot) остановит 90% простых ботов.
|
||||||
|
|
||||||
|
**Рекомендация:** добавить `<input name="website" style="display:none" tabindex="-1" autocomplete="off">` и проверку на сервере.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Restore бэкапа загружает 300 МБ в память
|
||||||
|
**Файл:** `server.js:191-194`
|
||||||
|
```js
|
||||||
|
const uploadBackup = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 300 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- `memoryStorage()` — под нагрузкой DoS (OOM killer).
|
||||||
|
- Архив не проверяется на содержимое до распаковки.
|
||||||
|
|
||||||
|
**Рекомендация:** использовать `diskStorage` во временную директорию, лимит 50 МБ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. CSP отключён
|
||||||
|
**Файл:** `server.js:47`
|
||||||
|
```js
|
||||||
|
app.use(helmet({ contentSecurityPolicy: false }));
|
||||||
|
```
|
||||||
|
- Весь фронтенд на inline-скриптах и атрибутных обработчиках — строгий CSP их заблокирует.
|
||||||
|
- Без CSP — риск XSS через инъекции в ошибки/настройки.
|
||||||
|
|
||||||
|
**Рекомендация:** рефакторинг фронтенда на внешние JS-файлы → включить CSP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Сравнение токена без timing-safe
|
||||||
|
**Файл:** `server.js:54`
|
||||||
|
- `token !== ADMIN_PASSWORD` — уязвим к timing attack.
|
||||||
|
|
||||||
|
**Рекомендация:** `crypto.timingSafeEqual(Buffer.from(token), Buffer.from(ADMIN_PASSWORD))`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Поле `files` принимает любые расширения
|
||||||
|
**Файл:** `server.js:83-93`
|
||||||
|
- `fileFilter` проверяет только `photo` (image MIME).
|
||||||
|
- Поле `files` принимает **что угодно** — `.html`, `.js`, `.svg` (SVG может содержать JS).
|
||||||
|
- Имена генерируются случайно, но при угадывании токена — вредоносный файл отдаётся как есть.
|
||||||
|
|
||||||
|
**Рекомендация:** добавить тот же блок-лист расширений для `files`, отдавать как `download` (не inline).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Нет лимита на размер JSON-body
|
||||||
|
**Файл:** `server.js:48`
|
||||||
|
```js
|
||||||
|
app.use(express.json());
|
||||||
|
```
|
||||||
|
- Нет `limit` — можно слать огромные JSON, забивать память.
|
||||||
|
|
||||||
|
**Рекомендация:** `express.json({ limit: '1mb' })`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 СРЕДНИЙ ПРИОРИТЕТ
|
||||||
|
|
||||||
|
| # | Проблема | Файл/Место |
|
||||||
|
|---|----------|------------|
|
||||||
|
| 11 | Share-ссылки никогда не истекают | `db/init.sql:32-41` — нет `expires_at` |
|
||||||
|
| 12 | Нет аудит-лога админ-действий | — |
|
||||||
|
| 13 | Стектрейсы утекают в non-production | `server.js:1155-1157` |
|
||||||
|
| 14 | Нет HSTS / secure cookies / принудительного HTTPS | `server.js:1169-1180` |
|
||||||
|
| 15 | Имена учеников в URL параметрах share | `public/share.html:105` |
|
||||||
|
| 16 | Отсутствует валидация `spam_interval_min` ≥ 1 | `server.js:1004` — можно поставить 0 и отключить антиспам |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔵 АНТИСПАМ — ПРОБЕЛЫ
|
||||||
|
|
||||||
|
| Мера | Статус | Что нужно |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| IP-based rate limit | ❌ | `rateLimit` по IP на `/api/entries` |
|
||||||
|
| Honeypot поле | ❌ | Скрытый input в форме + проверка сервером |
|
||||||
|
| CAPTCHA / Turnstile | ❌ | Опционально: Cloudflare Turnstile |
|
||||||
|
| Мин. интервал спама | ⚠️ Можно 0 | Валидация: минимум 1 минута |
|
||||||
|
| Квота суммарного объёма на IP/день | ❌ | Добавить (напр. 100 МБ/день) |
|
||||||
|
| Лимит файлов на запрос | ✅ 10 файлов | Оставить |
|
||||||
|
| Суммарный размер на запрос | ✅ 30 МБ | Оставить |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ УЖЕ ИСПРАВЛЕНО (подтверждено в текущем коде)
|
||||||
|
|
||||||
|
- ❌ Убран фолбэк-пароль `'admin'` — старт невозможен без `ADMIN_PASSWORD`
|
||||||
|
- ❌ CORS полностью удалён
|
||||||
|
- ❌ Порт БД 5432 не опубликован, креды из `.env`
|
||||||
|
- ❌ `escapeHtml` исправлен (`&`)
|
||||||
|
- ❌ `express-rate-limit` на API роутах
|
||||||
|
- ❌ Валидация restore-данных + безопасный `safeUnlink` (path traversal защита)
|
||||||
|
- ❌ `helmet` + security-заголовки (кроме CSP)
|
||||||
|
- ❌ Блок-лист расширений загрузки + лимит 30 МБ/запись
|
||||||
|
- ❌ Параметризованные запросы (нет SQL-инъекций)
|
||||||
|
- ❌ Токены файлов криптостойкие (16 байт hex)
|
||||||
|
- ❌ Файлы не перезаписываются (случайные имена)
|
||||||
|
- ❌ Multer-лимиты на размеры есть
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 ПЛАН ДЕЙСТВИЙ (must-do перед публикацией)
|
||||||
|
|
||||||
|
### Фаза 1 — Критично (до публичного запуска)
|
||||||
|
1. **Хеш админ-токена** (bcrypt) + `crypto.timingSafeEqual` для сравнения
|
||||||
|
2. **Защита файлов** — отдача только в контексте валидной share-ссылки или подписанные URL
|
||||||
|
3. **IP rate limit** на `POST /api/entries` (10 req / 15 мин на IP)
|
||||||
|
4. **Honeypot** в публичной форме
|
||||||
|
5. **Backup restore на диск** (diskStorage, лимит 50 МБ)
|
||||||
|
6. **Мин. spam_interval_min = 1** (валидация в settings)
|
||||||
|
|
||||||
|
### Фаза 2 — Укрепление (высокий приоритет)
|
||||||
|
7. **CSP** — рефакторинг inline-скриптов → внешние файлы
|
||||||
|
8. **HSTS** через reverse-proxy (Caddy/nginx)
|
||||||
|
9. **Блок-лист расширений для `files`** + отдача как download
|
||||||
|
10. **JSON body limit** (`1mb`)
|
||||||
|
11. **`expires_at` для share_links** + опция анонимизации
|
||||||
|
12. **Аудит-лог** админ-действий (логин, CRUD, backup/restore)
|
||||||
|
|
||||||
|
### Фаза 3 — Приватность и соответствие
|
||||||
|
13. **Анонимизация share-ссылок** (опция скрыть имена)
|
||||||
|
14. **Политика хранения** — автоудаление старых записей
|
||||||
|
15. **Privacy notice** на публичной форме
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## БЫСТРЫЕ ПОБЕДЫ (можно внедрить сегодня)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 1. Timing-safe админ-проверка (server.js:52-56)
|
||||||
|
const crypto = require('crypto');
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
const token = req.headers['x-admin-token'];
|
||||||
|
const expected = Buffer.from(ADMIN_PASSWORD);
|
||||||
|
const provided = Buffer.from(token || '');
|
||||||
|
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. IP rate limit на публичную запись (server.js:969)
|
||||||
|
const entryIpLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
keyGenerator: req => req.ip,
|
||||||
|
message: { error: 'Too many submissions from this IP' }
|
||||||
|
});
|
||||||
|
app.post('/api/entries', entryIpLimiter, entryLimiter, ...);
|
||||||
|
|
||||||
|
// 3. Honeypot в форме (index.html) — скрытое поле
|
||||||
|
// <input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none">
|
||||||
|
// В хендлере: if (req.body.website) return res.status(400).json({ error: 'Spam detected' });
|
||||||
|
|
||||||
|
// 4. JSON body limit (server.js:48)
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
// 5. Блок-лист для project files (server.js:83-93)
|
||||||
|
const BLOCKED_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||||||
|
if (file.fieldname === 'files' && ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файлы для доработки (приоритет)
|
||||||
|
|
||||||
|
| Файл | Что править |
|
||||||
|
|------|-------------|
|
||||||
|
| `server.js:52-56` | timing-safe compare, bcrypt-хеш |
|
||||||
|
| `server.js:969` | IP rate limiter + honeypot проверка |
|
||||||
|
| `server.js:191-194` | diskStorage для backup restore |
|
||||||
|
| `server.js:83-93` | блок-лист для `files` |
|
||||||
|
| `server.js:48` | `express.json({ limit: '1mb' })` |
|
||||||
|
| `server.js:1004` | валидация `spam_interval_min >= 1` |
|
||||||
|
| `public/index.html` | honeypot input |
|
||||||
|
| `db/init.sql` | добавить `expires_at` в `share_links` |
|
||||||
|
| `docker-compose.yml` | раскомментировать Caddy для TLS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Аудит выполнен: 2026-09-07*
|
||||||
|
*Статус: готово к внедрению Фазы 1*
|
||||||
+175
@@ -1,26 +1,104 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS branches (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
address TEXT,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||||
|
ip VARCHAR(64) PRIMARY KEY,
|
||||||
|
reason VARCHAR(100) NOT NULL,
|
||||||
|
banned_until TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS groups (
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(100) NOT NULL UNIQUE,
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
day_of_week INT,
|
||||||
|
time_start TIME,
|
||||||
|
time_end TIME,
|
||||||
|
cover_path VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS modules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
lessons_count INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
photo_path VARCHAR(255),
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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 (
|
CREATE TABLE IF NOT EXISTS students (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(150) NOT NULL UNIQUE,
|
name VARCHAR(150) NOT NULL UNIQUE,
|
||||||
group_id INT REFERENCES groups(id),
|
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()
|
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 (
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
student_name VARCHAR(150) NOT NULL,
|
student_name VARCHAR(150) NOT NULL,
|
||||||
group_id INT NOT NULL REFERENCES groups(id),
|
group_id INT NOT NULL REFERENCES groups(id),
|
||||||
|
module_id INT REFERENCES modules(id) ON DELETE SET NULL,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
|
description_original TEXT,
|
||||||
|
description_ai TEXT,
|
||||||
|
ai_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
ai_checked_at TIMESTAMPTZ,
|
||||||
|
ai_error TEXT,
|
||||||
photo_path VARCHAR(255),
|
photo_path VARCHAR(255),
|
||||||
|
photo_original_path VARCHAR(255),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (TG_OP = 'INSERT') THEN
|
||||||
|
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
|
||||||
|
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
|
||||||
|
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
|
||||||
|
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT
|
value TEXT
|
||||||
@@ -28,6 +106,20 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
|
|
||||||
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_student_message', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_entry_date', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_student_names', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS share_links (
|
CREATE TABLE IF NOT EXISTS share_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -37,9 +129,19 @@ CREATE TABLE IF NOT EXISTS share_links (
|
|||||||
student_name VARCHAR(150),
|
student_name VARCHAR(150),
|
||||||
date_from DATE,
|
date_from DATE,
|
||||||
date_to DATE,
|
date_to DATE,
|
||||||
|
show_student_names BOOLEAN,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
access_password_hash VARCHAR(255),
|
||||||
|
message TEXT,
|
||||||
|
link_url VARCHAR(500),
|
||||||
|
show_student_message BOOLEAN,
|
||||||
|
show_entry_date BOOLEAN,
|
||||||
|
show_group_photos BOOLEAN,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at);
|
||||||
|
|
||||||
INSERT INTO groups (name) VALUES
|
INSERT INTO groups (name) VALUES
|
||||||
('Beginner'),
|
('Beginner'),
|
||||||
('Intermediate'),
|
('Intermediate'),
|
||||||
@@ -52,6 +154,7 @@ CREATE TABLE IF NOT EXISTS group_photos (
|
|||||||
photo_path VARCHAR(255) NOT NULL,
|
photo_path VARCHAR(255) NOT NULL,
|
||||||
caption TEXT,
|
caption TEXT,
|
||||||
taken_at DATE,
|
taken_at DATE,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,3 +169,75 @@ CREATE TABLE IF NOT EXISTS project_files (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
detached_at TIMESTAMPTZ
|
detached_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_photos (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
photo_path VARCHAR(255) NOT NULL,
|
||||||
|
caption TEXT,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(150),
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'tutor' CHECK (role IN ('admin','tutor')),
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_branches (
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, branch_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS photo_jobs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
action VARCHAR(20) NOT NULL DEFAULT 'ai',
|
||||||
|
params JSONB,
|
||||||
|
before_path VARCHAR(255),
|
||||||
|
after_path VARCHAR(255),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
applied BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
finished_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
target JSONB,
|
||||||
|
ip VARCHAR(45),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ CREATE TABLE IF NOT EXISTS students (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||||
|
ip VARCHAR(64) PRIMARY KEY,
|
||||||
|
reason VARCHAR(100) NOT NULL,
|
||||||
|
banned_until TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id);
|
ALTER TABLE students ADD COLUMN IF NOT EXISTS 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 (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -13,6 +22,14 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
|
|
||||||
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
UPDATE settings
|
||||||
|
SET value = 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'
|
||||||
|
WHERE key = 'ai_prompt' AND value IN (
|
||||||
|
'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.',
|
||||||
|
'Ты — редактор текстов. Исправь ошибки. Верни ТОЛЬКО исправленный текст.'
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS share_links (
|
CREATE TABLE IF NOT EXISTS share_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -25,6 +42,21 @@ CREATE TABLE IF NOT EXISTS share_links (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_message BOOLEAN;
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_entry_date BOOLEAN;
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_group_photos BOOLEAN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'share_links' AND column_name = 'anonymize_names') THEN
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_names BOOLEAN;
|
||||||
|
UPDATE share_links SET show_student_names = (NOT anonymize_names) WHERE anonymize_names IS NOT NULL;
|
||||||
|
ALTER TABLE share_links DROP COLUMN anonymize_names;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO students (name)
|
INSERT INTO students (name)
|
||||||
@@ -48,7 +80,94 @@ CREATE UNIQUE INDEX IF NOT EXISTS project_files_token_key ON project_files(token
|
|||||||
ALTER TABLE project_files ALTER COLUMN entry_id DROP NOT NULL;
|
ALTER TABLE project_files ALTER COLUMN entry_id DROP NOT NULL;
|
||||||
ALTER TABLE project_files ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ;
|
ALTER TABLE project_files ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_photos (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
photo_path VARCHAR(255) NOT NULL,
|
||||||
|
caption TEXT,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
|
||||||
|
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_student_message', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_entry_date', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_student_names', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
DELETE FROM settings WHERE key = 'share_anonymize_names';
|
||||||
|
INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
||||||
|
UPDATE entries SET description_original = description WHERE description_original IS NULL;
|
||||||
|
UPDATE entries SET ai_status = 'skipped' WHERE ai_status = 'pending';
|
||||||
|
INSERT INTO settings (key, value) VALUES ('ai_autocheck_migrated', '1');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS branches (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
address TEXT,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS day_of_week INT;
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_start TIME;
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end TIME;
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS cover_path VARCHAR(255);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(150),
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'tutor' CHECK (role IN ('admin','tutor')),
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_branches (
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, branch_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
|
||||||
|
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS group_photos (
|
CREATE TABLE IF NOT EXISTS group_photos (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -59,4 +178,74 @@ CREATE TABLE IF NOT EXISTS group_photos (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE group_photos ADD COLUMN IF NOT EXISTS sort_order INT DEFAULT 0;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
|
CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
|
||||||
|
|
||||||
|
UPDATE entries e SET photo_path = p.photo_path
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (entry_id) entry_id, photo_path
|
||||||
|
FROM entry_photos ORDER BY entry_id, sort_order, id
|
||||||
|
) p
|
||||||
|
WHERE e.photo_path IS NULL AND p.entry_id = e.id;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (TG_OP = 'INSERT') THEN
|
||||||
|
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
|
||||||
|
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
|
||||||
|
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
|
||||||
|
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS photo_jobs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
action VARCHAR(20) NOT NULL DEFAULT 'ai',
|
||||||
|
params JSONB,
|
||||||
|
before_path VARCHAR(255),
|
||||||
|
after_path VARCHAR(255),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
applied BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
finished_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
|
||||||
|
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS modules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
lessons_count INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS student_photos (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
photo_path VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Migration: Add anonymize_names, expires_at, access_password_hash to share_links
|
||||||
|
-- Run this on existing database
|
||||||
|
|
||||||
|
-- 1. Add anonymize_names column (default false = show names)
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS anonymize_names BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
-- 2. Add expires_at column (default 7 days from creation, nullable for backward compat)
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- 3. Add access_password_hash column for bcrypt password protection
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS access_password_hash VARCHAR(255);
|
||||||
|
|
||||||
|
-- 4. Update existing links to have expires_at = created_at + 7 days (optional, for existing links)
|
||||||
|
-- UPDATE share_links SET expires_at = created_at + interval '7 days' WHERE expires_at IS NULL;
|
||||||
|
|
||||||
|
-- 5. Add index for cleanup of expired links
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at);
|
||||||
|
|
||||||
|
-- 6. Add public message and external link shown on the share page
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
|
||||||
|
|
||||||
|
-- 7. Per-link visibility overrides (NULL = fall back to global settings)
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_message BOOLEAN;
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_entry_date BOOLEAN;
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_group_photos BOOLEAN;
|
||||||
|
|
||||||
|
-- 8. Replace anonymize_names with positive show_student_names (NULL = inherit global setting)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'share_links' AND column_name = 'anonymize_names') THEN
|
||||||
|
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS show_student_names BOOLEAN;
|
||||||
|
UPDATE share_links SET show_student_names = (NOT anonymize_names) WHERE anonymize_names IS NOT NULL;
|
||||||
|
ALTER TABLE share_links DROP COLUMN anonymize_names;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
+101
-6
@@ -6,13 +6,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: whereldo
|
POSTGRES_DB: whereldo
|
||||||
POSTGRES_USER: app
|
POSTGRES_USER: app
|
||||||
POSTGRES_PASSWORD: app
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
|
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U app -d whereldo"]
|
test: ["CMD-SHELL", "pg_isready -U app -d whereldo"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
@@ -20,13 +18,27 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
network: host
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
expose:
|
||||||
|
- "3003"
|
||||||
|
- "3443"
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3003:3003"
|
||||||
- "3443:3443"
|
- "3443:3443"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://app:app@db:5432/whereldo
|
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
||||||
|
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||||
|
AI_PROMPT: ${AI_PROMPT:-}
|
||||||
|
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
||||||
|
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
||||||
|
NODE_ENV: production
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -34,5 +46,88 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
|
|
||||||
|
# Публикация через Tailscale (Serve / Funnel) без проброса портов.
|
||||||
|
# Приложение доступно по https://whatido.<tailnet>.ts.net
|
||||||
|
# tailscale:
|
||||||
|
# image: tailscale/tailscale:latest
|
||||||
|
# hostname: whatido
|
||||||
|
# restart: unless-stopped
|
||||||
|
# network_mode: host
|
||||||
|
# cap_add:
|
||||||
|
# - NET_ADMIN
|
||||||
|
# - SYS_MODULE
|
||||||
|
# environment:
|
||||||
|
# SSL_CERT_FILE: /etc/tailscale/app-certs/cert.pem
|
||||||
|
# volumes:
|
||||||
|
# - /var/lib/tailscale:/var/lib/tailscale
|
||||||
|
# - /dev/net/tun:/dev/net/tun
|
||||||
|
# - /lib/modules:/lib/modules:ro
|
||||||
|
# - ./certs:/etc/tailscale/app-certs:ro
|
||||||
|
# - ./start-tailscale.sh:/start-tailscale.sh:ro
|
||||||
|
# command: ["/bin/sh", "/start-tailscale.sh"]
|
||||||
|
# depends_on:
|
||||||
|
# - app
|
||||||
|
|
||||||
|
# Публикация наружу через Cloudflare Tunnel (cloudflared), Quick Tunnel:
|
||||||
|
# случайный публичный URL *.trycloudflare.com, который печатается в логах:
|
||||||
|
# docker compose logs -f cloudflared
|
||||||
|
# Сборка из Dockerfile.cloudflared добавляет опциональный WireGuard:
|
||||||
|
# если в wg/wg0.conf лежит конфиг — контейнер сначала поднимает VPN и
|
||||||
|
# только потом запускает туннель (исход Cloudflare через VPN). Без конфига
|
||||||
|
# туннель стартует сразу, как в базовой схеме.
|
||||||
|
# cloudflared:
|
||||||
|
# build:
|
||||||
|
# context: .
|
||||||
|
# dockerfile: Dockerfile.cloudflared
|
||||||
|
# restart: unless-stopped
|
||||||
|
# cap_add:
|
||||||
|
# - NET_ADMIN
|
||||||
|
# privileged: true
|
||||||
|
# volumes:
|
||||||
|
# - ./wg:/etc/wireguard:ro
|
||||||
|
# environment:
|
||||||
|
# TZ: Europe/Moscow
|
||||||
|
# CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
|
||||||
|
# WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
|
||||||
|
# depends_on:
|
||||||
|
# - app
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
text-corrector:
|
||||||
|
image: ghcr.io/ggml-org/llama.cpp:server
|
||||||
|
container_name: text-corrector
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
entrypoint: ["/bin/sh", "/start-text-corrector.sh"]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||||
|
AI_MODEL_REPO: ${AI_MODEL_REPO:-Qwen/Qwen2.5-1.5B-Instruct-GGUF}
|
||||||
|
HUGGINGFACE_TOKEN: ${HUGGINGFACE_TOKEN:-}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./models:/models
|
||||||
|
- ./start-text-corrector.sh:/start-text-corrector.sh:ro
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
|
||||||
|
# ИИ-улучшение фотографий (Real-ESRGAN x2, CPU).
|
||||||
|
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
|
||||||
|
photo-ai:
|
||||||
|
build: ./photo-ai
|
||||||
|
container_name: photo-ai
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MODEL_PATH: /models/RealESRGAN_x2plus.pth
|
||||||
|
MAX_INPUT_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
volumes:
|
||||||
|
- photo-ai-models:/models
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
photo-ai-models:
|
||||||
|
|||||||
Binary file not shown.
Generated
+2510
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -6,10 +6,18 @@
|
|||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"bcrypt": "^5.1.1",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"express-rate-limit": "^8.7.0",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
|
"lucide": "^1.44.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"tar": "^7.4.3"
|
"tar": "^7.4.3"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"bcrypt@5.1.1": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, UploadFile
|
||||||
|
from fastapi.responses import Response
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
import torch
|
||||||
|
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||||
|
from realesrgan import RealESRGANer
|
||||||
|
|
||||||
|
MODEL_PATH = os.environ.get('MODEL_PATH', '/models/RealESRGAN_x2plus.pth')
|
||||||
|
MODEL_URL = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth'
|
||||||
|
MAX_PIXELS = int(os.environ.get('MAX_INPUT_PIXELS', str(4_000_000)))
|
||||||
|
|
||||||
|
lock = threading.Lock()
|
||||||
|
upsampler = None
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_model():
|
||||||
|
if not os.path.exists(MODEL_PATH):
|
||||||
|
os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
|
||||||
|
tmp = MODEL_PATH + '.tmp'
|
||||||
|
urllib.request.urlretrieve(MODEL_URL, tmp)
|
||||||
|
os.replace(tmp, MODEL_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def load_model():
|
||||||
|
global upsampler
|
||||||
|
ensure_model()
|
||||||
|
model = RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
|
||||||
|
upsampler = RealESRGANer(
|
||||||
|
scale=2,
|
||||||
|
model_path=MODEL_PATH,
|
||||||
|
model=model,
|
||||||
|
tile=256,
|
||||||
|
tile_pad=10,
|
||||||
|
pre_pad=0,
|
||||||
|
half=False,
|
||||||
|
device='cpu',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event('startup')
|
||||||
|
async def startup():
|
||||||
|
await asyncio.to_thread(load_model)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/health')
|
||||||
|
def health():
|
||||||
|
return {'ok': upsampler is not None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/enhance')
|
||||||
|
async def enhance(image: UploadFile = File(...), scale: int = Form(2)):
|
||||||
|
data = await image.read()
|
||||||
|
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if img is None:
|
||||||
|
return Response('bad image', status_code=400)
|
||||||
|
if img.shape[0] * img.shape[1] > MAX_PIXELS:
|
||||||
|
r = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
|
||||||
|
img = cv2.resize(img, (int(img.shape[1] * r), int(img.shape[0] * r)), interpolation=cv2.INTER_AREA)
|
||||||
|
outscale = min(max(int(scale), 2), 4)
|
||||||
|
with lock:
|
||||||
|
out, _ = upsampler.enhance(img, outscale=outscale)
|
||||||
|
ok, enc = cv2.imencode('.jpg', out, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
|
||||||
|
if not ok:
|
||||||
|
return Response('encode failed', status_code=500)
|
||||||
|
return Response(enc.tobytes(), media_type='image/jpeg')
|
||||||
+267
-22
@@ -4,13 +4,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
|
|
||||||
.layout{display:flex;min-height:100dvh}
|
.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{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column}
|
||||||
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)}
|
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0}
|
||||||
|
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
|
||||||
|
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
|
||||||
|
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
||||||
|
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
|
||||||
|
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.user-box .user-role{color:var(--muted);font-size:.72rem}
|
||||||
.sidebar nav{flex:1;padding:12px 0}
|
.sidebar nav{flex:1;padding:12px 0}
|
||||||
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent}
|
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent}
|
||||||
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
|
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
|
||||||
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.06);border-left-color:var(--accent);font-weight:500}
|
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.06);border-left-color:var(--accent);font-weight:500}
|
||||||
|
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0}
|
||||||
|
.sidebar nav a[data-nav=dashboard] svg{color:#6366f1}
|
||||||
|
.sidebar nav a[data-nav=journal] svg{color:#0ea5e9}
|
||||||
|
.sidebar nav a[data-nav=students] svg{color:#14b8a6}
|
||||||
|
.sidebar nav a[data-nav=groups] svg{color:#8b5cf6}
|
||||||
|
.sidebar nav a[data-nav=files] svg{color:#f59e0b}
|
||||||
|
.sidebar nav a[data-nav=links] svg{color:#06b6d4}
|
||||||
|
.sidebar nav a[data-nav=trash] svg{color:#ef4444}
|
||||||
|
.sidebar nav a[data-nav=branches] svg{color:#10b981}
|
||||||
|
.sidebar nav a[data-nav=users] svg{color:#ec4899}
|
||||||
|
.sidebar nav a[data-nav=worker] svg{color:#a855f7}
|
||||||
|
.sidebar nav a[data-nav=audit] svg{color:#64748b}
|
||||||
|
.sidebar nav a[data-nav=bans] svg{color:#dc2626}
|
||||||
|
.sidebar nav a[data-nav=settings] svg{color:#475569}
|
||||||
|
.sidebar nav a.active svg{color:var(--accent)}
|
||||||
|
.sidebar .bottom a[data-nav=home] svg{color:#2563eb}
|
||||||
.sidebar .bottom{padding:12px 20px;border-top:1px solid var(--border)}
|
.sidebar .bottom{padding:12px 20px;border-top:1px solid var(--border)}
|
||||||
.sidebar .bottom a{display:block;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
|
.sidebar .version{color:var(--muted);font-size:.72rem;padding:0 20px 8px}
|
||||||
|
.sidebar .bottom a{display:flex;align-items:center;gap:10px;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
|
||||||
.sidebar .bottom a:hover{color:var(--text)}
|
.sidebar .bottom a:hover{color:var(--text)}
|
||||||
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
|
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
|
||||||
|
|
||||||
@@ -21,14 +44,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px}
|
.stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px}
|
||||||
.stat .num{font-size:2rem;font-weight:700;color:var(--accent)}
|
.stat .num{font-size:2rem;font-weight:700;color:var(--accent)}
|
||||||
.stat .label{color:var(--muted);font-size:.8rem;margin-top:4px}
|
.stat .label{color:var(--muted);font-size:.8rem;margin-top:4px}
|
||||||
|
.stat .stat-sub{font-size:1rem;color:var(--muted);font-weight:600}
|
||||||
|
.stat-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:14px}
|
||||||
|
.stat-bar i{display:block;height:100%;border-radius:3px}
|
||||||
|
|
||||||
.filters{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end}
|
.filters{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end}
|
||||||
.filters select,.filters input[type=date]{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem}
|
.filters select,.filters input[type=date]{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem}
|
||||||
.filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px}
|
.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{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)}
|
.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,#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{border-color:var(--accent)}
|
#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{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}
|
.pager button{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 12px;font-size:.85rem;cursor:pointer}
|
||||||
@@ -45,15 +71,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.card .group{color:var(--accent);font-size:.8rem;margin-top:2px}
|
.card .group{color:var(--accent);font-size:.8rem;margin-top:2px}
|
||||||
.card .desc{color:var(--muted);font-size:.85rem;margin-top:8px;line-height:1.4;white-space:pre-wrap}
|
.card .desc{color:var(--muted);font-size:.85rem;margin-top:8px;line-height:1.4;white-space:pre-wrap}
|
||||||
.card .time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
.card .time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
||||||
.entry-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
.entry-files{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:8px}
|
||||||
.entry-files .f-item{display:inline-flex;align-items:center;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:2px}
|
.entry-files .f-item{display:flex;align-items:center;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:2px;min-width:0}
|
||||||
.entry-files .f-item:hover{border-color:var(--accent)}
|
.entry-files .f-item:hover{border-color:var(--accent)}
|
||||||
.entry-files .f-item a{display:inline-flex;align-items:center;gap:4px;color:var(--text);text-decoration:none;padding:2px 6px;font-size:.75rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.entry-files .f-item a{display:inline-flex;align-items:center;gap:4px;color:var(--text);text-decoration:none;padding:2px 6px;font-size:.75rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
|
||||||
.entry-files .f-item a:hover{color:var(--accent)}
|
.entry-files .f-item a:hover{color:var(--accent)}
|
||||||
.entry-files .f-item a::before{content:'📎'}
|
.entry-files .f-item a::before{content:'📎'}
|
||||||
.entry-files .f-detach{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.75rem;line-height:1;padding:2px 5px;border-radius:4px}
|
.entry-files .f-detach{flex-shrink:0;background:none;border:none;color:var(--muted);cursor:pointer;font-size:.75rem;line-height:1;padding:2px 5px;border-radius:4px}
|
||||||
.entry-files .f-detach:hover{color:#ef4444;background:rgba(239,68,68,.1)}
|
.entry-files .f-detach:hover{color:#ef4444;background:rgba(239,68,68,.1)}
|
||||||
.entry-files a{display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);text-decoration:none;border-radius:6px;padding:4px 8px;font-size:.75rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.entry-files a{display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);text-decoration:none;border-radius:6px;padding:3px 8px;font-size:.75rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
||||||
.entry-files a:hover{border-color:var(--accent);color:var(--accent)}
|
.entry-files a:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
.entry-files a::before{content:'📎'}
|
.entry-files a::before{content:'📎'}
|
||||||
.f-actions{display:flex;align-items:center;flex-shrink:0}
|
.f-actions{display:flex;align-items:center;flex-shrink:0}
|
||||||
@@ -71,14 +97,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.view-toggle button:hover{color:var(--text)}
|
.view-toggle button:hover{color:var(--text)}
|
||||||
.view-toggle button.active{background:var(--accent);color:#fff}
|
.view-toggle button.active{background:var(--accent);color:#fff}
|
||||||
.journal-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
.journal-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
||||||
.journal-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column}
|
.journal-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column;position:relative}
|
||||||
.journal-card img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:pointer;display:block;flex-shrink:0}
|
.journal-card img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:pointer;display:block;flex-shrink:0}
|
||||||
|
.group-badge{position:absolute;top:8px;left:8px;z-index:2;max-width:calc(100% - 16px);background:rgba(0,0,0,.55);backdrop-filter:blur(4px);color:#fff;font-size:.72rem;font-weight:600;line-height:1.3;padding:3px 9px;border-radius:999px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.journal-card .no-photo{aspect-ratio:4/3;background:var(--bg);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
.journal-card .no-photo{aspect-ratio:4/3;background:var(--bg);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||||
.journal-card .no-photo svg{width:64%;height:64%;color:#c7cbd0}
|
.journal-card .no-photo svg{width:64%;height:64%;color:#c7cbd0}
|
||||||
.journal-card .no-photo svg *{fill:currentColor}
|
.journal-card .no-photo svg *{fill:currentColor}
|
||||||
.journal-card .body{padding:12px 14px;display:flex;flex-direction:column;gap:6px;flex:1;min-width:0}
|
.journal-card .body{padding:12px 14px;display:flex;flex-direction:column;gap:6px;flex:1;min-width:0}
|
||||||
.journal-card .name{font-weight:600;font-size:.95rem}
|
.journal-card .name{font-weight:600;font-size:.95rem}
|
||||||
.journal-card .group{color:var(--accent);font-size:.8rem}
|
|
||||||
.journal-card .desc{color:var(--muted);font-size:.85rem;line-height:1.4;white-space:pre-wrap;flex:1}
|
.journal-card .desc{color:var(--muted);font-size:.85rem;line-height:1.4;white-space:pre-wrap;flex:1}
|
||||||
.journal-card .foot{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem}
|
.journal-card .foot{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem}
|
||||||
.journal-card .foot .edit,.journal-card .foot .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1rem;padding:4px}
|
.journal-card .foot .edit,.journal-card .foot .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1rem;padding:4px}
|
||||||
@@ -105,6 +131,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.item .name-input:focus{border-color:var(--accent)}
|
.item .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{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 .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{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 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}
|
.attach-list .cur{color:var(--muted);font-size:.75rem;margin-left:auto}
|
||||||
@@ -128,7 +158,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.group-card .name-input:focus{border-color:var(--accent)}
|
.group-card .name-input:focus{border-color:var(--accent)}
|
||||||
.group-card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.2rem;padding:4px;flex-shrink:0}
|
.group-card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.2rem;padding:4px;flex-shrink:0}
|
||||||
.group-card .del:hover{color:#ef4444}
|
.group-card .del:hover{color:#ef4444}
|
||||||
.group-card .group-schedule{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;align-items:start}
|
.group-card .group-schedule{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:8px;align-items:start}
|
||||||
.group-card .group-schedule label{font-size:.75rem;color:var(--muted);display:block;margin-bottom:4px}
|
.group-card .group-schedule label{font-size:.75rem;color:var(--muted);display:block;margin-bottom:4px}
|
||||||
.group-card .group-schedule select,.group-card .group-schedule input[type=time]{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem}
|
.group-card .group-schedule select,.group-card .group-schedule input[type=time]{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem}
|
||||||
.group-card .group-foot{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
.group-card .group-foot{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||||
@@ -136,7 +166,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.group-card .save-sched:hover{opacity:.85}
|
.group-card .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{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)}
|
.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{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 label{font-weight:600;display:block;margin-bottom:4px}
|
||||||
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
|
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
|
||||||
@@ -144,7 +174,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.settings-card .btn-link:hover{opacity:.85;border-color:transparent}
|
.settings-card .btn-link:hover{opacity:.85;border-color:transparent}
|
||||||
.group-card .sched-status{font-size:.8rem;color:var(--muted)}
|
.group-card .sched-status{font-size:.8rem;color:var(--muted)}
|
||||||
@media(max-width:1024px){.group-grid{grid-template-columns:repeat(2,1fr)}}
|
@media(max-width:1024px){.group-grid{grid-template-columns:repeat(2,1fr)}}
|
||||||
@media(max-width:640px){.group-grid{grid-template-columns:1fr}}
|
@media(max-width:640px){
|
||||||
|
.group-grid{grid-template-columns:1fr}
|
||||||
|
.group-card .group-schedule{grid-template-columns:1fr 1fr}
|
||||||
|
}
|
||||||
|
@media(max-width:480px){
|
||||||
|
.group-card .group-schedule{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
|
||||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
|
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
|
||||||
.modal-overlay.open{display:flex}
|
.modal-overlay.open{display:flex}
|
||||||
@@ -164,11 +200,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.edit-modal .actions button:hover{opacity:.85}
|
.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{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}
|
.toast.show{opacity:1}
|
||||||
|
.pf-photos{display:flex;flex-wrap:wrap;gap:10px}
|
||||||
|
.pf-photo{position:relative;width:96px;height:96px;border-radius:10px;overflow:hidden;border:2px solid var(--border);cursor:pointer;flex:0 0 auto}
|
||||||
|
.pf-photo.main{border-color:var(--accent)}
|
||||||
|
.pf-photo img{width:100%;height:100%;object-fit:cover;display:block}
|
||||||
|
.pf-badge{position:absolute;left:4px;bottom:4px;background:var(--accent);color:#fff;font-size:.65rem;font-weight:600;padding:2px 6px;border-radius:6px}
|
||||||
|
.pf-del{position:absolute;top:4px;right:4px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||||
|
.pf-del:hover{background:#ef4444}
|
||||||
|
|
||||||
/* --- Фото группы (хронология) --- */
|
/* --- Фото группы (хронология) --- */
|
||||||
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;cursor:pointer;display:block;background:var(--bg)}
|
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;display:block;background:var(--bg)}
|
||||||
.group-cover:hover{opacity:.9}
|
.group-cover:hover{opacity:.9}
|
||||||
.empty-cover{background:var(--bg);border:1px dashed var(--border);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:1.6rem;cursor:pointer}
|
.empty-cover{background:var(--bg);border:1px dashed var(--border);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:1.6rem}
|
||||||
.empty-cover:hover{border-color:var(--accent);color:var(--accent)}
|
.empty-cover:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
.photo-btn{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer;transition:all .15s}
|
.photo-btn{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer;transition:all .15s}
|
||||||
.photo-btn:hover{border-color:var(--accent);opacity:.85}
|
.photo-btn:hover{border-color:var(--accent);opacity:.85}
|
||||||
@@ -181,15 +224,22 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.gallery-foot .cancel{background:var(--border);color:var(--text);border:none;border-radius:8px;padding:10px 20px;font-size:.9rem;font-weight:600;cursor:pointer;transition:opacity .15s}
|
.gallery-foot .cancel{background:var(--border);color:var(--text);border:none;border-radius:8px;padding:10px 20px;font-size:.9rem;font-weight:600;cursor:pointer;transition:opacity .15s}
|
||||||
.gallery-foot .cancel:hover{opacity:.85}
|
.gallery-foot .cancel:hover{opacity:.85}
|
||||||
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:2px}
|
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:2px}
|
||||||
.photo-item{background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
|
.photo-item{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;cursor:grab}
|
||||||
.photo-item img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:pointer;display:block;transition:opacity .15s}
|
.photo-item.dragging{opacity:.5;border-color:#8b5cf6;box-shadow:0 0 0 2px rgba(139,92,246,.3)}
|
||||||
.photo-item img:hover{opacity:.9}
|
.photo-item.drag-over{border-color:#8b5cf6}
|
||||||
|
.photo-item img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:grab;display:block;transition:opacity .15s}
|
||||||
|
.photo-item:hover img{opacity:.9}
|
||||||
|
.photo-item .drag-hint{position:absolute;top:6px;left:6px;z-index:2;background:rgba(0,0,0,.55);color:#fff;border-radius:6px;padding:2px 7px;font-size:.85rem;line-height:1.2;cursor:grab;user-select:none;pointer-events:none}
|
||||||
.photo-item .p-cap{padding:8px 10px;font-size:.78rem;color:var(--text);min-height:1.2em;white-space:pre-wrap;word-break:break-word}
|
.photo-item .p-cap{padding:8px 10px;font-size:.78rem;color:var(--text);min-height:1.2em;white-space:pre-wrap;word-break:break-word}
|
||||||
.photo-item .p-date{font-size:.72rem;color:var(--muted);padding:0 10px 6px}
|
.photo-item .p-date{font-size:.72rem;color:var(--muted);padding:0 10px 6px}
|
||||||
.photo-item .p-actions{display:flex;justify-content:flex-end;gap:4px;padding:6px 8px;border-top:1px solid var(--border)}
|
.photo-item .p-actions{display:flex;justify-content:flex-end;gap:4px;padding:6px 8px;border-top:1px solid var(--border)}
|
||||||
.photo-item .p-actions button{background:none;border:none;cursor:pointer;font-size:1rem;color:var(--muted);padding:4px}
|
.photo-item .p-actions button{background:none;border:none;cursor:pointer;font-size:1rem;color:var(--muted);padding:4px}
|
||||||
.photo-item .p-actions .p-edit:hover{color:var(--accent)}
|
.photo-item .p-actions .p-edit:hover{color:var(--accent)}
|
||||||
.photo-item .p-actions .p-del:hover{color:#ef4444}
|
.photo-item .p-actions .p-del:hover{color:#ef4444}
|
||||||
|
.photo-item .p-actions .p-cover:hover{color:#8b5cf6}
|
||||||
|
.photo-item .p-actions .p-cover.active{color:#8b5cf6}
|
||||||
|
.photo-item .p-actions .p-cover:disabled{cursor:default;opacity:.75}
|
||||||
|
.photo-item .p-actions .p-cover:disabled:hover{color:var(--muted)}
|
||||||
.edit-modal input[type=file]{padding:8px;cursor:pointer}
|
.edit-modal input[type=file]{padding:8px;cursor:pointer}
|
||||||
.edit-modal input[type=file]::file-selector-button{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 12px;font-size:.8rem;font-weight:600;cursor:pointer;margin-right:10px;transition:border-color .15s}
|
.edit-modal input[type=file]::file-selector-button{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 12px;font-size:.8rem;font-weight:600;cursor:pointer;margin-right:10px;transition:border-color .15s}
|
||||||
.edit-modal input[type=file]::file-selector-button:hover{border-color:var(--accent)}
|
.edit-modal input[type=file]::file-selector-button:hover{border-color:var(--accent)}
|
||||||
@@ -212,7 +262,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
|
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
|
||||||
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
|
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
|
||||||
.page-sub{color:var(--muted);font-size:.85rem}
|
.page-sub{color:var(--muted);font-size:.85rem}
|
||||||
.settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px}
|
.settings-stack{display:flex;flex-direction:column;gap:20px}
|
||||||
|
.link-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}
|
||||||
|
.link-fields > .full{grid-column:1/-1}
|
||||||
|
@media(max-width:600px){.link-fields{grid-template-columns:1fr}}
|
||||||
|
.settings-stack-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;width:100%}
|
||||||
|
@media(max-width:900px){.settings-stack-grid{grid-template-columns:1fr}}
|
||||||
|
.settings-stack-grid > .settings-card{max-width:none;min-width:0;height:100%}
|
||||||
|
.settings-stack-grid > .settings-card.wide{grid-column:1/-1}
|
||||||
.settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px}
|
.settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px}
|
||||||
.settings-card .card-head .ic{width:38px;height:38px;border-radius:11px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0}
|
.settings-card .card-head .ic{width:38px;height:38px;border-radius:11px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0}
|
||||||
.settings-card .card-head h3{font-size:1.05rem;font-weight:700;margin:0}
|
.settings-card .card-head h3{font-size:1.05rem;font-weight:700;margin:0}
|
||||||
@@ -246,12 +303,24 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.save-bar .hint{color:var(--muted);font-size:.85rem;display:inline-flex;align-items:center;gap:8px}
|
.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}}
|
@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-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:20px;align-items:start}
|
||||||
.dash-col{display:flex;flex-direction:column;gap:20px;min-width:0}
|
.dash-col{display:flex;flex-direction:column;gap:20px;min-width:0}
|
||||||
.dash-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:20px;display:flex;flex-direction:column;gap:16px}
|
.dash-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:20px;display:flex;flex-direction:column;gap:16px}
|
||||||
.dash-card h3{font-size:1rem;font-weight:700;display:flex;align-items:center;gap:9px;margin:0}
|
.dash-card h3{font-size:1rem;font-weight:700;display:flex;align-items:center;gap:9px;margin:0}
|
||||||
.dash-card h3 .ic{width:30px;height:30px;border-radius:9px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:.95rem;flex-shrink:0}
|
.dash-card h3 .ic{width:30px;height:30px;border-radius:9px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:.95rem;flex-shrink:0}
|
||||||
|
.dash-card h3 .ic i,.settings-card .card-head .ic i{width:17px;height:17px;flex-shrink:0}
|
||||||
|
.dash-card h3 .ic svg,.settings-card .card-head .ic svg{width:17px;height:17px;flex-shrink:0;color:var(--primary)}
|
||||||
|
.btn-primary svg{width:16px;height:16px;vertical-align:-3px}
|
||||||
|
.save-bar .hint svg{width:15px;height:15px;flex-shrink:0}
|
||||||
.dash-empty{color:var(--muted);font-size:.82rem;padding:8px 0}
|
.dash-empty{color:var(--muted);font-size:.82rem;padding:8px 0}
|
||||||
.dash-link-row{margin-top:2px;text-align:right}
|
.dash-link-row{margin-top:2px;text-align:right}
|
||||||
.dash-link-row a{color:var(--accent);text-decoration:none;font-size:.8rem;font-weight:600}
|
.dash-link-row a{color:var(--accent);text-decoration:none;font-size:.8rem;font-weight:600}
|
||||||
@@ -262,7 +331,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.bar:hover{opacity:1}
|
.bar:hover{opacity:1}
|
||||||
.bar.today{opacity:1}
|
.bar.today{opacity:1}
|
||||||
.bar-day{font-size:.62rem;color:var(--muted);white-space:nowrap}
|
.bar-day{font-size:.62rem;color:var(--muted);white-space:nowrap}
|
||||||
.recent{display:flex;flex-direction:column;gap:2px}
|
.recent{display:grid;grid-template-columns:repeat(2,1fr);gap:2px 16px}
|
||||||
.recent-item{display:flex;align-items:center;gap:12px;padding:8px 6px;border-radius:10px;transition:background .15s}
|
.recent-item{display:flex;align-items:center;gap:12px;padding:8px 6px;border-radius:10px;transition:background .15s}
|
||||||
.recent-item:hover{background:var(--bg)}
|
.recent-item:hover{background:var(--bg)}
|
||||||
.recent-item img{width:44px;height:44px;border-radius:8px;object-fit:cover;flex-shrink:0;cursor:pointer}
|
.recent-item img{width:44px;height:44px;border-radius:8px;object-fit:cover;flex-shrink:0;cursor:pointer}
|
||||||
@@ -280,6 +349,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.actions-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
.actions-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
||||||
.action-btn{display:flex;flex-direction:column;align-items:center;gap:8px;padding:16px 10px;background:var(--bg);border:1px solid var(--border);border-radius:12px;color:var(--text);text-decoration:none;font-size:.8rem;font-weight:600;transition:all .15s}
|
.action-btn{display:flex;flex-direction:column;align-items:center;gap:8px;padding:16px 10px;background:var(--bg);border:1px solid var(--border);border-radius:12px;color:var(--text);text-decoration:none;font-size:.8rem;font-weight:600;transition:all .15s}
|
||||||
.action-btn .aic{font-size:1.3rem}
|
.action-btn .aic{font-size:1.3rem}
|
||||||
|
.action-btn .aic i{width:22px;height:22px}
|
||||||
|
.recent-item .thumb i{width:18px;height:18px}
|
||||||
.action-btn:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-2px)}
|
.action-btn:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-2px)}
|
||||||
.active-list{display:flex;flex-direction:column;gap:8px}
|
.active-list{display:flex;flex-direction:column;gap:8px}
|
||||||
.active-item{display:flex;justify-content:space-between;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:9px 12px}
|
.active-item{display:flex;justify-content:space-between;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:9px 12px}
|
||||||
@@ -296,4 +367,178 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.stat .label a{color:var(--accent);text-decoration:none;font-weight:600}
|
.stat .label a{color:var(--accent);text-decoration:none;font-weight:600}
|
||||||
.stat .label a:hover{text-decoration:underline}
|
.stat .label a:hover{text-decoration:underline}
|
||||||
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
|
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
|
||||||
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}}
|
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}.recent{grid-template-columns:1fr}}
|
||||||
|
/* --- System Info Dashboard --- */
|
||||||
|
.sys-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
|
||||||
|
@media(max-width:480px){.sys-grid{grid-template-columns:1fr;gap:10px}}
|
||||||
|
.sys-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:8px;min-width:0}
|
||||||
|
.sys-card.loading{color:var(--muted);justify-content:center;align-items:center;min-height:80px}
|
||||||
|
.sys-card.err{color:#ef4444}
|
||||||
|
.sys-title{font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||||
|
.sys-item{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:4px 0}
|
||||||
|
.sys-label{font-size:.82rem;color:var(--muted)}
|
||||||
|
.sys-value{font-size:.9rem;font-weight:600;color:var(--text);text-align:right;word-break:break-word}
|
||||||
|
.lucide-inline{display:inline-flex;align-items:center;vertical-align:-2px}
|
||||||
|
.lucide-inline svg{width:14px;height:14px;flex-shrink:0}
|
||||||
|
.sys-details{margin-top:8px;padding-top:8px;border-top:1px solid var(--border)}
|
||||||
|
.sys-details summary{cursor:pointer;font-size:.75rem;color:var(--muted);font-weight:600}
|
||||||
|
.sys-table-list{margin-top:8px;display:flex;flex-direction:column;gap:4px}
|
||||||
|
.sys-table-row{display:flex;justify-content:space-between;font-size:.75rem;padding:2px 0}
|
||||||
|
.sys-table-row span:first-child{color:var(--muted)}
|
||||||
|
.sys-table-row span:last-child{font-weight:600;color:var(--text)}
|
||||||
|
.sys-bar-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px}
|
||||||
|
.sys-bar{height:100%;transition:width .3s,background .3s}
|
||||||
|
.sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px}
|
||||||
|
|
||||||
|
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||||
|
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
|
||||||
|
.audit-table th{text-align:left;color:var(--muted);font-weight:600;padding:8px 10px;border-bottom:1px solid var(--border);font-size:.75rem;text-transform:uppercase;letter-spacing:.03em}
|
||||||
|
.audit-table td{padding:10px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||||
|
.audit-table tr:last-child td{border-bottom:none}
|
||||||
|
.audit-table tr:hover td{background:rgba(37,99,235,.04)}
|
||||||
|
.audit-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto}
|
||||||
|
.audit-details{background:none;border:none;padding:0;margin:0;color:var(--accent);font-size:.8rem;text-align:left;cursor:pointer;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.audit-details:hover{text-decoration:underline}
|
||||||
|
.audit-details-body{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:60vh;overflow:auto}
|
||||||
|
.audit-details-body pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text)}
|
||||||
|
|
||||||
|
/* --- AI Correction --- */
|
||||||
|
.ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px}
|
||||||
|
.ai-btn:hover{color:var(--accent);background:rgba(37,99,235,.1)}
|
||||||
|
.ai-btn:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
#aiSuggestion{font-size:.9rem;line-height:1.5}
|
||||||
|
|
||||||
|
/* --- AI auto-check --- */
|
||||||
|
.ai-badge{display:inline-block;font-size:.68rem;font-weight:600;line-height:1;padding:3px 8px;border-radius:999px;margin-left:6px;vertical-align:middle;background:rgba(37,99,235,.1);color:var(--accent);white-space:nowrap}
|
||||||
|
.ai-badge-ic{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:8px;cursor:help;flex-shrink:0}
|
||||||
|
.ai-badge-ic svg{width:14px;height:14px;display:block}
|
||||||
|
.ai-badge-done{background:rgba(22,163,74,.12);color:#16a34a}
|
||||||
|
.ai-badge-error{background:rgba(239,68,68,.12);color:#ef4444}
|
||||||
|
.ai-badge-skipped,.ai-badge-reverted{background:rgba(148,163,184,.18);color:var(--muted)}
|
||||||
|
.ai-badge-ic.ai-badge-processing .lucide-loader{animation:spin 1s linear infinite}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.ai-details{background:none;border:none;padding:0;margin:0;color:var(--accent);font-size:.8rem;cursor:pointer;white-space:nowrap}
|
||||||
|
.ai-details:hover{text-decoration:underline}
|
||||||
|
.ai-detail-block{margin-top:4px}
|
||||||
|
.ai-detail-block + .ai-detail-block{margin-top:14px}
|
||||||
|
.ai-detail-label{font-size:.75rem;color:var(--muted);margin-bottom:6px;font-weight:600}
|
||||||
|
.ai-detail-block pre{margin:0;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text);max-height:34vh;overflow:auto}
|
||||||
|
.ai-status{margin-top:6px;font-size:.78rem;color:var(--muted)}
|
||||||
|
.ai-queue{margin-top:8px;font-size:.78rem;color:var(--muted)}
|
||||||
|
|
||||||
|
/* --- AI worker monitor --- */
|
||||||
|
.worker-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex-shrink:0;background:#94a3b8}
|
||||||
|
.worker-dot.on{background:#16a34a;animation:wpulse 1.6s infinite}
|
||||||
|
.worker-dot.busy{background:#f59e0b;animation:wpulse 1.2s infinite}
|
||||||
|
.worker-dot.err{background:#ef4444}
|
||||||
|
@keyframes wpulse{0%{box-shadow:0 0 0 0 rgba(22,163,74,.45)}70%{box-shadow:0 0 0 7px rgba(22,163,74,0)}100%{box-shadow:0 0 0 0 rgba(22,163,74,0)}}
|
||||||
|
.worker-state{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600}
|
||||||
|
.worker-counts{display:flex;flex-wrap:wrap;gap:10px;margin-top:16px}
|
||||||
|
.worker-counts .chip{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:7px 14px;font-size:.8rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
|
||||||
|
.worker-counts .chip b{font-weight:700}
|
||||||
|
.worker-counts .chip.pending b{color:#f59e0b}
|
||||||
|
.worker-counts .chip.done b{color:#16a34a}
|
||||||
|
.worker-counts .chip.error b{color:#ef4444}
|
||||||
|
.worker-counts .chip.skipped b{color:var(--muted)}
|
||||||
|
.worker-counts .chip.reverted b{color:var(--accent)}
|
||||||
|
|
||||||
|
@keyframes entryEnter{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
|
||||||
|
@keyframes entryPopIn{0%{opacity:0;transform:translateY(26px) scale(.95)}55%{opacity:1;transform:translateY(-5px) scale(1.015)}100%{opacity:1;transform:none}}
|
||||||
|
@keyframes entryFadeOut{to{opacity:0}}
|
||||||
|
.entry-enter{animation:entryEnter .4s cubic-bezier(.22,1,.36,1) both}
|
||||||
|
.entry-appear{animation:entryPopIn .6s cubic-bezier(.22,1,.36,1) both;position:relative}
|
||||||
|
.entry-appear::before{content:'';position:absolute;inset:-1px;border-radius:inherit;box-shadow:0 0 0 3px rgba(247,201,14,.9),0 10px 30px rgba(247,201,14,.4);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none}
|
||||||
|
.entry-appear::after{content:'';position:absolute;inset:0;border-radius:inherit;background:linear-gradient(180deg,rgba(247,201,14,.16),transparent 55%);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none}
|
||||||
|
@media (prefers-reduced-motion: reduce){.entry-enter,.entry-appear,.entry-appear::before,.entry-appear::after{animation:none}}
|
||||||
|
|
||||||
|
.ai-profiles-list{margin-top:12px;display:flex;flex-direction:column;gap:8px}
|
||||||
|
.ai-profile-item{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--card)}
|
||||||
|
.ai-profile-item.active{border-color:var(--accent);background:rgba(37,99,235,.05)}
|
||||||
|
.ai-profile-item .pi-info{flex:1;min-width:0}
|
||||||
|
.ai-profile-item .pi-name{font-weight:600;font-size:.9rem;display:flex;align-items:center;gap:8px}
|
||||||
|
.ai-profile-item .pi-meta{color:var(--muted);font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.ai-profile-item .pi-actions{display:flex;gap:6px;flex-shrink:0}
|
||||||
|
.pi-btn{background:none;border:1px solid var(--border);border-radius:8px;padding:5px 10px;font-size:.78rem;cursor:pointer;color:var(--text);transition:all .15s}
|
||||||
|
.pi-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
|
.pi-btn.danger:hover{border-color:#ef4444;color:#ef4444}
|
||||||
|
.pi-badge{font-size:.68rem;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(22,163,74,.12);color:#16a34a;white-space:nowrap}
|
||||||
|
|
||||||
|
/* --- Настройки (UX/UI v2) --- */
|
||||||
|
body[data-page=settings] .page-head{align-items:flex-end}
|
||||||
|
.page-status{margin-left:auto;display:inline-flex;align-items:center;gap:8px;font-size:.8rem;color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:6px 14px;white-space:nowrap;transition:opacity .3s}
|
||||||
|
.page-status.loaded{opacity:0;pointer-events:none}
|
||||||
|
.page-status.err{color:#ef4444;opacity:1}
|
||||||
|
.head-btn{display:inline-flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--border);color:var(--muted);border-radius:10px;padding:8px 14px;font-size:.82rem;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap;flex-shrink:0}
|
||||||
|
.head-btn:hover{color:var(--accent);border-color:var(--accent)}
|
||||||
|
.head-btn svg{width:14px;height:14px}
|
||||||
|
.settings-layout{display:grid;grid-template-columns:190px minmax(0,1fr);gap:28px;align-items:start}
|
||||||
|
.settings-nav{position:sticky;top:16px;display:flex;flex-direction:column;gap:2px}
|
||||||
|
.settings-nav a{display:block;padding:9px 13px;border-radius:10px;color:var(--muted);text-decoration:none;font-size:.86rem;font-weight:500;transition:all .15s;white-space:nowrap}
|
||||||
|
.settings-nav a:hover{color:var(--text);background:var(--card)}
|
||||||
|
.settings-nav a.active{color:var(--accent);background:var(--card);font-weight:600;box-shadow:inset 2px 0 0 var(--accent)}
|
||||||
|
.settings-nav a svg{width:15px;height:15px;flex-shrink:0;vertical-align:-3px;margin-right:2px}
|
||||||
|
.settings-layout .settings-stack,.settings-layout .settings-stack-grid{max-width:none}
|
||||||
|
.settings-card[id]{scroll-margin-top:16px}
|
||||||
|
.settings-card{transition:border-color .2s,box-shadow .2s}
|
||||||
|
.settings-card:target{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
|
||||||
|
.head-live{margin-left:auto;font-size:.72rem;color:var(--muted);white-space:nowrap}
|
||||||
|
.toggle{position:relative;display:flex;align-items:center;gap:12px;padding:10px 12px;background:var(--bg);border:1px solid transparent;border-radius:10px;cursor:pointer;transition:border-color .15s,background .15s}
|
||||||
|
.toggle:hover{border-color:var(--border);background:var(--card)}
|
||||||
|
.toggle input{position:absolute;opacity:0;width:1px;height:1px}
|
||||||
|
.toggle-track{position:relative;width:40px;height:22px;border-radius:999px;background:#d4d4d4;flex-shrink:0;transition:background .2s}
|
||||||
|
.toggle-thumb{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);transition:transform .2s}
|
||||||
|
.toggle input:checked ~ .toggle-track{background:var(--accent)}
|
||||||
|
.toggle input:checked ~ .toggle-track .toggle-thumb{transform:translateX(18px)}
|
||||||
|
.toggle input:focus-visible ~ .toggle-track{box-shadow:0 0 0 3px rgba(37,99,235,.25)}
|
||||||
|
.toggle-text{display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||||
|
.toggle-text b{font-size:.87rem;font-weight:600}
|
||||||
|
.toggle-text small{font-size:.74rem;color:var(--muted);line-height:1.4}
|
||||||
|
.settings-field{position:relative}
|
||||||
|
.field-error{color:#ef4444;font-size:.75rem;font-weight:600;min-height:0;display:block}
|
||||||
|
.settings-input.invalid{border-color:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.1)}
|
||||||
|
.settings-input.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.84rem;line-height:1.5;min-height:110px;resize:vertical}
|
||||||
|
.preset-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px}
|
||||||
|
.preset-btn{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:5px 12px;font-size:.75rem;font-weight:600;color:var(--muted);cursor:pointer;transition:all .15s}
|
||||||
|
.preset-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
|
.preset-btn.active{background:rgba(37,99,235,.08);border-color:var(--accent);color:var(--accent)}
|
||||||
|
.toast.error{background:#dc2626}
|
||||||
|
.queue-link{color:var(--accent);text-decoration:none;font-weight:600;font-size:.8rem;white-space:nowrap}
|
||||||
|
.queue-link:hover{text-decoration:underline}
|
||||||
|
.ai-queue{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||||
|
.ai-queue .chip{background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:6px 12px;font-size:.78rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
|
||||||
|
.ai-queue .chip b{color:var(--accent);font-weight:700}
|
||||||
|
.ban-chip{flex-direction:column;align-items:flex-start;gap:2px;padding-right:8px}
|
||||||
|
.chip-sub{font-size:.72rem;color:var(--muted);font-weight:400}
|
||||||
|
.unban-btn{background:none;border:none;color:var(--accent);cursor:pointer;font-weight:700;font-size:.9rem;padding:0 2px;line-height:1}
|
||||||
|
.unban-btn:hover{color:#ef4444}
|
||||||
|
.ban-chip.confirming{background:rgba(239,68,68,.07);border-color:rgba(239,68,68,.4)}
|
||||||
|
.chip-confirm{display:inline-flex;align-items:center;gap:8px;font-size:.74rem;font-weight:600;white-space:nowrap;padding-right:4px}
|
||||||
|
.chip-yes,.chip-no{background:none;border:none;cursor:pointer;font-weight:700;padding:0}
|
||||||
|
.chip-yes{color:#16a34a}
|
||||||
|
.chip-no{color:var(--muted)}
|
||||||
|
.btn-primary.danger-ghost{background:var(--card);border:1px solid rgba(239,68,68,.35);color:#ef4444}
|
||||||
|
.btn-primary.danger-ghost:hover{border-color:#ef4444;opacity:1}
|
||||||
|
.ai-test-status{font-size:.85rem;min-height:1.3em}
|
||||||
|
.ai-test-status .ok{color:#16a34a;font-weight:600}
|
||||||
|
.ai-test-status .fail{color:#ef4444;font-weight:600}
|
||||||
|
.save-bar{animation:saveBarIn .25s cubic-bezier(.22,1,.36,1)}
|
||||||
|
.save-bar.show{border-color:rgba(245,158,11,.5)}
|
||||||
|
.save-bar-actions{display:flex;gap:10px;flex-wrap:wrap}
|
||||||
|
@keyframes saveBarIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
|
||||||
|
.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--muted);cursor:pointer;transition:all .15s}
|
||||||
|
.icon-btn:hover{color:var(--accent);border-color:var(--accent)}
|
||||||
|
.icon-btn svg{width:14px;height:14px}
|
||||||
|
.icon-btn.spinning svg{animation:spin 1s linear infinite}
|
||||||
|
.sys-card.warn{border-color:rgba(245,158,11,.5);background:rgba(245,158,11,.05)}
|
||||||
|
.sys-card.danger{border-color:rgba(239,68,68,.5);background:rgba(239,68,68,.05)}
|
||||||
|
@media(max-width:1024px){
|
||||||
|
.settings-layout{grid-template-columns:1fr;gap:14px}
|
||||||
|
.settings-nav{position:sticky;top:0;z-index:40;flex-direction:row;gap:6px;overflow-x:auto;background:var(--bg);padding:8px 0;scrollbar-width:none}
|
||||||
|
.settings-nav::-webkit-scrollbar{display:none}
|
||||||
|
.settings-nav a{border:1px solid var(--border);background:var(--card);border-radius:999px;padding:7px 14px;font-size:.8rem}
|
||||||
|
.settings-nav a.active{box-shadow:none;border-color:var(--accent)}
|
||||||
|
.settings-card[id]{scroll-margin-top:60px}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Админ-панель</title>
|
<title>Админ-панель</title>
|
||||||
<script>location.replace('dashboard.html')</script>
|
<meta http-equiv="refresh" content="0;url=dashboard.html">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+129
-26
@@ -1,52 +1,137 @@
|
|||||||
const API = location.origin;
|
const API = location.origin;
|
||||||
let token = sessionStorage.getItem('adminToken');
|
let token = sessionStorage.getItem('authToken');
|
||||||
function hdr() { return { 'X-Admin-Token': token }; }
|
let currentUser = null;
|
||||||
function hdrJson() { return { 'Content-Type': 'application/json', 'X-Admin-Token': token }; }
|
function hdr() { return { 'X-Auth-Token': token }; }
|
||||||
|
function hdrJson() { return { 'Content-Type': 'application/json', 'X-Auth-Token': token }; }
|
||||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
function thumbSrc(p) {
|
||||||
|
if (!p) return p;
|
||||||
|
if (p.startsWith('/uploads/')) return `${API}/uploads/thumb/${p.slice('/uploads/'.length)}`;
|
||||||
|
if (p.includes('/api/files/')) return p + (p.includes('?') ? '&' : '?') + 'thumb=1';
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Auth check ---
|
// --- Auth check ---
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
if (!token) { location.href = 'login.html'; return false; }
|
if (!token) { location.href = 'login.html'; return false; }
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/stats`, { headers: hdr() });
|
const res = await fetch(`${API}/api/auth/me`, { headers: hdr() });
|
||||||
if (!res.ok) { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; }
|
if (!res.ok) { cleanupSession(); return false; }
|
||||||
|
currentUser = await res.json();
|
||||||
return true;
|
return true;
|
||||||
} catch { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; }
|
} catch { cleanupSession(); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function doLogout() {
|
function cleanupSession() {
|
||||||
sessionStorage.removeItem('adminToken');
|
sessionStorage.removeItem('authToken');
|
||||||
|
token = null;
|
||||||
|
currentUser = null;
|
||||||
location.href = 'login.html';
|
location.href = 'login.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAdmin() { return currentUser && currentUser.role === 'admin'; }
|
||||||
|
|
||||||
|
async function requireAdminPage() {
|
||||||
|
if (!(await checkAuth())) return false;
|
||||||
|
if (!isAdmin()) { location.href = 'journal.html'; return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
try { await fetch(`${API}/api/auth/logout`, { method: 'POST', headers: hdr() }); } catch {}
|
||||||
|
cleanupSession();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Sidebar ---
|
// --- Sidebar ---
|
||||||
const NAV = [
|
|
||||||
{ page: 'dashboard', label: 'Дашборд' },
|
|
||||||
{ page: 'journal', label: 'Журнал' },
|
|
||||||
{ page: 'trash', label: 'Корзина' },
|
|
||||||
{ page: 'files', label: 'Файлы' },
|
|
||||||
{ page: 'links', label: 'Ссылки' },
|
|
||||||
{ page: 'students', label: 'Ученики' },
|
|
||||||
{ page: 'groups', label: 'Группы' },
|
|
||||||
{ page: 'settings', label: 'Настройки' }
|
|
||||||
];
|
|
||||||
function buildSidebar(active) {
|
function buildSidebar(active) {
|
||||||
const el = document.getElementById('sidebar');
|
const el = document.getElementById('sidebar');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
const base = [
|
||||||
|
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
||||||
|
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
||||||
|
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
||||||
|
{ page: 'groups', label: 'Группы', icon: 'users' },
|
||||||
|
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
||||||
|
{ page: 'links', label: 'Ссылки', icon: 'link' },
|
||||||
|
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
||||||
|
];
|
||||||
|
const adminNav = [
|
||||||
|
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
|
||||||
|
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
|
||||||
|
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
|
||||||
|
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
|
||||||
|
{ page: 'audit', label: 'Аудит', icon: 'scroll-text' },
|
||||||
|
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
||||||
|
{ page: 'settings', label: 'Настройки', icon: 'settings' },
|
||||||
|
];
|
||||||
|
const nav = [...base, ...(isAdmin() ? adminNav : [])];
|
||||||
|
const userName = currentUser?.name || currentUser?.username || '';
|
||||||
|
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="logo">Админ-панель</div>
|
<div class="logo">
|
||||||
|
<span class="logo-ic" id="sidebarLogo"></span>
|
||||||
|
<span id="sidebarName">Админ-панель</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-box">
|
||||||
|
<div class="user-name">${esc(userName)}</div>
|
||||||
|
<div class="user-role">${roleLabel}</div>
|
||||||
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
${NAV.map(n => `<a href="${n.page}.html" class="${n.page === active ? 'active' : ''}">${n.label}</a>`).join('')}
|
${nav.map(n => `<a href="${n.page}.html" data-nav="${n.page}" class="${n.page === active ? 'active' : ''}"><i data-lucide="${n.icon}"></i>${n.label}</a>`).join('')}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
<a href="/">На главную</a>
|
<div class="version" id="sidebarVersion"></div>
|
||||||
<a class="logout" onclick="doLogout()">Выйти</a>
|
<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>`;
|
</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 ---
|
// --- Common UI ---
|
||||||
|
function renderIcons() {
|
||||||
|
if (window.lucide && typeof window.lucide.createIcons === 'function') {
|
||||||
|
window.lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showToast(msg) {
|
function showToast(msg) {
|
||||||
const t = document.getElementById('toast');
|
const t = document.getElementById('toast');
|
||||||
|
if (!t) return;
|
||||||
t.textContent = msg;
|
t.textContent = msg;
|
||||||
t.classList.add('show');
|
t.classList.add('show');
|
||||||
setTimeout(() => t.classList.remove('show'), 3000);
|
setTimeout(() => t.classList.remove('show'), 3000);
|
||||||
@@ -59,7 +144,7 @@ function showImg(src) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fillAutocomplete() {
|
async function fillAutocomplete() {
|
||||||
const res = await fetch(`${API}/api/students`);
|
const res = await fetch(`${API}/api/students`, { headers: hdr() });
|
||||||
const students = await res.json();
|
const students = await res.json();
|
||||||
const names = students.map(s => s.name);
|
const names = students.map(s => s.name);
|
||||||
const dlEdit = document.getElementById('studentEditList');
|
const dlEdit = document.getElementById('studentEditList');
|
||||||
@@ -135,14 +220,32 @@ function copyText(text) {
|
|||||||
function renderPager(el, page, totalPages, onPage) {
|
function renderPager(el, page, totalPages, onPage) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (totalPages <= 1) { el.innerHTML = ''; return; }
|
if (totalPages <= 1) { el.innerHTML = ''; return; }
|
||||||
let html = `<button ${page === 1 ? 'disabled' : ''} onclick="(${onPage})(${page - 1})">‹</button>`;
|
let html = `<button ${page === 1 ? 'disabled' : ''} data-nav="-1">‹</button>`;
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
if (totalPages > 9 && i > 2 && i < totalPages - 1 && Math.abs(i - page) > 2) {
|
if (totalPages > 9 && i > 2 && i < totalPages - 1 && Math.abs(i - page) > 2) {
|
||||||
if (i === 3) html += `<button disabled>…</button>`;
|
if (i === 3) html += `<button disabled>…</button>`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
html += `<button class="${i === page ? 'active' : ''}" onclick="(${onPage})(${i})">${i}</button>`;
|
html += `<button class="${i === page ? 'active' : ''}" data-page="${i}">${i}</button>`;
|
||||||
}
|
}
|
||||||
html += `<button ${page === totalPages ? 'disabled' : ''} onclick="(${onPage})(${page + 1})">›</button>`;
|
html += `<button ${page === totalPages ? 'disabled' : ''} data-nav="1">›</button>`;
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
|
el._onPage = onPage;
|
||||||
|
el._page = page;
|
||||||
|
if (!el._bound) {
|
||||||
|
el._bound = true;
|
||||||
|
el.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('button');
|
||||||
|
if (!btn || btn.disabled) return;
|
||||||
|
const p = btn.dataset.page ? parseInt(btn.dataset.page, 10) : el._page + parseInt(btn.dataset.nav, 10);
|
||||||
|
if (el._onPage) el._onPage(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const imgEl = e.target.closest('[data-img]');
|
||||||
|
if (imgEl) { e.preventDefault(); showImg(imgEl.dataset.img); return; }
|
||||||
|
const out = e.target.closest('[data-action="logout"]');
|
||||||
|
if (out) { e.preventDefault(); doLogout(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Аудит — Админ-панель</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="audit">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Аудит-журнал</h2>
|
||||||
|
<p class="page-sub">Действия администратора</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-row">
|
||||||
|
<select id="auditActionFilter" class="settings-input" style="max-width:280px">
|
||||||
|
<option value="">Все действия</option>
|
||||||
|
</select>
|
||||||
|
<input type="search" id="auditFilter" class="settings-input" placeholder="Фильтр по действию..." style="max-width:300px">
|
||||||
|
<button class="btn-link" id="auditRefreshBtn" type="button" title="Обновить">Обновить</button>
|
||||||
|
<span class="hint" id="auditCount"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audit-wrap" style="margin-top:14px">
|
||||||
|
<table class="audit-table" id="auditTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
<th>Детали</th>
|
||||||
|
<th>IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="auditEmpty" style="display:none">Записей пока нет.</div>
|
||||||
|
</div>
|
||||||
|
<div class="pager" id="auditPager"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-overlay" id="auditDetailsModal">
|
||||||
|
<div class="edit-modal" style="max-width:640px">
|
||||||
|
<h3 id="auditDetailsTitle">Детали действия</h3>
|
||||||
|
<div class="audit-details-body"><pre id="auditDetailsBody"></pre></div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="cancel" id="auditDetailsCloseBtn">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/audit.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Блокировки IP — Админ-панель</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="bans">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Блокировки IP</h2>
|
||||||
|
<p class="page-sub">Автобан на 24 часа за антиспам и брутфорс. Здесь можно разбанить или добавить вручную</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-row" style="margin-bottom:16px">
|
||||||
|
<button class="btn-primary" id="addBanBtn" type="button">
|
||||||
|
<span style="font-size:1.2rem">+</span> Забанить IP вручную
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audit-wrap">
|
||||||
|
<table class="audit-table" id="bansTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Причина</th>
|
||||||
|
<th>Заблокирован</th>
|
||||||
|
<th>Разблокируется</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="bansBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="bansEmpty" style="display:none">Активных блокировок нет</div>
|
||||||
|
</div>
|
||||||
|
<div class="pager" id="pager"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="banModal">
|
||||||
|
<div class="edit-modal" style="max-width:440px">
|
||||||
|
<h3>Заблокировать IP</h3>
|
||||||
|
<div class="settings-stack" style="gap:12px;max-width:none">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>IP-адрес <span style="color:#ef4444">*</span></label>
|
||||||
|
<input type="text" id="bIp" class="settings-input" placeholder="например: 185.220.101.34" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Причина</label>
|
||||||
|
<input type="text" id="bReason" class="settings-input" placeholder="например: Спам с этого адреса" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Срок (часов, 1–720)</label>
|
||||||
|
<input type="number" id="bHours" class="settings-input" min="1" max="720" value="24">
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
|
||||||
|
<button type="button" class="btn-primary ghost" id="bCancel">Отмена</button>
|
||||||
|
<button type="button" class="btn-primary" id="bSave">Заблокировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/bans.js"></script>
|
||||||
|
<style>
|
||||||
|
.reason-badge{font-size:.7rem;font-weight:600;padding:2px 10px;border-radius:999px;white-space:nowrap}
|
||||||
|
.reason-honeypot{color:#ea580c;background:rgba(234,88,12,.12)}
|
||||||
|
.reason-login-bruteforce,.reason-share-password-bruteforce{color:#7c3aed;background:rgba(124,58,237,.12)}
|
||||||
|
.reason-manual{color:#ef4444;background:rgba(239,68,68,.12)}
|
||||||
|
.row-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px 8px;border-radius:6px}
|
||||||
|
.row-btn:hover{background:var(--bg);color:var(--accent)}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Филиалы — Админ-панель</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="branches">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Филиалы</h2>
|
||||||
|
<p class="page-sub">Управление филиалами и привязка групп</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-row" style="margin-bottom:16px">
|
||||||
|
<button class="btn-primary" id="addBranchBtn" type="button">
|
||||||
|
<span style="font-size:1.2rem">+</span> Добавить филиал
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="branches-grid" id="branchesGrid">
|
||||||
|
<div class="empty">Загрузка…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Branch Modal -->
|
||||||
|
<div class="modal-overlay" id="branchModal">
|
||||||
|
<div class="edit-modal" style="max-width:480px">
|
||||||
|
<h3 id="branchModalTitle">Создать филиал</h3>
|
||||||
|
<form id="branchForm" class="settings-stack" style="gap:12px">
|
||||||
|
<input type="hidden" name="id" id="branchId">
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Название <span style="color:#ef4444">*</span></label>
|
||||||
|
<input type="text" name="name" class="settings-input" required placeholder="Например: Центральный офис" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Адрес</label>
|
||||||
|
<input type="text" name="address" class="settings-input" placeholder="Улица, дом, офис">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Телефон</label>
|
||||||
|
<input type="text" name="phone" class="settings-input" placeholder="+7 (XXX) XXX-XX-XX">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="branchCancelBtn">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary" id="branchSubmitBtn">Создать</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="imgModal">
|
||||||
|
<img id="imgModalSrc" alt="">
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/branches.js"></script>
|
||||||
|
<style>
|
||||||
|
.branches-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.branch-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
transition: box-shadow .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.branch-card:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,.08);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.branch-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.branch-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .95rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.branch-badge {
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(37,99,235,.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.branch-meta {
|
||||||
|
font-size: .78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.branch-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.branch-actions button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.branch-actions button:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.branch-actions .branch-delete:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239,68,68,.1);
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.branches-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+17
-123
@@ -19,17 +19,17 @@
|
|||||||
<div class="dash-grid">
|
<div class="dash-grid">
|
||||||
<div class="dash-col">
|
<div class="dash-col">
|
||||||
<div class="dash-card">
|
<div class="dash-card">
|
||||||
<h3><span class="ic">📊</span> Динамика записей (14 дней)</h3>
|
<h3><span class="ic"><i data-lucide="bar-chart-3"></i></span> Динамика записей (14 дней)</h3>
|
||||||
<div class="chart" id="activityChart" title=""></div>
|
<div class="chart" id="activityChart" title=""></div>
|
||||||
<div class="dash-link-row"><a href="journal.html">Открыть журнал →</a></div>
|
<div class="dash-link-row"><a href="journal.html">Открыть журнал →</a></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-card">
|
<div class="dash-card">
|
||||||
<h3><span class="ic">🕒</span> Последние записи</h3>
|
<h3><span class="ic"><i data-lucide="clock"></i></span> Последние записи</h3>
|
||||||
<div class="recent" id="recentList"></div>
|
<div class="recent" id="recentList"></div>
|
||||||
<div class="dash-link-row"><a href="journal.html">Все записи →</a></div>
|
<div class="dash-link-row"><a href="journal.html">Все записи →</a></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-card">
|
<div class="dash-card">
|
||||||
<h3><span class="ic">📸</span> Фото групп</h3>
|
<h3><span class="ic"><i data-lucide="camera"></i></span> Фото групп</h3>
|
||||||
<div class="photos-grid-dash" id="dashPhotos"></div>
|
<div class="photos-grid-dash" id="dashPhotos"></div>
|
||||||
<div class="dash-link-row"><a href="groups.html">Фото групп →</a></div>
|
<div class="dash-link-row"><a href="groups.html">Фото групп →</a></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,22 +37,24 @@
|
|||||||
|
|
||||||
<div class="dash-col">
|
<div class="dash-col">
|
||||||
<div class="dash-card">
|
<div class="dash-card">
|
||||||
<h3><span class="ic">⚡</span> Быстрые действия</h3>
|
<h3><span class="ic"><i data-lucide="zap"></i></span> Быстрые действия</h3>
|
||||||
<div class="actions-grid">
|
<div class="actions-grid">
|
||||||
<a class="action-btn" href="journal.html"><span class="aic">📖</span>Журнал</a>
|
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
|
||||||
<a class="action-btn" href="files.html"><span class="aic">📎</span>Файлы</a>
|
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
|
||||||
<a class="action-btn" href="groups.html"><span class="aic">👥</span>Группы</a>
|
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
|
||||||
<a class="action-btn" href="links.html"><span class="aic">🔗</span>Ссылки</a>
|
<a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a>
|
||||||
<a class="action-btn" href="trash.html"><span class="aic">🗑️</span>Корзина</a>
|
<a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a>
|
||||||
<a class="action-btn" href="settings.html"><span class="aic">🛡️</span>Бэкап</a>
|
<a class="action-btn" href="worker.html"><span class="aic"><i data-lucide="bot"></i></span>Воркер ИИ</a>
|
||||||
|
<a class="action-btn" href="settings.html"><span class="aic"><i data-lucide="shield"></i></span>Бэкап</a>
|
||||||
|
<a class="action-btn" href="bans.html"><span class="aic"><i data-lucide="ban"></i></span>Блокировки</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-card">
|
<div class="dash-card">
|
||||||
<h3><span class="ic">🟢</span> Активные сейчас группы</h3>
|
<h3><span class="ic"><i data-lucide="activity"></i></span> Активные сейчас группы</h3>
|
||||||
<div class="active-list" id="activeList"></div>
|
<div class="active-list" id="activeList"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-card">
|
<div class="dash-card">
|
||||||
<h3><span class="ic">🏆</span> Топ учеников</h3>
|
<h3><span class="ic"><i data-lucide="trophy"></i></span> Топ учеников</h3>
|
||||||
<div class="top-list" id="topList"></div>
|
<div class="top-list" id="topList"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,120 +62,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc" alt="">
|
<img id="imgModalSrc" alt="">
|
||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/dashboard.js"></script>
|
||||||
function plural(n, one, few, many) {
|
|
||||||
n = Math.abs(n) % 100; const n1 = n % 10;
|
|
||||||
if (n > 10 && n < 20) return many;
|
|
||||||
if (n1 > 1 && n1 < 5) return few;
|
|
||||||
if (n1 === 1) return one;
|
|
||||||
return many;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStats(s) {
|
|
||||||
document.getElementById('stats').innerHTML = `
|
|
||||||
<div class="stat"><div class="num">${s.entries}</div><div class="label">Всего записей</div></div>
|
|
||||||
<div class="stat"><div class="num">${s.students}</div><div class="label">Учеников</div></div>
|
|
||||||
<div class="stat"><div class="num">${s.groups}</div><div class="label">Групп</div></div>
|
|
||||||
<div class="stat"><div class="num">${s.today}</div><div class="label">Сегодня</div></div>
|
|
||||||
<div class="stat"><div class="num" style="color:${s.trash ? '#ef4444' : 'var(--muted)'}">${s.trash}</div><div class="label"><a href="trash.html">В корзине →</a></div></div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function last14days() {
|
|
||||||
const out = [];
|
|
||||||
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActivity(activity) {
|
|
||||||
const map = {};
|
|
||||||
activity.forEach(a => { map[a.d] = a.n; });
|
|
||||||
const days = last14days().map(d => {
|
|
||||||
const iso = d.toISOString().slice(0, 10);
|
|
||||||
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
|
|
||||||
});
|
|
||||||
const max = Math.max(1, ...days.map(x => x.n));
|
|
||||||
const todayIso = new Date().toISOString().slice(0, 10);
|
|
||||||
document.getElementById('activityChart').innerHTML = days.map(x => `
|
|
||||||
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
|
|
||||||
<div class="bar ${x.iso === todayIso ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
|
|
||||||
<div class="bar-day">${x.label}</div>
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRecent(rows) {
|
|
||||||
const el = document.getElementById('recentList');
|
|
||||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Записей пока нет</div>'; return; }
|
|
||||||
el.innerHTML = rows.map(e => `
|
|
||||||
<div class="recent-item">
|
|
||||||
${e.photo_path
|
|
||||||
? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">`
|
|
||||||
: `<div class="thumb">🎓</div>`}
|
|
||||||
<div class="r-info">
|
|
||||||
<div class="r-name">${esc(e.student_name)}</div>
|
|
||||||
<div class="r-meta">${esc(e.group_name)} · ${new Date(e.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
|
|
||||||
</div>
|
|
||||||
<a class="r-link" href="journal.html" title="В журнал">→</a>
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActive(rows) {
|
|
||||||
const el = document.getElementById('activeList');
|
|
||||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Сейчас нет идущих групп</div>'; return; }
|
|
||||||
el.innerHTML = rows.map(g => `
|
|
||||||
<div class="active-item">
|
|
||||||
<span class="a-name">${esc(g.name)}</span>
|
|
||||||
<span class="a-right">
|
|
||||||
<span class="a-time">${g.day_label} ${g.time_start}–${g.time_end}</span>
|
|
||||||
<span class="a-now">сейчас</span>
|
|
||||||
</span>
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTop(rows) {
|
|
||||||
const el = document.getElementById('topList');
|
|
||||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Пока нет данных</div>'; return; }
|
|
||||||
el.innerHTML = rows.map((x, i) => `
|
|
||||||
<div class="top-item">
|
|
||||||
<span class="top-rank ${i > 2 ? 'alt' : ''}">${i + 1}</span>
|
|
||||||
<span class="top-name">${esc(x.student_name)}</span>
|
|
||||||
<span class="top-count">${x.n}</span>
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPhotos(rows) {
|
|
||||||
const el = document.getElementById('dashPhotos');
|
|
||||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Фото групп пока нет</div>'; return; }
|
|
||||||
el.innerHTML = rows.map(p => `
|
|
||||||
<div class="dash-photo" title="${esc(p.group_name)}">
|
|
||||||
<img src="${API}${p.photo_path}" onclick="showImg('${API}${p.photo_path}')" alt="">
|
|
||||||
${p.caption ? `<div class="pcap">${esc(p.caption)}</div>` : ''}
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDashboard() {
|
|
||||||
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
|
|
||||||
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
|
|
||||||
const d = await res.json();
|
|
||||||
renderStats(d.stats);
|
|
||||||
renderActivity(d.activity);
|
|
||||||
renderRecent(d.recent_entries);
|
|
||||||
renderActive(d.active_groups);
|
|
||||||
renderTop(d.top_students);
|
|
||||||
renderPhotos(d.photos);
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (await checkAuth()) {
|
|
||||||
buildSidebar(document.body.dataset.page);
|
|
||||||
loadDashboard();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+3
-18
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Ошибка — WhatIDo</title>
|
<title>Ошибка — WhatIDo</title>
|
||||||
<link rel="stylesheet" href="admin.css">
|
<link rel="stylesheet" href="/admin.css">
|
||||||
<style>
|
<style>
|
||||||
.error-page{display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:24px;text-align:center}
|
.error-page{display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:24px;text-align:center}
|
||||||
.error-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:48px 40px;max-width:400px;width:100%}
|
.error-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:48px 40px;max-width:400px;width:100%}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<p class="error-message" id="errorMessage">Запрашиваемая страница не существует или была перемещена.</p>
|
<p class="error-message" id="errorMessage">Запрашиваемая страница не существует или была перемещена.</p>
|
||||||
<div class="error-actions">
|
<div class="error-actions">
|
||||||
<a href="/" class="ghost">На главную</a>
|
<a href="/" class="ghost">На главную</a>
|
||||||
<button onclick="history.back()">Назад</button>
|
<button type="button" id="backBtn">Назад</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="error-details" id="errorDetails" style="display:none">
|
<div class="error-details" id="errorDetails" style="display:none">
|
||||||
<strong>Детали:</strong>
|
<strong>Детали:</strong>
|
||||||
@@ -37,21 +37,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script src="/js/error.js"></script>
|
||||||
(function(){
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const code = params.get('code') || '404';
|
|
||||||
const title = params.get('title') || '';
|
|
||||||
const message = params.get('message') || '';
|
|
||||||
const details = params.get('details') || '';
|
|
||||||
document.getElementById('errorCode').textContent = code;
|
|
||||||
document.getElementById('errorTitle').textContent = title || (code === '404' ? 'Страница не найдена' : 'Ошибка');
|
|
||||||
document.getElementById('errorMessage').textContent = message || (code === '404' ? 'Запрашиваемая страница не существует или была перемещена.' : 'Произошла ошибка на сервере.');
|
|
||||||
if (details) {
|
|
||||||
document.getElementById('errorDetails').style.display = 'block';
|
|
||||||
document.getElementById('errorStack').textContent = details;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+5
-164
@@ -25,8 +25,8 @@
|
|||||||
<div><label>По</label><input type="date" id="dateTo"></div>
|
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||||
<div><label>Тип</label>
|
<div><label>Тип</label>
|
||||||
<div class="view-toggle" id="tabToggle">
|
<div class="view-toggle" id="tabToggle">
|
||||||
<button data-mode="attached" onclick="setMode('attached')">Прикреплённые</button>
|
<button data-mode="attached" type="button">Прикреплённые</button>
|
||||||
<button data-mode="detached" onclick="setMode('detached')">Откреплённые</button>
|
<button data-mode="detached" type="button" id="detachedTab">Откреплённые</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,172 +35,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/files.js"></script>
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
let page = 1;
|
|
||||||
let totalFiles = 0;
|
|
||||||
let currentFiles = [];
|
|
||||||
let groupsLoaded = false;
|
|
||||||
let studentsLoaded = false;
|
|
||||||
let searchTimer = null;
|
|
||||||
let mode = 'attached';
|
|
||||||
|
|
||||||
function setMode(m) {
|
|
||||||
mode = m === 'detached' ? 'detached' : 'attached';
|
|
||||||
document.querySelectorAll('#tabToggle button').forEach(b => {
|
|
||||||
b.classList.toggle('active', b.dataset.mode === mode);
|
|
||||||
});
|
|
||||||
const show = mode === 'attached';
|
|
||||||
const keep = ['searchInput', 'studentFilter', 'groupFilter', 'dateFrom', 'dateTo'].map(id => document.getElementById(id));
|
|
||||||
if (!show) { keep.forEach(el => { el.value = ''; }); }
|
|
||||||
keep.forEach(el => { el.parentElement.style.display = show ? '' : 'none'; });
|
|
||||||
page = 1;
|
|
||||||
loadFiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImageFile(name) {
|
|
||||||
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanSize(b) {
|
|
||||||
if (!b) return '';
|
|
||||||
if (b < 1024) return b + ' Б';
|
|
||||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
|
||||||
return (b / 1048576).toFixed(1) + ' МБ';
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileCardHTML(f) {
|
|
||||||
const url = `${API}/api/files/${f.token}`;
|
|
||||||
const thumb = isImageFile(f.name)
|
|
||||||
? `<img src="${url}" onclick="showImg('${url}')" alt="">`
|
|
||||||
: `<div class="file-icon">📄</div>`;
|
|
||||||
const target = isImageFile(f.name)
|
|
||||||
? `href="#" onclick="showImg('${url}');return false;"`
|
|
||||||
: `href="${url}" download`;
|
|
||||||
const meta = mode === 'detached'
|
|
||||||
? `<div class="group" style="color:#f59e0b">Откреплён от записи, не привязан</div>`
|
|
||||||
: `<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>`;
|
|
||||||
const action = mode === 'detached'
|
|
||||||
? `<button class="f-action f-del" onclick="deleteFile(${f.id})" title="Удалить файл навсегда">Удалить</button>`
|
|
||||||
: `<button class="f-action" onclick="detachFile(${f.id})" title="Открепить файл от записи">Открепить</button>`;
|
|
||||||
return `
|
|
||||||
<div class="card">
|
|
||||||
${thumb}
|
|
||||||
<div class="info">
|
|
||||||
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
|
|
||||||
${meta}
|
|
||||||
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
|
|
||||||
</div>
|
|
||||||
<div class="f-actions">${action}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGrid() {
|
|
||||||
const grid = document.getElementById('grid');
|
|
||||||
if (!currentFiles.length) {
|
|
||||||
grid.className = 'grid';
|
|
||||||
grid.innerHTML = mode === 'detached'
|
|
||||||
? '<div class="empty">Откреплённых файлов нет</div>'
|
|
||||||
: '<div class="empty">Файлов не найдено</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
grid.className = 'grid';
|
|
||||||
grid.innerHTML = currentFiles.map(fileCardHTML).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detachFile(id) {
|
|
||||||
if (!confirm('Открепить файл от записи? Он переместится во вкладку «Откреплённые».')) return;
|
|
||||||
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
|
|
||||||
if (res.ok) { showToast('Файл откреплён'); loadFiles(); }
|
|
||||||
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFile(id) {
|
|
||||||
if (!confirm('Удалить файл навсегда? Отменить это действие нельзя.')) return;
|
|
||||||
const res = await fetch(`${API}/api/files/${id}`, { method: 'DELETE', headers: hdr() });
|
|
||||||
if (res.ok) { showToast('Файл удалён'); loadFiles(); }
|
|
||||||
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGroupOptions() {
|
|
||||||
if (groupsLoaded) return;
|
|
||||||
const sel = document.getElementById('groupFilter');
|
|
||||||
while (sel.options.length > 1) sel.remove(1);
|
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
|
||||||
const groups = await res.json();
|
|
||||||
groups.forEach(g => {
|
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = g.id; o.textContent = g.name;
|
|
||||||
sel.appendChild(o);
|
|
||||||
});
|
|
||||||
groupsLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStudentOptions() {
|
|
||||||
if (studentsLoaded) return;
|
|
||||||
const dl = document.getElementById('studentFilterList');
|
|
||||||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
|
||||||
const names = await res.json();
|
|
||||||
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
|
||||||
studentsLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFiles() {
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
if (mode === 'attached') {
|
|
||||||
const q = document.getElementById('searchInput').value;
|
|
||||||
const sn = document.getElementById('studentFilter').value;
|
|
||||||
const gid = document.getElementById('groupFilter').value;
|
|
||||||
const df = document.getElementById('dateFrom').value;
|
|
||||||
const dt = document.getElementById('dateTo').value;
|
|
||||||
if (q) p.set('search', q);
|
|
||||||
if (sn) p.set('student_name', sn);
|
|
||||||
if (gid) p.set('group_id', gid);
|
|
||||||
if (df) p.set('date_from', df);
|
|
||||||
if (dt) p.set('date_to', dt);
|
|
||||||
}
|
|
||||||
p.set('limit', PAGE_SIZE);
|
|
||||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
|
||||||
const res = await fetch(`${API}/api/files${mode === 'detached' ? '/detached' : ''}?${p}`, { headers: hdr() });
|
|
||||||
const data = await res.json();
|
|
||||||
currentFiles = data.files;
|
|
||||||
totalFiles = data.total;
|
|
||||||
renderGrid();
|
|
||||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalFiles / PAGE_SIZE)), goPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goPage(p) {
|
|
||||||
page = p;
|
|
||||||
loadFiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadFiles(); });
|
|
||||||
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadFiles(); });
|
|
||||||
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadFiles(); });
|
|
||||||
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadFiles(); });
|
|
||||||
document.getElementById('searchInput').addEventListener('input', () => {
|
|
||||||
clearTimeout(searchTimer);
|
|
||||||
searchTimer = setTimeout(() => { page = 1; loadFiles(); }, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (await checkAuth()) {
|
|
||||||
buildSidebar(document.body.dataset.page);
|
|
||||||
document.querySelectorAll('#tabToggle button').forEach(b => {
|
|
||||||
b.classList.toggle('active', b.dataset.mode === mode);
|
|
||||||
});
|
|
||||||
loadGroupOptions();
|
|
||||||
loadStudentOptions();
|
|
||||||
loadFiles();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+33
-280
@@ -11,316 +11,69 @@
|
|||||||
<div class="sidebar" id="sidebar"></div>
|
<div class="sidebar" id="sidebar"></div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<h2>Группы</h2>
|
<h2>Группы</h2>
|
||||||
<div class="add-row">
|
<div class="add-row" style="align-items:center;gap:8px">
|
||||||
<input type="text" id="newGroup" placeholder="Название группы">
|
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
|
||||||
<button onclick="addGroup()">Добавить</button>
|
<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>
|
||||||
<div class="list" id="groupList"></div>
|
<div class="list" id="groupList"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="photosModal" onclick="if(event.target===this)closePhotos()">
|
<div class="modal-overlay" id="photosModal">
|
||||||
<div class="gallery-modal">
|
<div class="gallery-modal">
|
||||||
<div class="gallery-head">
|
<div class="gallery-head">
|
||||||
<h3 id="photosTitle">Фото</h3>
|
<h3 id="photosTitle">Фото</h3>
|
||||||
<button class="upload-btn" onclick="openAddPhoto()">Загрузить фото</button>
|
<button class="upload-btn" type="button" id="openPhotoBtn">Загрузить фото</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="photo-grid" id="photoGrid"><div class="empty">Загрузка...</div></div>
|
<div class="photo-grid" id="photoGrid"><div class="empty">Загрузка...</div></div>
|
||||||
<div class="pager" id="photosPager"></div>
|
<div class="pager" id="photosPager"></div>
|
||||||
<div class="gallery-foot">
|
<div class="gallery-foot">
|
||||||
<button class="cancel" onclick="closePhotos()">Закрыть</button>
|
<button class="cancel" type="button" id="closePhotosBtn">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="photoFormModal" onclick="if(event.target===this)closePhotoForm()">
|
<div class="modal-overlay" id="photoFormModal">
|
||||||
<div class="edit-modal">
|
<div class="edit-modal" style="max-width:480px">
|
||||||
<h3 id="photoFormTitle">Добавить фото</h3>
|
<h3 id="photoFormTitle">Добавить фото</h3>
|
||||||
<div>
|
<form id="photoForm" class="settings-stack" style="gap:12px">
|
||||||
<label>Выбрать файл (jpg/png и т.п.)</label>
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Выбрать файл (jpg/png и т.п.) <span style="color:#ef4444">*</span></label>
|
||||||
<input type="file" id="photoFile" accept="image/*">
|
<input type="file" id="photoFile" accept="image/*">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
<div id="photoFormPreview" class="photo-preview" style="display:none"><img id="photoPreviewImg" alt=""></div>
|
<div id="photoFormPreview" class="photo-preview" style="display:none"><img id="photoPreviewImg" alt=""></div>
|
||||||
<div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
<label>Подпись</label>
|
<label>Подпись</label>
|
||||||
<textarea id="photoCaption" placeholder="Например: выступление, тренировка..."></textarea>
|
<textarea id="photoCaption" class="settings-input" placeholder="Например: выступление, тренировка..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
<label>Дата съёмки</label>
|
<label>Дата съёмки</label>
|
||||||
<input type="date" id="photoTakenAt">
|
<input type="date" id="photoTakenAt" class="settings-input">
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
|
||||||
<button class="cancel" onclick="closePhotoForm()">Отмена</button>
|
|
||||||
<button class="save" onclick="savePhotoForm()">Сохранить</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="closePhotoFormBtn">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="vendor/jszip.min.js"></script>
|
<script src="vendor/jszip.min.js"></script>
|
||||||
<script>
|
<script src="js/groups.js"></script>
|
||||||
// --- Groups ---
|
|
||||||
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
|
||||||
|
|
||||||
async function loadGroups() {
|
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
|
||||||
const groups = await res.json();
|
|
||||||
const list = document.getElementById('groupList');
|
|
||||||
if (!groups.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Нет групп</div>'; return; }
|
|
||||||
list.innerHTML = `<div class="group-grid">` + groups.map(g => {
|
|
||||||
const schedSet = g.day_of_week != null && g.time_start && g.time_end;
|
|
||||||
return `
|
|
||||||
<div class="group-card">
|
|
||||||
${g.cover_path
|
|
||||||
? `<img class="group-cover" src="${API}${g.cover_path}" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Фото группы (хронология)" alt="">`
|
|
||||||
: `<div class="group-cover empty-cover" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Фото группы (хронология)">📷</div>`}
|
|
||||||
<div class="group-head">
|
|
||||||
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
|
|
||||||
<button class="del" onclick="delGroup(${g.id})" title="Удалить">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="group-schedule">
|
|
||||||
<div><label>День</label>
|
|
||||||
<select id="dow-${g.id}">
|
|
||||||
<option value="">—</option>
|
|
||||||
${DAYS.map((d, i) => `<option value="${i}" ${g.day_of_week === i ? 'selected' : ''}>${d}</option>`).join('')}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div><label>С</label>
|
|
||||||
<input type="time" id="ts-${g.id}" value="${g.time_start ? g.time_start.slice(0,5) : ''}">
|
|
||||||
</div>
|
|
||||||
<div><label>По</label>
|
|
||||||
<input type="time" id="te-${g.id}" value="${g.time_end ? g.time_end.slice(0,5) : ''}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="group-foot">
|
|
||||||
<div style="display:flex;gap:8px">
|
|
||||||
<button class="save-sched" onclick="saveGroup(${g.id})">Сохранить</button>
|
|
||||||
<button class="photo-btn" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Фото группы (хронология)">Фото</button>
|
|
||||||
<button class="btn-link" onclick="exportGroup(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Экспортировать записи группы">CSV</button>
|
|
||||||
<button class="btn-link" onclick="exportGroupUrls(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Скачать .url файлы учеников группы">URL</button>
|
|
||||||
</div>
|
|
||||||
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('') + `</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveGroup(id) {
|
|
||||||
const name = document.getElementById(`name-${id}`).value.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const dow = document.getElementById(`dow-${id}`).value;
|
|
||||||
const ts = document.getElementById(`ts-${id}`).value;
|
|
||||||
const te = document.getElementById(`te-${id}`).value;
|
|
||||||
const res = await fetch(`${API}/api/groups/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: hdrJson(),
|
|
||||||
body: JSON.stringify({ name, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
loadGroups();
|
|
||||||
const dayLabel = dow !== '' ? DAYS[dow] : 'без расписания';
|
|
||||||
const timeLabel = ts && te ? `, ${ts}–${te}` : '';
|
|
||||||
showToast(`Сохранено: «${name}» — ${dayLabel}${timeLabel}`);
|
|
||||||
} else { const e = await res.json(); alert(e.error); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addGroup() {
|
|
||||||
const input = document.getElementById('newGroup');
|
|
||||||
const name = input.value.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
|
|
||||||
if (res.ok) { input.value = ''; loadGroups(); }
|
|
||||||
else { const e = await res.json(); alert(e.error); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function delGroup(id) {
|
|
||||||
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
|
|
||||||
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
|
||||||
loadGroups();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Фото группы (хронология) ---
|
|
||||||
const PHOTO_PAGE_SIZE = 24;
|
|
||||||
let currentGroupId = null;
|
|
||||||
let currentGroupName = '';
|
|
||||||
let photos = [];
|
|
||||||
let photoPage = 1;
|
|
||||||
let totalPhotos = 0;
|
|
||||||
let editingPhotoId = null;
|
|
||||||
|
|
||||||
async function openPhotos(groupId, groupName) {
|
|
||||||
currentGroupId = groupId;
|
|
||||||
currentGroupName = groupName;
|
|
||||||
photoPage = 1;
|
|
||||||
editingPhotoId = null;
|
|
||||||
document.getElementById('photosTitle').textContent = `Фото — ${groupName}`;
|
|
||||||
document.getElementById('photosModal').classList.add('open');
|
|
||||||
await loadGroupPhotos();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePhotos() {
|
|
||||||
document.getElementById('photosModal').classList.remove('open');
|
|
||||||
currentGroupId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGroupPhotos() {
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
p.set('limit', PHOTO_PAGE_SIZE);
|
|
||||||
p.set('offset', (photoPage - 1) * PHOTO_PAGE_SIZE);
|
|
||||||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos?${p}`, { headers: hdr() });
|
|
||||||
const data = await res.json();
|
|
||||||
photos = data.photos;
|
|
||||||
totalPhotos = data.total;
|
|
||||||
document.getElementById('photoGrid').className = 'photo-grid';
|
|
||||||
document.getElementById('photoGrid').innerHTML = photos.length
|
|
||||||
? photos.map(photoHTML).join('')
|
|
||||||
: '<div class="empty" style="grid-column:1/-1">Фото пока нет. Загрузите первое фото.</div>';
|
|
||||||
renderPager(document.getElementById('photosPager'), photoPage, Math.max(1, Math.ceil(totalPhotos / PHOTO_PAGE_SIZE)), goPhotoPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
|
|
||||||
|
|
||||||
function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; }
|
|
||||||
|
|
||||||
function photoHTML(p) {
|
|
||||||
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
|
|
||||||
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
|
|
||||||
return `
|
|
||||||
<div class="photo-item">
|
|
||||||
<img src="${API}${p.photo_path}" onclick="showImg('${API}${p.photo_path}')" alt="">
|
|
||||||
<div class="p-cap">${esc(p.caption || '')}</div>
|
|
||||||
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
|
|
||||||
<div class="p-actions">
|
|
||||||
<button class="p-edit" onclick="openEditPhoto(${p.id})" title="Редактировать">✎</button>
|
|
||||||
<button class="p-del" onclick="deletePhoto(${p.id})" title="Удалить">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAddPhoto() {
|
|
||||||
editingPhotoId = null;
|
|
||||||
document.getElementById('photoFormTitle').textContent = `Добавить фото — ${currentGroupName}`;
|
|
||||||
document.getElementById('photoFile').value = '';
|
|
||||||
document.getElementById('photoCaption').value = '';
|
|
||||||
document.getElementById('photoTakenAt').value = '';
|
|
||||||
document.getElementById('photoFormPreview').style.display = 'none';
|
|
||||||
document.getElementById('photoFormModal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEditPhoto(id) {
|
|
||||||
const p = photos.find(x => x.id === id);
|
|
||||||
if (!p) return;
|
|
||||||
editingPhotoId = id;
|
|
||||||
document.getElementById('photoFormTitle').textContent = 'Редактировать фото';
|
|
||||||
document.getElementById('photoFile').value = '';
|
|
||||||
document.getElementById('photoCaption').value = p.caption || '';
|
|
||||||
document.getElementById('photoTakenAt').value = p.taken_at || '';
|
|
||||||
const prev = document.getElementById('photoFormPreview');
|
|
||||||
const img = document.getElementById('photoPreviewImg');
|
|
||||||
img.src = `${API}${p.photo_path}`;
|
|
||||||
prev.style.display = 'block';
|
|
||||||
document.getElementById('photoFormModal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePhotoForm() {
|
|
||||||
document.getElementById('photoFormModal').classList.remove('open');
|
|
||||||
editingPhotoId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewPhotoFile() {
|
|
||||||
const file = document.getElementById('photoFile').files[0];
|
|
||||||
const prev = document.getElementById('photoFormPreview');
|
|
||||||
if (!file) { prev.style.display = 'none'; return; }
|
|
||||||
const img = document.getElementById('photoPreviewImg');
|
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
prev.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePhotoForm() {
|
|
||||||
const caption = document.getElementById('photoCaption').value.trim();
|
|
||||||
const taken_at = document.getElementById('photoTakenAt').value;
|
|
||||||
if (editingPhotoId) {
|
|
||||||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${editingPhotoId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: hdrJson(),
|
|
||||||
body: JSON.stringify({ caption: caption || null, taken_at: taken_at || null })
|
|
||||||
});
|
|
||||||
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
|
|
||||||
showToast('Фото обновлено');
|
|
||||||
} else {
|
|
||||||
const file = document.getElementById('photoFile').files[0];
|
|
||||||
if (!file) { alert('Выберите файл'); return; }
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('photo', file);
|
|
||||||
fd.append('caption', caption);
|
|
||||||
fd.append('taken_at', taken_at);
|
|
||||||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: hdr(),
|
|
||||||
body: fd
|
|
||||||
});
|
|
||||||
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
|
|
||||||
showToast('Фото добавлено в хронологию');
|
|
||||||
}
|
|
||||||
closePhotoForm();
|
|
||||||
loadGroupPhotos();
|
|
||||||
loadGroups();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePhoto(id) {
|
|
||||||
if (!confirm('Удалить это фото безвозвратно?')) return;
|
|
||||||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${id}`, { method: 'DELETE', headers: hdr() });
|
|
||||||
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
|
|
||||||
showToast('Фото удалено');
|
|
||||||
loadGroupPhotos();
|
|
||||||
loadGroups();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportGroup(id, name) {
|
|
||||||
const res = await fetch(`${API}/api/entries?group_id=${id}`, { headers: hdr() });
|
|
||||||
const { entries } = await res.json();
|
|
||||||
if (!entries.length) { alert('Нет данных для экспорта'); return; }
|
|
||||||
const rows = [['Фамилия Имя', 'Группа', 'Описание', 'Дата', 'Ссылка на страницу']];
|
|
||||||
entries.forEach(e => {
|
|
||||||
rows.push([
|
|
||||||
e.student_name,
|
|
||||||
e.group_name,
|
|
||||||
e.description.replace(/\n/g, ' '),
|
|
||||||
new Date(e.created_at).toLocaleString('ru'),
|
|
||||||
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
|
|
||||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `group_${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
showToast(`Экспортировано записей: ${entries.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportGroupUrls(groupId, groupName) {
|
|
||||||
const res = await fetch(`${API}/api/students`, { headers: hdr() });
|
|
||||||
const students = await res.json();
|
|
||||||
const list = students.filter(s => s.group_id === groupId);
|
|
||||||
if (!list.length) { alert('В группе нет учеников'); return; }
|
|
||||||
await exportStudentUrlZip(list, `group_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
|
|
||||||
showToast(`Экспортировано: ${list.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
|
|
||||||
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (await checkAuth()) {
|
|
||||||
buildSidebar(document.body.dataset.page);
|
|
||||||
loadGroups();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+40
-202
@@ -9,7 +9,10 @@
|
|||||||
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
|
: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}
|
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}
|
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}
|
.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}}
|
@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.show{opacity:1}
|
||||||
.toast.error{background:#dc2626}
|
.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{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{color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 12px;border-radius:8px;transition:color .15s}
|
||||||
nav a:hover{color:var(--text)}
|
nav a:hover{color:var(--text)}
|
||||||
@@ -83,26 +92,31 @@ nav a:hover{color:var(--text)}
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="admin.html">Админка</a>
|
<a href="admin.html">Админка</a>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 id="title">Что мы узнали на занятии</h1>
|
<div class="brand-head">
|
||||||
<p class="sub">Отметь, что ты делал(а) на занятии</p>
|
<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">
|
<form class="form" id="form">
|
||||||
<div class="photo-col">
|
<div class="photo-col">
|
||||||
<div class="photo-card">
|
<div class="photo-card">
|
||||||
<div class="preview-wrap">
|
<div class="preview-wrap">
|
||||||
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
|
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
|
||||||
<button type="button" class="remove-photo" id="removePhotoBtn" onclick="removePhoto()">Убрать фото</button>
|
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="photo-btns">
|
<div class="photo-btns">
|
||||||
<button type="button" class="cam" onclick="openCam()">Камера</button>
|
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="files-card">
|
<div class="files-card">
|
||||||
<div class="files-head">
|
<div class="files-head">
|
||||||
<label>Файлы проекта</label>
|
<label>Файлы проекта</label>
|
||||||
<button type="button" class="files-clear" id="filesClearBtn" onclick="clearFiles()">Очистить</button>
|
<button type="button" class="files-clear" id="filesClearBtn">Очистить</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="files-btn" onclick="document.getElementById('filesInput').click()">Выбрать файлы</button>
|
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
|
||||||
<input type="file" id="filesInput" multiple hidden>
|
<input type="file" id="filesInput" multiple hidden>
|
||||||
<div class="files-list" id="filesList"></div>
|
<div class="files-list" id="filesList"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,9 +125,8 @@ nav a:hover{color:var(--text)}
|
|||||||
<div class="fields-col">
|
<div class="fields-col">
|
||||||
<div class="field" id="nameField">
|
<div class="field" id="nameField">
|
||||||
<label>Фамилия и имя</label>
|
<label>Фамилия и имя</label>
|
||||||
<input type="text" id="nameInput" list="studentList" placeholder="Например, Иванов Иван" required>
|
<input type="text" id="nameInput" placeholder="Например, Иванов Иван" required autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<datalist id="studentList"></datalist>
|
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Группа</label>
|
<label>Группа</label>
|
||||||
@@ -122,15 +135,30 @@ nav a:hover{color:var(--text)}
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label>Что сделал</label>
|
<label>Что сделал</label>
|
||||||
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
||||||
|
|
||||||
<button class="btn" type="submit" id="submitBtn">Отправить</button>
|
<button class="btn" type="submit" id="submitBtn">Отправить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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">
|
<footer class="footer">
|
||||||
<div class="f-left" id="footerLeft"></div>
|
<div class="f-left" id="footerLeft"></div>
|
||||||
<div class="f-right" id="footerRight"></div>
|
<div class="f-right" id="footerRight"></div>
|
||||||
@@ -141,204 +169,14 @@ nav a:hover{color:var(--text)}
|
|||||||
<video id="camVideo" autoplay playsinline></video>
|
<video id="camVideo" autoplay playsinline></video>
|
||||||
<canvas id="camCanvas"></canvas>
|
<canvas id="camCanvas"></canvas>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="cancel" onclick="closeCam()">Отмена</button>
|
<button type="button" class="cancel" id="cancelCamBtn">Отмена</button>
|
||||||
<button class="capture" onclick="capture()">Снять</button>
|
<button type="button" class="capture" id="captureBtn">Снять</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast">Готово!</div>
|
<div class="toast" id="toast">Готово!</div>
|
||||||
|
|
||||||
<script>
|
<script src="js/index.js"></script>
|
||||||
const API = location.origin;
|
|
||||||
const form = document.getElementById('form');
|
|
||||||
const preview = document.getElementById('preview');
|
|
||||||
const placeholderSrc = preview.src;
|
|
||||||
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
|
||||||
const groupInput = document.getElementById('groupInput');
|
|
||||||
const toast = document.getElementById('toast');
|
|
||||||
const camVideo = document.getElementById('camVideo');
|
|
||||||
const camCanvas = document.getElementById('camCanvas');
|
|
||||||
const camModal = document.getElementById('camModal');
|
|
||||||
let capturedBlob = null;
|
|
||||||
let stream = null;
|
|
||||||
const descInput = document.getElementById('descInput');
|
|
||||||
const filesInput = document.getElementById('filesInput');
|
|
||||||
const filesList = document.getElementById('filesList');
|
|
||||||
const filesClearBtn = document.getElementById('filesClearBtn');
|
|
||||||
let selectedFiles = [];
|
|
||||||
|
|
||||||
function humanSize(b) {
|
|
||||||
if (b < 1024) return b + ' Б';
|
|
||||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
|
||||||
return (b / 1048576).toFixed(1) + ' МБ';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFiles() {
|
|
||||||
filesList.innerHTML = '';
|
|
||||||
selectedFiles.forEach((f, i) => {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'file-row';
|
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'f-name';
|
|
||||||
name.textContent = f.name;
|
|
||||||
name.title = f.name;
|
|
||||||
const size = document.createElement('span');
|
|
||||||
size.className = 'f-size';
|
|
||||||
size.textContent = humanSize(f.size);
|
|
||||||
const del = document.createElement('button');
|
|
||||||
del.type = 'button';
|
|
||||||
del.textContent = '✕';
|
|
||||||
del.onclick = () => removeFile(i);
|
|
||||||
row.append(name, size, del);
|
|
||||||
filesList.appendChild(row);
|
|
||||||
});
|
|
||||||
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFile(i) {
|
|
||||||
selectedFiles.splice(i, 1);
|
|
||||||
renderFiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearFiles() {
|
|
||||||
selectedFiles = [];
|
|
||||||
filesInput.value = '';
|
|
||||||
renderFiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
filesInput.addEventListener('change', () => {
|
|
||||||
const keep = 10 - selectedFiles.length;
|
|
||||||
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
|
||||||
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
|
||||||
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
|
||||||
filesInput.value = '';
|
|
||||||
renderFiles();
|
|
||||||
});
|
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
let studentName = '';
|
|
||||||
function escName(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
||||||
if (params.get('nameInput')) {
|
|
||||||
studentName = params.get('nameInput');
|
|
||||||
document.getElementById('nameInput').value = studentName;
|
|
||||||
const field = document.getElementById('nameField');
|
|
||||||
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const res = await fetch(`${API}/api/students`);
|
|
||||||
const students = await res.json();
|
|
||||||
const dl = document.getElementById('studentList');
|
|
||||||
students.forEach(s => {
|
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = s.name;
|
|
||||||
dl.appendChild(o);
|
|
||||||
});
|
|
||||||
const me = studentName ? students.find(s => s.name === studentName) : null;
|
|
||||||
if (me && me.group_id) {
|
|
||||||
groupInput.innerHTML = `<option value="${me.group_id}" selected>${escName(me.group_name || `Группа ${me.group_id}`)}</option>`;
|
|
||||||
groupInput.value = String(me.group_id);
|
|
||||||
} else {
|
|
||||||
const groupsRes = await fetch(`${API}/api/groups/active`);
|
|
||||||
const groups = await groupsRes.json();
|
|
||||||
groups.forEach(g => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = g.id;
|
|
||||||
opt.textContent = g.name;
|
|
||||||
groupInput.appendChild(opt);
|
|
||||||
});
|
|
||||||
if (!groups.length) {
|
|
||||||
groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const fRes = await fetch(`${API}/api/public-settings`);
|
|
||||||
const f = await fRes.json();
|
|
||||||
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
|
|
||||||
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
})();
|
|
||||||
|
|
||||||
function showPreview(src) {
|
|
||||||
preview.src = src;
|
|
||||||
preview.classList.add('has-photo');
|
|
||||||
removePhotoBtn.classList.add('show');
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePhoto() {
|
|
||||||
preview.src = placeholderSrc;
|
|
||||||
preview.classList.remove('has-photo');
|
|
||||||
removePhotoBtn.classList.remove('show');
|
|
||||||
capturedBlob = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openCam() {
|
|
||||||
try {
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
|
|
||||||
camVideo.srcObject = stream;
|
|
||||||
camModal.classList.add('open');
|
|
||||||
} catch (e) {
|
|
||||||
alert('Не удалось открыть камеру: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCam() {
|
|
||||||
camModal.classList.remove('open');
|
|
||||||
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function capture() {
|
|
||||||
camCanvas.width = camVideo.videoWidth;
|
|
||||||
camCanvas.height = camVideo.videoHeight;
|
|
||||||
const ctx = camCanvas.getContext('2d');
|
|
||||||
ctx.translate(camCanvas.width, 0);
|
|
||||||
ctx.scale(-1, 1);
|
|
||||||
ctx.drawImage(camVideo, 0, 0);
|
|
||||||
camCanvas.toBlob(blob => {
|
|
||||||
capturedBlob = blob;
|
|
||||||
showPreview(URL.createObjectURL(blob));
|
|
||||||
closeCam();
|
|
||||||
}, 'image/jpeg', 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = document.getElementById('submitBtn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Отправка...';
|
|
||||||
|
|
||||||
const fd = new FormData();
|
|
||||||
if (capturedBlob) fd.append('photo', capturedBlob, 'photo.jpg');
|
|
||||||
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
|
||||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
|
||||||
fd.append('group_id', groupInput.value);
|
|
||||||
fd.append('description', document.getElementById('descInput').value);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
|
|
||||||
if (res.status === 429) {
|
|
||||||
const e = await res.json().catch(() => ({}));
|
|
||||||
toast.textContent = e.error || 'Уже ответили: подождите';
|
|
||||||
toast.classList.add('show', 'error');
|
|
||||||
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
form.reset();
|
|
||||||
removePhoto();
|
|
||||||
clearFiles();
|
|
||||||
toast.textContent = 'Готово!';
|
|
||||||
toast.classList.remove('error');
|
|
||||||
toast.classList.add('show');
|
|
||||||
setTimeout(() => toast.classList.remove('show'), 2500);
|
|
||||||
} catch (err) {
|
|
||||||
alert('Ошибка: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Отправить';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+187
-375
@@ -5,6 +5,34 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Журнал — Админ-панель</title>
|
<title>Журнал — Админ-панель</title>
|
||||||
<link rel="stylesheet" href="admin.css">
|
<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>
|
</head>
|
||||||
<body data-page="journal">
|
<body data-page="journal">
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
@@ -15,14 +43,15 @@
|
|||||||
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
|
<div><label>Поиск</label><input type="text" id="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><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><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="dateFrom"></div>
|
||||||
<div><label>По</label><input type="date" id="dateTo"></div>
|
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||||
<div><label> </label><button onclick="exportCSV()" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
<div><label> </label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
||||||
<div><label> </label><button onclick="openLinkCreate()" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
|
<div><label> </label><button type="button" id="createLinkBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
|
||||||
<div><label> </label>
|
<div><label> </label>
|
||||||
<div class="view-toggle" id="viewToggle">
|
<div class="view-toggle" id="viewToggle">
|
||||||
<button data-view="list" onclick="setView('list')">Список</button>
|
<button data-view="list" type="button">Список</button>
|
||||||
<button data-view="cards" onclick="setView('cards')">Карточки</button>
|
<button data-view="cards" type="button">Карточки</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,408 +60,191 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="editModal" onclick="if(event.target===this)closeEdit()">
|
<div class="modal-overlay" id="editModal">
|
||||||
<div class="edit-modal">
|
<div class="edit-modal" style="max-width:720px">
|
||||||
<h3>Редактирование записи</h3>
|
<h3>Редактирование записи</h3>
|
||||||
<div>
|
<form id="editForm" class="link-fields" style="gap:12px">
|
||||||
<label>Фамилия и имя</label>
|
<div class="settings-field">
|
||||||
<input type="text" id="editName" list="studentEditList">
|
<label>Фамилия и имя <span style="color:#ef4444">*</span></label>
|
||||||
|
<input type="text" id="editName" class="settings-input" list="studentEditList" required autocomplete="off">
|
||||||
<datalist id="studentEditList"></datalist>
|
<datalist id="studentEditList"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="settings-field">
|
||||||
<label>Группа</label>
|
<label>Группа <span style="color:#ef4444">*</span></label>
|
||||||
<select id="editGroup"></select>
|
<select id="editGroup" class="settings-input" required></select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="settings-field">
|
||||||
<label>Что делала(а)</label>
|
<label>Тема модуля</label>
|
||||||
<textarea id="editDesc"></textarea>
|
<input type="text" id="editModule" class="settings-input" list="editModuleList" placeholder="Начните вводить название..." autocomplete="off">
|
||||||
|
<datalist id="editModuleList"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="settings-field full">
|
||||||
<button class="cancel" onclick="closeEdit()">Отмена</button>
|
<label>Что делала(а) <span style="color:#ef4444">*</span>
|
||||||
<button class="save" onclick="saveEdit()">Сохранить</button>
|
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
|
||||||
|
</label>
|
||||||
|
<textarea id="editDesc" class="settings-input" required></textarea>
|
||||||
|
<div id="editAiStatus" class="ai-status" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Фотография</label>
|
||||||
|
<input type="file" id="editPhoto" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.avif,.ico,.heic,.heif,.jfif">
|
||||||
|
<div class="files-preview" id="editPhotoPreview"></div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Прикрепить файлы</label>
|
||||||
|
<input type="file" id="editFiles" multiple accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.zip,.rar,.7z,.jpg,.jpeg,.png,.gif,.webp,.bmp,.avif,.ico,.heic,.heif,.jfif">
|
||||||
|
<div class="files-preview" id="editFilesPreview"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="editCancelBtn">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="aiCorrectModal">
|
||||||
|
<div class="edit-modal" style="max-width:560px">
|
||||||
|
<h3>✨ Исправление текста</h3>
|
||||||
|
<div style="font-size:.85rem;color:var(--muted);margin-bottom:12px">ИИ предлагает вариант:</div>
|
||||||
|
<div id="aiSuggestion" style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;min-height:80px;max-height:300px;overflow:auto;white-space:pre-wrap;font-family:inherit"></div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:12px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="aiRejectBtn">Отклонить</button>
|
||||||
|
<button type="button" class="btn-primary" id="aiAcceptBtn">Применить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="linkModal" onclick="if(event.target===this)closeLinkCreate()">
|
<div class="modal-overlay" id="linkModal">
|
||||||
<div class="edit-modal">
|
<div class="edit-modal" style="max-width:640px">
|
||||||
<div id="linkCreateView">
|
<div id="linkCreateView">
|
||||||
<h3>Создать ссылку</h3>
|
<h3>Создать ссылку</h3>
|
||||||
<div style="font-size:.85rem;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;line-height:1.5" id="linkPreview"></div>
|
<div style="font-size:.85rem;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;line-height:1.5" id="linkPreview"></div>
|
||||||
<div>
|
<div class="link-fields">
|
||||||
<label>Название ссылки</label>
|
<div class="settings-field">
|
||||||
<input type="text" id="linkNameInput">
|
<label>Название ссылки <span style="color:#ef4444">*</span></label>
|
||||||
|
<input type="text" id="linkNameInput" class="settings-input" required placeholder="Например: Апрельский журнал">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="settings-field">
|
||||||
<label>Группа (если выбрана)</label>
|
<label>Группа (если выбрана)</label>
|
||||||
<input type="text" id="linkGroupPreview" readonly style="background:var(--bg);color:var(--muted);cursor:not-allowed">
|
<input type="text" id="linkGroupPreview" class="settings-input" readonly style="background:var(--bg);color:var(--muted);cursor:not-allowed">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="settings-field">
|
||||||
<label>Ученик (необязательно)</label>
|
<label>Ученик (необязательно)</label>
|
||||||
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Ссылка</label>
|
||||||
|
<input type="url" id="linkUrl" class="settings-input" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Сообщение</label>
|
||||||
|
<textarea id="linkMessage" class="settings-input" rows="3" placeholder="Текст, который увидят на публичной странице"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Истекает через (дни)</label>
|
||||||
|
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Пароль для доступа (необязательно)</label>
|
||||||
|
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowNames">
|
||||||
|
Показывать фамилии и имена учеников
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowMessage">
|
||||||
|
Показать сообщение ученика
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowDate">
|
||||||
|
Показать дату в карточке записи
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowPhotos">
|
||||||
|
Показывать фото группы
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="linkCreateCloseBtn">Отмена</button>
|
||||||
|
<button type="button" class="btn-primary" id="linkCreateSaveBtn">Создать</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
|
||||||
<button class="cancel" onclick="closeLinkCreate()">Отмена</button>
|
|
||||||
<button class="save" onclick="createLink()">Создать</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="linkResultView" style="display:none">
|
<div id="linkResultView" style="display:none">
|
||||||
<h3>Ссылка готова</h3>
|
<h3>Ссылка готова</h3>
|
||||||
<div style="font-size:.85rem;color:var(--muted);margin-bottom:8px">Открывается публичная страница с карточками — без входа и выбора параметров. Ссылку можно изменить или удалить в разделе «Ссылки».</div>
|
<div style="font-size:.85rem;color:var(--muted);margin-bottom:8px">Открывается публичная страница с карточками — без входа и выбора параметров. Ссылку можно изменить или удалить в разделе «Ссылки».</div>
|
||||||
<input type="text" id="linkUrlResult" readonly style="font-size:.8rem;color:var(--accent)">
|
<input type="text" id="linkUrlResult" class="settings-input" readonly style="font-size:.8rem;color:var(--accent)">
|
||||||
<div class="actions">
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
<a class="cancel-btn" href="links.html">К списку ссылок</a>
|
<a class="btn-primary ghost" href="links.html" style="text-decoration:none">К списку ссылок</a>
|
||||||
<button class="save" onclick="copyLink()">Копировать</button>
|
<button class="btn-primary" type="button" id="linkCopyBtn">Копировать</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="enhanceModal">
|
||||||
|
<div class="edit-modal enhance-modal">
|
||||||
|
<h3>Улучшение фотографии</h3>
|
||||||
|
<div class="enhance-body">
|
||||||
|
<div class="enhance-compare" id="enhanceCompare">
|
||||||
|
<div class="enhance-stage" id="enhanceStage">
|
||||||
|
<img id="enhanceOrig" alt="Оригинал">
|
||||||
|
</div>
|
||||||
|
<div class="enhance-top" id="enhanceTop" style="clip-path:inset(0 0 0 50%)">
|
||||||
|
<div class="enhance-stage" id="enhanceInner">
|
||||||
|
<canvas id="enhanceCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="enhance-divider" id="enhanceDivider"><div class="enhance-handle">⇔</div></div>
|
||||||
|
<div class="enhance-badge left">Оригинал</div>
|
||||||
|
<div class="enhance-badge right">Результат</div>
|
||||||
|
<div class="enhance-loading" id="enhanceLoading">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
<div class="enhance-sliders">
|
||||||
|
<div class="settings-field"><label>Яркость <span id="enhanceBrightnessVal">100%</span></label><input type="range" id="enhanceBrightness" min="50" max="150" value="100"></div>
|
||||||
|
<div class="settings-field"><label>Контраст <span id="enhanceContrastVal">100%</span></label><input type="range" id="enhanceContrast" min="50" max="150" value="100"></div>
|
||||||
|
<div class="settings-field"><label>Насыщенность <span id="enhanceSaturateVal">100%</span></label><input type="range" id="enhanceSaturate" min="0" max="200" value="100"></div>
|
||||||
|
<div class="settings-field"><label>Шумодав <span id="enhanceDenoiseVal">0%</span></label><input type="range" id="enhanceDenoise" min="0" max="100" value="0"></div>
|
||||||
|
<div class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot enhance-foot">
|
||||||
|
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
|
||||||
|
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
|
||||||
|
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
||||||
|
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
||||||
|
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
|
||||||
|
<button type="button" class="btn-primary" id="enhanceSaveBtn">Применить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="photoHistoryModal">
|
||||||
|
<div class="edit-modal enhance-modal">
|
||||||
|
<h3 id="photoHistoryTitle">История изменений фотографии</h3>
|
||||||
|
<div id="photoHistoryList" style="display:flex;flex-direction:column;gap:12px;max-height:60vh;overflow:auto"></div>
|
||||||
|
<div class="card-foot enhance-foot">
|
||||||
|
<button type="button" class="btn-primary ghost" id="photoHistoryCloseBtn">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/journal.js"></script>
|
||||||
// --- Journal ---
|
|
||||||
let currentEntries = [];
|
|
||||||
let editId = null;
|
|
||||||
let editGroups = [];
|
|
||||||
let groupsLoaded = false;
|
|
||||||
let studentsLoaded = false;
|
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
let page = 1;
|
|
||||||
let totalEntries = 0;
|
|
||||||
let searchTimer = null;
|
|
||||||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
|
||||||
|
|
||||||
function isImageFile(name) {
|
|
||||||
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function filesHTML(files) {
|
|
||||||
if (!files || !files.length) return '';
|
|
||||||
return `<div class="entry-files">${files.map(f => {
|
|
||||||
const link = isImageFile(f.name)
|
|
||||||
? `<a class="f-link" href="#" onclick="showImg('${API}/api/files/${f.token}');return false;" title="Открыть">${esc(f.name)}</a>`
|
|
||||||
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`;
|
|
||||||
return `<span class="f-item">${link}<button class="f-detach" onclick="detachFile(${f.id})" title="Открепить файл">✕</button></span>`;
|
|
||||||
}).join('')}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFile(id) {
|
|
||||||
for (const e of currentEntries) {
|
|
||||||
const f = (e.files || []).find(x => x.id === id);
|
|
||||||
if (f) return f;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detachFile(id) {
|
|
||||||
const f = findFile(id);
|
|
||||||
if (!f) return;
|
|
||||||
if (!confirm(`Открепить файл «${f.name}» от записи? Он переместится в «Файлы → Откреплённые».`)) return;
|
|
||||||
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
|
|
||||||
if (res.ok) {
|
|
||||||
showToast('Файл откреплён');
|
|
||||||
loadEntries();
|
|
||||||
} else {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
alert(err.error || 'Ошибка');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function gridEntryHTML(e) {
|
|
||||||
return `
|
|
||||||
<div class="card">
|
|
||||||
${e.photo_path ? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">` : ''}
|
|
||||||
<div class="info">
|
|
||||||
<div class="name">${esc(e.student_name)}</div>
|
|
||||||
<div class="group">${esc(e.group_name)}</div>
|
|
||||||
<div class="desc">${esc(e.description)}</div>
|
|
||||||
${filesHTML(e.files)}
|
|
||||||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
|
||||||
</div>
|
|
||||||
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
|
|
||||||
<button class="del" onclick="delEntry(${e.id})" title="Удалить">×</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cardEntryHTML(e) {
|
|
||||||
return `
|
|
||||||
<div class="journal-card">
|
|
||||||
${e.photo_path
|
|
||||||
? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">`
|
|
||||||
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
|
||||||
<div class="body">
|
|
||||||
<div class="name">${esc(e.student_name)}</div>
|
|
||||||
<div class="group">${esc(e.group_name)}</div>
|
|
||||||
<div class="desc">${esc(e.description)}</div>
|
|
||||||
${filesHTML(e.files)}
|
|
||||||
</div>
|
|
||||||
<div class="foot">
|
|
||||||
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
|
||||||
<span>
|
|
||||||
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
|
|
||||||
<button class="del" onclick="delEntry(${e.id})" title="Удалить">×</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGrid() {
|
|
||||||
const grid = document.getElementById('grid');
|
|
||||||
if (!currentEntries.length) {
|
|
||||||
grid.className = 'grid';
|
|
||||||
grid.innerHTML = '<div class="empty">Ничего не найдено</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (view === 'cards') {
|
|
||||||
grid.className = 'journal-cards';
|
|
||||||
grid.innerHTML = currentEntries.map(cardEntryHTML).join('');
|
|
||||||
} else {
|
|
||||||
grid.className = 'grid';
|
|
||||||
grid.innerHTML = currentEntries.map(gridEntryHTML).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setView(v) {
|
|
||||||
view = v === 'cards' ? 'cards' : 'list';
|
|
||||||
localStorage.setItem('journalView', view);
|
|
||||||
document.querySelectorAll('#viewToggle button').forEach(b => {
|
|
||||||
b.classList.toggle('active', b.dataset.view === view);
|
|
||||||
});
|
|
||||||
renderGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDefaultDates() {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
document.getElementById('dateFrom').value = today;
|
|
||||||
document.getElementById('dateTo').value = today;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGroupOptions() {
|
|
||||||
if (groupsLoaded) return;
|
|
||||||
const sel = document.getElementById('groupFilter');
|
|
||||||
while (sel.options.length > 1) sel.remove(1);
|
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
|
||||||
const groups = await res.json();
|
|
||||||
groups.forEach(g => {
|
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = g.id; o.textContent = g.name;
|
|
||||||
sel.appendChild(o);
|
|
||||||
});
|
|
||||||
groupsLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStudentOptions() {
|
|
||||||
if (studentsLoaded) return;
|
|
||||||
const dl = document.getElementById('studentFilterList');
|
|
||||||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
|
||||||
const names = await res.json();
|
|
||||||
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
|
||||||
studentsLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEntries() {
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
const q = document.getElementById('searchInput').value;
|
|
||||||
const sn = document.getElementById('studentFilter').value;
|
|
||||||
const gid = document.getElementById('groupFilter').value;
|
|
||||||
const df = document.getElementById('dateFrom').value;
|
|
||||||
const dt = document.getElementById('dateTo').value;
|
|
||||||
if (q) p.set('search', q);
|
|
||||||
if (sn) p.set('student_name', sn);
|
|
||||||
if (gid) p.set('group_id', gid);
|
|
||||||
if (df) p.set('date_from', df);
|
|
||||||
if (dt) p.set('date_to', dt);
|
|
||||||
p.set('limit', PAGE_SIZE);
|
|
||||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
|
||||||
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
|
||||||
const data = await res.json();
|
|
||||||
currentEntries = data.entries;
|
|
||||||
totalEntries = data.total;
|
|
||||||
renderGrid();
|
|
||||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goPage(p) {
|
|
||||||
page = p;
|
|
||||||
loadEntries();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEdit(id) {
|
|
||||||
const e = currentEntries.find(x => x.id === id);
|
|
||||||
if (!e) return;
|
|
||||||
editId = id;
|
|
||||||
fillAutocomplete();
|
|
||||||
document.getElementById('editName').value = e.student_name;
|
|
||||||
document.getElementById('editDesc').value = e.description;
|
|
||||||
const sel = document.getElementById('editGroup');
|
|
||||||
if (!editGroups.length) {
|
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
|
||||||
editGroups = await res.json();
|
|
||||||
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
|
||||||
}
|
|
||||||
sel.value = e.group_id;
|
|
||||||
document.getElementById('editModal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEdit() {
|
|
||||||
document.getElementById('editModal').classList.remove('open');
|
|
||||||
editId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEdit() {
|
|
||||||
if (!editId) return;
|
|
||||||
const name = document.getElementById('editName').value.trim();
|
|
||||||
const group_id = document.getElementById('editGroup').value;
|
|
||||||
const description = document.getElementById('editDesc').value.trim();
|
|
||||||
if (!name || !group_id || !description) { alert('Заполните все поля'); return; }
|
|
||||||
const res = await fetch(`${API}/api/entries/${editId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: hdrJson(),
|
|
||||||
body: JSON.stringify({ student_name: name, group_id, description })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
closeEdit();
|
|
||||||
loadEntries();
|
|
||||||
showToast('Запись обновлена');
|
|
||||||
} else { const err = await res.json(); alert(err.error); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function delEntry(id) {
|
|
||||||
if (!confirm('Переместить запись в корзину? Файлы и фото сохранятся.')) return;
|
|
||||||
await fetch(`${API}/api/entries/${id}`, { method: 'DELETE', headers: hdr() });
|
|
||||||
showToast('Запись перемещена в корзину');
|
|
||||||
loadEntries();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
|
||||||
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
|
||||||
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
|
|
||||||
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
|
|
||||||
document.getElementById('searchInput').addEventListener('input', () => {
|
|
||||||
clearTimeout(searchTimer);
|
|
||||||
searchTimer = setTimeout(() => { page = 1; loadEntries(); }, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function exportCSV() {
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
const q = document.getElementById('searchInput').value;
|
|
||||||
const sn = document.getElementById('studentFilter').value;
|
|
||||||
const gid = document.getElementById('groupFilter').value;
|
|
||||||
const df = document.getElementById('dateFrom').value;
|
|
||||||
const dt = document.getElementById('dateTo').value;
|
|
||||||
if (q) p.set('search', q);
|
|
||||||
if (sn) p.set('student_name', sn);
|
|
||||||
if (gid) p.set('group_id', gid);
|
|
||||||
if (df) p.set('date_from', df);
|
|
||||||
if (dt) p.set('date_to', dt);
|
|
||||||
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
|
||||||
const entries = (await res.json()).entries;
|
|
||||||
if (!entries.length) { alert('Нет данных для экспорта'); return; }
|
|
||||||
const rows = [['Фамилия и имя', 'Группа', 'Описание', 'Дата', 'Фото']];
|
|
||||||
entries.forEach(e => {
|
|
||||||
rows.push([
|
|
||||||
e.student_name,
|
|
||||||
e.group_name,
|
|
||||||
e.description.replace(/\n/g, ' '),
|
|
||||||
new Date(e.created_at).toLocaleString('ru'),
|
|
||||||
e.photo_path ? `${API}${e.photo_path}` : ''
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
|
|
||||||
const bom = '\uFEFF';
|
|
||||||
const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
const dateLabel = df && dt ? `${df}_${dt}` : 'all';
|
|
||||||
a.download = `journal_${dateLabel}.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (await checkAuth()) {
|
|
||||||
buildSidebar(document.body.dataset.page);
|
|
||||||
document.querySelectorAll('#viewToggle button').forEach(b => {
|
|
||||||
b.classList.toggle('active', b.dataset.view === view);
|
|
||||||
});
|
|
||||||
setDefaultDates();
|
|
||||||
loadGroupOptions();
|
|
||||||
loadStudentOptions();
|
|
||||||
loadEntries();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// --- Ссылки ---
|
|
||||||
let linkGroups = [];
|
|
||||||
let linkStudentsLoaded = false;
|
|
||||||
let createdLinkUrl = '';
|
|
||||||
|
|
||||||
function fmtD(d) { return d ? d.split('-').reverse().join('.') : 'весь период'; }
|
|
||||||
|
|
||||||
async function openLinkCreate() {
|
|
||||||
const gid = document.getElementById('groupFilter').value;
|
|
||||||
if (!gid) { alert('Сначала выберите группу в фильтре'); return; }
|
|
||||||
const df = document.getElementById('dateFrom').value;
|
|
||||||
const dt = document.getElementById('dateTo').value;
|
|
||||||
if (!linkGroups.length) {
|
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
|
||||||
linkGroups = await res.json();
|
|
||||||
}
|
|
||||||
if (!linkStudentsLoaded) {
|
|
||||||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
|
||||||
const names = await res.json();
|
|
||||||
const dl = document.getElementById('linkStudentList');
|
|
||||||
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
|
||||||
linkStudentsLoaded = true;
|
|
||||||
}
|
|
||||||
// Capture current student filter
|
|
||||||
const currentStudent = document.getElementById('studentFilter').value;
|
|
||||||
const g = linkGroups.find(x => String(x.id) === String(gid));
|
|
||||||
document.getElementById('linkPreview').innerHTML =
|
|
||||||
`Будут показаны записи: <b>${esc(g ? g.name : '')}</b><br>Период: <b>${fmtD(df)}</b> — <b>${fmtD(dt)}</b>`;
|
|
||||||
document.getElementById('linkGroupPreview').value = g ? g.name : '';
|
|
||||||
const dfl = df.split('-').reverse().join('.');
|
|
||||||
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
|
|
||||||
document.getElementById('linkStudent').value = currentStudent;
|
|
||||||
document.getElementById('linkCreateView').style.display = '';
|
|
||||||
document.getElementById('linkResultView').style.display = 'none';
|
|
||||||
document.getElementById('linkModal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLinkCreate() {
|
|
||||||
document.getElementById('linkModal').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createLink() {
|
|
||||||
const name = document.getElementById('linkNameInput').value.trim();
|
|
||||||
const group_id = document.getElementById('groupFilter').value;
|
|
||||||
const student_name = document.getElementById('linkStudent').value;
|
|
||||||
const date_from = document.getElementById('dateFrom').value;
|
|
||||||
const date_to = document.getElementById('dateTo').value;
|
|
||||||
if (!name) { alert('Введите название'); return; }
|
|
||||||
const res = await fetch(`${API}/api/links`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: hdrJson(),
|
|
||||||
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null })
|
|
||||||
});
|
|
||||||
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
|
|
||||||
const link = await res.json();
|
|
||||||
createdLinkUrl = `${location.origin}/s/${link.token}`;
|
|
||||||
document.getElementById('linkUrlResult').value = createdLinkUrl;
|
|
||||||
document.getElementById('linkCreateView').style.display = 'none';
|
|
||||||
document.getElementById('linkResultView').style.display = '';
|
|
||||||
showToast('Ссылка создана');
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyLink() { copyText(createdLinkUrl); }
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
const ACTION_LABELS = {
|
||||||
|
'auth.login': 'Вход в систему',
|
||||||
|
'settings.update': 'Изменены настройки',
|
||||||
|
'user.create': 'Создан пользователь',
|
||||||
|
'user.update': 'Изменён пользователь',
|
||||||
|
'user.delete': 'Удалён пользователь',
|
||||||
|
'ip.ban': 'Заблокирован IP-адрес',
|
||||||
|
'ip.unban': 'Разблокирован IP-адрес',
|
||||||
|
'group.create': 'Создана группа',
|
||||||
|
'group.update': 'Изменена группа',
|
||||||
|
'group.delete': 'Удалена группа',
|
||||||
|
'group.photo.create': 'Загружена фото группы',
|
||||||
|
'group.photo.update': 'Изменено фото группы',
|
||||||
|
'group.photo.reorder': 'Изменён порядок фото группы',
|
||||||
|
'group.photo.set_cover': 'Обновлена обложка группы',
|
||||||
|
'group.photo.delete': 'Удалено фото группы',
|
||||||
|
'branch.create': 'Создан филиал',
|
||||||
|
'branch.update': 'Изменён филиал',
|
||||||
|
'branch.delete': 'Удалён филиал',
|
||||||
|
'student.create': 'Добавлен ученик',
|
||||||
|
'student.update': 'Изменён ученик',
|
||||||
|
'student.delete': 'Удалён ученик',
|
||||||
|
'student.batch-group': 'Ученики перенесены в группу',
|
||||||
|
'module.create': 'Создан модуль',
|
||||||
|
'module.update': 'Изменён модуль',
|
||||||
|
'module.delete': 'Модуль отключён',
|
||||||
|
'module.restore': 'Модуль включён',
|
||||||
|
'entry.update': 'Изменена запись',
|
||||||
|
'entry.photo.update': 'Изменено фото записи',
|
||||||
|
'entry.photo.set_main': 'Главное фото записи',
|
||||||
|
'entry.photo.delete': 'Удалено фото записи',
|
||||||
|
'entry.photo.enhance': 'Фото улучшено',
|
||||||
|
'entry.photo.enhance-ai.queue': 'ИИ-обработка фото поставлена в очередь',
|
||||||
|
'entry.photo.apply': 'Применён результат обработки фото',
|
||||||
|
'entry.photo.reject': 'Отклонён результат обработки фото',
|
||||||
|
'entry.photo.rollback': 'Фото отката к версии',
|
||||||
|
'entry.photo.restore_original': 'Возвращён оригинал фото',
|
||||||
|
'photo.job.preview': 'Создан предпросмотр обработки фото',
|
||||||
|
'photo.job.retry': 'Повтор обработки фото',
|
||||||
|
'photo.job.error': 'Ошибка обработки фото',
|
||||||
|
'photo-jobs.wake': 'Фото-воркер разбужен',
|
||||||
|
'photo-jobs.enabled': 'Переключена обработка фото',
|
||||||
|
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
|
||||||
|
'entry.soft-delete': 'Запись в корзину',
|
||||||
|
'entry.restore': 'Запись восстановлена',
|
||||||
|
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||||
|
'link.create': 'Создана ссылка',
|
||||||
|
'link.update': 'Изменена ссылка',
|
||||||
|
'link.delete': 'Удалена ссылка',
|
||||||
|
'backup.download': 'Скачан бэкап',
|
||||||
|
'backup.restore': 'Выполнено восстановление',
|
||||||
|
'entry.ai.auto-check': 'ИИ-автопроверка записи',
|
||||||
|
'entry.ai.retry': 'Повтор ИИ-проверки записи',
|
||||||
|
'entry.ai.recheck': 'Запись отправлена на ИИ-проверку',
|
||||||
|
'entry.ai.revert': 'Возвращён оригинал текста записи',
|
||||||
|
'entry.ai.error': 'Ошибка ИИ-проверки записи',
|
||||||
|
'ai.wake': 'Воркер ИИ разбужен вручную',
|
||||||
|
'ai.enabled': 'Переключена автопроверка ИИ',
|
||||||
|
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
|
||||||
|
'ai.profile.create': 'Создан профиль ИИ',
|
||||||
|
'ai.profile.update': 'Изменён профиль ИИ',
|
||||||
|
'ai.profile.delete': 'Удалён профиль ИИ',
|
||||||
|
'ai.profile.activate': 'Выбран активный профиль ИИ',
|
||||||
|
'export.student': 'Экспорт данных ученика',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUDIT_PAGE_SIZE = 50;
|
||||||
|
let auditPage = 1;
|
||||||
|
let auditTotal = 0;
|
||||||
|
let auditRows = [];
|
||||||
|
|
||||||
|
function fmtTime(t) {
|
||||||
|
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function dt(t) {
|
||||||
|
if (!t) return '';
|
||||||
|
if (typeof t === 'object') {
|
||||||
|
return esc(JSON.stringify(t).slice(0, 120));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(t);
|
||||||
|
return esc(JSON.stringify(o).slice(0, 120));
|
||||||
|
} catch { return esc(String(t).slice(0, 120)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullTarget(t) {
|
||||||
|
if (!t) return '';
|
||||||
|
if (typeof t === 'object') return JSON.stringify(t, null, 2);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(t), null, 2);
|
||||||
|
} catch { return String(t); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPager(container, page, totalPages, onPageChange) {
|
||||||
|
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
||||||
|
const pages = [];
|
||||||
|
const maxVisible = 5;
|
||||||
|
let start = Math.max(1, page - Math.floor(maxVisible / 2));
|
||||||
|
let end = Math.min(totalPages, start + maxVisible - 1);
|
||||||
|
if (end - start + 1 < maxVisible) start = Math.max(1, end - maxVisible + 1);
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
let html = '';
|
||||||
|
if (page > 1) html += `<button data-page="${page - 1}">«</button>`;
|
||||||
|
if (start > 1) { html += `<button data-page="1">1</button>`; if (start > 2) html += `<span class="pager-ellipsis">…</span>`; }
|
||||||
|
for (const p of pages) html += `<button data-page="${p}"${p === page ? ' class="active"' : ''}>${p}</button>`;
|
||||||
|
if (end < totalPages) { if (end < totalPages - 1) html += `<span class="pager-ellipsis">…</span>`; html += `<button data-page="${totalPages}">${totalPages}</button>`; }
|
||||||
|
if (page < totalPages) html += `<button data-page="${page + 1}">»</button>`;
|
||||||
|
container.innerHTML = html;
|
||||||
|
container.querySelectorAll('button').forEach(b => b.addEventListener('click', () => onPageChange(parseInt(b.dataset.page, 10))));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudit() {
|
||||||
|
const el = document.getElementById('auditTable').querySelector('tbody');
|
||||||
|
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('limit', AUDIT_PAGE_SIZE);
|
||||||
|
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
|
||||||
|
const action = document.getElementById('auditActionFilter').value;
|
||||||
|
if (action) p.set('action', action);
|
||||||
|
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
|
||||||
|
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||||
|
const rows = await res.json();
|
||||||
|
// Total count for pagination (approximate: if we got less than page size, we're on last page)
|
||||||
|
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
|
||||||
|
auditTotal = totalPages;
|
||||||
|
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
|
||||||
|
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
|
||||||
|
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
|
||||||
|
if (!filtered.length) {
|
||||||
|
document.getElementById('auditEmpty').style.display = 'block';
|
||||||
|
el.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
document.getElementById('auditEmpty').style.display = 'none';
|
||||||
|
auditRows = filtered;
|
||||||
|
el.innerHTML = filtered.map((r, i) => `
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||||
|
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
|
||||||
|
<td><button type="button" class="audit-details" data-audit-idx="${i}">${dt(r.target)}</button></td>
|
||||||
|
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goAuditPage(p) {
|
||||||
|
auditPage = p;
|
||||||
|
loadAudit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillActionFilter() {
|
||||||
|
const sel = document.getElementById('auditActionFilter');
|
||||||
|
const current = sel.value;
|
||||||
|
const opts = Object.entries(ACTION_LABELS)
|
||||||
|
.sort((a, b) => a[1].localeCompare(b[1], 'ru'))
|
||||||
|
.map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`)
|
||||||
|
.join('');
|
||||||
|
sel.innerHTML = '<option value="">Все действия</option>' + opts;
|
||||||
|
sel.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('auditActionFilter').addEventListener('change', () => { auditPage = 1; loadAudit(); });
|
||||||
|
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
|
||||||
|
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
|
||||||
|
|
||||||
|
document.getElementById('auditTable').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.audit-details');
|
||||||
|
if (!btn) return;
|
||||||
|
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
|
||||||
|
if (!r) return;
|
||||||
|
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
|
||||||
|
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target);
|
||||||
|
document.getElementById('auditDetailsModal').classList.add('open');
|
||||||
|
});
|
||||||
|
document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('auditDetailsModal').classList.remove('open');
|
||||||
|
});
|
||||||
|
document.getElementById('auditDetailsModal').addEventListener('click', e => {
|
||||||
|
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await requireAdminPage()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
fillActionFilter();
|
||||||
|
loadAudit();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
const PAGE_SIZE = 15;
|
||||||
|
const REASONS = {
|
||||||
|
'honeypot': 'Антиспам-поле заполнено',
|
||||||
|
'login-bruteforce': 'Подбор пароля (10 неудачных входов)',
|
||||||
|
'share-password-bruteforce': 'Подбор пароля ссылки',
|
||||||
|
'manual': 'Вручную',
|
||||||
|
};
|
||||||
|
|
||||||
|
let allBans = [];
|
||||||
|
let page = 1;
|
||||||
|
|
||||||
|
function reasonLabel(r) {
|
||||||
|
return REASONS[r] || r || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBans() {
|
||||||
|
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
|
||||||
|
const tbody = document.getElementById('bansBody');
|
||||||
|
if (!res.ok) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки (${res.status})</td></tr>`;
|
||||||
|
allBans = [];
|
||||||
|
renderPager(document.getElementById('pager'), 1, 1, () => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allBans = await res.json();
|
||||||
|
const maxPage = Math.max(1, Math.ceil(allBans.length / PAGE_SIZE));
|
||||||
|
if (page > maxPage) page = maxPage;
|
||||||
|
renderPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const tbody = document.getElementById('bansBody');
|
||||||
|
const empty = document.getElementById('bansEmpty');
|
||||||
|
const slice = allBans.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
empty.style.display = allBans.length ? 'none' : 'block';
|
||||||
|
tbody.innerHTML = slice.map(b => {
|
||||||
|
const reason = reasonLabel(b.reason);
|
||||||
|
const rcls = ['honeypot', 'login-bruteforce', 'share-password-bruteforce', 'manual'].includes(b.reason)
|
||||||
|
? `reason-${b.reason}` : 'reason-manual';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><b>${esc(b.ip)}</b></td>
|
||||||
|
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
|
||||||
|
<td>${new Date(b.created_at).toLocaleString('ru')}</td>
|
||||||
|
<td>${new Date(b.banned_until).toLocaleString('ru')}</td>
|
||||||
|
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(allBans.length / PAGE_SIZE)), p => { page = p; renderPage(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUnban(ip) {
|
||||||
|
const ban = allBans.find(b => b.ip === ip) || {};
|
||||||
|
if (!confirm(`Удалить IP ${ip} из блокировки (${reasonLabel(ban.reason)})?`)) return;
|
||||||
|
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) showToast('IP удалён из блокировки: ' + ip);
|
||||||
|
else showToast('Ошибка разблокировки');
|
||||||
|
loadBans();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('bansBody').addEventListener('click', e => {
|
||||||
|
const ip = e.target.closest('[data-unban]')?.dataset.unban;
|
||||||
|
if (ip) doUnban(ip);
|
||||||
|
});
|
||||||
|
|
||||||
|
function openBanModal() {
|
||||||
|
document.getElementById('bIp').value = '';
|
||||||
|
document.getElementById('bReason').value = '';
|
||||||
|
document.getElementById('bHours').value = 24;
|
||||||
|
document.getElementById('banModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBanModal() {
|
||||||
|
document.getElementById('banModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBan() {
|
||||||
|
const ip = document.getElementById('bIp').value.trim();
|
||||||
|
const reason = document.getElementById('bReason').value.trim();
|
||||||
|
const hours = parseInt(document.getElementById('bHours').value, 10) || 24;
|
||||||
|
if (!ip) { showToast('Укажите IP-адрес'); return; }
|
||||||
|
const res = await fetch(`${API}/api/bans`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ ip, reason: reason || 'manual', hours }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`IP ${ip} заблокирован на ${hours} ч`);
|
||||||
|
closeBanModal();
|
||||||
|
loadBans();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Ошибка блокировки');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addBanBtn').addEventListener('click', openBanModal);
|
||||||
|
document.getElementById('bCancel').addEventListener('click', closeBanModal);
|
||||||
|
document.getElementById('bSave').addEventListener('click', saveBan);
|
||||||
|
document.getElementById('banModal').addEventListener('click', e => { if (e.target.id === 'banModal') closeBanModal(); });
|
||||||
|
document.getElementById('bIp').addEventListener('keydown', e => { if (e.key === 'Enter') saveBan(); });
|
||||||
|
document.getElementById('bHours').addEventListener('keydown', e => { if (e.key === 'Enter') saveBan(); });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await requireAdminPage()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
loadBans();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
let branches = [];
|
||||||
|
|
||||||
|
async function loadBranches() {
|
||||||
|
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
branches = await res.json();
|
||||||
|
renderBranches();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBranches() {
|
||||||
|
const el = document.getElementById('branchesGrid');
|
||||||
|
if (!branches.length) {
|
||||||
|
el.innerHTML = '<div class="empty" style="grid-column:1/-1">Филиалов пока нет. Нажмите «Добавить филиал».</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = branches.map(b => `
|
||||||
|
<div class="branch-card" data-id="${b.id}">
|
||||||
|
<div class="branch-header">
|
||||||
|
<span class="branch-name">${esc(b.name)}</span>
|
||||||
|
<span class="branch-badge">${b.groups_count} ${plural(b.groups_count, 'группа', 'группы', 'групп')}</span>
|
||||||
|
</div>
|
||||||
|
${b.address ? `<div class="branch-meta">📍 ${esc(b.address)}</div>` : ''}
|
||||||
|
${b.phone ? `<div class="branch-meta">📞 ${esc(b.phone)}</div>` : ''}
|
||||||
|
<div class="branch-actions">
|
||||||
|
<button class="branch-edit" data-edit-branch="${b.id}" title="Редактировать">✎</button>
|
||||||
|
${b.groups_count === 0 ? `<button class="branch-delete" data-del-branch="${b.id}" title="Удалить">×</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(n, one, few, many) {
|
||||||
|
n = Math.abs(n) % 100; const n1 = n % 10;
|
||||||
|
if (n > 10 && n < 20) return many;
|
||||||
|
if (n1 > 1 && n1 < 5) return few;
|
||||||
|
if (n1 === 1) return one;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBranchModal(id = null) {
|
||||||
|
const modal = document.getElementById('branchModal');
|
||||||
|
const form = document.getElementById('branchForm');
|
||||||
|
const title = document.getElementById('branchModalTitle');
|
||||||
|
const submitBtn = document.getElementById('branchSubmitBtn');
|
||||||
|
form.reset();
|
||||||
|
document.getElementById('branchId').value = '';
|
||||||
|
if (id) {
|
||||||
|
const b = branches.find(x => x.id === id);
|
||||||
|
if (!b) return;
|
||||||
|
title.textContent = 'Редактировать филиал';
|
||||||
|
submitBtn.textContent = 'Сохранить';
|
||||||
|
form.name.value = b.name;
|
||||||
|
form.address.value = b.address || '';
|
||||||
|
form.phone.value = b.phone || '';
|
||||||
|
document.getElementById('branchId').value = b.id;
|
||||||
|
} else {
|
||||||
|
title.textContent = 'Создать филиал';
|
||||||
|
submitBtn.textContent = 'Создать';
|
||||||
|
}
|
||||||
|
modal.classList.add('open');
|
||||||
|
setTimeout(() => form.name.focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBranchModal() {
|
||||||
|
document.getElementById('branchModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('branchForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(e.target);
|
||||||
|
const id = fd.get('id');
|
||||||
|
const name = fd.get('name').trim();
|
||||||
|
const address = fd.get('address').trim();
|
||||||
|
const phone = fd.get('phone').trim();
|
||||||
|
if (!name) { alert('Название обязательно'); return; }
|
||||||
|
|
||||||
|
const url = id ? `${API}/api/branches/${id}` : `${API}/api/branches`;
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ name, address: address || null, phone: phone || null })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(id ? 'Филиал обновлён' : 'Филиал создан');
|
||||||
|
closeBranchModal();
|
||||||
|
loadBranches();
|
||||||
|
} else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteBranch(id) {
|
||||||
|
if (!confirm('Удалить этот филиал?')) return;
|
||||||
|
const res = await fetch(`${API}/api/branches/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Филиал удалён');
|
||||||
|
loadBranches();
|
||||||
|
} else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addBranchBtn').addEventListener('click', () => openBranchModal());
|
||||||
|
document.getElementById('branchModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBranchModal(); });
|
||||||
|
document.getElementById('branchCancelBtn').addEventListener('click', closeBranchModal);
|
||||||
|
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||||
|
|
||||||
|
document.getElementById('branchesGrid').addEventListener('click', e => {
|
||||||
|
const ed = e.target.closest('[data-edit-branch]');
|
||||||
|
if (ed) { openBranchModal(parseInt(ed.dataset.editBranch, 10)); return; }
|
||||||
|
const dl = e.target.closest('[data-del-branch]');
|
||||||
|
if (dl) { deleteBranch(parseInt(dl.dataset.delBranch, 10)); }
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await requireAdminPage()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
loadBranches();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
function plural(n, one, few, many) {
|
||||||
|
n = Math.abs(n) % 100; const n1 = n % 10;
|
||||||
|
if (n > 10 && n < 20) return many;
|
||||||
|
if (n1 > 1 && n1 < 5) return few;
|
||||||
|
if (n1 === 1) return one;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats(s, disk) {
|
||||||
|
let diskHtml = '';
|
||||||
|
if (disk) {
|
||||||
|
const pct = disk.used_pct;
|
||||||
|
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
|
||||||
|
diskHtml = `
|
||||||
|
<div class="stat">
|
||||||
|
<div>${disk.used} <span class="stat-sub">/ ${disk.total}</span></div>
|
||||||
|
<div class="label">Диск: свободно ${disk.free} (${100 - pct}%)</div>
|
||||||
|
<div class="stat-bar"><i style="width:${pct}%;background:${color}"></i></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
document.getElementById('stats').innerHTML = `
|
||||||
|
<div class="stat"><div class="num">${s.entries}</div><div class="label">Всего записей</div></div>
|
||||||
|
<div class="stat"><div class="num">${s.students}</div><div class="label">Учеников</div></div>
|
||||||
|
<div class="stat"><div class="num">${s.groups}</div><div class="label">Групп</div></div>
|
||||||
|
<div class="stat"><div class="num">${s.today}</div><div class="label">Сегодня</div></div>
|
||||||
|
<div class="stat"><div class="num" style="color:${s.trash ? '#ef4444' : 'var(--muted)'}">${s.trash}</div><div class="label"><a href="trash.html">В корзине →</a></div></div>
|
||||||
|
${diskHtml}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function last14days() {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActivity(activity) {
|
||||||
|
const map = {};
|
||||||
|
activity.forEach(a => { map[a.d] = a.n; });
|
||||||
|
const days = last14days().map(d => {
|
||||||
|
const iso = d.toISOString().slice(0, 10);
|
||||||
|
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
|
||||||
|
});
|
||||||
|
const max = Math.max(1, ...days.map(x => x.n));
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
document.getElementById('activityChart').innerHTML = days.map(x => `
|
||||||
|
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
|
||||||
|
<div class="bar ${x.iso === todayIso ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
|
||||||
|
<div class="bar-day">${x.label}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecent(rows) {
|
||||||
|
const el = document.getElementById('recentList');
|
||||||
|
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Записей пока нет</div>'; return; }
|
||||||
|
el.innerHTML = rows.map(e => `
|
||||||
|
<div class="recent-item">
|
||||||
|
${e.photo_path
|
||||||
|
? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">`
|
||||||
|
: `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`}
|
||||||
|
<div class="r-info">
|
||||||
|
<div class="r-name">${esc(e.student_name)}</div>
|
||||||
|
<div class="r-meta">${esc(e.group_name)} · ${new Date(e.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
</div>
|
||||||
|
<a class="r-link" href="journal.html" title="В журнал">→</a>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActive(rows) {
|
||||||
|
const el = document.getElementById('activeList');
|
||||||
|
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Сейчас нет идущих групп</div>'; return; }
|
||||||
|
el.innerHTML = rows.map(g => `
|
||||||
|
<div class="active-item">
|
||||||
|
<span class="a-name">${esc(g.name)}</span>
|
||||||
|
<span class="a-right">
|
||||||
|
<span class="a-time">${g.day_label} ${g.time_start}–${g.time_end}</span>
|
||||||
|
<span class="a-now">сейчас</span>
|
||||||
|
</span>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTop(rows) {
|
||||||
|
const el = document.getElementById('topList');
|
||||||
|
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Пока нет данных</div>'; return; }
|
||||||
|
el.innerHTML = rows.map((x, i) => `
|
||||||
|
<div class="top-item">
|
||||||
|
<span class="top-rank ${i > 2 ? 'alt' : ''}">${i + 1}</span>
|
||||||
|
<span class="top-name">${esc(x.student_name)}</span>
|
||||||
|
<span class="top-count">${x.n}</span>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPhotos(rows) {
|
||||||
|
const el = document.getElementById('dashPhotos');
|
||||||
|
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Фото групп пока нет</div>'; return; }
|
||||||
|
el.innerHTML = rows.map(p => `
|
||||||
|
<div class="dash-photo" title="${esc(p.group_name)}">
|
||||||
|
<img src="${thumbSrc(p.photo_path)}" data-img="${API}${p.photo_path}" loading="lazy" decoding="async" alt="">
|
||||||
|
${p.caption ? `<div class="pcap">${esc(p.caption)}</div>` : ''}
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
|
||||||
|
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
|
||||||
|
const d = await res.json();
|
||||||
|
renderStats(d.stats, d.disk);
|
||||||
|
renderActivity(d.activity);
|
||||||
|
renderRecent(d.recent_entries);
|
||||||
|
renderActive(d.active_groups);
|
||||||
|
renderTop(d.top_students);
|
||||||
|
renderPhotos(d.photos);
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await checkAuth()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
loadDashboard();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
(function(){
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const code = params.get('code') || '404';
|
||||||
|
const title = params.get('title') || '';
|
||||||
|
const message = params.get('message') || '';
|
||||||
|
const details = params.get('details') || '';
|
||||||
|
document.getElementById('errorCode').textContent = code;
|
||||||
|
document.getElementById('errorTitle').textContent = title || (code === '404' ? 'Страница не найдена' : 'Ошибка');
|
||||||
|
document.getElementById('errorMessage').textContent = message || (code === '404' ? 'Запрашиваемая страница не существует или была перемещена.' : 'Произошла ошибка на сервере.');
|
||||||
|
if (details) {
|
||||||
|
document.getElementById('errorDetails').style.display = 'block';
|
||||||
|
document.getElementById('errorStack').textContent = details;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.querySelector('.error-actions button').addEventListener('click', () => history.back());
|
||||||
|
|
||||||
|
fetch('/api/public-settings').then(r => r.ok ? r.json() : null).then(s => {
|
||||||
|
if (s && (s.system_name || '').trim()) document.title = `Ошибка — ${s.system_name}`;
|
||||||
|
}).catch(() => {});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
const PAGE_SIZE = 20;
|
||||||
|
let page = 1;
|
||||||
|
let totalFiles = 0;
|
||||||
|
let currentFiles = [];
|
||||||
|
let groupsLoaded = false;
|
||||||
|
let studentsLoaded = false;
|
||||||
|
let searchTimer = null;
|
||||||
|
let mode = 'attached';
|
||||||
|
|
||||||
|
function setMode(m) {
|
||||||
|
mode = m === 'detached' ? 'detached' : 'attached';
|
||||||
|
document.querySelectorAll('#tabToggle button').forEach(b => {
|
||||||
|
b.classList.toggle('active', b.dataset.mode === mode);
|
||||||
|
});
|
||||||
|
const show = mode === 'attached';
|
||||||
|
const keep = ['searchInput', 'studentFilter', 'groupFilter', 'dateFrom', 'dateTo'].map(id => document.getElementById(id));
|
||||||
|
if (!show) { keep.forEach(el => { el.value = ''; }); }
|
||||||
|
keep.forEach(el => { el.parentElement.style.display = show ? '' : 'none'; });
|
||||||
|
page = 1;
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageFile(name) {
|
||||||
|
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanSize(b) {
|
||||||
|
if (!b) return '';
|
||||||
|
if (b < 1024) return b + ' Б';
|
||||||
|
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||||
|
return (b / 1048576).toFixed(1) + ' МБ';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileCardHTML(f) {
|
||||||
|
const url = `${API}/api/files/${f.token}`;
|
||||||
|
const thumb = isImageFile(f.name)
|
||||||
|
? `<img src="${thumbSrc(url)}" data-img="${esc(url)}" loading="lazy" decoding="async" alt="">`
|
||||||
|
: `<div class="file-icon">📄</div>`;
|
||||||
|
const target = isImageFile(f.name)
|
||||||
|
? `href="#" data-img="${esc(url)}"`
|
||||||
|
: `href="${url}" download`;
|
||||||
|
const meta = mode === 'detached'
|
||||||
|
? `<div class="group" style="color:#f59e0b">Откреплён от записи, не привязан</div>`
|
||||||
|
: `<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>`;
|
||||||
|
const action = mode === 'detached'
|
||||||
|
? `<button class="f-action f-del" data-delete="${f.id}" title="Удалить файл навсегда">Удалить</button>`
|
||||||
|
: `<button class="f-action" data-detach="${f.id}" title="Открепить файл от записи">Открепить</button>`;
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
${thumb}
|
||||||
|
<div class="info">
|
||||||
|
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
|
||||||
|
${meta}
|
||||||
|
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="f-actions">${action}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
if (!currentFiles.length) {
|
||||||
|
grid.className = 'grid';
|
||||||
|
grid.innerHTML = mode === 'detached'
|
||||||
|
? '<div class="empty">Откреплённых файлов нет</div>'
|
||||||
|
: '<div class="empty">Файлов не найдено</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.className = 'grid';
|
||||||
|
grid.innerHTML = currentFiles.map(fileCardHTML).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detachFile(id) {
|
||||||
|
if (!confirm('Открепить файл от записи? Он переместится во вкладку «Откреплённые».')) return;
|
||||||
|
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
|
||||||
|
if (res.ok) { showToast('Файл откреплён'); loadFiles(); }
|
||||||
|
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(id) {
|
||||||
|
if (!confirm('Удалить файл навсегда? Отменить это действие нельзя.')) return;
|
||||||
|
const res = await fetch(`${API}/api/files/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) { showToast('Файл удалён'); loadFiles(); }
|
||||||
|
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupOptions() {
|
||||||
|
if (groupsLoaded) return;
|
||||||
|
const sel = document.getElementById('groupFilter');
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
|
const groups = await res.json();
|
||||||
|
groups.forEach(g => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = g.id; o.textContent = g.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
groupsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStudentOptions() {
|
||||||
|
if (studentsLoaded) return;
|
||||||
|
const dl = document.getElementById('studentFilterList');
|
||||||
|
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||||||
|
const names = await res.json();
|
||||||
|
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
||||||
|
studentsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (mode === 'attached') {
|
||||||
|
const q = document.getElementById('searchInput').value;
|
||||||
|
const sn = document.getElementById('studentFilter').value;
|
||||||
|
const gid = document.getElementById('groupFilter').value;
|
||||||
|
const df = document.getElementById('dateFrom').value;
|
||||||
|
const dt = document.getElementById('dateTo').value;
|
||||||
|
if (q) p.set('search', q);
|
||||||
|
if (sn) p.set('student_name', sn);
|
||||||
|
if (gid) p.set('group_id', gid);
|
||||||
|
if (df) p.set('date_from', df);
|
||||||
|
if (dt) p.set('date_to', dt);
|
||||||
|
}
|
||||||
|
p.set('limit', PAGE_SIZE);
|
||||||
|
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||||
|
const res = await fetch(`${API}/api/files${mode === 'detached' ? '/detached' : ''}?${p}`, { headers: hdr() });
|
||||||
|
const data = await res.json();
|
||||||
|
currentFiles = data.files;
|
||||||
|
totalFiles = data.total;
|
||||||
|
renderGrid();
|
||||||
|
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalFiles / PAGE_SIZE)), goPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(p) {
|
||||||
|
page = p;
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||||
|
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||||
|
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||||
|
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||||
|
document.getElementById('searchInput').addEventListener('input', () => {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => { page = 1; loadFiles(); }, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||||
|
document.querySelectorAll('#tabToggle button').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
|
||||||
|
|
||||||
|
document.getElementById('grid').addEventListener('click', e => {
|
||||||
|
const dt = e.target.closest('[data-detach]');
|
||||||
|
if (dt) { detachFile(parseInt(dt.dataset.detach, 10)); return; }
|
||||||
|
const del = e.target.closest('[data-delete]');
|
||||||
|
if (del) { deleteFile(parseInt(del.dataset.delete, 10)); }
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await checkAuth()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
const detachedTab = document.getElementById('detachedTab');
|
||||||
|
if (detachedTab) detachedTab.style.display = isAdmin() ? '' : 'none';
|
||||||
|
if (mode === 'detached' && !isAdmin()) { mode = 'attached'; }
|
||||||
|
document.querySelectorAll('#tabToggle button').forEach(b => {
|
||||||
|
b.classList.toggle('active', b.dataset.mode === mode);
|
||||||
|
});
|
||||||
|
loadGroupOptions();
|
||||||
|
loadStudentOptions();
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||||
|
let branchesCache = [];
|
||||||
|
let tutorsCache = [];
|
||||||
|
let allGroups = [];
|
||||||
|
let currentCover = null;
|
||||||
|
|
||||||
|
async function loadBranchesCache() {
|
||||||
|
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
|
||||||
|
if (res.ok) branchesCache = await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTutorsCache() {
|
||||||
|
const res = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
|
||||||
|
if (res.ok) tutorsCache = await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tutorSelectHTML(selectedId) {
|
||||||
|
const opts = tutorsCache.map(t => `<option value="${t.id}" ${t.id === selectedId ? 'selected' : ''}>${esc(t.name || t.username)}</option>`).join('');
|
||||||
|
return `<div><label>Тутор</label><select class="tutor-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— не назначен —</option>${opts}</select></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchSelectHTML(selectedId) {
|
||||||
|
const opts = branchesCache.map(b => `<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
|
||||||
|
return `<div><label>Филиал</label><select class="branch-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— без филиала —</option>${opts}</select></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
await loadBranchesCache();
|
||||||
|
await loadTutorsCache();
|
||||||
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
|
const groups = await res.json();
|
||||||
|
allGroups = groups;
|
||||||
|
const list = document.getElementById('groupList');
|
||||||
|
if (!groups.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Нет групп</div>'; return; }
|
||||||
|
list.innerHTML = `<div class="group-grid">` + groups.map(g => {
|
||||||
|
const schedSet = g.day_of_week != null && g.time_start && g.time_end;
|
||||||
|
return `
|
||||||
|
<div class="group-card" data-group-id="${g.id}">
|
||||||
|
${g.cover_path
|
||||||
|
? `<img class="group-cover" src="${thumbSrc(g.cover_path)}" loading="lazy" decoding="async" alt="">`
|
||||||
|
: `<div class="group-cover empty-cover">📷</div>`}
|
||||||
|
<div class="group-head">
|
||||||
|
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
|
||||||
|
<button class="del" data-del-group="${g.id}" title="Удалить">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="group-schedule">
|
||||||
|
${branchSelectHTML(g.branch_id)}
|
||||||
|
${tutorSelectHTML(g.tutor_id)}
|
||||||
|
<div><label>День</label>
|
||||||
|
<select id="dow-${g.id}">
|
||||||
|
<option value="">—</option>
|
||||||
|
${DAYS.map((d, i) => `<option value="${i}" ${g.day_of_week === i ? 'selected' : ''}>${d}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><label>С</label>
|
||||||
|
<input type="time" id="ts-${g.id}" value="${g.time_start ? g.time_start.slice(0,5) : ''}">
|
||||||
|
</div>
|
||||||
|
<div><label>По</label>
|
||||||
|
<input type="time" id="te-${g.id}" value="${g.time_end ? g.time_end.slice(0,5) : ''}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group-foot">
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="save-sched" data-save-group="${g.id}">Сохранить</button>
|
||||||
|
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">Фото</button>
|
||||||
|
<button class="btn-link" data-export-group="${g.id}" data-group-name="${esc(g.name)}" title="Экспортировать записи группы">CSV</button>
|
||||||
|
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
|
||||||
|
</div>
|
||||||
|
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('') + `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGroup(id) {
|
||||||
|
const name = document.getElementById(`name-${id}`).value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const nameInput = document.getElementById(`name-${id}`);
|
||||||
|
const groupCard = nameInput.closest('.group-card');
|
||||||
|
const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null;
|
||||||
|
const branch_id = branchSelect ? branchSelect.value : '';
|
||||||
|
const tutorSelect = groupCard ? groupCard.querySelector('.tutor-select') : null;
|
||||||
|
const tutor_id = tutorSelect ? (tutorSelect.value || null) : undefined;
|
||||||
|
const dow = document.getElementById(`dow-${id}`).value;
|
||||||
|
const ts = document.getElementById(`ts-${id}`).value;
|
||||||
|
const te = document.getElementById(`te-${id}`).value;
|
||||||
|
const res = await fetch(`${API}/api/groups/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
loadGroups();
|
||||||
|
const dayLabel = dow !== '' ? DAYS[dow] : 'без расписания';
|
||||||
|
const timeLabel = ts && te ? `, ${ts}–${te}` : '';
|
||||||
|
showToast(`Сохранено: «${name}» — ${dayLabel}${timeLabel}`);
|
||||||
|
} else { const e = await res.json(); alert(e.error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGroup() {
|
||||||
|
const input = document.getElementById('newGroup');
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
await loadBranchesCache();
|
||||||
|
await loadTutorsCache();
|
||||||
|
const branchSelect = document.getElementById('newGroupBranch');
|
||||||
|
const branch_id = branchSelect ? branchSelect.value : '';
|
||||||
|
const tutorSelect = document.getElementById('newGroupTutor');
|
||||||
|
const tutor_id = tutorSelect && tutorSelect.value ? tutorSelect.value : null;
|
||||||
|
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id }) });
|
||||||
|
if (res.ok) { input.value = ''; loadGroups(); }
|
||||||
|
else { const e = await res.json(); alert(e.error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delGroup(id) {
|
||||||
|
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
|
||||||
|
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
loadGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHOTO_PAGE_SIZE = 24;
|
||||||
|
let currentGroupId = null;
|
||||||
|
let currentGroupName = '';
|
||||||
|
let photos = [];
|
||||||
|
let photoPage = 1;
|
||||||
|
let totalPhotos = 0;
|
||||||
|
let editingPhotoId = null;
|
||||||
|
|
||||||
|
async function openPhotos(groupId, groupName) {
|
||||||
|
currentGroupId = groupId;
|
||||||
|
currentGroupName = groupName;
|
||||||
|
const g = allGroups.find(x => String(x.id) === String(groupId));
|
||||||
|
currentCover = g ? g.cover_path : null;
|
||||||
|
photoPage = 1;
|
||||||
|
editingPhotoId = null;
|
||||||
|
document.getElementById('photosTitle').textContent = `Фото — ${groupName}`;
|
||||||
|
document.getElementById('photosModal').classList.add('open');
|
||||||
|
await loadGroupPhotos();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePhotos() {
|
||||||
|
document.getElementById('photosModal').classList.remove('open');
|
||||||
|
currentGroupId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupPhotos() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('limit', PHOTO_PAGE_SIZE);
|
||||||
|
p.set('offset', (photoPage - 1) * PHOTO_PAGE_SIZE);
|
||||||
|
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos?${p}`, { headers: hdr() });
|
||||||
|
const data = await res.json();
|
||||||
|
photos = data.photos;
|
||||||
|
totalPhotos = data.total;
|
||||||
|
document.getElementById('photoGrid').className = 'photo-grid';
|
||||||
|
document.getElementById('photoGrid').innerHTML = photos.length
|
||||||
|
? photos.map(photoHTML).join('')
|
||||||
|
: '<div class="empty" style="grid-column:1/-1">Фото пока нет. Загрузите первое фото.</div>';
|
||||||
|
renderPager(document.getElementById('photosPager'), photoPage, Math.max(1, Math.ceil(totalPhotos / PHOTO_PAGE_SIZE)), goPhotoPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
|
||||||
|
|
||||||
|
function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; }
|
||||||
|
|
||||||
|
function photoHTML(p) {
|
||||||
|
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
|
||||||
|
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
|
||||||
|
const isCover = currentCover && p.photo_path === currentCover;
|
||||||
|
return `
|
||||||
|
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
|
||||||
|
<div class="drag-hint" title="Перетащите, чтобы изменить порядок">⠿</div>
|
||||||
|
<img src="${thumbSrc(p.photo_path)}" data-img="${API}${p.photo_path}" loading="lazy" decoding="async" alt="">
|
||||||
|
<div class="p-cap">${esc(p.caption || '')}</div>
|
||||||
|
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
|
||||||
|
<div class="p-actions">
|
||||||
|
<button class="p-cover${isCover ? ' active' : ''}" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" title="${isCover ? 'Текущая обложка' : 'Сделать обложкой'}" ${isCover ? 'disabled' : ''}>🖼️</button>
|
||||||
|
<button class="p-edit" data-edit-photo="${p.id}" title="Редактировать">✎</button>
|
||||||
|
<button class="p-del" data-del-photo="${p.id}" title="Удалить">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddPhoto() {
|
||||||
|
editingPhotoId = null;
|
||||||
|
document.getElementById('photoFormTitle').textContent = `Добавить фото — ${currentGroupName}`;
|
||||||
|
document.getElementById('photoFile').value = '';
|
||||||
|
document.getElementById('photoCaption').value = '';
|
||||||
|
document.getElementById('photoTakenAt').value = '';
|
||||||
|
document.getElementById('photoFormPreview').style.display = 'none';
|
||||||
|
document.getElementById('photoFormModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditPhoto(id) {
|
||||||
|
const p = photos.find(x => x.id === id);
|
||||||
|
if (!p) return;
|
||||||
|
editingPhotoId = id;
|
||||||
|
document.getElementById('photoFormTitle').textContent = 'Редактировать фото';
|
||||||
|
document.getElementById('photoFile').value = '';
|
||||||
|
document.getElementById('photoCaption').value = p.caption || '';
|
||||||
|
document.getElementById('photoTakenAt').value = p.taken_at || '';
|
||||||
|
const prev = document.getElementById('photoFormPreview');
|
||||||
|
const img = document.getElementById('photoPreviewImg');
|
||||||
|
img.src = `${API}${p.photo_path}`;
|
||||||
|
prev.style.display = 'block';
|
||||||
|
document.getElementById('photoFormModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePhotoForm() {
|
||||||
|
document.getElementById('photoFormModal').classList.remove('open');
|
||||||
|
editingPhotoId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewPhotoFile() {
|
||||||
|
const file = document.getElementById('photoFile').files[0];
|
||||||
|
const prev = document.getElementById('photoFormPreview');
|
||||||
|
if (!file) { prev.style.display = 'none'; return; }
|
||||||
|
const img = document.getElementById('photoPreviewImg');
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
prev.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePhotoForm() {
|
||||||
|
const caption = document.getElementById('photoCaption').value.trim();
|
||||||
|
const taken_at = document.getElementById('photoTakenAt').value;
|
||||||
|
if (editingPhotoId) {
|
||||||
|
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${editingPhotoId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ caption: caption || null, taken_at: taken_at || null })
|
||||||
|
});
|
||||||
|
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
|
||||||
|
showToast('Фото обновлено');
|
||||||
|
} else {
|
||||||
|
const file = document.getElementById('photoFile').files[0];
|
||||||
|
if (!file) { alert('Выберите файл'); return; }
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', file);
|
||||||
|
fd.append('caption', caption);
|
||||||
|
fd.append('taken_at', taken_at);
|
||||||
|
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: hdr(),
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
|
||||||
|
showToast('Фото добавлено в хронологию');
|
||||||
|
}
|
||||||
|
closePhotoForm();
|
||||||
|
loadGroupPhotos();
|
||||||
|
loadGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePhoto(id) {
|
||||||
|
if (!confirm('Удалить это фото безвозвратно?')) return;
|
||||||
|
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
|
||||||
|
const p = photos.find(x => x.id === id);
|
||||||
|
if (p && currentCover === p.photo_path) currentCover = null;
|
||||||
|
showToast('Фото удалено');
|
||||||
|
loadGroupPhotos();
|
||||||
|
loadGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportGroup(id, name) {
|
||||||
|
const res = await fetch(`${API}/api/entries?group_id=${id}`, { headers: hdr() });
|
||||||
|
const { entries } = await res.json();
|
||||||
|
if (!entries.length) { alert('Нет данных для экспорта'); return; }
|
||||||
|
const rows = [['Фамилия Имя', 'Группа', 'Описание', 'Дата', 'Ссылка на страницу']];
|
||||||
|
entries.forEach(e => {
|
||||||
|
rows.push([
|
||||||
|
e.student_name,
|
||||||
|
e.group_name,
|
||||||
|
e.description.replace(/\n/g, ' '),
|
||||||
|
new Date(e.created_at).toLocaleString('ru'),
|
||||||
|
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
|
||||||
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `group_${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast(`Экспортировано записей: ${entries.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportGroupUrls(groupId, groupName) {
|
||||||
|
const res = await fetch(`${API}/api/students`, { headers: hdr() });
|
||||||
|
const students = await res.json();
|
||||||
|
const list = students.filter(s => s.group_id === groupId);
|
||||||
|
if (!list.length) { alert('В группе нет учеников'); return; }
|
||||||
|
await exportStudentUrlZip(list, `group_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
|
||||||
|
showToast(`Экспортировано: ${list.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
|
||||||
|
document.getElementById('addGroupBtn').addEventListener('click', addGroup);
|
||||||
|
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
|
||||||
|
document.getElementById('photoForm').addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
savePhotoForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); });
|
||||||
|
document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); });
|
||||||
|
document.getElementById('openPhotoBtn').addEventListener('click', openAddPhoto);
|
||||||
|
document.getElementById('closePhotosBtn').addEventListener('click', closePhotos);
|
||||||
|
document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm);
|
||||||
|
|
||||||
|
document.getElementById('groupList').addEventListener('click', e => {
|
||||||
|
const sg = e.target.closest('[data-save-group]');
|
||||||
|
if (sg) { saveGroup(parseInt(sg.dataset.saveGroup, 10)); return; }
|
||||||
|
const dg = e.target.closest('[data-del-group]');
|
||||||
|
if (dg) { delGroup(parseInt(dg.dataset.delGroup, 10)); return; }
|
||||||
|
const eg = e.target.closest('[data-export-group]');
|
||||||
|
if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; }
|
||||||
|
const eu = e.target.closest('[data-export-urls]');
|
||||||
|
if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; }
|
||||||
|
const photoBtn = e.target.closest('.photo-btn');
|
||||||
|
if (photoBtn) {
|
||||||
|
const id = parseInt(photoBtn.dataset.groupId, 10);
|
||||||
|
const name = photoBtn.dataset.groupName;
|
||||||
|
if (!isNaN(id)) openPhotos(id, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateGroupCoverUI(coverPath) {
|
||||||
|
const card = document.querySelector(`.group-card[data-group-id="${currentGroupId}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
const g = allGroups.find(x => String(x.id) === String(currentGroupId));
|
||||||
|
const existing = card.querySelector('.group-cover');
|
||||||
|
if (existing && existing.tagName === 'IMG') { existing.src = API + coverPath; return; }
|
||||||
|
const el = document.createElement('img');
|
||||||
|
el.className = 'group-cover';
|
||||||
|
el.src = API + coverPath;
|
||||||
|
el.alt = '';
|
||||||
|
if (existing) existing.replaceWith(el);
|
||||||
|
else card.insertBefore(el, card.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragPhotoId = null;
|
||||||
|
|
||||||
|
async function persistPhotoOrder() {
|
||||||
|
const items = [...document.querySelectorAll('#photoGrid .photo-item')];
|
||||||
|
const order = items.map(el => parseInt(el.dataset.photoId, 10));
|
||||||
|
if (JSON.stringify(order) === JSON.stringify(photos.map(p => p.id))) return;
|
||||||
|
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ order })
|
||||||
|
});
|
||||||
|
if (res.ok) showToast('Порядок фото сохранён');
|
||||||
|
else {
|
||||||
|
try { const err = await res.json(); alert(err.error || 'Ошибка'); } catch { alert('Ошибка'); }
|
||||||
|
loadGroupPhotos();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('photoGrid').addEventListener('dragstart', e => {
|
||||||
|
const item = e.target.closest('.photo-item');
|
||||||
|
if (!item) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
dragPhotoId = parseInt(item.dataset.photoId, 10);
|
||||||
|
item.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', String(dragPhotoId));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('photoGrid').addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const target = e.target.closest('.photo-item');
|
||||||
|
const dragged = dragPhotoId != null ? document.querySelector(`.photo-item[data-photo-id="${dragPhotoId}"]`) : null;
|
||||||
|
if (!target || !dragged || target === dragged) return;
|
||||||
|
if (target.contains(dragged)) return;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const grid = document.getElementById('photoGrid');
|
||||||
|
grid.insertBefore(dragged, e.clientX > rect.left + rect.width / 2 ? target.nextSibling : target);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('photoGrid').addEventListener('dragend', e => {
|
||||||
|
const item = e.target.closest('.photo-item');
|
||||||
|
if (item) item.classList.remove('dragging');
|
||||||
|
if (dragPhotoId != null) persistPhotoOrder();
|
||||||
|
dragPhotoId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('photoGrid').addEventListener('drop', e => e.preventDefault());
|
||||||
|
|
||||||
|
document.getElementById('photoGrid').addEventListener('click', async e => {
|
||||||
|
const coverBtn = e.target.closest('.p-cover');
|
||||||
|
if (coverBtn) {
|
||||||
|
const photoId = parseInt(coverBtn.dataset.photoId, 10);
|
||||||
|
const photoPath = coverBtn.dataset.photoPath;
|
||||||
|
if (!isNaN(photoId)) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${photoId}/cover`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: hdrJson(),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const g = await res.json();
|
||||||
|
currentCover = g.cover_path || null;
|
||||||
|
const grp = allGroups.find(x => String(x.id) === String(currentGroupId));
|
||||||
|
if (grp) grp.cover_path = currentCover;
|
||||||
|
showToast('Обложка обновлена');
|
||||||
|
updateGroupCoverUI(currentCover);
|
||||||
|
loadGroupPhotos();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.error || 'Ошибка');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка сети: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ed = e.target.closest('[data-edit-photo]');
|
||||||
|
if (ed) { openEditPhoto(parseInt(ed.dataset.editPhoto, 10)); return; }
|
||||||
|
const dl = e.target.closest('[data-del-photo]');
|
||||||
|
if (dl) { deletePhoto(parseInt(dl.dataset.delPhoto, 10)); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function populateNewGroupBranch() {
|
||||||
|
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
const branches = await res.json();
|
||||||
|
const sel = document.getElementById('newGroupBranch');
|
||||||
|
sel.innerHTML = '<option value="">— без филиала —</option>' + branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
const tRes = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
|
||||||
|
if (tRes.ok) {
|
||||||
|
const tutors = await tRes.json();
|
||||||
|
const tSel = document.getElementById('newGroupTutor');
|
||||||
|
tSel.innerHTML = '<option value="">— не назначен —</option>' + tutors.map(t => `<option value="${t.id}">${esc(t.name || t.username)}</option>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await checkAuth()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
await populateNewGroupBranch();
|
||||||
|
loadGroups();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
const API = location.origin;
|
||||||
|
const form = document.getElementById('form');
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
const placeholderSrc = preview.src;
|
||||||
|
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
||||||
|
const groupInput = document.getElementById('groupInput');
|
||||||
|
const moduleInput = document.getElementById('moduleInput');
|
||||||
|
const moduleInputList = document.getElementById('moduleInputList');
|
||||||
|
const moduleField = document.getElementById('moduleField');
|
||||||
|
const moduleNames = new Map();
|
||||||
|
let moduleSearchTimer = null;
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
const camVideo = document.getElementById('camVideo');
|
||||||
|
const camCanvas = document.getElementById('camCanvas');
|
||||||
|
const camModal = document.getElementById('camModal');
|
||||||
|
let capturedBlob = null;
|
||||||
|
let stream = null;
|
||||||
|
const descInput = document.getElementById('descInput');
|
||||||
|
const filesInput = document.getElementById('filesInput');
|
||||||
|
const filesList = document.getElementById('filesList');
|
||||||
|
const filesClearBtn = document.getElementById('filesClearBtn');
|
||||||
|
let selectedFiles = [];
|
||||||
|
const sentPanel = document.getElementById('sentPanel');
|
||||||
|
const sentTitle = document.getElementById('sentTitle');
|
||||||
|
const sentText = document.getElementById('sentText');
|
||||||
|
const sentTimer = document.getElementById('sentTimer');
|
||||||
|
const SENT_KEY = 'whatido_entry_sent_at';
|
||||||
|
let entryCooldownMin = 30;
|
||||||
|
let captureWidth = 640;
|
||||||
|
let captureHeight = 480;
|
||||||
|
let captureQuality = 0.92;
|
||||||
|
let sentTimerId = null;
|
||||||
|
|
||||||
|
function unlockForm() {
|
||||||
|
clearInterval(sentTimerId);
|
||||||
|
sentTimerId = null;
|
||||||
|
localStorage.removeItem(SENT_KEY);
|
||||||
|
sentPanel.style.display = 'none';
|
||||||
|
form.style.display = 'grid';
|
||||||
|
toast.textContent = 'Можно отправить новую запись';
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(() => toast.classList.remove('show'), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickSentTimer(unlockTime) {
|
||||||
|
const remain = Math.max(0, unlockTime - Date.now());
|
||||||
|
if (remain <= 0) {
|
||||||
|
sentTimer.textContent = '';
|
||||||
|
unlockForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const m = Math.floor(remain / 60000);
|
||||||
|
const s = Math.floor((remain % 60000) / 1000);
|
||||||
|
sentTimer.textContent = 'Новую запись можно отправить через ' + m + ' мин ' + s + ' сек';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSent(title, text, unlockTime) {
|
||||||
|
form.style.display = 'none';
|
||||||
|
sentPanel.style.display = 'flex';
|
||||||
|
sentTitle.textContent = title;
|
||||||
|
sentText.textContent = text;
|
||||||
|
tickSentTimer(unlockTime);
|
||||||
|
clearInterval(sentTimerId);
|
||||||
|
sentTimerId = setInterval(() => tickSentTimer(unlockTime), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cooldownMs() { return entryCooldownMin * 60 * 1000; }
|
||||||
|
|
||||||
|
function checkPreviousSend() {
|
||||||
|
const raw = localStorage.getItem(SENT_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const sentAt = parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(sentAt)) { localStorage.removeItem(SENT_KEY); return; }
|
||||||
|
const unlockTime = sentAt + cooldownMs();
|
||||||
|
if (Date.now() < unlockTime) {
|
||||||
|
showSent('Запись уже отправлена', 'Ты уже отправил(а) запись сегодня. Дождись окончания отсчёта, чтобы отправить новую.', unlockTime);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(SENT_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanSize(b) {
|
||||||
|
if (b < 1024) return b + ' Б';
|
||||||
|
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||||
|
return (b / 1048576).toFixed(1) + ' МБ';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles() {
|
||||||
|
filesList.innerHTML = '';
|
||||||
|
selectedFiles.forEach((f, i) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'file-row';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'f-name';
|
||||||
|
name.textContent = f.name;
|
||||||
|
name.title = f.name;
|
||||||
|
const size = document.createElement('span');
|
||||||
|
size.className = 'f-size';
|
||||||
|
size.textContent = humanSize(f.size);
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.type = 'button';
|
||||||
|
del.textContent = '✕';
|
||||||
|
del.addEventListener('click', () => removeFile(i));
|
||||||
|
row.append(name, size, del);
|
||||||
|
filesList.appendChild(row);
|
||||||
|
});
|
||||||
|
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(i) {
|
||||||
|
selectedFiles.splice(i, 1);
|
||||||
|
renderFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFiles() {
|
||||||
|
selectedFiles = [];
|
||||||
|
filesInput.value = '';
|
||||||
|
renderFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
filesInput.addEventListener('change', () => {
|
||||||
|
const keep = 10 - selectedFiles.length;
|
||||||
|
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
||||||
|
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
||||||
|
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
||||||
|
filesInput.value = '';
|
||||||
|
renderFiles();
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
let studentName = '';
|
||||||
|
function escName(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
if (params.get('nameInput')) {
|
||||||
|
studentName = params.get('nameInput');
|
||||||
|
document.getElementById('nameInput').value = studentName;
|
||||||
|
const field = document.getElementById('nameField');
|
||||||
|
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModuleOptions(list) {
|
||||||
|
list.forEach(m => moduleNames.set(m.name, m.id));
|
||||||
|
moduleInputList.innerHTML = '';
|
||||||
|
list.forEach(m => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m.name;
|
||||||
|
moduleInputList.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchModules(q, limit) {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (q) p.set('search', q);
|
||||||
|
p.set('limit', String(limit));
|
||||||
|
p.set('active', '1');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/modules?${p}`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.modules || [];
|
||||||
|
} catch (e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveModuleId(name) {
|
||||||
|
const n = (name || '').trim();
|
||||||
|
if (!n) return null;
|
||||||
|
if (moduleNames.has(n)) return moduleNames.get(n);
|
||||||
|
const list = await fetchModules(n, 5);
|
||||||
|
renderModuleOptions(list);
|
||||||
|
const lower = n.toLowerCase();
|
||||||
|
const exact = list.find(m => m.name.toLowerCase() === lower);
|
||||||
|
return exact ? exact.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const list = await fetchModules('', 50);
|
||||||
|
if (list.length) {
|
||||||
|
renderModuleOptions(list);
|
||||||
|
moduleInput.required = true;
|
||||||
|
moduleField.style.display = '';
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
const res = await fetch(`${API}/api/students`);
|
||||||
|
const students = await res.json();
|
||||||
|
const me = studentName ? students.find(s => s.name === studentName) : null;
|
||||||
|
if (me && me.group_id) {
|
||||||
|
groupInput.innerHTML = `<option value="${me.group_id}" selected>${escName(me.group_name || `Группа ${me.group_id}`)}</option>`;
|
||||||
|
groupInput.value = String(me.group_id);
|
||||||
|
} else {
|
||||||
|
const groupsRes = await fetch(`${API}/api/groups/active`);
|
||||||
|
const groups = await groupsRes.json();
|
||||||
|
groups.forEach(g => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = g.id;
|
||||||
|
opt.textContent = g.name;
|
||||||
|
groupInput.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (!groups.length) {
|
||||||
|
groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fRes = await fetch(`${API}/api/public-settings`);
|
||||||
|
const f = await fRes.json();
|
||||||
|
const title = document.getElementById('title');
|
||||||
|
if (title && (f.system_name || '').trim()) {
|
||||||
|
title.textContent = f.system_name;
|
||||||
|
document.title = f.system_name;
|
||||||
|
}
|
||||||
|
const brandLogo = document.getElementById('brandLogo');
|
||||||
|
if (brandLogo && f.system_logo) {
|
||||||
|
brandLogo.src = f.system_logo;
|
||||||
|
brandLogo.hidden = false;
|
||||||
|
brandLogo.alt = f.system_name || '';
|
||||||
|
}
|
||||||
|
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
|
||||||
|
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
||||||
|
const c = parseInt(f.spam_interval_min, 10);
|
||||||
|
if (Number.isFinite(c) && c >= 1) entryCooldownMin = c;
|
||||||
|
const w = parseInt(f.photo_capture_width, 10);
|
||||||
|
const h = parseInt(f.photo_capture_height, 10);
|
||||||
|
if (Number.isFinite(w) && w >= 160 && w <= 4096) captureWidth = w;
|
||||||
|
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
||||||
|
const q = parseFloat(f.photo_capture_quality);
|
||||||
|
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
checkPreviousSend();
|
||||||
|
})();
|
||||||
|
|
||||||
|
function showPreview(src) {
|
||||||
|
preview.src = src;
|
||||||
|
preview.classList.add('has-photo');
|
||||||
|
removePhotoBtn.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePhoto() {
|
||||||
|
preview.src = placeholderSrc;
|
||||||
|
preview.classList.remove('has-photo');
|
||||||
|
removePhotoBtn.classList.remove('show');
|
||||||
|
capturedBlob = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCam() {
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
|
||||||
|
camVideo.srcObject = stream;
|
||||||
|
camModal.classList.add('open');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Не удалось открыть камеру: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCam() {
|
||||||
|
camModal.classList.remove('open');
|
||||||
|
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture() {
|
||||||
|
camCanvas.width = camVideo.videoWidth;
|
||||||
|
camCanvas.height = camVideo.videoHeight;
|
||||||
|
const ctx = camCanvas.getContext('2d');
|
||||||
|
ctx.translate(camCanvas.width, 0);
|
||||||
|
ctx.scale(-1, 1);
|
||||||
|
ctx.drawImage(camVideo, 0, 0);
|
||||||
|
camCanvas.toBlob(blob => {
|
||||||
|
capturedBlob = blob;
|
||||||
|
showPreview(URL.createObjectURL(blob));
|
||||||
|
closeCam();
|
||||||
|
}, 'image/jpeg', captureQuality);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!capturedBlob) {
|
||||||
|
toast.textContent = 'Сначала сделайте фотографию';
|
||||||
|
toast.classList.add('show', 'error');
|
||||||
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let moduleId = '';
|
||||||
|
if (moduleField.style.display !== 'none') {
|
||||||
|
moduleId = await resolveModuleId(moduleInput.value);
|
||||||
|
if (!moduleId) {
|
||||||
|
toast.textContent = 'Выберите тему модуля из списка подсказок';
|
||||||
|
toast.classList.add('show', 'error');
|
||||||
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Отправка...';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', capturedBlob, 'photo.jpg');
|
||||||
|
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
||||||
|
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||||
|
fd.append('group_id', groupInput.value);
|
||||||
|
fd.append('module_id', moduleId || '');
|
||||||
|
fd.append('description', document.getElementById('descInput').value);
|
||||||
|
fd.append('website', document.getElementById('hpWebsite').value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
|
||||||
|
if (res.status === 429) {
|
||||||
|
const e = await res.json().catch(() => ({}));
|
||||||
|
toast.textContent = e.error || 'Уже ответили: подождите';
|
||||||
|
toast.classList.add('show', 'error');
|
||||||
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
form.reset();
|
||||||
|
removePhoto();
|
||||||
|
clearFiles();
|
||||||
|
localStorage.setItem(SENT_KEY, String(Date.now()));
|
||||||
|
showSent('Запись отправлена!', 'Спасибо! Твоя запись уже передана преподавателю.', Date.now() + cooldownMs());
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Отправить';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
removePhotoBtn.addEventListener('click', removePhoto);
|
||||||
|
document.getElementById('openCamBtn').addEventListener('click', openCam);
|
||||||
|
filesClearBtn.addEventListener('click', clearFiles);
|
||||||
|
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
||||||
|
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
||||||
|
document.getElementById('captureBtn').addEventListener('click', capture);
|
||||||
|
moduleInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(moduleSearchTimer);
|
||||||
|
const q = moduleInput.value.trim();
|
||||||
|
moduleSearchTimer = setTimeout(async () => {
|
||||||
|
renderModuleOptions(await fetchModules(q, 20));
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
moduleInput.addEventListener('focus', async () => {
|
||||||
|
if (!moduleInput.value.trim()) renderModuleOptions(await fetchModules('', 50));
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
|||||||
|
let links = [];
|
||||||
|
let groups = [];
|
||||||
|
let editingId = null;
|
||||||
|
let studentsLoaded = false;
|
||||||
|
let linkSettings = null;
|
||||||
|
let linkPage = 1;
|
||||||
|
const LINK_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
async function loadLinkSettings() {
|
||||||
|
if (!linkSettings) {
|
||||||
|
const res = await fetch(`${API}/api/settings`, { headers: hdr() });
|
||||||
|
linkSettings = await res.json().catch(() => ({}));
|
||||||
|
}
|
||||||
|
return linkSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkUrl(token) { return `${location.origin}/s/${token}`; }
|
||||||
|
|
||||||
|
async function loadLinks() {
|
||||||
|
await loadLinkSettings();
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('limit', LINK_PAGE_SIZE);
|
||||||
|
p.set('offset', (linkPage - 1) * LINK_PAGE_SIZE);
|
||||||
|
const res = await fetch(`${API}/api/links?${p}`, { headers: hdr() });
|
||||||
|
const data = await res.json();
|
||||||
|
links = data.items;
|
||||||
|
const total = data.total;
|
||||||
|
const el = document.getElementById('linksList');
|
||||||
|
if (!links.length) { el.innerHTML = `<div class="empty">${linkPage > 1 ? 'На этой странице ссылок нет.' : 'Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».'}</div>`; renderPager(document.getElementById('pager'), 1, 1, goLinkPage); return; }
|
||||||
|
const now = Date.now();
|
||||||
|
el.innerHTML = links.map(l => {
|
||||||
|
const namesHidden = l.show_student_names != null ? !l.show_student_names : linkSettings.share_show_student_names === 'false';
|
||||||
|
const expired = l.expires_at && new Date(l.expires_at).getTime() < now;
|
||||||
|
const daysLeft = l.expires_at ? Math.ceil((new Date(l.expires_at).getTime() - now) / 86400000) : null;
|
||||||
|
const soon = !expired && daysLeft !== null && daysLeft <= 1;
|
||||||
|
return `
|
||||||
|
<div class="item${expired ? ' expired' : ''}">
|
||||||
|
<div style="min-width:0">
|
||||||
|
<div class="name">${esc(l.name)}${expired ? ' <span class="badge-expired">истекла</span>' : soon ? ' <span class="badge-soon">истекает</span>' : ''}</div>
|
||||||
|
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
|
||||||
|
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
||||||
|
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
||||||
|
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
||||||
|
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${new Date(l.expires_at).toLocaleDateString('ru')}</span>` : l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
||||||
|
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
|
||||||
|
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
||||||
|
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
||||||
|
<a class="btn-link" style="text-decoration:none" href="/s/${l.token}" target="_blank" title="Открыть">Открыть</a>
|
||||||
|
<a class="btn-link" style="text-decoration:none" href="/r/${l.token}" target="_blank" title="Отчёт ученика">Отчёт</a>
|
||||||
|
<button class="btn-link" data-copy="${esc(linkUrl(l.token))}" title="Копировать ссылку">Копировать</button>
|
||||||
|
<button class="btn-link" data-edit-link="${l.id}" title="Редактировать">✎</button>
|
||||||
|
<button class="btn-link" style="color:var(--muted)" data-del-link="${l.id}" title="Удалить">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;}).join('');
|
||||||
|
renderPager(document.getElementById('pager'), linkPage, Math.max(1, Math.ceil(total / LINK_PAGE_SIZE)), goLinkPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goLinkPage(p) {
|
||||||
|
linkPage = p;
|
||||||
|
loadLinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
|
||||||
|
|
||||||
|
async function openLinkModal() {
|
||||||
|
editingId = null;
|
||||||
|
document.getElementById('linkModalTitle').textContent = 'Новая ссылка';
|
||||||
|
document.getElementById('linkName').value = '';
|
||||||
|
document.getElementById('linkDateFrom').value = '';
|
||||||
|
document.getElementById('linkDateTo').value = '';
|
||||||
|
document.getElementById('linkExpiresDays').value = '7';
|
||||||
|
document.getElementById('linkShowNames').checked = false;
|
||||||
|
document.getElementById('linkPassword').value = '';
|
||||||
|
document.getElementById('linkMessage').value = '';
|
||||||
|
document.getElementById('linkUrl').value = '';
|
||||||
|
const s = await loadLinkSettings();
|
||||||
|
document.getElementById('linkShowNames').checked = s.share_show_student_names !== 'false';
|
||||||
|
document.getElementById('linkShowMessage').checked = s.share_show_student_message === 'true';
|
||||||
|
document.getElementById('linkShowDate').checked = s.share_show_entry_date === 'true';
|
||||||
|
document.getElementById('linkShowPhotos').checked = s.share_show_group_photos !== 'false';
|
||||||
|
if (!groups.length) {
|
||||||
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
|
groups = await res.json();
|
||||||
|
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
|
||||||
|
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
if (!studentsLoaded) {
|
||||||
|
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||||||
|
const names = await res.json();
|
||||||
|
const dl = document.getElementById('linkStudentList');
|
||||||
|
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
||||||
|
studentsLoaded = true;
|
||||||
|
}
|
||||||
|
document.getElementById('linkModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLinkEdit(id) {
|
||||||
|
const l = links.find(x => x.id === id);
|
||||||
|
if (!l) return;
|
||||||
|
editingId = id;
|
||||||
|
document.getElementById('linkModalTitle').textContent = 'Редактирование ссылки';
|
||||||
|
document.getElementById('linkName').value = l.name;
|
||||||
|
document.getElementById('linkDateFrom').value = l.date_from || '';
|
||||||
|
document.getElementById('linkDateTo').value = l.date_to || '';
|
||||||
|
document.getElementById('linkExpiresDays').value = l.expires_at ? Math.ceil((new Date(l.expires_at) - new Date()) / (1000 * 60 * 60 * 24)) : '7';
|
||||||
|
document.getElementById('linkShowNames').checked = l.show_student_names != null ? !!l.show_student_names : true;
|
||||||
|
document.getElementById('linkPassword').value = '';
|
||||||
|
document.getElementById('linkMessage').value = l.message || '';
|
||||||
|
document.getElementById('linkUrl').value = l.link_url || '';
|
||||||
|
const s = await loadLinkSettings();
|
||||||
|
document.getElementById('linkShowNames').checked = l.show_student_names != null ? !!l.show_student_names : s.share_show_student_names !== 'false';
|
||||||
|
document.getElementById('linkShowMessage').checked = l.show_student_message != null ? !!l.show_student_message : s.share_show_student_message === 'true';
|
||||||
|
document.getElementById('linkShowDate').checked = l.show_entry_date != null ? !!l.show_entry_date : s.share_show_entry_date === 'true';
|
||||||
|
document.getElementById('linkShowPhotos').checked = l.show_group_photos != null ? !!l.show_group_photos : s.share_show_group_photos !== 'false';
|
||||||
|
if (!groups.length) {
|
||||||
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
|
groups = await res.json();
|
||||||
|
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
|
||||||
|
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
document.getElementById('linkGroup').value = l.group_id || '';
|
||||||
|
if (!studentsLoaded) {
|
||||||
|
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||||||
|
const names = await res.json();
|
||||||
|
const dl = document.getElementById('linkStudentList');
|
||||||
|
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
||||||
|
studentsLoaded = true;
|
||||||
|
}
|
||||||
|
document.getElementById('linkStudent').value = l.student_name || '';
|
||||||
|
document.getElementById('linkModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLinkModal() {
|
||||||
|
document.getElementById('linkModal').classList.remove('open');
|
||||||
|
editingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLink() {
|
||||||
|
const name = document.getElementById('linkName').value.trim();
|
||||||
|
const group_id = document.getElementById('linkGroup').value;
|
||||||
|
const student_name = document.getElementById('linkStudent').value;
|
||||||
|
const date_from = document.getElementById('linkDateFrom').value;
|
||||||
|
const date_to = document.getElementById('linkDateTo').value;
|
||||||
|
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
|
||||||
|
const show_student_names = document.getElementById('linkShowNames').checked;
|
||||||
|
const access_password = document.getElementById('linkPassword').value;
|
||||||
|
const message = document.getElementById('linkMessage').value.trim();
|
||||||
|
const link_url = document.getElementById('linkUrl').value.trim();
|
||||||
|
if (!name) { alert('Введите название'); return; }
|
||||||
|
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
|
||||||
|
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, show_student_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null, show_student_message: document.getElementById('linkShowMessage').checked, show_entry_date: document.getElementById('linkShowDate').checked, show_group_photos: document.getElementById('linkShowPhotos').checked });
|
||||||
|
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
|
||||||
|
method: editingId ? 'PUT' : 'POST',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
closeLinkModal();
|
||||||
|
loadLinks();
|
||||||
|
showToast(editingId ? 'Ссылка обновлена' : 'Ссылка создана');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.error || 'Ошибка');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delLink(id) {
|
||||||
|
if (!confirm('Удалить ссылку?')) return;
|
||||||
|
await fetch(`${API}/api/links/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
loadLinks();
|
||||||
|
showToast('Ссылка удалена');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('linkModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeLinkModal(); });
|
||||||
|
document.getElementById('linkCancelBtn').addEventListener('click', closeLinkModal);
|
||||||
|
document.getElementById('linkSaveBtn').addEventListener('click', saveLink);
|
||||||
|
|
||||||
|
document.getElementById('linksList').addEventListener('click', e => {
|
||||||
|
const cp = e.target.closest('[data-copy]');
|
||||||
|
if (cp) { copyText(cp.dataset.copy); return; }
|
||||||
|
const ed = e.target.closest('[data-edit-link]');
|
||||||
|
if (ed) { openLinkEdit(parseInt(ed.dataset.editLink, 10)); return; }
|
||||||
|
const dl = e.target.closest('[data-del-link]');
|
||||||
|
if (dl) { delLink(parseInt(dl.dataset.delLink, 10)); }
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await checkAuth()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
loadLinks();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
const API = location.origin;
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
const username = document.getElementById('userInput').value.trim();
|
||||||
|
const password = document.getElementById('pwdInput').value;
|
||||||
|
if (!username || !password) return;
|
||||||
|
const err = document.getElementById('loginErr');
|
||||||
|
err.style.display = 'none';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password, website: document.getElementById('hpWebsite').value }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok && data.token) {
|
||||||
|
sessionStorage.setItem('authToken', data.token);
|
||||||
|
location.href = 'dashboard.html';
|
||||||
|
} else {
|
||||||
|
err.textContent = data.error || 'Неверный логин или пароль';
|
||||||
|
err.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
err.textContent = 'Ошибка соединения';
|
||||||
|
err.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||||
|
document.getElementById('userInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||||
|
document.querySelector('.login button').addEventListener('click', doLogin);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/public-settings`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const s = await res.json();
|
||||||
|
const title = document.getElementById('loginTitle');
|
||||||
|
if (title && (s.system_name || '').trim()) title.textContent = s.system_name;
|
||||||
|
const logo = document.getElementById('loginLogo');
|
||||||
|
if (logo && s.system_logo) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = s.system_logo;
|
||||||
|
img.alt = '';
|
||||||
|
logo.appendChild(img);
|
||||||
|
logo.style.display = 'flex';
|
||||||
|
}
|
||||||
|
document.title = title ? title.textContent + ' — Вход' : document.title;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
||||||
|
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')}
|
||||||
@@ -0,0 +1,752 @@
|
|||||||
|
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine'];
|
||||||
|
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans'];
|
||||||
|
const SPAM_MAX = 10080;
|
||||||
|
const BAN_REASONS = {
|
||||||
|
'honeypot': 'Антиспам-поле',
|
||||||
|
'login-bruteforce': 'Подбор пароля входа',
|
||||||
|
'share-password-bruteforce': 'Подбор пароля ссылки',
|
||||||
|
'manual': 'Вручную',
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentLogo = '';
|
||||||
|
let logoOriginal = '';
|
||||||
|
|
||||||
|
function renderLogoPreview() {
|
||||||
|
const img = document.getElementById('logoPreviewImg');
|
||||||
|
const fb = document.getElementById('logoPreviewFallback');
|
||||||
|
const rm = document.getElementById('logoRemoveBtn');
|
||||||
|
if (!img || !fb || !rm) return;
|
||||||
|
const hasLogo = !!currentLogo;
|
||||||
|
img.style.display = hasLogo ? 'block' : 'none';
|
||||||
|
fb.style.display = hasLogo ? 'none' : '';
|
||||||
|
if (hasLogo) img.src = currentLogo;
|
||||||
|
rm.hidden = !hasLogo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldValue(el) {
|
||||||
|
return el.type === 'checkbox' ? String(el.checked) : el.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBtnLoading(btn, on, spinnerClass) {
|
||||||
|
if (on) {
|
||||||
|
btn.dataset.label = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
const cls = spinnerClass || (btn.classList.contains('ghost') ? 'dark' : '');
|
||||||
|
btn.innerHTML = `<span class="spinner ${cls}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = btn.dataset.label || btn.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
if (!t) return;
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.add('show', 'error');
|
||||||
|
clearTimeout(t._errTimer);
|
||||||
|
t._errTimer = setTimeout(() => t.classList.remove('error'), 3400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFieldError(id, msg) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const err = document.getElementById(`${id}Error`);
|
||||||
|
if (el) el.classList.add('invalid');
|
||||||
|
if (err) { err.textContent = msg; err.hidden = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFieldError(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const err = document.getElementById(`${id}Error`);
|
||||||
|
if (el) el.classList.remove('invalid');
|
||||||
|
if (err) err.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(n, one, few, many) {
|
||||||
|
const a = n % 10, b = n % 100;
|
||||||
|
if (a === 1 && b !== 11) return one;
|
||||||
|
if (a >= 2 && a <= 4 && (b < 12 || b > 14)) return few;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeMinutes(min) {
|
||||||
|
const n = Number(min);
|
||||||
|
if (!Number.isFinite(n) || n < 1) return '';
|
||||||
|
if (n === 1440) return 'сутки';
|
||||||
|
if (n > 1440 && n % 1440 === 0) {
|
||||||
|
const d = n / 1440;
|
||||||
|
return `${d} ${plural(d, 'сутки', 'суток', 'суток')}`;
|
||||||
|
}
|
||||||
|
const h = Math.floor(n / 60);
|
||||||
|
const m = n % 60;
|
||||||
|
const parts = [];
|
||||||
|
if (h) parts.push(`${h} ${plural(h, 'час', 'часа', 'часов')}`);
|
||||||
|
if (m) parts.push(`${m} ${plural(m, 'минута', 'минуты', 'минут')}`);
|
||||||
|
if (!parts.length) parts.push(`${n} ${plural(n, 'минута', 'минуты', 'минут')}`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDirty() {
|
||||||
|
const dirtyCount = DIRTY_FIELDS.filter(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return String(el.dataset.original ?? '') !== fieldValue(el);
|
||||||
|
}).length;
|
||||||
|
document.getElementById('saveBar').classList.toggle('show', dirtyCount > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDirty() {
|
||||||
|
DIRTY_FIELDS.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.addEventListener('input', updateDirty);
|
||||||
|
el.addEventListener('change', updateDirty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotDirty() {
|
||||||
|
DIRTY_FIELDS.forEach(id => {
|
||||||
|
document.getElementById(id).dataset.original = fieldValue(document.getElementById(id));
|
||||||
|
});
|
||||||
|
updateDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertChanges() {
|
||||||
|
DIRTY_FIELDS.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const original = el.dataset.original ?? '';
|
||||||
|
if (el.type === 'checkbox') el.checked = original === 'true';
|
||||||
|
else el.value = original;
|
||||||
|
});
|
||||||
|
currentLogo = logoOriginal;
|
||||||
|
renderLogoPreview();
|
||||||
|
clearFieldError('spamInterval');
|
||||||
|
updateSpamUi();
|
||||||
|
updateDirty();
|
||||||
|
showToast('Изменения отменены');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSectionNav() {
|
||||||
|
const nav = document.getElementById('settingsNav');
|
||||||
|
if (!nav) return;
|
||||||
|
const links = [...nav.querySelectorAll('a')];
|
||||||
|
const smooth = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
|
||||||
|
let ticking = false;
|
||||||
|
const setActive = () => {
|
||||||
|
ticking = false;
|
||||||
|
let current = SECTION_IDS[0];
|
||||||
|
for (const id of SECTION_IDS) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && el.getBoundingClientRect().top <= 170) current = id;
|
||||||
|
}
|
||||||
|
links.forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#${current}`));
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if (!ticking) { ticking = true; requestAnimationFrame(setActive); }
|
||||||
|
}, { passive: true });
|
||||||
|
setActive();
|
||||||
|
nav.addEventListener('click', e => {
|
||||||
|
const a = e.target.closest('a');
|
||||||
|
if (!a) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const target = document.getElementById(a.getAttribute('href').slice(1));
|
||||||
|
if (target) target.scrollIntoView({ behavior: smooth, block: 'start' });
|
||||||
|
history.replaceState(null, '', a.getAttribute('href'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const [sRes, stRes] = await Promise.all([
|
||||||
|
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||||
|
fetch(`${API}/api/stats`, { headers: hdr() }),
|
||||||
|
]);
|
||||||
|
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
|
||||||
|
const s = await sRes.json();
|
||||||
|
document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
|
||||||
|
currentLogo = s.system_logo || '';
|
||||||
|
logoOriginal = currentLogo;
|
||||||
|
renderLogoPreview();
|
||||||
|
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||||
|
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||||
|
document.getElementById('footerRight').value = s.footer_right || '';
|
||||||
|
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
||||||
|
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
|
||||||
|
document.getElementById('shareShowMessage').checked = s.share_show_student_message === 'true';
|
||||||
|
document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true';
|
||||||
|
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
|
||||||
|
document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false';
|
||||||
|
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
|
||||||
|
const pr = document.getElementById('photoResolution');
|
||||||
|
if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution;
|
||||||
|
if (!pr.value) pr.value = '640x480';
|
||||||
|
document.getElementById('photoQuality').value = s.photo_capture_quality !== undefined && s.photo_capture_quality !== '' ? s.photo_capture_quality : '0.92';
|
||||||
|
const pe = document.getElementById('photoEngine');
|
||||||
|
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
||||||
|
if (!pe.value) pe.value = 'auto';
|
||||||
|
snapshotDirty();
|
||||||
|
updateSpamUi();
|
||||||
|
loadAiQueue();
|
||||||
|
loadAiProfiles();
|
||||||
|
renderStats(stRes);
|
||||||
|
loadSystemInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderStats(res) {
|
||||||
|
const el = document.getElementById('dataStats');
|
||||||
|
if (!el) return;
|
||||||
|
if (!res || !res.ok) { el.innerHTML = '<span class="chip">Статистика недоступна</span>'; return; }
|
||||||
|
const st = await res.json();
|
||||||
|
el.innerHTML = `
|
||||||
|
<span class="chip">Записей: <b>${st.entries}</b></span>
|
||||||
|
<span class="chip">Учеников: <b>${st.students}</b></span>
|
||||||
|
<span class="chip">Групп: <b>${st.groups}</b></span>
|
||||||
|
<span class="chip">Сегодня: <b>${st.today}</b></span>
|
||||||
|
${st.trash ? `<span class="chip">В корзине: <b>${st.trash}</b> <a href="trash.html">→</a></span>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStats() {
|
||||||
|
const el = document.getElementById('dataStats');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = '<span class="chip">Обновление…</span>';
|
||||||
|
try {
|
||||||
|
await renderStats(await fetch(`${API}/api/stats`, { headers: hdr() }));
|
||||||
|
} catch {
|
||||||
|
el.innerHTML = '<span class="chip">Ошибка сети</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSystemInfo() {
|
||||||
|
const el = document.getElementById('systemInfo');
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
renderSystemInfo(await res.json());
|
||||||
|
const live = document.getElementById('systemLive');
|
||||||
|
if (live) live.textContent = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||||
|
} catch {
|
||||||
|
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSystemInfo(si) {
|
||||||
|
const db = si.database || {};
|
||||||
|
const photos = si.photos || {};
|
||||||
|
const files = si.files || {};
|
||||||
|
const uploads = si.uploads || {};
|
||||||
|
const disk = si.disk;
|
||||||
|
|
||||||
|
let tablesHtml = '';
|
||||||
|
if (db.tables && db.tables.length) {
|
||||||
|
tablesHtml = '<details class="sys-details"><summary>Таблицы БД</summary><div class="sys-table-list">' +
|
||||||
|
db.tables.map(t => `<div class="sys-table-row"><span>${esc(t.name)}</span><span>${esc(t.size)}</span></div>`).join('') +
|
||||||
|
'</div></details>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let diskHtml = '';
|
||||||
|
if (disk) {
|
||||||
|
const pct = disk.used_pct;
|
||||||
|
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
|
||||||
|
const cardCls = pct > 90 ? ' danger' : pct > 70 ? ' warn' : '';
|
||||||
|
diskHtml = `
|
||||||
|
<div class="sys-card${cardCls}">
|
||||||
|
<div class="sys-title">Диск</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Заполнено</span><span class="sys-value">${pct}%</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Использовано</span><span class="sys-value">${esc(disk.used)} / ${esc(disk.total)}</span></div>
|
||||||
|
<div class="sys-bar-wrap"><div class="sys-bar" style="width:${pct}%; background:${color}"></div></div>
|
||||||
|
<div class="sys-meta">Свободно: ${esc(disk.free)} (${100 - pct}%)</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('systemInfo').innerHTML = `
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">База данных</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(db.size || '—')}</span></div>
|
||||||
|
${tablesHtml}
|
||||||
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Фото</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0} · ${esc(photos.group_photos?.size || '0 B')}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0} · ${esc(photos.entry_photos?.size || '0 B')}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b> · <b>${esc(photos.total_size || '0 B')}</b></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Файлы</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Проектные файлы</span><span class="sys-value">${files.project_files?.count || 0}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Загрузки</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(uploads.size || '0 B')}</span></div>
|
||||||
|
</div>
|
||||||
|
${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAiProfiles() {
|
||||||
|
const listEl = document.getElementById('aiProfilesList');
|
||||||
|
const select = document.getElementById('aiActiveProfile');
|
||||||
|
if (!listEl) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
|
||||||
|
if (!res.ok) { listEl.innerHTML = '<span class="chip">Не удалось загрузить профили</span>'; return; }
|
||||||
|
const data = await res.json();
|
||||||
|
const profiles = data.profiles || [];
|
||||||
|
if (select) {
|
||||||
|
select.innerHTML = `<option value="native">Нативная — ${esc(data.native.model)}</option>` +
|
||||||
|
profiles.map(p => `<option value="${esc(p.id)}">${esc(p.name)} — ${esc(p.model)}</option>`).join('');
|
||||||
|
select.value = data.active;
|
||||||
|
}
|
||||||
|
const items = [{ id: 'native', name: 'Нативная (llama.cpp в Docker)', base_url: data.native.base_url, model: data.native.model, native: true }].concat(profiles);
|
||||||
|
listEl.innerHTML = items.map(p => `
|
||||||
|
<div class="ai-profile-item${data.active === p.id ? ' active' : ''}">
|
||||||
|
<div class="pi-info">
|
||||||
|
<div class="pi-name">${esc(p.name)}${data.active === p.id ? ' <span class="pi-badge">по умолчанию</span>' : ''}</div>
|
||||||
|
<div class="pi-meta">${esc(p.model)} · ${esc(p.base_url)}${p.max_tokens ? ` · max_tokens: ${esc(p.max_tokens)}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pi-actions">
|
||||||
|
${p.native ? '' : `<button type="button" class="pi-btn" data-edit-profile="${esc(p.id)}">Изменить</button>`}
|
||||||
|
${p.native ? '' : `<button type="button" class="pi-btn danger" data-del-profile="${esc(p.id)}" data-del-name="${esc(p.name)}">Удалить</button>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch { listEl.innerHTML = '<span class="chip">Ошибка загрузки</span>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let aiProfileEditingId = null;
|
||||||
|
|
||||||
|
function openAiProfileForm(profile) {
|
||||||
|
aiProfileEditingId = profile ? profile.id : null;
|
||||||
|
document.getElementById('aiProfileModalTitle').textContent = profile ? 'Изменить профиль ИИ' : 'Добавить профиль ИИ';
|
||||||
|
document.getElementById('aiProfileModal').classList.add('open');
|
||||||
|
document.getElementById('aiProfileName').value = profile ? profile.name : '';
|
||||||
|
document.getElementById('aiProfileModel').value = profile ? profile.model : '';
|
||||||
|
document.getElementById('aiProfileBaseUrl').value = profile ? profile.base_url : '';
|
||||||
|
document.getElementById('aiProfileApiKey').value = '';
|
||||||
|
document.getElementById('aiProfileMaxTokens').value = profile && profile.max_tokens ? profile.max_tokens : '';
|
||||||
|
document.getElementById('aiProfileTestStatus').textContent = '';
|
||||||
|
document.getElementById('aiProfileName').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAiProfileForm() {
|
||||||
|
aiProfileEditingId = null;
|
||||||
|
document.getElementById('aiProfileModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiProfileFormData() {
|
||||||
|
return {
|
||||||
|
name: document.getElementById('aiProfileName').value,
|
||||||
|
model: document.getElementById('aiProfileModel').value,
|
||||||
|
base_url: document.getElementById('aiProfileBaseUrl').value,
|
||||||
|
api_key: document.getElementById('aiProfileApiKey').value,
|
||||||
|
max_tokens: document.getElementById('aiProfileMaxTokens').value.trim() === '' ? null : document.getElementById('aiProfileMaxTokens').value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAiQueue() {
|
||||||
|
const el = document.getElementById('aiQueueInfo');
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/ai/queue`, { headers: hdr() });
|
||||||
|
if (!res.ok) { el.textContent = ''; return; }
|
||||||
|
const q = await res.json();
|
||||||
|
const c = q.counts || {};
|
||||||
|
el.innerHTML = `
|
||||||
|
<span class="chip">Автопроверка: <b>${q.enabled ? 'включена' : 'выключена'}</b></span>
|
||||||
|
<span class="chip">Модель: <b>${esc(q.model || '—')}</b></span>
|
||||||
|
<span class="chip">В очереди: <b>${c.pending || 0}</b></span>
|
||||||
|
<span class="chip">Проверено: <b>${c.done || 0}</b></span>
|
||||||
|
<span class="chip">Ошибок: <b>${c.error || 0}</b></span>
|
||||||
|
<span class="chip">Пропущено: <b>${c.skipped || 0}</b></span>
|
||||||
|
<a class="queue-link" href="worker.html">Монитор воркера →</a>`;
|
||||||
|
} catch { el.textContent = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAiProfile() {
|
||||||
|
const body = aiProfileFormData();
|
||||||
|
const st = document.getElementById('aiProfileTestStatus');
|
||||||
|
st.innerHTML = '<span class="spinner dark"></span> Сохранение...';
|
||||||
|
const res = await fetch(aiProfileEditingId ? `${API}/api/ai/profiles/${aiProfileEditingId}` : `${API}/api/ai/profiles`, {
|
||||||
|
method: aiProfileEditingId ? 'PUT' : 'POST',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
st.textContent = '';
|
||||||
|
showToast(aiProfileEditingId ? 'Профиль обновлён' : 'Профиль добавлен');
|
||||||
|
closeAiProfileForm();
|
||||||
|
loadAiProfiles();
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
st.innerHTML = `<span class="fail">${esc(err.error || 'Ошибка сохранения')}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAiProfile() {
|
||||||
|
const st = document.getElementById('aiProfileTestStatus');
|
||||||
|
st.textContent = 'Проверка…';
|
||||||
|
const res = await fetch(`${API}/api/ai/profiles/test`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(aiProfileFormData()) });
|
||||||
|
const d = await res.json().catch(() => ({ ok: false, error: 'Ошибка' }));
|
||||||
|
st.innerHTML = d.ok
|
||||||
|
? `<span class="ok">✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''}</span>`
|
||||||
|
: `<span class="fail">✗ ${esc(d.error || 'Не удалось подключиться')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateAiProfile(id) {
|
||||||
|
const res = await fetch(`${API}/api/ai/profiles/activate`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ id }) });
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Модель по умолчанию обновлена');
|
||||||
|
loadAiProfiles();
|
||||||
|
loadAiQueue();
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
showError(err.error || 'Ошибка');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAiProfile(id, name, btn) {
|
||||||
|
if (btn && !btn.dataset.confirming) {
|
||||||
|
btn.dataset.confirming = '1';
|
||||||
|
btn.classList.add('confirming');
|
||||||
|
btn.textContent = 'Точно?';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (btn.isConnected) {
|
||||||
|
delete btn.dataset.confirming;
|
||||||
|
btn.classList.remove('confirming');
|
||||||
|
btn.textContent = 'Удалить';
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API}/api/ai/profiles/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Профиль удалён');
|
||||||
|
loadAiProfiles();
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
showError(err.error || 'Ошибка удаления');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const spamInput = document.getElementById('spamInterval');
|
||||||
|
|
||||||
|
function updateSpamUi() {
|
||||||
|
const raw = spamInput.value;
|
||||||
|
const n = Math.round(Number(raw));
|
||||||
|
const valid = raw !== '' && Number.isFinite(n) && n >= 1;
|
||||||
|
const hint = document.getElementById('spamIntervalHint');
|
||||||
|
if (hint) hint.textContent = valid ? `Следующая отправка будет доступна через ${humanizeMinutes(n)}` : 'Минимальное значение — 1 минута';
|
||||||
|
document.querySelectorAll('#spamPresets .preset-btn').forEach(b => b.classList.toggle('active', valid && Number(b.dataset.min) === n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampSpam(v) {
|
||||||
|
let n = Math.round(Number(v));
|
||||||
|
if (!Number.isFinite(n) || n < 1) n = 1;
|
||||||
|
if (n > SPAM_MAX) n = SPAM_MAX;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
spamInput.addEventListener('change', () => {
|
||||||
|
const n = Math.round(Number(spamInput.value));
|
||||||
|
if (spamInput.value === '' || !Number.isFinite(n) || n < 1) {
|
||||||
|
spamInput.value = clampSpam(spamInput.dataset.original ?? 30);
|
||||||
|
updateDirty();
|
||||||
|
}
|
||||||
|
updateSpamUi();
|
||||||
|
});
|
||||||
|
spamInput.addEventListener('input', updateSpamUi);
|
||||||
|
|
||||||
|
document.getElementById('spamPresets').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('[data-min]');
|
||||||
|
if (!btn) return;
|
||||||
|
spamInput.value = btn.dataset.min;
|
||||||
|
spamInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
spamInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('aiProfileAddBtn').addEventListener('click', () => openAiProfileForm(null));
|
||||||
|
document.getElementById('aiProfileSaveBtn').addEventListener('click', saveAiProfile);
|
||||||
|
document.getElementById('aiProfileTestBtn').addEventListener('click', testAiProfile);
|
||||||
|
document.getElementById('aiProfileCancelBtn').addEventListener('click', closeAiProfileForm);
|
||||||
|
document.getElementById('aiProfileModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAiProfileForm(); });
|
||||||
|
document.getElementById('aiActiveProfile').addEventListener('change', e => activateAiProfile(e.target.value));
|
||||||
|
document.getElementById('aiProfilesList').addEventListener('click', e => {
|
||||||
|
const del = e.target.closest('[data-del-profile]');
|
||||||
|
if (del) { deleteAiProfile(del.dataset.delProfile, del.dataset.delName, del); return; }
|
||||||
|
const edit = e.target.closest('[data-edit-profile]');
|
||||||
|
if (edit) {
|
||||||
|
fetch(`${API}/api/ai/profiles`, { headers: hdr() })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const p = (data.profiles || []).find(x => x.id === edit.dataset.editProfile);
|
||||||
|
if (p) openAiProfileForm(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
const v = spamInput.value;
|
||||||
|
const n = Number(v);
|
||||||
|
if (v === '' || !Number.isFinite(n) || Math.round(n) < 1) {
|
||||||
|
showFieldError('spamInterval', 'Укажите целое число минут (минимум 1)');
|
||||||
|
spamInput.focus();
|
||||||
|
showError('Проверьте поле «Интервал ожидания»');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearFieldError('spamInterval');
|
||||||
|
const btn = document.getElementById('saveBtn');
|
||||||
|
btn.dataset.loadingText = 'Сохранение...';
|
||||||
|
setBtnLoading(btn, true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/settings`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ settings: {
|
||||||
|
system_name: document.getElementById('systemName').value.trim(),
|
||||||
|
spam_interval_min: String(Math.round(Number(v))),
|
||||||
|
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||||
|
footer_right: document.getElementById('footerRight').value.trim(),
|
||||||
|
ai_prompt: document.getElementById('aiPrompt').value.trim(),
|
||||||
|
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
|
||||||
|
share_show_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false',
|
||||||
|
share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false',
|
||||||
|
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
|
||||||
|
share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false',
|
||||||
|
cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(),
|
||||||
|
photo_capture_resolution: document.getElementById('photoResolution').value,
|
||||||
|
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
||||||
|
photo_enhance_engine: document.getElementById('photoEngine').value,
|
||||||
|
} })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Настройки сохранены');
|
||||||
|
logoOriginal = currentLogo;
|
||||||
|
snapshotDirty();
|
||||||
|
updateSpamUi();
|
||||||
|
} else {
|
||||||
|
const e = await res.json().catch(() => ({}));
|
||||||
|
showError(e.error || 'Ошибка сохранения');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showError('Ошибка сети при сохранении');
|
||||||
|
} finally {
|
||||||
|
setBtnLoading(btn, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBackup() {
|
||||||
|
const btn = document.getElementById('backupBtn');
|
||||||
|
btn.dataset.loadingText = 'Формирование...';
|
||||||
|
setBtnLoading(btn, true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
||||||
|
if (!res.ok) { showError('Ошибка скачивания: ' + (await res.text())); return; }
|
||||||
|
const blob = await res.blob();
|
||||||
|
const cd = res.headers.get('Content-Disposition') || '';
|
||||||
|
const m = cd.match(/filename="([^"]+)"/);
|
||||||
|
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast('Бэкап скачан');
|
||||||
|
} catch {
|
||||||
|
showError('Ошибка сети при формировании бэкапа');
|
||||||
|
} finally {
|
||||||
|
setBtnLoading(btn, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
|
||||||
|
const st = document.getElementById('restoreStatus');
|
||||||
|
const backupBtn = document.getElementById('backupBtn');
|
||||||
|
const restoreBtn = document.getElementById('restoreBtn');
|
||||||
|
st.classList.remove('err');
|
||||||
|
st.innerHTML = '<span class="spinner dark"></span> Восстановление...';
|
||||||
|
backupBtn.disabled = true;
|
||||||
|
restoreBtn.disabled = true;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('backup', file);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
|
||||||
|
} catch (err) {
|
||||||
|
st.textContent = '';
|
||||||
|
showError('Ошибка сети: ' + err.message);
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
backupBtn.disabled = false;
|
||||||
|
restoreBtn.disabled = false;
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Данные восстановлены');
|
||||||
|
st.textContent = '';
|
||||||
|
loadSettings();
|
||||||
|
} else {
|
||||||
|
st.classList.add('err');
|
||||||
|
st.innerHTML = 'Ошибка восстановления';
|
||||||
|
showError(await res.text());
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadBans() {
|
||||||
|
const el = document.getElementById('bansList');
|
||||||
|
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
|
||||||
|
if (res.status === 403) { document.getElementById('sec-bans').style.display = 'none'; return; }
|
||||||
|
if (!res.ok) { el.innerHTML = '<span class="chip">Не удалось загрузить</span>'; return; }
|
||||||
|
const bans = await res.json();
|
||||||
|
if (!bans.length) { el.innerHTML = '<span class="chip">Активных блокировок нет</span>'; return; }
|
||||||
|
el.innerHTML = bans.map(b => `
|
||||||
|
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
|
||||||
|
<b>${esc(b.ip)}</b>
|
||||||
|
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span>
|
||||||
|
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
|
||||||
|
</span>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUnban(ip) {
|
||||||
|
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) { showToast('IP разблокирован: ' + ip); loadBans(); }
|
||||||
|
else showError('Ошибка разблокировки');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('bansList').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('[data-unban]');
|
||||||
|
if (!btn) return;
|
||||||
|
const ip = btn.dataset.unban;
|
||||||
|
const chip = btn.closest('[data-chip]');
|
||||||
|
if (!chip || chip.dataset.confirming) return;
|
||||||
|
chip.dataset.confirming = '1';
|
||||||
|
chip.classList.add('confirming');
|
||||||
|
btn.outerHTML = `
|
||||||
|
<span class="chip-confirm">
|
||||||
|
Разблокировать?
|
||||||
|
<button type="button" class="chip-yes" data-unban-yes>Да</button>
|
||||||
|
<button type="button" class="chip-no" data-unban-no>Нет</button>
|
||||||
|
</span>`;
|
||||||
|
chip.querySelector('[data-unban-yes]').addEventListener('click', () => doUnban(ip));
|
||||||
|
chip.querySelector('[data-unban-no]').addEventListener('click', () => loadBans());
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('backupBtn').addEventListener('click', downloadBackup);
|
||||||
|
document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click());
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
||||||
|
document.getElementById('resetBtn').addEventListener('click', revertChanges);
|
||||||
|
|
||||||
|
const systemRefreshBtn = document.getElementById('systemRefreshBtn');
|
||||||
|
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', async () => {
|
||||||
|
systemRefreshBtn.classList.add('spinning');
|
||||||
|
await loadSystemInfo();
|
||||||
|
systemRefreshBtn.classList.remove('spinning');
|
||||||
|
});
|
||||||
|
|
||||||
|
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
|
||||||
|
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
|
||||||
|
statsRefreshBtn.classList.add('spinning');
|
||||||
|
await refreshStats();
|
||||||
|
statsRefreshBtn.classList.remove('spinning');
|
||||||
|
});
|
||||||
|
|
||||||
|
function logoStatusText(html) {
|
||||||
|
const st = document.getElementById('logoUploadStatus');
|
||||||
|
if (st) st.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('logoUploadBtn').addEventListener('click', () => document.getElementById('logoInput').click());
|
||||||
|
|
||||||
|
document.getElementById('logoInput').addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const btn = document.getElementById('logoUploadBtn');
|
||||||
|
if (file.size > 1024 * 1024) {
|
||||||
|
e.target.value = '';
|
||||||
|
showError('Логотип не должен превышать 1 МБ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
logoStatusText('<span class="spinner dark"></span> Загрузка...');
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('logo', file);
|
||||||
|
const res = await fetch(`${API}/api/settings/logo`, { method: 'POST', headers: hdr(), body: fd });
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
currentLogo = d.system_logo || '';
|
||||||
|
renderLogoPreview();
|
||||||
|
logoStatusText('');
|
||||||
|
showToast('Логотип обновлён');
|
||||||
|
} else {
|
||||||
|
logoStatusText(`<span class="fail">${esc(d.error || 'Ошибка загрузки')}</span>`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logoStatusText('<span class="fail">Ошибка сети</span>');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('logoRemoveBtn').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Удалить логотип? Название системы сохранится.')) return;
|
||||||
|
logoStatusText('<span class="spinner dark"></span> Удаление...');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/settings/logo`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
currentLogo = '';
|
||||||
|
logoOriginal = '';
|
||||||
|
renderLogoPreview();
|
||||||
|
logoStatusText('');
|
||||||
|
showToast('Логотип удалён');
|
||||||
|
} else {
|
||||||
|
logoStatusText('<span class="fail">Ошибка удаления</span>');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logoStatusText('<span class="fail">Ошибка сети</span>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
const modal = document.getElementById('aiProfileModal');
|
||||||
|
const saveBtn = document.getElementById('saveBtn');
|
||||||
|
if (!modal.classList.contains('open') && document.getElementById('saveBar').classList.contains('show') && !saveBtn.disabled) saveSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', e => {
|
||||||
|
if (document.getElementById('saveBar').classList.contains('show')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await requireAdminPage()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
bindDirty();
|
||||||
|
initSectionNav();
|
||||||
|
updateSpamUi();
|
||||||
|
const authStatus = document.getElementById('authStatus');
|
||||||
|
try {
|
||||||
|
await loadSettings();
|
||||||
|
if (authStatus) authStatus.classList.add('loaded');
|
||||||
|
} catch {
|
||||||
|
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
||||||
|
}
|
||||||
|
loadBans();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||||
|
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
|
||||||
|
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
|
||||||
|
function isImageFile(name){return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
|
||||||
|
function thumbSrc(p){
|
||||||
|
if(!p)return p;
|
||||||
|
if(p.startsWith('/uploads/'))return '/uploads/thumb/'+p.slice('/uploads/'.length);
|
||||||
|
if(p.includes('/api/files/'))return p+(p.includes('?')?'&':'?')+'thumb=1';
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
let countdownInterval = null;
|
||||||
|
let countdownTarget = null;
|
||||||
|
let countdownScrollHandler = null;
|
||||||
|
|
||||||
|
function formatRemaining(ms) {
|
||||||
|
if (ms <= 0) return null;
|
||||||
|
const totalSec = Math.floor(ms / 1000);
|
||||||
|
const days = Math.floor(totalSec / 86400);
|
||||||
|
const hours = Math.floor((totalSec % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||||
|
const seconds = totalSec % 60;
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (days > 0) parts.push(`${days} ${declension(days, 'день', 'дня', 'дней')}`);
|
||||||
|
if (hours > 0) parts.push(`${hours} ${declension(hours, 'час', 'часа', 'часов')}`);
|
||||||
|
if (minutes > 0) parts.push(`${minutes} ${declension(minutes, 'минута', 'минуты', 'минут')}`);
|
||||||
|
if (days === 0) parts.push(`${seconds} ${declension(seconds, 'секунда', 'секунды', 'секунд')}`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function declension(n, one, few, many) {
|
||||||
|
const mod10 = n % 10;
|
||||||
|
const mod100 = n % 100;
|
||||||
|
if (mod100 >= 11 && mod100 <= 19) return many;
|
||||||
|
if (mod10 === 1) return one;
|
||||||
|
if (mod10 >= 2 && mod10 <= 4) return few;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowCountdown() {
|
||||||
|
const hero = document.querySelector('.hero');
|
||||||
|
if (!hero) return true;
|
||||||
|
return window.scrollY > hero.offsetHeight - 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCountdownVisibility() {
|
||||||
|
const el = document.getElementById('countdown');
|
||||||
|
if (!countdownTarget) { el.classList.remove('visible'); return; }
|
||||||
|
if (countdownTarget - Date.now() <= 0) { el.classList.remove('visible'); return; }
|
||||||
|
if (shouldShowCountdown()) el.classList.add('visible');
|
||||||
|
else el.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCountdown() {
|
||||||
|
const el = document.getElementById('countdown');
|
||||||
|
if (!countdownTarget) { el.classList.remove('visible'); return; }
|
||||||
|
const now = Date.now();
|
||||||
|
const remaining = countdownTarget - now;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
el.classList.remove('visible');
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = formatRemaining(remaining);
|
||||||
|
if (!text) { el.classList.remove('visible'); return; }
|
||||||
|
el.classList.remove('warn', 'urgent');
|
||||||
|
if (remaining < 3600000) el.classList.add('urgent');
|
||||||
|
else if (remaining < 86400000) el.classList.add('warn');
|
||||||
|
el.querySelector('.cd-text').innerHTML = `Ссылка доступна ещё <strong>${text}</strong>`;
|
||||||
|
syncCountdownVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown(expiresAt) {
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
|
||||||
|
if (countdownScrollHandler) { window.removeEventListener('scroll', countdownScrollHandler); countdownScrollHandler = null; }
|
||||||
|
const el = document.getElementById('countdown');
|
||||||
|
if (!expiresAt) { el.classList.remove('visible'); return; }
|
||||||
|
countdownTarget = new Date(expiresAt).getTime();
|
||||||
|
countdownScrollHandler = syncCountdownVisibility;
|
||||||
|
window.addEventListener('scroll', countdownScrollHandler, { passive: true });
|
||||||
|
updateCountdown();
|
||||||
|
countdownInterval = setInterval(updateCountdown, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileType(name){
|
||||||
|
if(/\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image';
|
||||||
|
if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music';
|
||||||
|
if(/\.(pptx?|odp|key)$/i.test(name||'')) return 'presentation';
|
||||||
|
if(/\.(pdf|docx?|xlsx?|txt|rtf|odt|ods|csv|md)$/i.test(name||'')) return 'document';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
function fileIcon(t){
|
||||||
|
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
|
||||||
|
return icons[t]||icons.other;
|
||||||
|
}
|
||||||
|
function filesHTML(files, password, shareToken){
|
||||||
|
if(!files||!files.length)return '';
|
||||||
|
const pw = password ? `?password=${encodeURIComponent(password)}` : '';
|
||||||
|
return `<div class="files-block">
|
||||||
|
<div class="files-label">Работы резидента</div>
|
||||||
|
<div class="files">${files.map(f=>{
|
||||||
|
const t = fileType(f.name);
|
||||||
|
const url = `/api/share/${shareToken}/files/${f.token}${pw}`;
|
||||||
|
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
|
||||||
|
?`href="#" data-img="${esc(url)}"`
|
||||||
|
:`href="${url}" download`}>
|
||||||
|
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
|
||||||
|
</a>`;
|
||||||
|
}).join('')}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentToken = '';
|
||||||
|
let currentPassword = null;
|
||||||
|
|
||||||
|
async function fetchShareData(token, password) {
|
||||||
|
const headers = {};
|
||||||
|
if (password) headers['x-share-password'] = password;
|
||||||
|
const res = await fetch('/api/share/' + token, { headers });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return { ok: res.ok, status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadShare() {
|
||||||
|
const token = location.pathname.split('/').filter(Boolean).pop() || '';
|
||||||
|
currentToken = token;
|
||||||
|
currentPassword = null;
|
||||||
|
|
||||||
|
const tryLoad = async (pwd) => {
|
||||||
|
const result = await fetchShareData(token, pwd);
|
||||||
|
if (result.ok) {
|
||||||
|
renderShare(result.data, pwd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (result.status === 401 && result.data.passwordRequired) {
|
||||||
|
showPasswordModal();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (result.status === 410) {
|
||||||
|
showError('Срок действия ссылки истёк');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
showError('Ссылка не найдена или была удалена');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
await tryLoad(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPasswordModal() {
|
||||||
|
document.getElementById('passwordInput').value = '';
|
||||||
|
document.getElementById('passwordModal').classList.add('open');
|
||||||
|
document.getElementById('passwordInput').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePasswordModal() {
|
||||||
|
document.getElementById('passwordModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPassword() {
|
||||||
|
const pwd = document.getElementById('passwordInput').value;
|
||||||
|
if (!pwd) return;
|
||||||
|
const result = await fetchShareData(currentToken, pwd);
|
||||||
|
if (result.ok) {
|
||||||
|
hidePasswordModal();
|
||||||
|
currentPassword = pwd;
|
||||||
|
renderShare(result.data, pwd);
|
||||||
|
} else {
|
||||||
|
alert('Неверный пароль');
|
||||||
|
document.getElementById('passwordInput').value = '';
|
||||||
|
document.getElementById('passwordInput').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
document.getElementById('title').textContent = msg;
|
||||||
|
document.getElementById('grid').innerHTML = `<div class="empty"><div class="big">🤔</div>${esc(msg)}</div>`;
|
||||||
|
document.getElementById('photosBlock').style.display = 'none';
|
||||||
|
document.getElementById('shareMessage').style.display = 'none';
|
||||||
|
document.getElementById('shareLinkBlock').style.display = 'none';
|
||||||
|
document.getElementById('countdown').classList.remove('visible');
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
|
||||||
|
if (countdownScrollHandler) { window.removeEventListener('scroll', countdownScrollHandler); countdownScrollHandler = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShare(data, password) {
|
||||||
|
const heading = (data.name && String(data.name).trim()) || data.group_name || 'Записи занятий';
|
||||||
|
document.title = heading;
|
||||||
|
document.getElementById('title').textContent = heading;
|
||||||
|
const chips = [];
|
||||||
|
if (data.group_name) chips.push(`Группа: <b>${esc(data.group_name)}</b>`);
|
||||||
|
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
|
||||||
|
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
|
||||||
|
|
||||||
|
startCountdown(data.expires_at);
|
||||||
|
|
||||||
|
const msgEl = document.getElementById('shareMessage');
|
||||||
|
if (data.message) {
|
||||||
|
msgEl.textContent = data.message;
|
||||||
|
msgEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
msgEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkBlock = document.getElementById('shareLinkBlock');
|
||||||
|
linkBlock.innerHTML = '';
|
||||||
|
if (data.link_url && /^https?:\/\//i.test(data.link_url)) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'share-link-btn';
|
||||||
|
a.href = data.link_url;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
a.textContent = '🔗 Перейти по ссылке';
|
||||||
|
linkBlock.appendChild(a);
|
||||||
|
linkBlock.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
linkBlock.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pb = document.getElementById('photosBlock');
|
||||||
|
if (data.show_group_photos && data.photos && data.photos.length) {
|
||||||
|
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
|
||||||
|
<a class="gphoto" href="#" data-img="${esc(p.photo_path)}">
|
||||||
|
<img src="${thumbSrc(p.photo_path)}" alt="${esc(p.caption || '')}" loading="lazy" decoding="async">
|
||||||
|
${p.caption ? `<div class="gcap">${esc(p.caption)}</div>` : ''}
|
||||||
|
</a>
|
||||||
|
`).join('');
|
||||||
|
pb.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
pb.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
if (!data.entries.length) {
|
||||||
|
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isAnonymized = !data.show_student_names;
|
||||||
|
const showStudentMessage = !!data.show_student_message;
|
||||||
|
const showEntryDate = !!data.show_entry_date;
|
||||||
|
const dayKey = e => {
|
||||||
|
const d = new Date(e.created_at);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||||
|
};
|
||||||
|
const dayLabel = k => {
|
||||||
|
const [y,m,d] = k.split('-').map(Number);
|
||||||
|
return new Date(y, m-1, d).toLocaleDateString('ru-RU', { day:'numeric', month:'long', year:'numeric' });
|
||||||
|
};
|
||||||
|
const cardHTML = e => {
|
||||||
|
const body = [
|
||||||
|
!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '',
|
||||||
|
!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : '',
|
||||||
|
showStudentMessage && e.description ? `<div class="desc">${esc(e.description)}</div>` : '',
|
||||||
|
filesHTML(e.files, password, currentToken)
|
||||||
|
].join('');
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="photo">
|
||||||
|
${e.photo_path
|
||||||
|
? `<img src="${thumbSrc(e.photo_path)}" data-img="${esc(e.photo_path)}" loading="lazy" decoding="async" alt="">`
|
||||||
|
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
||||||
|
</div>
|
||||||
|
${body ? `<div class="body">${body}</div>` : ''}
|
||||||
|
${showEntryDate ? `<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
const groups = [];
|
||||||
|
let lastKey = null;
|
||||||
|
data.entries.forEach(e => {
|
||||||
|
const k = dayKey(e);
|
||||||
|
if (k !== lastKey) { groups.push({ key: k, entries: [] }); lastKey = k; }
|
||||||
|
groups[groups.length-1].entries.push(e);
|
||||||
|
});
|
||||||
|
grid.innerHTML = groups.map(g =>
|
||||||
|
`<div class="date-sep"><span class="date-chip">${esc(dayLabel(g.key))}</span><span class="date-line"></span></div>` +
|
||||||
|
g.entries.map(cardHTML).join('')
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('lightbox').addEventListener('click', () => document.getElementById('lightbox').classList.remove('open'));
|
||||||
|
document.getElementById('passwordModal').querySelector('button').addEventListener('click', submitPassword);
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const imgEl = e.target.closest('[data-img]');
|
||||||
|
if (imgEl) { e.preventDefault(); showImg(imgEl.dataset.img); }
|
||||||
|
});
|
||||||
|
|
||||||
|
(async()=>{
|
||||||
|
await loadShare();
|
||||||
|
})();
|
||||||
|
|
||||||
|
(async function(){
|
||||||
|
const notice = document.getElementById('cookieNotice');
|
||||||
|
const acceptBtn = document.getElementById('cookieAcceptBtn');
|
||||||
|
const COOKIE_KEY = 'whatido_cookie_consent';
|
||||||
|
if (!notice || !acceptBtn || localStorage.getItem(COOKIE_KEY)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/public-settings');
|
||||||
|
const s = await res.json();
|
||||||
|
const text = (s.cookie_notice_text || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
notice.querySelector('p').textContent = text;
|
||||||
|
notice.classList.remove('hidden');
|
||||||
|
} catch (e) { return; }
|
||||||
|
acceptBtn.addEventListener('click', () => {
|
||||||
|
localStorage.setItem(COOKIE_KEY, '1');
|
||||||
|
notice.classList.add('hidden');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
(async function(){
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/public-settings');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const s = await res.json();
|
||||||
|
const head = document.getElementById('brandHead');
|
||||||
|
if (!head) return;
|
||||||
|
const logo = document.getElementById('brandLogo');
|
||||||
|
const name = document.getElementById('brandName');
|
||||||
|
let any = false;
|
||||||
|
if (name && (s.system_name || '').trim()) {
|
||||||
|
name.textContent = s.system_name;
|
||||||
|
name.hidden = false;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
if (logo && s.system_logo) {
|
||||||
|
logo.src = s.system_logo;
|
||||||
|
logo.hidden = false;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
if (any) head.hidden = false;
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
let allStudents = [];
|
||||||
|
let allGroups = [];
|
||||||
|
let groupOptionsLoaded = false;
|
||||||
|
let sPage = 1;
|
||||||
|
const S_PAGE_SIZE = 25;
|
||||||
|
let sSearchTimer = null;
|
||||||
|
let aSearchTimer = null;
|
||||||
|
let attachGroupLoaded = false;
|
||||||
|
|
||||||
|
async function loadGroupOptions(sel, selected) {
|
||||||
|
if (!groupOptionsLoaded) {
|
||||||
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
|
allGroups = await res.json();
|
||||||
|
groupOptionsLoaded = true;
|
||||||
|
const filt = document.getElementById('studentGroupFilter');
|
||||||
|
if (filt && filt.options.length <= 1) {
|
||||||
|
filt.innerHTML = `<option value="">Все группы</option>` +
|
||||||
|
allGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sel) {
|
||||||
|
sel.innerHTML = `<option value="">— не привязана —</option>` +
|
||||||
|
allGroups.map(g => `<option value="${g.id}" ${selected == g.id ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStudents() {
|
||||||
|
const res = await fetch(`${API}/api/students`, { headers: hdr() });
|
||||||
|
allStudents = await res.json();
|
||||||
|
renderStudents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStudents() {
|
||||||
|
const q = document.getElementById('studentSearch').value.trim().toLowerCase();
|
||||||
|
const gid = document.getElementById('studentGroupFilter').value;
|
||||||
|
let filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
|
||||||
|
if (gid) filtered = filtered.filter(s => String(s.group_id) === gid);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / S_PAGE_SIZE));
|
||||||
|
if (sPage > totalPages) sPage = 1;
|
||||||
|
const slice = filtered.slice((sPage - 1) * S_PAGE_SIZE, sPage * S_PAGE_SIZE);
|
||||||
|
const list = document.getElementById('studentList');
|
||||||
|
if (!slice.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Ничего не найдено</div>'; }
|
||||||
|
else {
|
||||||
|
list.innerHTML = slice.map(s => `
|
||||||
|
<div class="item">
|
||||||
|
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
|
||||||
|
<select class="group-select" id="sgroup-${s.id}"></select>
|
||||||
|
<div class="student-actions">
|
||||||
|
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
|
||||||
|
<button class="btn-link" data-profile-student="${s.id}" title="Скачать профиль ученика (ZIP: фото и работы)">Профиль</button>
|
||||||
|
<button class="btn-link" data-profile-data="${s.id}" title="Данные профиля: заполняют разделы страницы профиля в архиве">Данные</button>
|
||||||
|
<button class="btn-link" data-export-student="${esc(s.name)}" title="Сформировать ZIP-отчёт: фотографии и файлы">Отчёт</button>
|
||||||
|
<button class="btn-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
|
||||||
|
<button class="btn-del" data-del-student="${s.id}" title="Удалить">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
slice.forEach(s => loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id));
|
||||||
|
}
|
||||||
|
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goStudentPage(p) {
|
||||||
|
sPage = p;
|
||||||
|
renderStudents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addStudent() {
|
||||||
|
const input = document.getElementById('newStudent');
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
|
||||||
|
if (res.ok) { input.value = ''; loadStudents(); fillAutocomplete(); showToast(`Добавлен: ${name}`); }
|
||||||
|
else { const e = await res.json(); alert(e.error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStudent(id) {
|
||||||
|
const name = document.getElementById(`sname-${id}`).value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const group_id = document.getElementById(`sgroup-${id}`).value;
|
||||||
|
const res = await fetch(`${API}/api/students/${id}`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ name, group_id }) });
|
||||||
|
if (res.ok) { loadStudents(); fillAutocomplete(); showToast(`Сохранено: ${name}`); }
|
||||||
|
else { const e = await res.json(); alert(e.error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let exportPendingName = null;
|
||||||
|
|
||||||
|
function openExportModal(name) {
|
||||||
|
exportPendingName = name;
|
||||||
|
document.getElementById('exportName').textContent = name;
|
||||||
|
document.getElementById('exportForm').reset();
|
||||||
|
document.getElementById('exportDateFrom').value = '';
|
||||||
|
document.getElementById('expEntries').checked = true;
|
||||||
|
document.getElementById('expPhotos').checked = true;
|
||||||
|
document.getElementById('expCaptions').checked = true;
|
||||||
|
document.getElementById('expFiles').checked = true;
|
||||||
|
document.getElementById('expDates').checked = true;
|
||||||
|
document.getElementById('exportModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeExportModal() {
|
||||||
|
document.getElementById('exportModal').classList.remove('open');
|
||||||
|
exportPendingName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExportPhotosToggle() {
|
||||||
|
const on = document.getElementById('expPhotos').checked;
|
||||||
|
document.getElementById('expCaptions').disabled = !on;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportStudent(name, opts) {
|
||||||
|
showToast(`Формируем отчёт: ${name}...`);
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams({ name });
|
||||||
|
if (opts.dateFrom) q.set('date_from', opts.dateFrom);
|
||||||
|
if (opts.dateTo) q.set('date_to', opts.dateTo);
|
||||||
|
q.set('include_entries', opts.entries ? '1' : '0');
|
||||||
|
q.set('include_photos', opts.photos ? '1' : '0');
|
||||||
|
q.set('include_captions', opts.captions ? '1' : '0');
|
||||||
|
q.set('include_files', opts.files ? '1' : '0');
|
||||||
|
q.set('show_dates', opts.dates ? '1' : '0');
|
||||||
|
const res = await fetch(`${API}/api/export/student?${q}`, { headers: hdr() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = await res.json();
|
||||||
|
alert(e.error || 'Не удалось сформировать отчёт');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
downloadBlob(blob, `student_${safeWindowsName(name)}_report.zip`);
|
||||||
|
showToast(`Готово: ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка при формировании отчёта');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitExport() {
|
||||||
|
const name = exportPendingName;
|
||||||
|
if (!name) return;
|
||||||
|
const captionsEl = document.getElementById('expCaptions');
|
||||||
|
const opts = {
|
||||||
|
entries: document.getElementById('expEntries').checked,
|
||||||
|
photos: document.getElementById('expPhotos').checked,
|
||||||
|
files: document.getElementById('expFiles').checked,
|
||||||
|
captions: captionsEl.checked && !captionsEl.disabled,
|
||||||
|
dates: document.getElementById('expDates').checked,
|
||||||
|
dateFrom: document.getElementById('exportDateFrom').value,
|
||||||
|
dateTo: document.getElementById('exportDateTo').value,
|
||||||
|
};
|
||||||
|
if (!opts.entries && !opts.photos && !opts.files) {
|
||||||
|
alert('Выберите хотя бы одну категорию: записи, фото или файлы');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (opts.dateFrom && opts.dateTo && opts.dateFrom > opts.dateTo) {
|
||||||
|
alert('Дата «С» позже даты «По»');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeExportModal();
|
||||||
|
await exportStudent(name, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportStudentProfile(id) {
|
||||||
|
const s = allStudents.find(x => x.id === id);
|
||||||
|
if (!s) return;
|
||||||
|
const btn = document.querySelector(`[data-profile-student="${id}"]`);
|
||||||
|
const label = btn ? btn.textContent : '';
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Готовим...'; }
|
||||||
|
showToast(`Формируем профиль: ${s.name}...`);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/export/student?name=${encodeURIComponent(s.name)}`, { headers: hdr() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = await res.json().catch(() => ({}));
|
||||||
|
showToast(e.error || 'Не удалось сформировать профиль');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${new Date().toISOString().slice(0, 10)}.zip`);
|
||||||
|
showToast(`Готово: ${s.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Ошибка при формировании профиля');
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = label; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileDataId = null;
|
||||||
|
let profilePhotos = [];
|
||||||
|
|
||||||
|
function openProfileData(id) {
|
||||||
|
profileDataId = id;
|
||||||
|
loadGroupOptions();
|
||||||
|
loadStudentPhotos(id);
|
||||||
|
fetch(`${API}/api/students/${id}/profile`, { headers: hdr() })
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('load'))))
|
||||||
|
.then(data => fillProfileForm(data.profile, data.name, data.photo_path))
|
||||||
|
.catch(() => fillProfileForm(null, '', ''));
|
||||||
|
document.getElementById('profileDataModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProfileData() {
|
||||||
|
document.getElementById('profileDataModal').classList.remove('open');
|
||||||
|
profileDataId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStudentPhotos(id) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
profilePhotos = data.photos || [];
|
||||||
|
document.getElementById('pfPhoto').value = data.photo_path || '';
|
||||||
|
renderStudentPhotos();
|
||||||
|
} catch (err) {
|
||||||
|
profilePhotos = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStudentPhotos() {
|
||||||
|
const wrap = document.getElementById('pfPhotos');
|
||||||
|
if (!wrap) return;
|
||||||
|
const main = document.getElementById('pfPhoto').value.trim();
|
||||||
|
if (!profilePhotos.length) {
|
||||||
|
wrap.innerHTML = '<span class="hint" style="margin:0">Пока нет загруженных фото</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.innerHTML = profilePhotos.map(p => `
|
||||||
|
<div class="pf-photo${p.photo_path === main ? ' main' : ''}" data-pf-set="${p.id}" title="Сделать главным">
|
||||||
|
<img src="${esc(p.photo_path)}" alt="" loading="lazy">
|
||||||
|
${p.photo_path === main ? '<span class="pf-badge">Главная</span>' : ''}
|
||||||
|
<button type="button" class="pf-del" data-pf-del="${p.id}" title="Удалить фото">✕</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadStudentPhoto(file) {
|
||||||
|
if (!profileDataId || !file) return;
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Можно загружать только изображения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Загружаем фото...');
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', file);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
|
||||||
|
if (res.ok) {
|
||||||
|
await loadStudentPhotos(profileDataId);
|
||||||
|
showToast('Фото загружено');
|
||||||
|
} else {
|
||||||
|
const e = await res.json().catch(() => ({}));
|
||||||
|
alert(e.error || 'Не удалось загрузить фото');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка при загрузке фото');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMainStudentPhoto(pid) {
|
||||||
|
if (!profileDataId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}/main`, { method: 'PUT', headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
const r = await res.json();
|
||||||
|
document.getElementById('pfPhoto').value = r.photo_path || '';
|
||||||
|
renderStudentPhotos();
|
||||||
|
showToast('Главное фото обновлено');
|
||||||
|
} else {
|
||||||
|
const e = await res.json().catch(() => ({}));
|
||||||
|
alert(e.error || 'Не удалось выбрать главное фото');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка при выборе главного фото');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delStudentPhoto(pid) {
|
||||||
|
if (!profileDataId) return;
|
||||||
|
if (!confirm('Удалить это фото?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
await loadStudentPhotos(profileDataId);
|
||||||
|
showToast('Фото удалено');
|
||||||
|
} else {
|
||||||
|
const e = await res.json().catch(() => ({}));
|
||||||
|
alert(e.error || 'Не удалось удалить фото');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка при удалении фото');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillProfileForm(profile, name, photoPath) {
|
||||||
|
const p = profile || {};
|
||||||
|
const set = (id, v) => { document.getElementById(id).value = v === null || v === undefined ? '' : String(v); };
|
||||||
|
document.getElementById('profileDataTitle').textContent = name || 'Профиль ученика';
|
||||||
|
set('pfRole', p.role); set('pfStatus', p.status); set('pfStatusNote', p.status_note);
|
||||||
|
set('pfCity', p.city); set('pfMentor', p.mentor); set('pfJoined', p.joined);
|
||||||
|
set('pfBio', p.bio); set('pfQuote', p.quote);
|
||||||
|
set('pfTags', (p.tags || []).join(', '));
|
||||||
|
set('pfContacts', (p.contacts || []).map(c => [c.icon || 'link', c.label, c.href || ''].join(' | ')).join('\n'));
|
||||||
|
set('pfAchievements', (p.achievements || []).join('\n'));
|
||||||
|
set('pfSkills', (p.skills || []).map(s => [s.group || 'Навыки', s.name, s.level || '', s.value === null || s.value === undefined ? '' : s.value].join(' | ')).join('\n'));
|
||||||
|
set('pfExperience', (p.experience || []).map(e => [e.title, e.company || '', e.period || '', e.date || '', e.badge || '', e.description || '', (e.tags || []).join(',')].join(' | ')).join('\n'));
|
||||||
|
set('pfEducation', (p.education || []).map(m => [m.module, m.progress === null || m.progress === undefined ? '' : m.progress, m.grade || '', m.teacher || ''].join(' | ')).join('\n'));
|
||||||
|
set('pfPhoto', photoPath || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectProfile() {
|
||||||
|
const val = id => document.getElementById(id).value.trim();
|
||||||
|
const lines = id => val(id).split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
|
const cols = line => line.split('|').map(s => s.trim());
|
||||||
|
const intOrNull = s => (s === '' || s === null || s === undefined ? null : Number(s));
|
||||||
|
const out = {
|
||||||
|
role: val('pfRole') || null,
|
||||||
|
status: val('pfStatus') || null,
|
||||||
|
status_note: val('pfStatusNote') || null,
|
||||||
|
city: val('pfCity') || null,
|
||||||
|
mentor: val('pfMentor') || null,
|
||||||
|
joined: val('pfJoined') || null,
|
||||||
|
bio: val('pfBio') || null,
|
||||||
|
quote: val('pfQuote') || null,
|
||||||
|
tags: val('pfTags') ? val('pfTags').split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||||
|
contacts: lines('pfContacts').map(l => {
|
||||||
|
const c = cols(l);
|
||||||
|
return { icon: c[0] || 'link', label: c[1] || '', href: c[2] || '' };
|
||||||
|
}).filter(c => c.label),
|
||||||
|
achievements: lines('pfAchievements'),
|
||||||
|
skills: lines('pfSkills').map(l => {
|
||||||
|
const c = cols(l);
|
||||||
|
return { group: c[0] || 'Навыки', name: c[1] || '', level: c[2] || null, value: intOrNull(c[3]) };
|
||||||
|
}).filter(s => s.name),
|
||||||
|
experience: lines('pfExperience').map(l => {
|
||||||
|
const c = cols(l);
|
||||||
|
return {
|
||||||
|
title: c[0] || '', company: c[1] || null, period: c[2] || null, date: c[3] || null,
|
||||||
|
badge: c[4] || null, description: c[5] || null,
|
||||||
|
tags: c[6] ? c[6].split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||||
|
};
|
||||||
|
}).filter(e => e.title),
|
||||||
|
education: lines('pfEducation').map(l => {
|
||||||
|
const c = cols(l);
|
||||||
|
return { module: c[0] || '', progress: intOrNull(c[1]), grade: c[2] || null, teacher: c[3] || null };
|
||||||
|
}).filter(m => m.module),
|
||||||
|
};
|
||||||
|
const hasData = Object.keys(out).some(k => (Array.isArray(out[k]) ? out[k].length : out[k]));
|
||||||
|
return hasData ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfileData(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!profileDataId) return;
|
||||||
|
const body = { profile: collectProfile() };
|
||||||
|
const photoPath = document.getElementById('pfPhoto').value.trim();
|
||||||
|
body.photo_path = photoPath || null;
|
||||||
|
const res = await fetch(`${API}/api/students/${profileDataId}/profile`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
closeProfileData();
|
||||||
|
loadStudents();
|
||||||
|
showToast('Данные профиля сохранены');
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
alert(err.error || 'Не удалось сохранить данные профиля');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delStudent(id) {
|
||||||
|
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
|
||||||
|
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
loadStudents();
|
||||||
|
fillAutocomplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchAdd() {
|
||||||
|
document.getElementById('batchText').value = '';
|
||||||
|
loadGroupOptions(document.getElementById('batchGroup'), '');
|
||||||
|
document.getElementById('batchModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBatchAdd() {
|
||||||
|
document.getElementById('batchModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchAdd() {
|
||||||
|
const raw = document.getElementById('batchText').value;
|
||||||
|
const names = [...new Set(raw.split('\n').map(s => s.trim()).filter(Boolean))];
|
||||||
|
if (!names.length) return;
|
||||||
|
const group_id = document.getElementById('batchGroup').value;
|
||||||
|
let added = 0, dupes = 0;
|
||||||
|
for (const name of names) {
|
||||||
|
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, group_id: group_id || null }) });
|
||||||
|
if (res.ok) added++;
|
||||||
|
else if (res.status === 409) dupes++;
|
||||||
|
}
|
||||||
|
closeBatchAdd();
|
||||||
|
loadStudents();
|
||||||
|
fillAutocomplete();
|
||||||
|
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('newStudent').addEventListener('keydown', e => { if (e.key === 'Enter') addStudent(); });
|
||||||
|
document.getElementById('batchForm').addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
batchAdd();
|
||||||
|
});
|
||||||
|
document.getElementById('studentSearch').addEventListener('input', () => {
|
||||||
|
clearTimeout(sSearchTimer);
|
||||||
|
sSearchTimer = setTimeout(() => { sPage = 1; renderStudents(); }, 300);
|
||||||
|
});
|
||||||
|
document.getElementById('studentGroupFilter').addEventListener('change', () => { sPage = 1; renderStudents(); });
|
||||||
|
|
||||||
|
function exportStudentsCSV() {
|
||||||
|
const gid = document.getElementById('studentGroupFilter').value;
|
||||||
|
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
|
||||||
|
if (!list.length) { alert('Нет данных для экспорта'); return; }
|
||||||
|
const rows = [['Фамилия Имя', 'Группа', 'Ссылка на страницу']];
|
||||||
|
list.forEach(s => {
|
||||||
|
rows.push([
|
||||||
|
s.name,
|
||||||
|
s.group_name || '',
|
||||||
|
`${location.origin}/?nameInput=${encodeURIComponent(s.name)}`
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
|
||||||
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
|
||||||
|
a.download = `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast(`Экспортировано: ${list.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportStudentsUrls() {
|
||||||
|
const gid = document.getElementById('studentGroupFilter').value;
|
||||||
|
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
|
||||||
|
if (!list.length) { alert('Нет данных для экспорта'); return; }
|
||||||
|
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
|
||||||
|
await exportStudentUrlZip(list, `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
|
||||||
|
showToast(`Экспортировано: ${list.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBatchAttach() {
|
||||||
|
const sel = document.getElementById('attachGroup');
|
||||||
|
if (!attachGroupLoaded) {
|
||||||
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
|
const groups = await res.json();
|
||||||
|
sel.innerHTML = `<option value="">— выберите группу —</option>` +
|
||||||
|
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||||
|
attachGroupLoaded = true;
|
||||||
|
}
|
||||||
|
document.getElementById('attachSearch').value = '';
|
||||||
|
renderAttachList();
|
||||||
|
document.getElementById('attachModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBatchAttach() {
|
||||||
|
document.getElementById('attachModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachList() {
|
||||||
|
const q = document.getElementById('attachSearch').value.trim().toLowerCase();
|
||||||
|
const filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
|
||||||
|
const list = document.getElementById('attachList');
|
||||||
|
if (!filtered.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:16px">Никого не найдено</div>'; return; }
|
||||||
|
list.innerHTML = filtered.map(s => `
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" value="${s.id}">
|
||||||
|
<span>${esc(s.name)}</span>
|
||||||
|
<span class="cur">${s.group_name ? esc(s.group_name) : ''}</span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchAttach() {
|
||||||
|
const group_id = document.getElementById('attachGroup').value;
|
||||||
|
const ids = [...document.querySelectorAll('#attachList input[type=checkbox]:checked')].map(c => c.value);
|
||||||
|
if (!group_id) { alert('Выберите группу'); return; }
|
||||||
|
if (!ids.length) { alert('Выберите учеников'); return; }
|
||||||
|
const res = await fetch(`${API}/api/students/batch-group`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ group_id, student_ids: ids })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const r = await res.json();
|
||||||
|
closeBatchAttach();
|
||||||
|
loadStudents();
|
||||||
|
showToast(`Прикреплено: ${r.updated}`);
|
||||||
|
} else { const e = await res.json(); alert(e.error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('attachForm').addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
batchAttach();
|
||||||
|
});
|
||||||
|
document.getElementById('attachSearch').addEventListener('input', () => {
|
||||||
|
clearTimeout(aSearchTimer);
|
||||||
|
aSearchTimer = setTimeout(renderAttachList, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('exportForm').addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
submitExport();
|
||||||
|
});
|
||||||
|
document.getElementById('exportCancelBtn').addEventListener('click', closeExportModal);
|
||||||
|
document.getElementById('expPhotos').addEventListener('change', onExportPhotosToggle);
|
||||||
|
document.getElementById('exportModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeExportModal(); });
|
||||||
|
|
||||||
|
document.getElementById('addStudentBtn').addEventListener('click', addStudent);
|
||||||
|
document.getElementById('batchAddBtn').addEventListener('click', openBatchAdd);
|
||||||
|
document.getElementById('batchAttachBtn').addEventListener('click', openBatchAttach);
|
||||||
|
document.getElementById('exportStudentsCsvBtn').addEventListener('click', exportStudentsCSV);
|
||||||
|
document.getElementById('exportStudentsUrlsBtn').addEventListener('click', exportStudentsUrls);
|
||||||
|
document.getElementById('batchModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchAdd(); });
|
||||||
|
document.getElementById('attachModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchAttach(); });
|
||||||
|
document.getElementById('batchCancelBtn').addEventListener('click', closeBatchAdd);
|
||||||
|
document.getElementById('attachCancelBtn').addEventListener('click', closeBatchAttach);
|
||||||
|
|
||||||
|
document.getElementById('studentList').addEventListener('click', e => {
|
||||||
|
const sv = e.target.closest('[data-save-student]');
|
||||||
|
if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; }
|
||||||
|
const cp = e.target.closest('[data-copy-student]');
|
||||||
|
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
|
||||||
|
const ex = e.target.closest('[data-export-student]');
|
||||||
|
if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; }
|
||||||
|
const pf = e.target.closest('[data-profile-student]');
|
||||||
|
if (pf) { exportStudentProfile(parseInt(pf.dataset.profileStudent, 10)); return; }
|
||||||
|
const pd = e.target.closest('[data-profile-data]');
|
||||||
|
if (pd) { openProfileData(parseInt(pd.dataset.profileData, 10)); return; }
|
||||||
|
const dl = e.target.closest('[data-del-student]');
|
||||||
|
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('profileDataForm').addEventListener('submit', saveProfileData);
|
||||||
|
document.getElementById('profileDataCancelBtn').addEventListener('click', closeProfileData);
|
||||||
|
document.getElementById('pfPhotoUploadBtn').addEventListener('click', () => document.getElementById('pfPhotoFile').click());
|
||||||
|
document.getElementById('pfPhotoFile').addEventListener('change', e => {
|
||||||
|
const f = e.target.files && e.target.files[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (f) uploadStudentPhoto(f);
|
||||||
|
});
|
||||||
|
document.getElementById('pfPhotos').addEventListener('click', e => {
|
||||||
|
const del = e.target.closest('[data-pf-del]');
|
||||||
|
if (del) { delStudentPhoto(parseInt(del.dataset.pfDel, 10)); return; }
|
||||||
|
const set = e.target.closest('[data-pf-set]');
|
||||||
|
if (set) setMainStudentPhoto(parseInt(set.dataset.pfSet, 10));
|
||||||
|
});
|
||||||
|
document.addEventListener('paste', e => {
|
||||||
|
if (!profileDataId) return;
|
||||||
|
if (!document.getElementById('profileDataModal').classList.contains('open')) return;
|
||||||
|
const items = e.clipboardData && e.clipboardData.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.type && it.type.startsWith('image/')) {
|
||||||
|
const f = it.getAsFile();
|
||||||
|
if (f) {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadStudentPhoto(f);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('profileDataModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeProfileData(); });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await checkAuth()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
fillAutocomplete();
|
||||||
|
loadGroupOptions();
|
||||||
|
loadStudents();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const PAGE_SIZE = 20;
|
||||||
|
let currentEntries = [];
|
||||||
|
let page = 1;
|
||||||
|
let totalEntries = 0;
|
||||||
|
|
||||||
|
function isImageFile(name) {
|
||||||
|
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filesHTML(files) {
|
||||||
|
if (!files || !files.length) return '';
|
||||||
|
return `<div class="entry-files">${files.map(f =>
|
||||||
|
isImageFile(f.name)
|
||||||
|
? `<a class="f-link" href="#" data-img="${API}/api/files/${f.token}" title="Открыть">${esc(f.name)}</a>`
|
||||||
|
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`
|
||||||
|
).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryHTML(e) {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">${esc(e.student_name)}</div>
|
||||||
|
<div class="group">${esc(e.group_name)}</div>
|
||||||
|
<div class="desc">${esc(e.description)}</div>
|
||||||
|
${filesHTML(e.files)}
|
||||||
|
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
||||||
|
<button class="del-btn" data-hard="${e.id}" title="Удалить навсегда (вместе с файлами)">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
grid.className = 'grid';
|
||||||
|
grid.innerHTML = currentEntries.length
|
||||||
|
? currentEntries.map(entryHTML).join('')
|
||||||
|
: '<div class="empty">Корзина пуста</div>';
|
||||||
|
document.getElementById('trashInfo').textContent =
|
||||||
|
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
|
||||||
|
document.getElementById('emptyBtn').disabled = totalEntries === 0;
|
||||||
|
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrash() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('limit', PAGE_SIZE);
|
||||||
|
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||||
|
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
|
||||||
|
const data = await res.json();
|
||||||
|
currentEntries = data.entries;
|
||||||
|
totalEntries = data.total;
|
||||||
|
renderGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(p) { page = p; loadTrash(); }
|
||||||
|
|
||||||
|
async function restoreEntry(id) {
|
||||||
|
const res = await fetch(`${API}/api/entries/${id}/restore`, { method: 'PUT', headers: hdr() });
|
||||||
|
if (res.ok) { showToast('Запись восстановлена'); loadTrash(); }
|
||||||
|
else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hardDelete(id) {
|
||||||
|
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
|
||||||
|
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||||
|
showToast('Запись удалена навсегда');
|
||||||
|
loadTrash();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emptyTrash() {
|
||||||
|
if (totalEntries === 0) return;
|
||||||
|
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
|
||||||
|
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
|
||||||
|
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
||||||
|
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||||
|
|
||||||
|
document.getElementById('grid').addEventListener('click', e => {
|
||||||
|
const card = e.target.closest('.card');
|
||||||
|
if (!card) return;
|
||||||
|
const rs = card.querySelector('[data-restore]');
|
||||||
|
if (rs && (e.target === rs || rs.contains(e.target))) { restoreEntry(parseInt(rs.dataset.restore, 10)); return; }
|
||||||
|
const hd = card.querySelector('[data-hard]');
|
||||||
|
if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); }
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await checkAuth()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
loadTrash();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
async function init() {
|
||||||
|
if (!(await checkAuth())) return;
|
||||||
|
if (!isAdmin()) { location.href = 'journal.html'; return; }
|
||||||
|
buildSidebar('users');
|
||||||
|
document.getElementById('addUserBtn').addEventListener('click', () => openModal(null));
|
||||||
|
document.getElementById('uCancel').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('uSave').addEventListener('click', saveUser);
|
||||||
|
document.getElementById('uRole').addEventListener('change', () => {
|
||||||
|
document.getElementById('uBranchesField').style.display = document.getElementById('uRole').value === 'admin' ? 'none' : '';
|
||||||
|
});
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
let branches = [];
|
||||||
|
let editingId = null;
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const [uRes, bRes] = await Promise.all([
|
||||||
|
fetch(`${API}/api/users`, { headers: hdr() }),
|
||||||
|
fetch(`${API}/api/branches`, { headers: hdr() }),
|
||||||
|
]);
|
||||||
|
if (!uRes.ok) { showToast('Ошибка загрузки пользователей'); return; }
|
||||||
|
branches = await bRes.json();
|
||||||
|
renderUsers(await uRes.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers(users) {
|
||||||
|
const tbody = document.querySelector('#usersTable tbody');
|
||||||
|
const empty = document.getElementById('usersEmpty');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
empty.style.display = users.length ? 'none' : 'block';
|
||||||
|
const branchById = {};
|
||||||
|
branches.forEach(b => { branchById[b.id] = b.name; });
|
||||||
|
users.forEach(u => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const branchNames = (u.branch_ids || []).map(id => branchById[id] || `#${id}`).join(', ') || '—';
|
||||||
|
const roleCls = u.role === 'admin' ? 'badge-admin' : 'badge-tutor';
|
||||||
|
const roleTxt = u.role === 'admin' ? 'Администратор' : 'Тутор';
|
||||||
|
const status = u.is_active ? '<span class="status-ok">Активен</span>' : '<span class="status-off">Заблокирован</span>';
|
||||||
|
const delBtn = u.id === currentUser.id
|
||||||
|
? ''
|
||||||
|
: `<button class="row-btn del" data-del="${u.id}" title="Удалить">×</button>`;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${u.id}</td>
|
||||||
|
<td>${esc(u.username)}</td>
|
||||||
|
<td>${esc(u.name || '')}</td>
|
||||||
|
<td><span class="${roleCls}">${roleTxt}</span></td>
|
||||||
|
<td class="user-branches">${esc(branchNames)}</td>
|
||||||
|
<td>${status}</td>
|
||||||
|
<td>
|
||||||
|
<button class="row-btn" data-edit="${u.id}" title="Редактировать">✎</button>
|
||||||
|
${delBtn}
|
||||||
|
</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('[data-edit]').forEach(b => {
|
||||||
|
const userId = Number(b.dataset.edit);
|
||||||
|
const user = users.find(u => u.id === userId);
|
||||||
|
b.addEventListener('click', () => openModal(user));
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => delUser(Number(b.dataset.del))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(user) {
|
||||||
|
editingId = user ? user.id : null;
|
||||||
|
document.getElementById('userModalTitle').textContent = user ? `Пользователь: ${user.username}` : 'Новый пользователь';
|
||||||
|
document.getElementById('uUsername').value = user ? user.username : '';
|
||||||
|
document.getElementById('uName').value = user ? (user.name || '') : '';
|
||||||
|
document.getElementById('uRole').value = user ? user.role : 'tutor';
|
||||||
|
document.getElementById('uPassword').value = '';
|
||||||
|
document.getElementById('uActive').checked = user ? user.is_active : true;
|
||||||
|
document.getElementById('pwdHint').textContent = user ? '(оставьте пустым, чтобы не менять)' : '(мин. 6 символов)';
|
||||||
|
document.getElementById('uRole').disabled = !!user && user.id === currentUser.id;
|
||||||
|
const sel = document.getElementById('uBranches');
|
||||||
|
sel.innerHTML = '';
|
||||||
|
branches.forEach(b => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = b.id;
|
||||||
|
o.textContent = b.name;
|
||||||
|
if (user && (user.branch_ids || []).includes(b.id)) o.selected = true;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
document.getElementById('uBranchesField').style.display = document.getElementById('uRole').value === 'admin' ? 'none' : '';
|
||||||
|
document.getElementById('userModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('userModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
|
const username = document.getElementById('uUsername').value.trim();
|
||||||
|
const name = document.getElementById('uName').value.trim() || null;
|
||||||
|
const role = document.getElementById('uRole').value;
|
||||||
|
const password = document.getElementById('uPassword').value;
|
||||||
|
const is_active = document.getElementById('uActive').checked;
|
||||||
|
const branchSel = document.getElementById('uBranches');
|
||||||
|
const branch_ids = role === 'admin' ? [] : [...branchSel.selectedOptions].map(o => Number(o.value));
|
||||||
|
if (!username) { showToast('Укажите логин'); return; }
|
||||||
|
if (!editingId && password.length < 6) { showToast('Пароль должен быть не короче 6 символов'); return; }
|
||||||
|
const payload = { username, name, role, is_active, branch_ids };
|
||||||
|
if (password) payload.password = password;
|
||||||
|
const res = await fetch(editingId ? `${API}/api/users/${editingId}` : `${API}/api/users`, {
|
||||||
|
method: editingId ? 'PUT' : 'POST',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) { showToast(data.error || 'Ошибка сохранения'); return; }
|
||||||
|
showToast('Сохранено');
|
||||||
|
closeModal();
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delUser(id) {
|
||||||
|
if (!confirm('Удалить пользователя?')) return;
|
||||||
|
const res = await fetch(`${API}/api/users/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
showToast(data.error || 'Ошибка удаления');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Пользователь удалён');
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
const AUTO_REFRESH_MS = 5000;
|
||||||
|
const RECENT_PAGE_SIZE = 15;
|
||||||
|
const PHOTO_PAGE_SIZE = 15;
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
pending: 'В очереди',
|
||||||
|
processing: 'Проверяется',
|
||||||
|
done: 'Проверено',
|
||||||
|
skipped: 'Пропущено',
|
||||||
|
error: 'Ошибка',
|
||||||
|
reverted: 'Оригинал',
|
||||||
|
};
|
||||||
|
const STATUS_BADGE = {
|
||||||
|
done: 'ai-badge-done',
|
||||||
|
error: 'ai-badge-error',
|
||||||
|
skipped: 'ai-badge-skipped',
|
||||||
|
reverted: 'ai-badge-reverted',
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = null;
|
||||||
|
let photoState = null;
|
||||||
|
let photoJobRow = null;
|
||||||
|
let refreshTimer = null;
|
||||||
|
let busy = false;
|
||||||
|
let photoBusy = false;
|
||||||
|
let recentRows = [];
|
||||||
|
let recentPage = 1;
|
||||||
|
let photoPage = 1;
|
||||||
|
let photoTotal = 0;
|
||||||
|
|
||||||
|
const PHOTO_ACTION_LABELS = {
|
||||||
|
ai: 'ИИ-обработка',
|
||||||
|
enhance: 'Улучшение',
|
||||||
|
restore: 'Возврат оригинала',
|
||||||
|
rollback: 'Откат',
|
||||||
|
};
|
||||||
|
const PHOTO_STATUS_LABELS = {
|
||||||
|
pending: 'В очереди',
|
||||||
|
processing: 'Обрабатывается',
|
||||||
|
done: 'Готово',
|
||||||
|
error: 'Ошибка',
|
||||||
|
rejected: 'Отклонено',
|
||||||
|
};
|
||||||
|
const PHOTO_STATUS_BADGE = {
|
||||||
|
done: 'ai-badge-done',
|
||||||
|
error: 'ai-badge-error',
|
||||||
|
processing: 'ai-badge-skipped',
|
||||||
|
};
|
||||||
|
|
||||||
|
function thumbUrl(p) {
|
||||||
|
if (!p) return '';
|
||||||
|
if (p.startsWith('/uploads/.originals/')) return `${API}${p}`;
|
||||||
|
return `${API}/uploads/thumb/${p.split('/').pop()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDur(ms) {
|
||||||
|
if (ms === null || ms === undefined || ms < 0) return '—';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (d) return `${d} д ${h} ч`;
|
||||||
|
if (h) return `${h} ч ${m} мин`;
|
||||||
|
if (m) return `${m} мин ${sec} с`;
|
||||||
|
return `${sec} с`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(t) {
|
||||||
|
if (!t) return '—';
|
||||||
|
try { return new Date(t).toLocaleString('ru'); } catch { return String(t); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ago(t) {
|
||||||
|
if (!t) return '—';
|
||||||
|
const diff = Date.now() - new Date(t).getTime();
|
||||||
|
if (Number.isNaN(diff)) return '—';
|
||||||
|
return fmtDur(diff) + ' назад';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(s) {
|
||||||
|
return `<span class="ai-badge ${STATUS_BADGE[s] || ''}">${esc(STATUS_LABELS[s] || s)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderState() {
|
||||||
|
const w = state.worker || {};
|
||||||
|
const svc = state.service || {};
|
||||||
|
const enabled = state.enabled;
|
||||||
|
const processing = !!w.processing;
|
||||||
|
const dotClass = !enabled ? '' : (svc.reachable ? (processing ? 'busy' : 'on') : 'err');
|
||||||
|
const label = !enabled
|
||||||
|
? 'Автопроверка выключена'
|
||||||
|
: (!svc.reachable ? 'Сервис модели недоступен' : (processing ? `Обрабатывается запись #${w.current_id ?? ''}` : 'Ожидание очереди'));
|
||||||
|
document.getElementById('stateLine').innerHTML = `<span class="worker-dot ${dotClass}"></span> ${esc(label)}`;
|
||||||
|
document.getElementById('toggleBtn').textContent = enabled ? 'Выключить автопроверку' : 'Включить автопроверку';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCards() {
|
||||||
|
const w = state.worker || {};
|
||||||
|
const svc = state.service || {};
|
||||||
|
const cfg = w.config || {};
|
||||||
|
const pct = w.checks ? Math.round(((w.corrected || 0) / w.checks) * 100) : 0;
|
||||||
|
document.getElementById('workerCards').innerHTML = `
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Состояние воркера</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Режим</span><span class="sys-value">${w.processing ? 'Обработка' : 'Ожидание'}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Текущая запись</span><span class="sys-value">${w.current_id ? '#' + w.current_id : '—'}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Работает</span><span class="sys-value">${fmtDur(w.uptime_ms)}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Запущен</span><span class="sys-value">${fmtTime(w.started_at)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Модель</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Модель</span><span class="sys-value">${esc(state.model)}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Сервис</span><span class="sys-value">${svc.reachable ? '<span class="lucide-inline" style="color:#16a34a"><i data-lucide="check"></i></span> доступен' : '<span class="lucide-inline" style="color:#ef4444"><i data-lucide="x"></i></span> недоступен'}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Отклик</span><span class="sys-value">${svc.latency_ms !== undefined ? svc.latency_ms + ' мс' : '—'}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Адрес</span><span class="sys-value" style="font-size:.75rem">${esc(state.ai_url || '')}</span></div>
|
||||||
|
${svc.error ? `<div class="sys-meta" style="color:#ef4444">${esc(svc.error)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Итоги работы</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Проверок</span><span class="sys-value">${w.checks || 0}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Исправлено</span><span class="sys-value">${w.corrected || 0} (${pct}%)</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Без изменений</span><span class="sys-value">${w.unchanged || 0}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Ошибок</span><span class="sys-value">${w.errors || 0}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Активность</span><span class="sys-value">${ago(w.last_at)}</span></div>
|
||||||
|
${w.last_error ? `<div class="sys-meta" style="color:#ef4444">${esc(w.last_error)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Параметры (мин. нагрузка)</div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Пауза простоя</span><span class="sys-value">${fmtDur(cfg.idle_min_ms)} → ${fmtDur(cfg.idle_max_ms)}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Таймаут запроса</span><span class="sys-value">${fmtDur(cfg.request_timeout_ms)}</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Макс. текст</span><span class="sys-value">${cfg.max_input_chars || 0} симв.</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Мин. текст</span><span class="sys-value">${cfg.min_text_chars || 0} симв.</span></div>
|
||||||
|
<div class="sys-item"><span class="sys-label">Повторы при сбое</span><span class="sys-value">${cfg.max_attempts || 0}</span></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCounts() {
|
||||||
|
const c = state.counts || {};
|
||||||
|
const chips = [
|
||||||
|
['pending', 'В очереди', c.pending || 0],
|
||||||
|
['processing', 'Проверяется', c.processing || 0],
|
||||||
|
['done', 'Проверено', c.done || 0],
|
||||||
|
['skipped', 'Пропущено', c.skipped || 0],
|
||||||
|
['error', 'Ошибка', c.error || 0],
|
||||||
|
['reverted', 'Оригинал', c.reverted || 0],
|
||||||
|
];
|
||||||
|
document.getElementById('workerCounts').innerHTML = chips
|
||||||
|
.map(([cls, label, n]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPending() {
|
||||||
|
const rows = state.pending || [];
|
||||||
|
document.getElementById('pendingCount').textContent = rows.length;
|
||||||
|
const tbody = document.querySelector('#pendingTable tbody');
|
||||||
|
const empty = document.getElementById('pendingEmpty');
|
||||||
|
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
|
||||||
|
empty.style.display = 'none';
|
||||||
|
tbody.innerHTML = rows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td>#${r.id}</td>
|
||||||
|
<td>${esc(r.student_name)}</td>
|
||||||
|
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
|
||||||
|
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecent() {
|
||||||
|
const filter = document.getElementById('recentFilter').value;
|
||||||
|
const all = state.recent || [];
|
||||||
|
const rows = filter ? all.filter(r => r.ai_status === filter) : all;
|
||||||
|
document.getElementById('recentCount').textContent = `${rows.length} из ${all.length}`;
|
||||||
|
const tbody = document.querySelector('#recentTable tbody');
|
||||||
|
const empty = document.getElementById('recentEmpty');
|
||||||
|
const maxPage = Math.max(1, Math.ceil(rows.length / RECENT_PAGE_SIZE));
|
||||||
|
if (recentPage > maxPage) recentPage = maxPage;
|
||||||
|
if (!rows.length) {
|
||||||
|
recentRows = [];
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
empty.style.display = 'block';
|
||||||
|
renderPager(document.getElementById('recentPager'), 1, 1, () => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.style.display = 'none';
|
||||||
|
const slice = rows.slice((recentPage - 1) * RECENT_PAGE_SIZE, recentPage * RECENT_PAGE_SIZE);
|
||||||
|
recentRows = slice;
|
||||||
|
tbody.innerHTML = slice.map((r, i) => `
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
|
||||||
|
<td>${esc(r.student_name)}</td>
|
||||||
|
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
|
||||||
|
<td>${statusBadge(r.ai_status)}</td>
|
||||||
|
<td>${r.changed ? '<span class="lucide-inline" style="color:#f59e0b"><i data-lucide="pencil"></i></span> исправлен' : '—'}</td>
|
||||||
|
<td><button type="button" class="ai-details" data-ai-idx="${i}">Подробнее</button></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
renderPager(document.getElementById('recentPager'), recentPage, maxPage, p => { recentPage = p; renderRecent(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderErrors() {
|
||||||
|
const rows = state.errors || [];
|
||||||
|
document.getElementById('errorsCount').textContent = rows.length;
|
||||||
|
document.getElementById('errorsCard').style.display = rows.length ? 'flex' : 'none';
|
||||||
|
document.querySelector('#errorsTable tbody').innerHTML = rows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
|
||||||
|
<td>#${r.id}</td>
|
||||||
|
<td>${esc(r.student_name)}</td>
|
||||||
|
<td style="color:#ef4444">${esc(r.ai_error || '')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!state) return;
|
||||||
|
renderState();
|
||||||
|
renderCards();
|
||||||
|
renderCounts();
|
||||||
|
renderPending();
|
||||||
|
renderRecent();
|
||||||
|
renderErrors();
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPhoto() {
|
||||||
|
if (!photoState) return;
|
||||||
|
const w = photoState.worker || {};
|
||||||
|
document.getElementById('photoStateHint').textContent = !photoState.enabled
|
||||||
|
? 'Обработка фотографий выключена'
|
||||||
|
: (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди'));
|
||||||
|
document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер';
|
||||||
|
const c = photoState.counts || {};
|
||||||
|
document.getElementById('photoCounts').innerHTML = [
|
||||||
|
['pending', 'В очереди', c.pending || 0],
|
||||||
|
['processing', 'Обрабатывается', c.processing || 0],
|
||||||
|
['done', 'Готово', c.done || 0],
|
||||||
|
['error', 'Ошибка', c.error || 0],
|
||||||
|
].map(([cls, label, n]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`).join('');
|
||||||
|
const rows = photoState.recent || [];
|
||||||
|
renderPhotoPending();
|
||||||
|
const tbody = document.querySelector('#photoTable tbody');
|
||||||
|
const empty = document.getElementById('photoEmpty');
|
||||||
|
const pager = document.getElementById('photoRecentPager');
|
||||||
|
const maxPage = Math.max(1, Math.ceil(photoTotal / PHOTO_PAGE_SIZE));
|
||||||
|
if (photoPage > maxPage) photoPage = maxPage;
|
||||||
|
if (!photoTotal) { tbody.innerHTML = ''; empty.style.display = 'block'; renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); }); return; }
|
||||||
|
empty.style.display = 'none';
|
||||||
|
tbody.innerHTML = rows.map(r => {
|
||||||
|
const before = r.before_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
|
||||||
|
const after = r.after_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.after_path))}" alt="стало">` : '<span style="color:var(--muted)">—</span>';
|
||||||
|
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback';
|
||||||
|
const badge = `<span class="ai-badge ${PHOTO_STATUS_BADGE[r.status] || ''}">${esc(PHOTO_STATUS_LABELS[r.status] || r.status)}</span>`;
|
||||||
|
return `<tr>
|
||||||
|
<td style="white-space:nowrap;color:var(--muted)">${fmtTime(r.finished_at)}</td>
|
||||||
|
<td>#${r.entry_id} ${esc(r.student_name || '')}</td>
|
||||||
|
<td>${esc(PHOTO_ACTION_LABELS[r.action] || r.action)}</td>
|
||||||
|
<td>${badge}</td>
|
||||||
|
<td>${before}</td>
|
||||||
|
<td>${after}</td>
|
||||||
|
<td style="color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.error || '')}">${r.error ? esc(r.error) : ''}</td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<button type="button" class="btn-link" data-photo-view="${r.id}">Смотреть</button>
|
||||||
|
${canRollback ? `<button type="button" class="btn-link" data-photo-rollback="${r.id}" data-photo-entry="${r.entry_id}">↩ Откатить</button>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); });
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPhotoPending() {
|
||||||
|
const rows = photoState.pending || [];
|
||||||
|
document.getElementById('photoPendingCount').textContent = rows.length;
|
||||||
|
const tbody = document.querySelector('#photoPendingTable tbody');
|
||||||
|
const empty = document.getElementById('photoPendingEmpty');
|
||||||
|
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
|
||||||
|
empty.style.display = 'none';
|
||||||
|
tbody.innerHTML = rows.map(r => {
|
||||||
|
const processing = r.status === 'processing';
|
||||||
|
const action = esc(PHOTO_ACTION_LABELS[r.action] || r.action);
|
||||||
|
const badge = `<span class="ai-badge ${processing ? 'ai-badge-skipped' : ''}">${processing ? '⏳ Обрабатывается' : 'В очереди'}</span>`;
|
||||||
|
return `<tr${processing ? ' style="background:rgba(59,130,246,.08)"' : ''}>
|
||||||
|
<td>#${r.id}</td>
|
||||||
|
<td>#${r.entry_id} ${esc(r.student_name || '')} <span style="color:var(--muted)">(${esc(r.group_name || '')})</span></td>
|
||||||
|
<td>${action} ${badge}</td>
|
||||||
|
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPhotoJob(id) {
|
||||||
|
return (photoState && photoState.recent || []).find(r => r.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPhotoJobClip(p) {
|
||||||
|
const v = Math.min(100, Math.max(0, p));
|
||||||
|
document.getElementById('photoJobTop').style.clipPath = `inset(0 0 0 ${v}%)`;
|
||||||
|
document.getElementById('photoJobDivider').style.left = v + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPhotoJob(r) {
|
||||||
|
photoJobRow = r;
|
||||||
|
document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`;
|
||||||
|
document.getElementById('photoJobBefore').src = thumbUrl(r.before_path);
|
||||||
|
document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : '';
|
||||||
|
document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none';
|
||||||
|
setPhotoJobClip(50);
|
||||||
|
document.getElementById('photoJobModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhoto() {
|
||||||
|
if (photoBusy) return;
|
||||||
|
photoBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/photo-jobs/status?recent_limit=${PHOTO_PAGE_SIZE}&recent_offset=${(photoPage - 1) * PHOTO_PAGE_SIZE}`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
photoState = await res.json();
|
||||||
|
photoTotal = photoState.recent_total || 0;
|
||||||
|
renderPhoto();
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
photoBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postPhoto(path_, body) {
|
||||||
|
const opts = { method: 'POST', headers: body ? hdrJson() : hdr() };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(`${API}${path_}`, opts);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) { showToast(data.error || 'Ошибка'); return null; }
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/ai/status`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
state = await res.json();
|
||||||
|
render();
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, body) {
|
||||||
|
const opts = { method: 'POST', headers: body ? hdrJson() : hdr() };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(`${API}${path}`, opts);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) { showToast(data.error || 'Ошибка'); return null; }
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wake() {
|
||||||
|
if (await post('/api/ai/wake')) { showToast('Воркер разбужен'); load(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled() {
|
||||||
|
const next = !(state && state.enabled);
|
||||||
|
const data = await post('/api/ai/enabled', { enabled: next });
|
||||||
|
if (data) { showToast(next ? 'Автопроверка включена' : 'Автопроверка выключена'); load(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requeueFailed() {
|
||||||
|
if (!confirm('Вернуть все записи с ошибкой в очередь на повторную проверку?')) return;
|
||||||
|
const data = await post('/api/ai/requeue-failed');
|
||||||
|
if (data) { showToast(`В очередь возвращено: ${data.count}`); load(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAutoRefresh(on) {
|
||||||
|
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
|
||||||
|
if (on) refreshTimer = setInterval(() => { load(); loadPhoto(); }, AUTO_REFRESH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('refreshBtn').addEventListener('click', () => { load(); loadPhoto(); });
|
||||||
|
document.getElementById('wakeBtn').addEventListener('click', wake);
|
||||||
|
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
|
||||||
|
document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
|
||||||
|
document.getElementById('recentFilter').addEventListener('change', () => { recentPage = 1; renderRecent(); });
|
||||||
|
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked));
|
||||||
|
document.getElementById('photoWakeBtn').addEventListener('click', async () => {
|
||||||
|
if (await postPhoto('/api/photo-jobs/wake')) { showToast('Фото-воркер разбужен'); loadPhoto(); }
|
||||||
|
});
|
||||||
|
document.getElementById('photoToggleBtn').addEventListener('click', async () => {
|
||||||
|
const next = !(photoState && photoState.enabled);
|
||||||
|
const data = await postPhoto('/api/photo-jobs/enabled', { enabled: next });
|
||||||
|
if (data) { showToast(next ? 'Обработка фото включена' : 'Обработка фото выключена'); loadPhoto(); }
|
||||||
|
});
|
||||||
|
document.getElementById('photoRequeueBtn').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Вернуть все задания с ошибкой в очередь на повторную обработку?')) return;
|
||||||
|
const data = await postPhoto('/api/photo-jobs/requeue-failed');
|
||||||
|
if (data) { showToast(`В очередь возвращено: ${data.count}`); loadPhoto(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('recentTable').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.ai-details');
|
||||||
|
if (!btn) return;
|
||||||
|
const r = recentRows[parseInt(btn.dataset.aiIdx, 10)];
|
||||||
|
if (!r) return;
|
||||||
|
document.getElementById('aiDetailTitle').textContent = `ИИ-проверка #${r.id} — ${r.student_name}`;
|
||||||
|
document.getElementById('aiDetailInput').textContent = r.description_original || '—';
|
||||||
|
document.getElementById('aiDetailOutput').textContent = r.description_ai || '—';
|
||||||
|
document.getElementById('aiDetailModal').classList.add('open');
|
||||||
|
});
|
||||||
|
document.getElementById('aiDetailCloseBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('aiDetailModal').classList.remove('open');
|
||||||
|
});
|
||||||
|
document.getElementById('aiDetailModal').addEventListener('click', e => {
|
||||||
|
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('photoTable').addEventListener('click', async e => {
|
||||||
|
const viewBtn = e.target.closest('[data-photo-view]');
|
||||||
|
if (viewBtn) {
|
||||||
|
const r = findPhotoJob(parseInt(viewBtn.dataset.photoView, 10));
|
||||||
|
if (r) openPhotoJob(r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rb = e.target.closest('[data-photo-rollback]');
|
||||||
|
if (rb) {
|
||||||
|
const entryId = rb.dataset.photoEntry;
|
||||||
|
const jobId = rb.dataset.photoRollback;
|
||||||
|
if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return;
|
||||||
|
const data = await postPhoto(`/api/entries/${entryId}/photo/jobs/${jobId}/rollback`);
|
||||||
|
if (data) { showToast('Фотография отката'); loadPhoto(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('photoJobCloseBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('photoJobModal').classList.remove('open');
|
||||||
|
});
|
||||||
|
document.getElementById('photoJobModal').addEventListener('click', e => {
|
||||||
|
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
|
||||||
|
});
|
||||||
|
document.getElementById('photoJobRollbackBtn').addEventListener('click', async () => {
|
||||||
|
const r = photoJobRow;
|
||||||
|
if (!r) return;
|
||||||
|
if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return;
|
||||||
|
const data = await postPhoto(`/api/entries/${r.entry_id}/photo/jobs/${r.id}/rollback`);
|
||||||
|
if (data) {
|
||||||
|
showToast('Фотография отката');
|
||||||
|
document.getElementById('photoJobModal').classList.remove('open');
|
||||||
|
loadPhoto();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(function initPhotoJobDrag() {
|
||||||
|
const area = document.getElementById('photoJobCompare');
|
||||||
|
let dragging = false;
|
||||||
|
const move = ev => {
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
setPhotoJobClip(((ev.clientX - rect.left) / rect.width) * 100);
|
||||||
|
};
|
||||||
|
area.addEventListener('pointerdown', ev => {
|
||||||
|
dragging = true;
|
||||||
|
area.setPointerCapture(ev.pointerId);
|
||||||
|
move(ev);
|
||||||
|
});
|
||||||
|
area.addEventListener('pointermove', ev => { if (dragging) move(ev); });
|
||||||
|
area.addEventListener('pointerup', () => { dragging = false; });
|
||||||
|
area.addEventListener('pointercancel', () => { dragging = false; });
|
||||||
|
})();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await requireAdminPage()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
load();
|
||||||
|
loadPhoto();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
+60
-143
@@ -13,168 +13,85 @@
|
|||||||
<h2>Ссылки</h2>
|
<h2>Ссылки</h2>
|
||||||
<p style="color:var(--muted);font-size:.85rem;margin-bottom:20px;max-width:680px">Каждая ссылка открывает публичную страницу с карточками записей выбранной группы за указанный период — без входа и выбора параметров.</p>
|
<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="list" id="linksList"><div class="empty">Загрузка...</div></div>
|
||||||
|
<div class="pager" id="pager"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="linkModal" onclick="if(event.target===this)closeLinkModal()">
|
<div class="modal-overlay" id="linkModal">
|
||||||
<div class="edit-modal">
|
<div class="edit-modal" style="max-width:640px">
|
||||||
<h3 id="linkModalTitle">Новая ссылка</h3>
|
<h3 id="linkModalTitle">Новая ссылка</h3>
|
||||||
<div>
|
<div class="link-fields">
|
||||||
<label>Название</label>
|
<div class="settings-field">
|
||||||
<input type="text" id="linkName" placeholder="Например: СБ1400 05.09">
|
<label>Название <span style="color:#ef4444">*</span></label>
|
||||||
|
<input type="text" id="linkName" class="settings-input" required placeholder="Например: СБ1400 05.09">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="settings-field">
|
||||||
<label>Группа</label>
|
<label>Группа</label>
|
||||||
<select id="linkGroup"><option value="">— без группы —</option></select>
|
<select id="linkGroup" class="settings-input"><option value="">— без группы —</option></select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="settings-field">
|
||||||
<label>Ученик (необязательно)</label>
|
<label>Ученик (необязательно)</label>
|
||||||
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:12px">
|
<div class="settings-field">
|
||||||
<div style="flex:1">
|
<label>Ссылка</label>
|
||||||
|
<input type="url" id="linkUrl" class="settings-input" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Сообщение</label>
|
||||||
|
<textarea id="linkMessage" class="settings-input" rows="3" placeholder="Текст, который увидят на публичной странице"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
<label>С</label>
|
<label>С</label>
|
||||||
<input type="date" id="linkDateFrom">
|
<input type="date" id="linkDateFrom" class="settings-input">
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1">
|
<div class="settings-field">
|
||||||
<label>По</label>
|
<label>По</label>
|
||||||
<input type="date" id="linkDateTo">
|
<input type="date" id="linkDateTo" class="settings-input">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Истекает через (дни)</label>
|
||||||
|
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Пароль для доступа (необязательно)</label>
|
||||||
|
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowNames">
|
||||||
|
Показывать фамилии и имена учеников
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowMessage">
|
||||||
|
Показать сообщение ученика
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowDate">
|
||||||
|
Показать дату в карточке записи
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkShowPhotos">
|
||||||
|
Показывать фото группы
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="linkCancelBtn">Отмена</button>
|
||||||
|
<button type="button" class="btn-primary" id="linkSaveBtn">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
|
||||||
<button class="cancel" onclick="closeLinkModal()">Отмена</button>
|
|
||||||
<button class="save" onclick="saveLink()">Сохранить</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/links.js"></script>
|
||||||
let links = [];
|
|
||||||
let groups = [];
|
|
||||||
let editingId = null;
|
|
||||||
let studentsLoaded = false;
|
|
||||||
|
|
||||||
function linkUrl(token) { return `${location.origin}/s/${token}`; }
|
|
||||||
|
|
||||||
async function loadLinks() {
|
|
||||||
const res = await fetch(`${API}/api/links`, { headers: hdr() });
|
|
||||||
links = await res.json();
|
|
||||||
const el = document.getElementById('linksList');
|
|
||||||
if (!links.length) { el.innerHTML = '<div class="empty">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
|
|
||||||
el.innerHTML = links.map(l => `
|
|
||||||
<div class="item">
|
|
||||||
<div style="min-width:0">
|
|
||||||
<div class="name">${esc(l.name)}</div>
|
|
||||||
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
|
|
||||||
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
|
||||||
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
|
||||||
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
|
||||||
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
|
||||||
<a class="btn-link" style="text-decoration:none" href="/s/${l.token}" target="_blank" title="Открыть">Открыть</a>
|
|
||||||
<button class="btn-link" onclick="copyText('${linkUrl(l.token)}')" title="Копировать ссылку">Копировать</button>
|
|
||||||
<button class="btn-link" onclick="openLinkEdit(${l.id})" title="Редактировать">✎</button>
|
|
||||||
<button class="btn-link" style="color:var(--muted)" onclick="delLink(${l.id})" title="Удалить">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
|
|
||||||
|
|
||||||
async function openLinkModal() {
|
|
||||||
editingId = null;
|
|
||||||
document.getElementById('linkModalTitle').textContent = 'Новая ссылка';
|
|
||||||
document.getElementById('linkName').value = '';
|
|
||||||
document.getElementById('linkDateFrom').value = '';
|
|
||||||
document.getElementById('linkDateTo').value = '';
|
|
||||||
if (!groups.length) {
|
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
|
||||||
groups = await res.json();
|
|
||||||
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
|
|
||||||
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
|
||||||
}
|
|
||||||
if (!studentsLoaded) {
|
|
||||||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
|
||||||
const names = await res.json();
|
|
||||||
const dl = document.getElementById('linkStudentList');
|
|
||||||
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
|
||||||
studentsLoaded = true;
|
|
||||||
}
|
|
||||||
document.getElementById('linkModal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openLinkEdit(id) {
|
|
||||||
const l = links.find(x => x.id === id);
|
|
||||||
if (!l) return;
|
|
||||||
editingId = id;
|
|
||||||
document.getElementById('linkModalTitle').textContent = 'Редактирование ссылки';
|
|
||||||
document.getElementById('linkName').value = l.name;
|
|
||||||
document.getElementById('linkDateFrom').value = l.date_from || '';
|
|
||||||
document.getElementById('linkDateTo').value = l.date_to || '';
|
|
||||||
if (!groups.length) {
|
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
|
||||||
groups = await res.json();
|
|
||||||
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
|
|
||||||
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
|
||||||
}
|
|
||||||
document.getElementById('linkGroup').value = l.group_id || '';
|
|
||||||
if (!studentsLoaded) {
|
|
||||||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
|
||||||
const names = await res.json();
|
|
||||||
const dl = document.getElementById('linkStudentList');
|
|
||||||
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
|
||||||
studentsLoaded = true;
|
|
||||||
}
|
|
||||||
document.getElementById('linkStudent').value = l.student_name || '';
|
|
||||||
document.getElementById('linkModal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLinkModal() {
|
|
||||||
document.getElementById('linkModal').classList.remove('open');
|
|
||||||
editingId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveLink() {
|
|
||||||
const name = document.getElementById('linkName').value.trim();
|
|
||||||
const group_id = document.getElementById('linkGroup').value;
|
|
||||||
const student_name = document.getElementById('linkStudent').value;
|
|
||||||
const date_from = document.getElementById('linkDateFrom').value;
|
|
||||||
const date_to = document.getElementById('linkDateTo').value;
|
|
||||||
if (!name) { alert('Введите название'); return; }
|
|
||||||
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null });
|
|
||||||
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
|
|
||||||
method: editingId ? 'PUT' : 'POST',
|
|
||||||
headers: hdrJson(),
|
|
||||||
body
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
closeLinkModal();
|
|
||||||
loadLinks();
|
|
||||||
showToast(editingId ? 'Ссылка обновлена' : 'Ссылка создана');
|
|
||||||
} else {
|
|
||||||
const err = await res.json();
|
|
||||||
alert(err.error || 'Ошибка');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function delLink(id) {
|
|
||||||
if (!confirm('Удалить ссылку?')) return;
|
|
||||||
await fetch(`${API}/api/links/${id}`, { method: 'DELETE', headers: hdr() });
|
|
||||||
loadLinks();
|
|
||||||
showToast('Ссылка удалена');
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (await checkAuth()) {
|
|
||||||
buildSidebar(document.body.dataset.page);
|
|
||||||
loadLinks();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+14
-25
@@ -9,7 +9,10 @@
|
|||||||
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
|
: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}
|
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{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{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 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}
|
.login button{width:100%;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:14px;font-size:1rem;font-weight:600;cursor:pointer}
|
||||||
@@ -22,31 +25,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="login">
|
<div class="login">
|
||||||
<h1>Админ-панель</h1>
|
<div class="brand">
|
||||||
<input type="password" id="pwdInput" placeholder="Пароль" autofocus>
|
<div class="logo" id="loginLogo"></div>
|
||||||
<button onclick="doLogin()">Войти</button>
|
<h1 id="loginTitle">Админ-панель</h1>
|
||||||
<div class="err" id="loginErr">Неверный пароль</div>
|
</div>
|
||||||
|
<input type="text" id="userInput" placeholder="Логин" autocomplete="username" autofocus>
|
||||||
|
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
|
||||||
|
<input type="text" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
||||||
|
<button type="button" id="loginBtn">Войти</button>
|
||||||
|
<div class="err" id="loginErr">Неверный логин или пароль</div>
|
||||||
<div class="back"><a href="/">На главную</a></div>
|
<div class="back"><a href="/">На главную</a></div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script src="js/login.js"></script>
|
||||||
const API = location.origin;
|
|
||||||
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
|
||||||
|
|
||||||
async function doLogin() {
|
|
||||||
const pwd = document.getElementById('pwdInput').value;
|
|
||||||
if (!pwd) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API}/api/stats`, { headers: { 'X-Admin-Token': pwd } });
|
|
||||||
if (res.ok) {
|
|
||||||
sessionStorage.setItem('adminToken', pwd);
|
|
||||||
location.href = 'dashboard.html';
|
|
||||||
} else {
|
|
||||||
document.getElementById('loginErr').style.display = 'block';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
document.getElementById('loginErr').style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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 Введение в базы данных | 8 Разработка интерфейсов 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
@@ -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)}
|
||||||
@@ -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>
|
||||||
+240
-173
@@ -12,30 +12,180 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h2>Настройки</h2>
|
<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>
|
||||||
|
|
||||||
<div class="settings-stack">
|
<div class="settings-layout">
|
||||||
<div class="settings-card">
|
<aside class="settings-nav" id="settingsNav">
|
||||||
|
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
|
||||||
|
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
|
||||||
|
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
||||||
|
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||||
|
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||||
|
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
||||||
|
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
||||||
|
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
||||||
|
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="settings-stack settings-stack-grid">
|
||||||
|
<div class="settings-card wide" id="sec-system">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic">🛡️</div>
|
<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" 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>
|
<h3>Защита от повторов</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. 0 — отключить.</p>
|
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
|
||||||
<div class="settings-row">
|
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
<label for="spamInterval">Интервал ожидания</label>
|
<label for="spamInterval">Интервал ожидания</label>
|
||||||
<div class="unit-row">
|
<div class="unit-row">
|
||||||
<input type="number" id="spamInterval" class="settings-input" min="0" max="1440" step="1">
|
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
|
||||||
<span class="unit">минут</span>
|
<span class="unit">минут</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-share">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="link"></i></div>
|
||||||
|
<h3>Страница публичной ссылки /s/<hash></h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Управление видимостью элементов на публичных страницах записей.</p>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="shareShowMessage">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Сообщение ученика</b><small>Текст «Что делала(а)» на странице по ссылке</small></span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="shareShowDate">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Дата записи</b><small>Дата в карточке записи</small></span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="shareShowNames">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Фамилии и имена</b><small>Имена учеников в галерее работ</small></span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="shareShowPhotos">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Фото группы</b><small>Групповое фото в шапке страницы</small></span>
|
||||||
|
</label>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="cookieNoticeText">Уведомление о cookie</label>
|
||||||
|
<textarea id="cookieNoticeText" class="settings-input" rows="3" style="min-height:80px"></textarea>
|
||||||
|
<span class="hint">Пусто — баннер не показывается</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-ai">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="bot"></i></div>
|
||||||
|
<h3>Промпт ИИ для исправления текста</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="aiAutoCheck">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Автопроверка новых записей</b><small>Без участия человека — воркер сам обрабатывает очередь</small></span>
|
||||||
|
</label>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="aiPrompt">Промпт</label>
|
||||||
|
<textarea id="aiPrompt" class="settings-input mono" rows="5" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="ai-queue" id="aiQueueInfo"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic">📄</div>
|
<div class="ic"><i data-lucide="boxes"></i></div>
|
||||||
|
<h3>Профили моделей ИИ</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Модель, выбранная по умолчанию, обрабатывает записи учеников. «Нативная» — llama.cpp в Docker. Можно добавить любого провайдера с OpenAI-совместимым API (OpenRouter, OpenAI, Ollama и др.).</p>
|
||||||
|
<div class="ai-queue" id="aiProfileActive"></div>
|
||||||
|
<div class="card-foot" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;min-width:0;max-width:100%">
|
||||||
|
Модель по умолчанию:
|
||||||
|
<select id="aiActiveProfile" class="settings-input" style="width:auto;min-width:0;max-width:100%;flex:1 1 200px">
|
||||||
|
<option value="native">Нативная (llama.cpp)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="btn-primary ghost" id="aiProfileAddBtn" type="button"><i data-lucide="plus"></i> Добавить профиль</button>
|
||||||
|
</div>
|
||||||
|
<div id="aiProfilesList" class="ai-profiles-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-backup">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="database"></i></div>
|
||||||
|
<h3>Резервное копирование</h3>
|
||||||
|
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||||
|
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||||
|
<div class="card-foot">
|
||||||
|
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
|
||||||
|
<button class="btn-primary ghost danger" id="restoreBtn" type="button"><i data-lucide="upload"></i> Восстановить из файла</button>
|
||||||
|
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div class="restore-status" id="restoreStatus"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-form">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="file-text"></i></div>
|
||||||
<h3>Футер на форме</h3>
|
<h3>Футер на форме</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Тексты показываются внизу формы ответа ученика.</p>
|
<p class="hint">Тексты показываются внизу формы ответа ученика.</p>
|
||||||
@@ -51,182 +201,99 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card" id="sec-photo">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic">🗄️</div>
|
<div class="ic"><i data-lucide="camera"></i></div>
|
||||||
<h3>Резервное копирование</h3>
|
<h3>Фотография с веб-камеры</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
|
||||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
<div class="settings-row">
|
||||||
<div class="card-foot">
|
<div class="settings-field">
|
||||||
<button class="btn-primary ghost" id="backupBtn" onclick="downloadBackup()">⬇️ Скачать бэкап</button>
|
<label for="photoResolution">Разрешение</label>
|
||||||
<button class="btn-primary ghost" id="restoreBtn" onclick="document.getElementById('restoreFile').click()">⬆️ Восстановить из файла</button>
|
<select id="photoResolution" class="settings-input">
|
||||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
<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>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-bans">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="shield-off"></i></div>
|
||||||
|
<h3>Блокировки IP</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке.</p>
|
||||||
|
<div class="data-stats" id="bansList"><span class="chip">Загрузка…</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="restore-status" id="restoreStatus"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="save-bar" id="saveBar">
|
<div class="save-bar" id="saveBar">
|
||||||
<span class="hint" id="saveHint">⚠️ Есть несохранённые изменения</span>
|
<span class="hint" id="saveHint"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
|
||||||
<button class="btn-primary" id="saveBtn" onclick="saveSettings()">Сохранить изменения</button>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/settings.js"></script>
|
||||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight'];
|
|
||||||
|
|
||||||
function setBtnLoading(btn, on, spinnerClass) {
|
|
||||||
if (on) {
|
|
||||||
btn.dataset.label = btn.innerHTML;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
|
|
||||||
} else {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = btn.dataset.label || btn.innerHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDirty() {
|
|
||||||
const dirty = DIRTY_FIELDS.some(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
return String(el.dataset.original ?? '') !== el.value;
|
|
||||||
});
|
|
||||||
document.getElementById('saveBar').classList.toggle('show', dirty);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindDirty() {
|
|
||||||
DIRTY_FIELDS.forEach(id => {
|
|
||||||
document.getElementById(id).addEventListener('input', updateDirty);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotDirty() {
|
|
||||||
DIRTY_FIELDS.forEach(id => {
|
|
||||||
document.getElementById(id).dataset.original = document.getElementById(id).value;
|
|
||||||
});
|
|
||||||
updateDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
const [sRes, stRes] = await Promise.all([
|
|
||||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
|
||||||
fetch(`${API}/api/stats`, { headers: hdr() }),
|
|
||||||
]);
|
|
||||||
const s = await sRes.json();
|
|
||||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
|
||||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
|
||||||
document.getElementById('footerRight').value = s.footer_right || '';
|
|
||||||
snapshotDirty();
|
|
||||||
if (stRes.ok) {
|
|
||||||
const st = await stRes.json();
|
|
||||||
document.getElementById('dataStats').innerHTML = `
|
|
||||||
<span class="chip">Записей: <b>${st.entries}</b></span>
|
|
||||||
<span class="chip">Учеников: <b>${st.students}</b></span>
|
|
||||||
<span class="chip">Групп: <b>${st.groups}</b></span>
|
|
||||||
<span class="chip">Сегодня: <b>${st.today}</b></span>
|
|
||||||
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
document.getElementById('dataStats').innerHTML = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings() {
|
|
||||||
const v = document.getElementById('spamInterval').value;
|
|
||||||
if (v === '' || Number(v) < 0) { alert('Введите число от 0'); return; }
|
|
||||||
const btn = document.getElementById('saveBtn');
|
|
||||||
btn.dataset.loadingText = 'Сохранение...';
|
|
||||||
setBtnLoading(btn, true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API}/api/settings`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: hdrJson(),
|
|
||||||
body: JSON.stringify({ settings: {
|
|
||||||
spam_interval_min: String(Math.round(Number(v))),
|
|
||||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
|
||||||
footer_right: document.getElementById('footerRight').value.trim(),
|
|
||||||
} })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
showToast('Настройки сохранены');
|
|
||||||
snapshotDirty();
|
|
||||||
} else { const e = await res.json(); alert(e.error); }
|
|
||||||
} finally {
|
|
||||||
setBtnLoading(btn, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadBackup() {
|
|
||||||
const btn = document.getElementById('backupBtn');
|
|
||||||
btn.dataset.loadingText = 'Формирование...';
|
|
||||||
setBtnLoading(btn, true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
|
||||||
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
|
|
||||||
const blob = await res.blob();
|
|
||||||
const cd = res.headers.get('Content-Disposition') || '';
|
|
||||||
const m = cd.match(/filename="([^"]+)"/);
|
|
||||||
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
showToast('Бэкап скачан');
|
|
||||||
} finally {
|
|
||||||
setBtnLoading(btn, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
|
|
||||||
const st = document.getElementById('restoreStatus');
|
|
||||||
const backupBtn = document.getElementById('backupBtn');
|
|
||||||
const restoreBtn = document.getElementById('restoreBtn');
|
|
||||||
st.classList.remove('err');
|
|
||||||
st.innerHTML = '<span class="spinner dark"></span> Восстановление...';
|
|
||||||
backupBtn.disabled = true;
|
|
||||||
restoreBtn.disabled = true;
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('backup', file);
|
|
||||||
let res;
|
|
||||||
try {
|
|
||||||
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
|
|
||||||
} catch (err) {
|
|
||||||
st.textContent = '';
|
|
||||||
alert('Ошибка сети: ' + err.message);
|
|
||||||
e.target.value = '';
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
backupBtn.disabled = false;
|
|
||||||
restoreBtn.disabled = false;
|
|
||||||
}
|
|
||||||
if (res.ok) {
|
|
||||||
showToast('Данные восстановлены');
|
|
||||||
st.textContent = '';
|
|
||||||
loadSettings();
|
|
||||||
} else {
|
|
||||||
st.classList.add('err');
|
|
||||||
st.innerHTML = 'Ошибка восстановления';
|
|
||||||
const t = await res.text();
|
|
||||||
alert('Ошибка: ' + t);
|
|
||||||
}
|
|
||||||
e.target.value = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (await checkAuth()) {
|
|
||||||
buildSidebar(document.body.dataset.page);
|
|
||||||
bindDirty();
|
|
||||||
loadSettings();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+55
-85
@@ -10,6 +10,10 @@
|
|||||||
:root{--yellow:#F7C90E;--dark:#211E22;--gray:#9C9C9C;--page:#f6f6f6;--card-bg:#fff;--radius:30px}
|
: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}
|
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{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 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 .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)}
|
.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-presentation .ficon{background:#F6E4E8}
|
||||||
.card .files a.ftype-other .ficon{background:#EFEFEF}
|
.card .files a.ftype-other .ficon{background:#EFEFEF}
|
||||||
.card .foot{margin-top:auto;padding:8px 6px 2px;font-size:.78rem;color:var(--gray)}
|
.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{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}
|
.empty .big{font-size:2rem;margin-bottom:10px}
|
||||||
.loading{grid-column:1/-1;text-align:center;color:var(--gray);padding:40px}
|
.loading{grid-column:1/-1;text-align:center;color:var(--gray);padding:40px}
|
||||||
@@ -54,106 +61,69 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
|||||||
@media(max-width:1000px){.grid{grid-template-columns:repeat(3,1fr)}.photos-grid{grid-template-columns:repeat(3,1fr)}}
|
@media(max-width:1000px){.grid{grid-template-columns:repeat(3,1fr)}.photos-grid{grid-template-columns:repeat(3,1fr)}}
|
||||||
@media(max-width:760px){.grid{grid-template-columns:repeat(2,1fr)}.photos-grid{grid-template-columns:repeat(2,1fr)}}
|
@media(max-width:760px){.grid{grid-template-columns:repeat(2,1fr)}.photos-grid{grid-template-columns:repeat(2,1fr)}}
|
||||||
@media(max-width:480px){.grid{grid-template-columns:1fr}.photos-grid{grid-template-columns:1fr}}
|
@media(max-width:480px){.grid{grid-template-columns:1fr}.photos-grid{grid-template-columns:1fr}}
|
||||||
|
.password-modal{position:fixed;inset:0;background:rgba(12,14,20,.85);display:none;align-items:center;justify-content:center;z-index:200;padding:24px}
|
||||||
|
.password-modal.open{display:flex}
|
||||||
|
.password-box{background:var(--card-bg);border-radius:16px;padding:24px;max-width:360px;width:100%;text-align:center}
|
||||||
|
.password-box h3{margin-bottom:8px}
|
||||||
|
.password-box p{color:var(--gray);font-size:.9rem;margin-bottom:16px}
|
||||||
|
.password-box input{width:100%;padding:14px 16px;border:1px solid #EBEBEB;border-radius:12px;font-size:1rem;margin-bottom:16px}
|
||||||
|
.password-box button{width:100%;padding:14px;background:var(--yellow);color:var(--dark);border:none;border-radius:12px;font-size:1rem;font-weight:700;cursor:pointer}
|
||||||
|
.password-box button:hover{opacity:.9}
|
||||||
|
.share-message{background:var(--card-bg);border-radius:20px;padding:20px 24px;margin-bottom:32px;box-shadow:0 1px 3px rgba(20,30,40,.06);font-size:.98rem;line-height:1.55;color:#3a3f4a;white-space:pre-wrap;word-break:break-word}
|
||||||
|
.share-link-block{margin-bottom:32px}
|
||||||
|
.share-link-btn{display:inline-flex;align-items:center;gap:8px;background:var(--dark);color:#fff;text-decoration:none;border-radius:999px;padding:13px 24px;font-size:.92rem;font-weight:700;transition:opacity .15s,transform .15s}
|
||||||
|
.share-link-btn:hover{opacity:.9;transform:translateY(-2px)}
|
||||||
|
.countdown{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(150%);z-index:50;display:flex;align-items:center;gap:10px;padding:14px 24px;font-size:.92rem;color:var(--dark);background:#fff;border-radius:16px;box-shadow:0 8px 30px rgba(20,30,40,.16);max-width:calc(100vw - 32px);opacity:0;transition:transform .3s ease,opacity .3s ease;pointer-events:none}
|
||||||
|
.countdown.visible{transform:translateX(-50%) translateY(0);opacity:1;pointer-events:auto}
|
||||||
|
.countdown .cd-icon{font-size:1.2rem}
|
||||||
|
.countdown .cd-text{color:#4a4f5a;line-height:1.4;text-align:center}
|
||||||
|
.countdown .cd-text strong{color:var(--dark);font-weight:700}
|
||||||
|
.countdown.warn{background:#FFF3E0}
|
||||||
|
.countdown.warn .cd-text strong{color:#E65100}
|
||||||
|
.countdown.urgent{background:#FFEBEE}
|
||||||
|
.countdown.urgent .cd-text strong{color:#C62828}
|
||||||
|
.cookie-notice{position:fixed;bottom:24px;right:24px;z-index:150;max-width:340px;background:var(--card-bg);border-radius:16px;padding:18px 20px;box-shadow:0 8px 30px rgba(20,30,40,.18);font-size:.9rem;line-height:1.5;color:#3a3f4a}
|
||||||
|
.cookie-notice.hidden{display:none}
|
||||||
|
.cookie-notice p{margin-bottom:14px}
|
||||||
|
.cookie-notice button{width:100%;padding:10px 14px;background:var(--yellow);color:var(--dark);border:none;border-radius:12px;font-size:.9rem;font-weight:700;cursor:pointer}
|
||||||
|
.cookie-notice button:hover{opacity:.9}
|
||||||
|
@media(max-width:480px){.cookie-notice{left:24px;right:24px;max-width:none}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section class="hero">
|
<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>
|
<h1 id="title">Загрузка...</h1>
|
||||||
<div class="chips" id="chips"></div>
|
<div class="chips" id="chips"></div>
|
||||||
</section>
|
</section>
|
||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
|
<div id="shareMessage" class="share-message" style="display:none"></div>
|
||||||
|
<div id="shareLinkBlock" class="share-link-block" style="display:none"></div>
|
||||||
<div id="photosBlock">
|
<div id="photosBlock">
|
||||||
<div class="photos-label">Фото группы</div>
|
<div class="photos-label">Фото группы</div>
|
||||||
<div class="photos-grid" id="photosGrid"></div>
|
<div class="photos-grid" id="photosGrid"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid" id="grid"><div class="loading">Карточки загружаются...</div></div>
|
<div class="grid" id="grid"><div class="loading">Карточки загружаются...</div></div>
|
||||||
</main>
|
</main>
|
||||||
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')"><img id="lightboxImg" alt=""></div>
|
<div class="lightbox" id="lightbox"><img id="lightboxImg" alt=""></div>
|
||||||
|
<div id="countdown" class="countdown"><span class="cd-icon">⏳</span><span class="cd-text"></span></div>
|
||||||
|
<div class="cookie-notice hidden" id="cookieNotice">
|
||||||
|
<p>Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.</p>
|
||||||
|
<button type="button" id="cookieAcceptBtn">Принять</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<div class="password-modal" id="passwordModal">
|
||||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
<div class="password-box">
|
||||||
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
|
<h3>🔒 Ссылка защищена паролем</h3>
|
||||||
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
|
<p>Введите пароль для доступа к записям</p>
|
||||||
function isImageFile(name){return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
|
<input type="password" id="passwordInput" placeholder="Пароль" autocomplete="current-password">
|
||||||
function fileType(name){
|
<button type="button" id="passwordSubmitBtn">Войти</button>
|
||||||
if(/\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image';
|
|
||||||
if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music';
|
|
||||||
if(/\.(pptx?|odp|key)$/i.test(name||'')) return 'presentation';
|
|
||||||
if(/\.(pdf|docx?|xlsx?|txt|rtf|odt|ods|csv|md)$/i.test(name||'')) return 'document';
|
|
||||||
return 'other';
|
|
||||||
}
|
|
||||||
function fileIcon(t){
|
|
||||||
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
|
|
||||||
return icons[t]||icons.other;
|
|
||||||
}
|
|
||||||
function filesHTML(files){
|
|
||||||
if(!files||!files.length)return '';
|
|
||||||
return `<div class="files-block">
|
|
||||||
<div class="files-label">Работы резидента</div>
|
|
||||||
<div class="files">${files.map(f=>{
|
|
||||||
const t = fileType(f.name);
|
|
||||||
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
|
|
||||||
?`href="#" onclick="showImg('/api/files/${f.token}');return false;"`
|
|
||||||
:`href="/api/files/${f.token}" download`}>
|
|
||||||
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
|
|
||||||
</a>`;
|
|
||||||
}).join('')}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
(async()=>{
|
|
||||||
const token = location.pathname.split('/').filter(Boolean).pop() || '';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/share/' + token);
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const data = await res.json();
|
|
||||||
const heading = data.group_name || data.name || 'Записи занятий';
|
|
||||||
document.title = heading;
|
|
||||||
document.getElementById('title').textContent = heading;
|
|
||||||
const chips = [];
|
|
||||||
if (data.group_name) chips.push(`Группа: <b>${esc(data.group_name)}</b>`);
|
|
||||||
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
|
|
||||||
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
|
|
||||||
|
|
||||||
const pb = document.getElementById('photosBlock');
|
|
||||||
if (data.photos && data.photos.length) {
|
|
||||||
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
|
|
||||||
<a class="gphoto" href="#" onclick="showImg('${p.photo_path}');return false;">
|
|
||||||
<img src="${p.photo_path}" alt="${esc(p.caption || '')}" loading="lazy">
|
|
||||||
${p.caption ? `<div class="gcap">${esc(p.caption)}</div>` : ''}
|
|
||||||
</a>
|
|
||||||
`).join('');
|
|
||||||
pb.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
pb.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
const grid = document.getElementById('grid');
|
|
||||||
if (!data.entries.length) {
|
|
||||||
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
grid.innerHTML = data.entries.map(e => `
|
|
||||||
<div class="card">
|
|
||||||
<div class="photo">
|
|
||||||
${e.photo_path
|
|
||||||
? `<img src="${e.photo_path}" onclick="showImg('${e.photo_path}')" alt="">`
|
|
||||||
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
</div>
|
||||||
<div class="name">${esc(e.student_name)}</div>
|
|
||||||
<div>${e.group_name ? `<span class="group">${esc(e.group_name)}</span>` : ''}</div>
|
<script src="/js/share.js"></script>
|
||||||
<div class="desc">${esc(e.description)}</div>
|
|
||||||
${filesHTML(e.files)}
|
|
||||||
</div>
|
|
||||||
<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch {
|
|
||||||
document.getElementById('title').textContent = 'Ссылка не найдена';
|
|
||||||
document.getElementById('grid').innerHTML = '<div class="empty"><div class="big">🤔</div>Эта ссылка не существует или была удалена</div>';
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+171
-249
@@ -14,283 +14,205 @@
|
|||||||
<div class="add-row">
|
<div class="add-row">
|
||||||
<input type="text" id="newStudent" placeholder="Фамилия Имя" list="studentAddList">
|
<input type="text" id="newStudent" placeholder="Фамилия Имя" list="studentAddList">
|
||||||
<datalist id="studentAddList"></datalist>
|
<datalist id="studentAddList"></datalist>
|
||||||
<button onclick="addStudent()">Добавить</button>
|
<button type="button" id="addStudentBtn">Добавить</button>
|
||||||
<button class="btn-ghost" onclick="openBatchAdd()">Пакетное добавление</button>
|
<button class="btn-ghost" type="button" id="batchAddBtn">Пакетное добавление</button>
|
||||||
<button class="btn-ghost" onclick="openBatchAttach()">Прикрепить к группе</button>
|
<button class="btn-ghost" type="button" id="batchAttachBtn">Прикрепить к группе</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
||||||
<input type="text" id="studentSearch" class="search-input" placeholder="Поиск по имени..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
|
<input type="text" id="studentSearch" class="search-input" placeholder="Поиск по имени..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
|
||||||
<select id="studentGroupFilter" class="search-input" style="padding:10px 14px"><option value="">Все группы</option></select>
|
<select id="studentGroupFilter" class="search-input" style="padding:10px 14px"><option value="">Все группы</option></select>
|
||||||
<button class="btn-link" onclick="exportStudentsCSV()">Экспорт CSV</button>
|
<button class="btn-link" type="button" id="exportStudentsCsvBtn">Экспорт CSV</button>
|
||||||
<button class="btn-link" onclick="exportStudentsUrls()">Экспорт URL</button>
|
<button class="btn-link" type="button" id="exportStudentsUrlsBtn">Экспорт URL</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="list" id="studentList"></div>
|
<div class="list" id="studentList"></div>
|
||||||
<div class="pager" id="pager"></div>
|
<div class="pager" id="pager"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="batchModal" onclick="if(event.target===this)closeBatchAdd()">
|
<div class="modal-overlay" id="batchModal">
|
||||||
<div class="edit-modal">
|
<div class="edit-modal" style="max-width:480px">
|
||||||
<h3>Пакетное добавление учеников</h3>
|
<h3>Пакетное добавление учеников</h3>
|
||||||
<div>
|
<form id="batchForm" class="settings-stack" style="gap:12px">
|
||||||
<label>Имена — каждое с новой строки</label>
|
<div class="settings-row">
|
||||||
<textarea id="batchText" placeholder="Иванов Иван Петров Пётр Сидоров Сидор"></textarea>
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Имена — каждое с новой строки <span style="color:#ef4444">*</span></label>
|
||||||
|
<textarea id="batchText" class="settings-input" style="min-height:120px" required placeholder="Иванов Иван Петров Пётр Сидоров Сидор"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
<label>Группа (необязательно)</label>
|
<label>Группа (необязательно)</label>
|
||||||
<select id="batchGroup"><option value="">— без группы —</option></select>
|
<select id="batchGroup" class="settings-input"><option value="">— без группы —</option></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
|
||||||
<button class="cancel" onclick="closeBatchAdd()">Отмена</button>
|
|
||||||
<button class="save" onclick="batchAdd()">Добавить</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="batchCancelBtn">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="attachModal" onclick="if(event.target===this)closeBatchAttach()">
|
<div class="modal-overlay" id="exportModal">
|
||||||
<div class="edit-modal">
|
<div class="edit-modal" style="max-width:480px">
|
||||||
<h3>Прикрепить к группе</h3>
|
<h3>Экспорт отчёта: <span id="exportName" style="color:var(--accent)"></span></h3>
|
||||||
<div>
|
<form id="exportForm" class="settings-stack" style="gap:12px">
|
||||||
<label>Группа</label>
|
<div class="settings-row">
|
||||||
<select id="attachGroup"><option value="">— выберите группу —</option></select>
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Период (необязательно)</label>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<input type="date" id="exportDateFrom" class="settings-input" style="min-width:0;flex:1">
|
||||||
|
<input type="date" id="exportDateTo" class="settings-input" style="min-width:0;flex:1">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<span class="hint">Ограничить записи/фото/файлы датами</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="expEntries" checked>
|
||||||
|
Записи занятий (текст)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="expPhotos" checked>
|
||||||
|
Фотографии
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="expCaptions" checked>
|
||||||
|
Подписи к фотографиям
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="expFiles" checked>
|
||||||
|
Файлы
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="expDates" checked>
|
||||||
|
Показывать даты
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="exportCancelBtn">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary">Скачать ZIP</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="attachModal">
|
||||||
|
<div class="edit-modal" style="max-width:480px">
|
||||||
|
<h3>Прикрепить к группе</h3>
|
||||||
|
<form id="attachForm" class="settings-stack" style="gap:12px">
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Группа <span style="color:#ef4444">*</span></label>
|
||||||
|
<select id="attachGroup" class="settings-input" required><option value="">— выберите группу —</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
<label>Ученики</label>
|
<label>Ученики</label>
|
||||||
<input type="text" id="attachSearch" class="search-input" placeholder="Поиск по имени..." style="width:100%;margin-bottom:8px">
|
<input type="text" id="attachSearch" class="settings-input" placeholder="Поиск по имени..." style="margin-bottom:8px">
|
||||||
<div class="attach-list" id="attachList"></div>
|
<div class="attach-list" id="attachList"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
|
||||||
<button class="cancel" onclick="closeBatchAttach()">Отмена</button>
|
|
||||||
<button class="save" onclick="batchAttach()">Прикрепить</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="attachCancelBtn">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary">Прикрепить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="profileDataModal">
|
||||||
|
<div class="edit-modal" style="max-width:900px;width:96%;max-height:92dvh;overflow-y:auto">
|
||||||
|
<h3>Данные профиля: <span id="profileDataTitle" style="color:var(--accent)"></span></h3>
|
||||||
|
<form id="profileDataForm" style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Роль / направление</label>
|
||||||
|
<input type="text" id="pfRole" class="settings-input" placeholder="Например: Ученик направления «Frontend-разработка»">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Статус</label>
|
||||||
|
<input type="text" id="pfStatus" class="settings-input" placeholder="Активно учится">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Примечание к статусу</label>
|
||||||
|
<input type="text" id="pfStatusNote" class="settings-input" placeholder="учится с сентября 2022">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Город</label>
|
||||||
|
<input type="text" id="pfCity" class="settings-input">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Наставник</label>
|
||||||
|
<input type="text" id="pfMentor" class="settings-input">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>В академии с</label>
|
||||||
|
<input type="text" id="pfJoined" class="settings-input" placeholder="В академии с сентября 2022">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field" style="grid-column:1/-1">
|
||||||
|
<label>О себе</label>
|
||||||
|
<textarea id="pfBio" class="settings-input" style="min-height:70px" placeholder="Короткий рассказ ученика о себе"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Цитата</label>
|
||||||
|
<input type="text" id="pfQuote" class="settings-input" placeholder="«Мой принцип: сначала понять, потом написать»">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Теги (через запятую)</label>
|
||||||
|
<input type="text" id="pfTags" class="settings-input" placeholder="HTML, CSS, JavaScript">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Контакты — строка: Иконка | Подпись | Ссылка</label>
|
||||||
|
<textarea id="pfContacts" class="settings-input" style="min-height:64px" placeholder="mail | maria@example.com | mailto:maria@example.com 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>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="vendor/jszip.min.js"></script>
|
<script src="vendor/jszip.min.js"></script>
|
||||||
<script>
|
<script src="js/students.js"></script>
|
||||||
// --- Students ---
|
|
||||||
let allStudents = [];
|
|
||||||
let allGroups = [];
|
|
||||||
let groupOptionsLoaded = false;
|
|
||||||
let sPage = 1;
|
|
||||||
const S_PAGE_SIZE = 25;
|
|
||||||
let sSearchTimer = null;
|
|
||||||
let aSearchTimer = null;
|
|
||||||
let attachGroupLoaded = false;
|
|
||||||
|
|
||||||
async function loadGroupOptions(sel, selected) {
|
|
||||||
if (!groupOptionsLoaded) {
|
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
|
||||||
allGroups = await res.json();
|
|
||||||
groupOptionsLoaded = true;
|
|
||||||
const filt = document.getElementById('studentGroupFilter');
|
|
||||||
if (filt && filt.options.length <= 1) {
|
|
||||||
filt.innerHTML = `<option value="">Все группы</option>` +
|
|
||||||
allGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sel) {
|
|
||||||
sel.innerHTML = `<option value="">— не привязана —</option>` +
|
|
||||||
allGroups.map(g => `<option value="${g.id}" ${selected == g.id ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStudents() {
|
|
||||||
const res = await fetch(`${API}/api/students`, { headers: hdr() });
|
|
||||||
allStudents = await res.json();
|
|
||||||
renderStudents();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStudents() {
|
|
||||||
const q = document.getElementById('studentSearch').value.trim().toLowerCase();
|
|
||||||
const gid = document.getElementById('studentGroupFilter').value;
|
|
||||||
let filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
|
|
||||||
if (gid) filtered = filtered.filter(s => String(s.group_id) === gid);
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / S_PAGE_SIZE));
|
|
||||||
if (sPage > totalPages) sPage = 1;
|
|
||||||
const slice = filtered.slice((sPage - 1) * S_PAGE_SIZE, sPage * S_PAGE_SIZE);
|
|
||||||
const list = document.getElementById('studentList');
|
|
||||||
if (!slice.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Ничего не найдено</div>'; }
|
|
||||||
else {
|
|
||||||
list.innerHTML = slice.map(s => `
|
|
||||||
<div class="item">
|
|
||||||
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
|
|
||||||
<select class="group-select" id="sgroup-${s.id}"></select>
|
|
||||||
<div class="student-actions">
|
|
||||||
<button class="btn-save" onclick="saveStudent(${s.id})">Сохранить</button>
|
|
||||||
<button class="btn-link" onclick="copyStudentLink('${esc(s.name).replace(/'/g, ''')}')" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
|
|
||||||
<button class="btn-del" onclick="delStudent(${s.id})" title="Удалить">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
slice.forEach(s => loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id));
|
|
||||||
}
|
|
||||||
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goStudentPage(p) {
|
|
||||||
sPage = p;
|
|
||||||
renderStudents();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addStudent() {
|
|
||||||
const input = document.getElementById('newStudent');
|
|
||||||
const name = input.value.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
|
|
||||||
if (res.ok) { input.value = ''; loadStudents(); fillAutocomplete(); showToast(`Добавлен: ${name}`); }
|
|
||||||
else { const e = await res.json(); alert(e.error); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveStudent(id) {
|
|
||||||
const name = document.getElementById(`sname-${id}`).value.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const group_id = document.getElementById(`sgroup-${id}`).value;
|
|
||||||
const res = await fetch(`${API}/api/students/${id}`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ name, group_id }) });
|
|
||||||
if (res.ok) { loadStudents(); fillAutocomplete(); showToast(`Сохранено: ${name}`); }
|
|
||||||
else { const e = await res.json(); alert(e.error); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function delStudent(id) {
|
|
||||||
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
|
|
||||||
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
|
|
||||||
loadStudents();
|
|
||||||
fillAutocomplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openBatchAdd() {
|
|
||||||
document.getElementById('batchText').value = '';
|
|
||||||
loadGroupOptions(document.getElementById('batchGroup'), '');
|
|
||||||
document.getElementById('batchModal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeBatchAdd() {
|
|
||||||
document.getElementById('batchModal').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function batchAdd() {
|
|
||||||
const raw = document.getElementById('batchText').value;
|
|
||||||
const names = [...new Set(raw.split('\n').map(s => s.trim()).filter(Boolean))];
|
|
||||||
if (!names.length) return;
|
|
||||||
const group_id = document.getElementById('batchGroup').value;
|
|
||||||
let added = 0, dupes = 0;
|
|
||||||
for (const name of names) {
|
|
||||||
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, group_id: group_id || null }) });
|
|
||||||
if (res.ok) added++;
|
|
||||||
else if (res.status === 409) dupes++;
|
|
||||||
}
|
|
||||||
closeBatchAdd();
|
|
||||||
loadStudents();
|
|
||||||
fillAutocomplete();
|
|
||||||
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('newStudent').addEventListener('keydown', e => { if (e.key === 'Enter') addStudent(); });
|
|
||||||
document.getElementById('studentSearch').addEventListener('input', () => {
|
|
||||||
clearTimeout(sSearchTimer);
|
|
||||||
sSearchTimer = setTimeout(() => { sPage = 1; renderStudents(); }, 300);
|
|
||||||
});
|
|
||||||
document.getElementById('studentGroupFilter').addEventListener('change', () => { sPage = 1; renderStudents(); });
|
|
||||||
|
|
||||||
function exportStudentsCSV() {
|
|
||||||
const gid = document.getElementById('studentGroupFilter').value;
|
|
||||||
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
|
|
||||||
if (!list.length) { alert('Нет данных для экспорта'); return; }
|
|
||||||
const rows = [['Фамилия Имя', 'Группа', 'Ссылка на страницу']];
|
|
||||||
list.forEach(s => {
|
|
||||||
rows.push([
|
|
||||||
s.name,
|
|
||||||
s.group_name || '',
|
|
||||||
`${location.origin}/?nameInput=${encodeURIComponent(s.name)}`
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
|
|
||||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
|
|
||||||
a.download = `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
showToast(`Экспортировано: ${list.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportStudentsUrls() {
|
|
||||||
const gid = document.getElementById('studentGroupFilter').value;
|
|
||||||
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
|
|
||||||
if (!list.length) { alert('Нет данных для экспорта'); return; }
|
|
||||||
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
|
|
||||||
await exportStudentUrlZip(list, `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
|
|
||||||
showToast(`Экспортировано: ${list.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Batch attach ---
|
|
||||||
async function openBatchAttach() {
|
|
||||||
const sel = document.getElementById('attachGroup');
|
|
||||||
if (!attachGroupLoaded) {
|
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
|
||||||
const groups = await res.json();
|
|
||||||
sel.innerHTML = `<option value="">— выберите группу —</option>` +
|
|
||||||
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
|
||||||
attachGroupLoaded = true;
|
|
||||||
}
|
|
||||||
document.getElementById('attachSearch').value = '';
|
|
||||||
renderAttachList();
|
|
||||||
document.getElementById('attachModal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeBatchAttach() {
|
|
||||||
document.getElementById('attachModal').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAttachList() {
|
|
||||||
const q = document.getElementById('attachSearch').value.trim().toLowerCase();
|
|
||||||
const filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
|
|
||||||
const list = document.getElementById('attachList');
|
|
||||||
if (!filtered.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:16px">Никого не найдено</div>'; return; }
|
|
||||||
list.innerHTML = filtered.map(s => `
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" value="${s.id}">
|
|
||||||
<span>${esc(s.name)}</span>
|
|
||||||
<span class="cur">${s.group_name ? esc(s.group_name) : ''}</span>
|
|
||||||
</label>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function batchAttach() {
|
|
||||||
const group_id = document.getElementById('attachGroup').value;
|
|
||||||
const ids = [...document.querySelectorAll('#attachList input[type=checkbox]:checked')].map(c => c.value);
|
|
||||||
if (!group_id) { alert('Выберите группу'); return; }
|
|
||||||
if (!ids.length) { alert('Выберите учеников'); return; }
|
|
||||||
const res = await fetch(`${API}/api/students/batch-group`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: hdrJson(),
|
|
||||||
body: JSON.stringify({ group_id, student_ids: ids })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const r = await res.json();
|
|
||||||
closeBatchAttach();
|
|
||||||
loadStudents();
|
|
||||||
showToast(`Прикреплено: ${r.updated}`);
|
|
||||||
} else { const e = await res.json(); alert(e.error); }
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('attachSearch').addEventListener('input', () => {
|
|
||||||
clearTimeout(aSearchTimer);
|
|
||||||
aSearchTimer = setTimeout(renderAttachList, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (await checkAuth()) {
|
|
||||||
buildSidebar(document.body.dataset.page);
|
|
||||||
fillAutocomplete();
|
|
||||||
loadGroupOptions();
|
|
||||||
loadStudents();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+4
-91
@@ -13,107 +13,20 @@
|
|||||||
<h2>Корзина</h2>
|
<h2>Корзина</h2>
|
||||||
<div class="trash-head">
|
<div class="trash-head">
|
||||||
<span class="trash-muted" id="trashInfo"></span>
|
<span class="trash-muted" id="trashInfo"></span>
|
||||||
<button class="danger-btn" id="emptyBtn" onclick="emptyTrash()">Очистить корзину</button>
|
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||||
<div class="pager" id="pager"></div>
|
<div class="pager" id="pager"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/trash.js"></script>
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
let currentEntries = [];
|
|
||||||
let page = 1;
|
|
||||||
let totalEntries = 0;
|
|
||||||
|
|
||||||
function isImageFile(name) {
|
|
||||||
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function filesHTML(files) {
|
|
||||||
if (!files || !files.length) return '';
|
|
||||||
return `<div class="entry-files">${files.map(f =>
|
|
||||||
isImageFile(f.name)
|
|
||||||
? `<a class="f-link" href="#" onclick="showImg('${API}/api/files/${f.token}');return false;" title="Открыть">${esc(f.name)}</a>`
|
|
||||||
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`
|
|
||||||
).join('')}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryHTML(e) {
|
|
||||||
return `
|
|
||||||
<div class="card">
|
|
||||||
${e.photo_path ? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">` : ''}
|
|
||||||
<div class="info">
|
|
||||||
<div class="name">${esc(e.student_name)}</div>
|
|
||||||
<div class="group">${esc(e.group_name)}</div>
|
|
||||||
<div class="desc">${esc(e.description)}</div>
|
|
||||||
${filesHTML(e.files)}
|
|
||||||
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
|
||||||
</div>
|
|
||||||
<button class="restore-btn" onclick="restoreEntry(${e.id})" title="Восстановить">Восстановить</button>
|
|
||||||
<button class="del-btn" onclick="hardDelete(${e.id})" title="Удалить навсегда (вместе с файлами)">×</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGrid() {
|
|
||||||
const grid = document.getElementById('grid');
|
|
||||||
grid.className = 'grid';
|
|
||||||
grid.innerHTML = currentEntries.length
|
|
||||||
? currentEntries.map(entryHTML).join('')
|
|
||||||
: '<div class="empty">Корзина пуста</div>';
|
|
||||||
document.getElementById('trashInfo').textContent =
|
|
||||||
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
|
|
||||||
document.getElementById('emptyBtn').disabled = totalEntries === 0;
|
|
||||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTrash() {
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
p.set('limit', PAGE_SIZE);
|
|
||||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
|
||||||
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
|
|
||||||
const data = await res.json();
|
|
||||||
currentEntries = data.entries;
|
|
||||||
totalEntries = data.total;
|
|
||||||
renderGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
function goPage(p) { page = p; loadTrash(); }
|
|
||||||
|
|
||||||
async function restoreEntry(id) {
|
|
||||||
const res = await fetch(`${API}/api/entries/${id}/restore`, { method: 'PUT', headers: hdr() });
|
|
||||||
if (res.ok) { showToast('Запись восстановлена'); loadTrash(); }
|
|
||||||
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hardDelete(id) {
|
|
||||||
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
|
|
||||||
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
|
||||||
showToast('Запись удалена навсегда');
|
|
||||||
loadTrash();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function emptyTrash() {
|
|
||||||
if (totalEntries === 0) return;
|
|
||||||
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
|
|
||||||
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
|
|
||||||
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
|
|
||||||
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (await checkAuth()) {
|
|
||||||
buildSidebar(document.body.dataset.page);
|
|
||||||
loadTrash();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Пользователи — Админ-панель</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="users">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Пользователи</h2>
|
||||||
|
<p class="page-sub">Управление доступом и ролями</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-row" style="margin-bottom:16px">
|
||||||
|
<button class="btn-primary" id="addUserBtn" type="button">
|
||||||
|
<span style="font-size:1.2rem">+</span> Добавить пользователя
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audit-wrap">
|
||||||
|
<table class="audit-table" id="usersTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Логин</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Филиалы</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="usersEmpty" style="display:none">Пользователи не найдены</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="userModal">
|
||||||
|
<div class="edit-modal" style="max-width:480px">
|
||||||
|
<h3 id="userModalTitle">Новый пользователь</h3>
|
||||||
|
<div class="settings-stack" style="gap:12px;max-width:none">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Логин <span style="color:#ef4444">*</span></label>
|
||||||
|
<input type="text" id="uUsername" class="settings-input" placeholder="login" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Имя</label>
|
||||||
|
<input type="text" id="uName" class="settings-input" placeholder="Имя Фамилия">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Роль</label>
|
||||||
|
<select id="uRole" class="settings-input">
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
<option value="tutor">Тутор</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field" id="uBranchesField">
|
||||||
|
<label>Филиалы (мультивыбор)</label>
|
||||||
|
<select id="uBranches" class="settings-input" multiple size="4"></select>
|
||||||
|
<span class="hint" style="font-size:.75rem">Тутор видит только свои филиалы. Выбор: Ctrl/Cmd+клик.</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Пароль <small id="pwdHint" style="font-weight:400;color:var(--muted)"></small></label>
|
||||||
|
<input type="password" id="uPassword" class="settings-input" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field" style="flex-direction:row;align-items:center;gap:8px">
|
||||||
|
<input type="checkbox" id="uActive" checked>
|
||||||
|
<label style="margin:0">Активен</label>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
|
||||||
|
<button type="button" class="btn-primary ghost" id="uCancel">Отмена</button>
|
||||||
|
<button type="button" class="btn-primary" id="uSave">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/users.js"></script>
|
||||||
|
<style>
|
||||||
|
.badge-admin,.badge-tutor{font-size:.7rem;font-weight:600;padding:2px 10px;border-radius:999px;white-space:nowrap}
|
||||||
|
.badge-admin{color:#7c3aed;background:rgba(124,58,237,.12)}
|
||||||
|
.badge-tutor{color:var(--accent);background:rgba(37,99,235,.1)}
|
||||||
|
.status-ok{font-size:.72rem;font-weight:600;color:#16a34a;background:rgba(22,163,74,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
|
||||||
|
.status-off{font-size:.72rem;font-weight:600;color:#ef4444;background:rgba(239,68,68,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
|
||||||
|
.row-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px 8px;border-radius:6px}
|
||||||
|
.row-btn:hover{background:var(--bg);color:var(--accent)}
|
||||||
|
.row-btn.del:hover{color:#ef4444;background:rgba(239,68,68,.1)}
|
||||||
|
.user-branches{max-width:220px;color:var(--muted);font-size:.78rem}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Vendored
+12
File diff suppressed because one or more lines are too long
@@ -0,0 +1,192 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Воркер ИИ — Админ-панель</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
<style>
|
||||||
|
.enhance-compare { position: relative; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
|
||||||
|
.enhance-stage { transform-origin: 0 0; }
|
||||||
|
.enhance-stage > img { display: block; width: 100%; height: auto; }
|
||||||
|
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
|
||||||
|
.enhance-top .enhance-stage { position: absolute; inset: 0; }
|
||||||
|
.enhance-top .enhance-stage > img { height: 100%; object-fit: fill; }
|
||||||
|
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); cursor: ew-resize; }
|
||||||
|
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
|
||||||
|
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
|
||||||
|
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
|
||||||
|
.enhance-badge.left { left: 10px; }
|
||||||
|
.enhance-badge.right { right: 10px; }
|
||||||
|
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-page="worker">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Воркер ИИ</h2>
|
||||||
|
<p class="page-sub">Отслеживание автоматической проверки сообщений записей</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-row">
|
||||||
|
<span class="worker-state" id="stateLine"><span class="worker-dot"></span> Загрузка…</span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:.85rem">
|
||||||
|
<input type="checkbox" id="autoRefresh"> Автообновление
|
||||||
|
</label>
|
||||||
|
<button class="btn-link" id="refreshBtn" type="button">Обновить</button>
|
||||||
|
<button class="btn-link" id="wakeBtn" type="button" title="Запустить обработку очереди немедленно">Разбудить</button>
|
||||||
|
<button class="btn-link" id="requeueBtn" type="button" title="Вернуть все записи с ошибкой в очередь">Перепроверить ошибки</button>
|
||||||
|
<button class="btn-link" id="photoWakeBtn" type="button" title="Разбудить фото-воркер">📷 Разбудить фото</button>
|
||||||
|
<button class="btn-link" id="photoToggleBtn" type="button">📷 …</button>
|
||||||
|
<button class="btn-link" id="toggleBtn" type="button">…</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sys-grid" id="workerCards" style="margin-top:16px">
|
||||||
|
<div class="sys-card loading">Загрузка…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="worker-counts" id="workerCounts"></div>
|
||||||
|
|
||||||
|
<div class="settings-stack settings-stack-grid" style="margin-top:20px">
|
||||||
|
<div class="settings-card wide">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="hourglass"></i></div>
|
||||||
|
<h3>Очередь (<span id="pendingCount">0</span>)</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Записи, ожидающие проверки моделью. Обновляются автоматически.</p>
|
||||||
|
<div class="audit-wrap">
|
||||||
|
<table class="audit-table" id="pendingTable">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Ученик</th><th>Группа</th><th>Создана</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="pendingEmpty" style="display:none">Очередь пуста.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card wide">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="history"></i></div>
|
||||||
|
<h3>Последние проверки</h3>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row" style="margin-bottom:10px">
|
||||||
|
<select id="recentFilter" class="settings-input" style="max-width:220px">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="done">Проверено</option>
|
||||||
|
<option value="skipped">Пропущено</option>
|
||||||
|
<option value="error">Ошибка</option>
|
||||||
|
<option value="reverted">Возвращён оригинал</option>
|
||||||
|
</select>
|
||||||
|
<span class="hint" id="recentCount"></span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-wrap">
|
||||||
|
<table class="audit-table" id="recentTable">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Проверено</th><th>Ученик</th><th>Группа</th><th>Статус</th><th>Изменения</th><th>Детали</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="recentEmpty" style="display:none">Проверок пока нет.</div>
|
||||||
|
</div>
|
||||||
|
<div class="pager" id="recentPager"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card wide">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="image"></i></div>
|
||||||
|
<h3>Обработка фотографий</h3>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row" style="margin-bottom:10px">
|
||||||
|
<span class="hint" id="photoStateHint"></span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button class="btn-link" id="photoRequeueBtn" type="button" title="Вернуть задания с ошибкой в очередь">Повторить ошибки фото</button>
|
||||||
|
</div>
|
||||||
|
<div class="worker-counts" id="photoCounts" style="margin-bottom:10px"></div>
|
||||||
|
<p class="hint">В очереди (<span id="photoPendingCount">0</span>)</p>
|
||||||
|
<div class="audit-wrap" style="margin-bottom:14px">
|
||||||
|
<table class="audit-table" id="photoPendingTable">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Задание</th><th>Запись</th><th>Действие</th><th>Поставлено</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="photoPendingEmpty" style="display:none">Очередь пуста.</div>
|
||||||
|
</div>
|
||||||
|
<div class="audit-wrap">
|
||||||
|
<table class="audit-table" id="photoTable">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Время</th><th>Запись</th><th>Действие</th><th>Статус</th><th>Было</th><th>Стало</th><th>Ошибка</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="photoEmpty" style="display:none">Обработок фотографий пока нет.</div>
|
||||||
|
</div>
|
||||||
|
<div class="pager" id="photoRecentPager"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card wide" id="errorsCard" style="display:none">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="triangle-alert"></i></div>
|
||||||
|
<h3>Ошибки (<span id="errorsCount">0</span>)</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Записи, которые не удалось обработать после всех попыток. Нажмите «Перепроверить ошибки», чтобы запустить их снова.</p>
|
||||||
|
<div class="audit-wrap">
|
||||||
|
<table class="audit-table" id="errorsTable">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Время</th><th>ID</th><th>Ученик</th><th>Ошибка</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-overlay" id="aiDetailModal">
|
||||||
|
<div class="edit-modal" style="max-width:680px">
|
||||||
|
<h3 id="aiDetailTitle">Детали ИИ-проверки</h3>
|
||||||
|
<div class="ai-detail-block">
|
||||||
|
<div class="ai-detail-label">Отправлено в модель</div>
|
||||||
|
<pre id="aiDetailInput"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="ai-detail-block">
|
||||||
|
<div class="ai-detail-label">Ответ модели</div>
|
||||||
|
<pre id="aiDetailOutput"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="cancel" id="aiDetailCloseBtn">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-overlay" id="photoJobModal">
|
||||||
|
<div class="edit-modal" style="max-width:900px">
|
||||||
|
<h3 id="photoJobTitle">Обработка фотографии</h3>
|
||||||
|
<div class="enhance-compare" id="photoJobCompare">
|
||||||
|
<div class="enhance-stage">
|
||||||
|
<img id="photoJobBefore" alt="Было">
|
||||||
|
</div>
|
||||||
|
<div class="enhance-top" id="photoJobTop" style="clip-path:inset(0 0 0 50%)">
|
||||||
|
<div class="enhance-stage enhance-stage-fill">
|
||||||
|
<img id="photoJobAfter" alt="Стало">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="enhance-divider" id="photoJobDivider"><div class="enhance-handle">⇔</div></div>
|
||||||
|
<div class="enhance-badge left">Было</div>
|
||||||
|
<div class="enhance-badge right">Стало</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:14px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="photoJobCloseBtn">Закрыть</button>
|
||||||
|
<button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/worker.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1473
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
@@ -14,6 +14,7 @@ echo ">> Копирование фотографий"
|
|||||||
rm -rf backups/_uploads
|
rm -rf backups/_uploads
|
||||||
mkdir -p backups/_uploads
|
mkdir -p backups/_uploads
|
||||||
docker cp "whatido-app-1:/app/uploads/." "backups/_uploads/"
|
docker cp "whatido-app-1:/app/uploads/." "backups/_uploads/"
|
||||||
|
rm -rf backups/_uploads/.thumbs
|
||||||
|
|
||||||
echo ">> Сборка архива"
|
echo ">> Сборка архива"
|
||||||
tar -czf "backups/whatido-backup-$STAMP.tar.gz" -C backups db.sql.gz _uploads
|
tar -czf "backups/whatido-backup-$STAMP.tar.gz" -C backups db.sql.gz _uploads
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
node "$ROOT/scripts/gen-version.js"
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Опциональный WireGuard: если в wg/wg0.conf лежит конфиг — поднимаем VPN
|
||||||
|
# и только после установки handshake запускаем туннель. Без конфига —
|
||||||
|
# cloudflared стартует сразу, как в базовой схеме Quick Tunnel.
|
||||||
|
if [ -f /etc/wireguard/wg0.conf ]; then
|
||||||
|
echo "WireGuard config found, bringing up wg0..."
|
||||||
|
# Папка wg/ смонтирована :ro, поэтому копируем в /tmp и ставим 600, чтобы
|
||||||
|
# wg-quick не ругался на world-accessible файл приватного ключа.
|
||||||
|
cp /etc/wireguard/wg0.conf /tmp/wg0.conf
|
||||||
|
chmod 600 /tmp/wg0.conf
|
||||||
|
cp /etc/resolv.conf /tmp/resolv.conf.default
|
||||||
|
wg-quick up /tmp/wg0.conf
|
||||||
|
# wg-quick/resolvconf перезаписывает resolv.conf (убирает docker-DNS 127.0.0.11),
|
||||||
|
# из-за чего внутреннее имя app не резолвится. Восстанавливаем прежний конфиг.
|
||||||
|
cat /tmp/resolv.conf.default > /etc/resolv.conf
|
||||||
|
echo "Waiting for WireGuard handshake (timeout: ${WG_HANDSHAKE_TIMEOUT:-60}s)..."
|
||||||
|
# Формат `wg show wg0 latest-handshakes`: "<pubkey> <epoch-ts>"; 0 = handshake ещё не было.
|
||||||
|
waited=0; handshake=0
|
||||||
|
while [ "$handshake" -eq 0 ] && [ "$waited" -lt "${WG_HANDSHAKE_TIMEOUT:-60}" ]; do
|
||||||
|
if wg show wg0 latest-handshakes | awk '{ if ($2 != 0) found=1 } END { exit !found }'; then
|
||||||
|
handshake=1
|
||||||
|
else
|
||||||
|
sleep 2
|
||||||
|
waited=$((waited + 2))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$handshake" -eq 1 ]; then
|
||||||
|
echo "WireGuard is up, starting Cloudflare tunnel through VPN..."
|
||||||
|
else
|
||||||
|
echo "WARNING: WireGuard handshake not established within ${WG_HANDSHAKE_TIMEOUT:-60}s; starting Cloudflare tunnel directly (no VPN egress)."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No WireGuard config, starting Cloudflare tunnel directly..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec cloudflared --no-autoupdate tunnel --url "$CLOUDFLARE_TUNNEL_URL"
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
tailscaled &
|
||||||
|
echo "Waiting for tailscaled..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
until tailscale status > /dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
tailscale up --hostname=whatido --accept-dns=false
|
||||||
|
echo "Tailscale is up, waiting for network to settle..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
tailscale funnel --bg --yes http://127.0.0.1:3003
|
||||||
|
echo "Funnel is ready!"
|
||||||
|
|
||||||
|
sleep infinity
|
||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MODEL_NAME="${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}"
|
||||||
|
MODEL_REPO="${AI_MODEL_REPO:-Qwen/Qwen2.5-1.5B-Instruct-GGUF}"
|
||||||
|
MODEL_PATH="/models/${MODEL_NAME}"
|
||||||
|
MODEL_URL="https://huggingface.co/${MODEL_REPO}/resolve/main/${MODEL_NAME}"
|
||||||
|
|
||||||
|
if [ ! -s "${MODEL_PATH}" ]; then
|
||||||
|
echo "[text-corrector] Model ${MODEL_PATH} not found, downloading from ${MODEL_URL}"
|
||||||
|
set -- -L --fail --retry 3 --retry-delay 5 --progress-bar -o "${MODEL_PATH}.part"
|
||||||
|
if [ -n "${HUGGINGFACE_TOKEN}" ]; then
|
||||||
|
echo "[text-corrector] Using Hugging Face token"
|
||||||
|
set -- "$@" -H "Authorization: Bearer ${HUGGINGFACE_TOKEN}"
|
||||||
|
fi
|
||||||
|
curl "$@" "${MODEL_URL}"
|
||||||
|
mv "${MODEL_PATH}.part" "${MODEL_PATH}"
|
||||||
|
echo "[text-corrector] Download complete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[text-corrector] Starting llama-server with ${MODEL_PATH}"
|
||||||
|
exec /app/llama-server -m "${MODEL_PATH}" --host 0.0.0.0 --port 8080 -c 4096 -n 256 -t 8 --temp 0.1
|
||||||
File diff suppressed because it is too large
Load Diff
+1209
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# dynv6 DDNS update script
|
||||||
|
# Environment variables expected:
|
||||||
|
# DYNDNS_TOKEN - dynv6 token
|
||||||
|
# DYNDNS_HOSTNAME - domain name (e.g., kiberone.dns.army)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TOKEN="${DYNDNS_TOKEN}"
|
||||||
|
HOSTNAME="${DYNDNS_HOSTNAME}"
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ] || [ -z "$HOSTNAME" ]; then
|
||||||
|
echo "ERROR: DYNDNS_TOKEN and DYNDNS_HOSTNAME must be set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date)] Updating dynv6 for $HOSTNAME..."
|
||||||
|
|
||||||
|
# Update IPv4
|
||||||
|
IPV4=$(curl -s -4 https://api.ipify.org 2>/dev/null || echo "")
|
||||||
|
if [ -n "$IPV4" ]; then
|
||||||
|
echo "[$(date)] Detected IPv4: $IPV4"
|
||||||
|
RESPONSE=$(curl -s "https://dynv6.com/api/update?hostname=${HOSTNAME}&token=${TOKEN}&ipv4=${IPV4}")
|
||||||
|
echo "[$(date)] IPv4 update response: $RESPONSE"
|
||||||
|
else
|
||||||
|
echo "[$(date)] No IPv4 detected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update IPv6
|
||||||
|
IPV6=$(curl -s -6 https://api6.ipify.org 2>/dev/null || echo "")
|
||||||
|
if [ -n "$IPV6" ]; then
|
||||||
|
echo "[$(date)] Detected IPv6: $IPV6"
|
||||||
|
RESPONSE=$(curl -s "https://dynv6.com/api/update?hostname=${HOSTNAME}&token=${TOKEN}&ipv6=${IPV6}")
|
||||||
|
echo "[$(date)] IPv6 update response: $RESPONSE"
|
||||||
|
else
|
||||||
|
echo "[$(date)] No IPv6 detected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date)] Update complete"
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
const IDLE_MIN_MS = 2000;
|
||||||
|
const IDLE_MAX_MS = 60000;
|
||||||
|
const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10);
|
||||||
|
const MAX_INPUT_CHARS = 2000;
|
||||||
|
const MIN_TEXT_CHARS = 4;
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const PHOTO_MAX_ATTEMPTS = 3;
|
||||||
|
const PHOTO_AI_TIMEOUT_MS = 300000;
|
||||||
|
|
||||||
|
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, originalsDir }) {
|
||||||
|
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
|
||||||
|
const IDLE_MIN = 2000;
|
||||||
|
const IDLE_MAX = 30000;
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
let stopped = false;
|
||||||
|
let idleMs = IDLE_MIN;
|
||||||
|
let wake = null;
|
||||||
|
let processing = false;
|
||||||
|
let currentId = null;
|
||||||
|
let startedAt = null;
|
||||||
|
const stats = { jobs: 0, done: 0, errors: 0, last_at: null, last_error: null };
|
||||||
|
const CONFIG = {
|
||||||
|
idle_min_ms: IDLE_MIN,
|
||||||
|
idle_max_ms: IDLE_MAX,
|
||||||
|
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS,
|
||||||
|
max_attempts: PHOTO_MAX_ATTEMPTS,
|
||||||
|
ai_url: AI_URL,
|
||||||
|
};
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const t = setTimeout(() => { wake = null; resolve(); }, ms);
|
||||||
|
wake = () => { clearTimeout(t); wake = null; resolve(); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
if (wake) wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isEnabled() {
|
||||||
|
const v = await getSetting('photo_worker_enabled', 'true');
|
||||||
|
return String(v) !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimNext() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`SELECT j.id, j.entry_id, j.action, j.params
|
||||||
|
FROM photo_jobs j
|
||||||
|
WHERE j.status = 'pending'
|
||||||
|
ORDER BY j.id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`
|
||||||
|
);
|
||||||
|
if (!rows.length) {
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await client.query(`UPDATE photo_jobs SET status = 'processing' WHERE id = $1`, [rows[0].id]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return rows[0];
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeUnlinkPath(p) {
|
||||||
|
try {
|
||||||
|
if (!p) return;
|
||||||
|
const abs = path.resolve(p);
|
||||||
|
const root = path.resolve(uploadsDir);
|
||||||
|
if (abs !== root && abs.startsWith(root + path.sep)) fs.unlinkSync(abs);
|
||||||
|
} catch (e) { console.error('photo worker unlink:', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupOldFile(oldRelPath) {
|
||||||
|
if (!oldRelPath) return null;
|
||||||
|
const oldAbs = path.join(uploadsDir, String(oldRelPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
|
||||||
|
if (!fs.existsSync(oldAbs)) return null;
|
||||||
|
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
|
||||||
|
const backupPath = path.join(originalsDir, backupName);
|
||||||
|
try {
|
||||||
|
fs.renameSync(oldAbs, backupPath);
|
||||||
|
return `/uploads/.originals/${backupName}`;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('photo worker backup failed:', e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function enhanceWithSharp(srcAbs, params) {
|
||||||
|
if (!sharp) throw new Error('sharp недоступен на сервере');
|
||||||
|
const p = params || {};
|
||||||
|
const clamp = (v, min, max, def) => {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
|
||||||
|
};
|
||||||
|
const brightness = clamp(p.brightness, 10, 300, 100);
|
||||||
|
const contrast = clamp(p.contrast, 10, 300, 100);
|
||||||
|
const saturate = clamp(p.saturate, 0, 300, 100);
|
||||||
|
const sharpAmt = clamp(p.sharp, 0, 100, 0);
|
||||||
|
const denoise = clamp(p.denoise, 0, 100, 0);
|
||||||
|
let pipeline = sharp(srcAbs).rotate();
|
||||||
|
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
|
||||||
|
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
|
||||||
|
if (contrast !== 100) {
|
||||||
|
const a = contrast / 100;
|
||||||
|
pipeline = pipeline.linear(a, 128 * (1 - a));
|
||||||
|
}
|
||||||
|
if (sharpAmt > 0) {
|
||||||
|
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
|
||||||
|
}
|
||||||
|
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||||||
|
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(path.join(uploadsDir, newName));
|
||||||
|
return `/uploads/${newName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAiEnhance(srcAbs) {
|
||||||
|
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
|
||||||
|
const buf = fs.readFileSync(srcAbs);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
|
||||||
|
fd.append('scale', '2');
|
||||||
|
const resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
|
||||||
|
const out = Buffer.from(await resp.arrayBuffer());
|
||||||
|
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||||||
|
fs.writeFileSync(path.join(uploadsDir, newName), out);
|
||||||
|
return `/uploads/${newName}`;
|
||||||
|
}
|
||||||
|
async function applyResult(job, newPath) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`,
|
||||||
|
[newPath, job.id]
|
||||||
|
);
|
||||||
|
if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath });
|
||||||
|
}
|
||||||
|
async function processOne(job) {
|
||||||
|
stats.jobs++;
|
||||||
|
const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entry_id]);
|
||||||
|
if (!rows.length || !rows[0].photo_path) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE photo_jobs SET status = 'error', error = 'У записи нет фото', finished_at = now() WHERE id = $1`,
|
||||||
|
[job.id]
|
||||||
|
);
|
||||||
|
stats.errors++;
|
||||||
|
stats.last_at = new Date().toISOString();
|
||||||
|
stats.last_error = 'У записи нет фото';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const photoPath = rows[0].photo_path;
|
||||||
|
const srcAbs = path.join(uploadsDir, String(photoPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
|
||||||
|
try {
|
||||||
|
const newPath = job.action === 'ai' ? await runAiEnhance(srcAbs) : await enhanceWithSharp(srcAbs, job.params);
|
||||||
|
await applyResult(job, newPath);
|
||||||
|
stats.done++;
|
||||||
|
stats.last_at = new Date().toISOString();
|
||||||
|
stats.last_error = null;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||||
|
stats.last_at = new Date().toISOString();
|
||||||
|
stats.last_error = message;
|
||||||
|
const { rows: cur } = await pool.query('SELECT attempts FROM photo_jobs WHERE id = $1', [job.id]);
|
||||||
|
const tries = ((cur[0] && cur[0].attempts) || 0) + 1;
|
||||||
|
if (tries < PHOTO_MAX_ATTEMPTS) {
|
||||||
|
await pool.query(`UPDATE photo_jobs SET status = 'pending', attempts = $1, error = $2 WHERE id = $3`, [tries, message, job.id]);
|
||||||
|
if (logAudit) await logAudit(null, 'photo.job.retry', { entry_id: job.entry_id, job_id: job.id, attempt: tries, error: message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE photo_jobs SET status = 'error', attempts = $1, error = $2, finished_at = now() WHERE id = $3`,
|
||||||
|
[tries, message, job.id]
|
||||||
|
);
|
||||||
|
stats.errors++;
|
||||||
|
if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loop() {
|
||||||
|
while (!stopped) {
|
||||||
|
let job = null;
|
||||||
|
try {
|
||||||
|
if (!(await isEnabled())) {
|
||||||
|
await sleep(IDLE_MAX);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
job = await claimNext();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Photo worker claim error:', e);
|
||||||
|
await sleep(IDLE_MAX);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!job) {
|
||||||
|
await sleep(idleMs);
|
||||||
|
idleMs = Math.min(idleMs * 2, IDLE_MAX);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
idleMs = IDLE_MIN;
|
||||||
|
processing = true;
|
||||||
|
currentId = job.id;
|
||||||
|
let ok = true;
|
||||||
|
try {
|
||||||
|
ok = await processOne(job);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Photo worker process error:', e);
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
currentId = null;
|
||||||
|
}
|
||||||
|
if (!ok) await sleep(IDLE_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetStale() {
|
||||||
|
await pool.query(`UPDATE photo_jobs SET status = 'pending' WHERE status = 'processing'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
startedAt = Date.now();
|
||||||
|
resetStale()
|
||||||
|
.catch((e) => console.error('Photo worker reset error:', e))
|
||||||
|
.finally(() => { loop().catch((e) => console.error('Photo worker loop error:', e)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInfo() {
|
||||||
|
return {
|
||||||
|
started_at: startedAt ? new Date(startedAt).toISOString() : null,
|
||||||
|
uptime_ms: startedAt ? Date.now() - startedAt : 0,
|
||||||
|
processing,
|
||||||
|
current_id: currentId,
|
||||||
|
config: CONFIG,
|
||||||
|
...stats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, notify, getInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
|
||||||
|
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
|
||||||
|
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||||
|
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
let stopped = false;
|
||||||
|
let idleMs = IDLE_MIN_MS;
|
||||||
|
let wake = null;
|
||||||
|
let processing = false;
|
||||||
|
let currentId = null;
|
||||||
|
let startedAt = null;
|
||||||
|
const stats = { checks: 0, corrected: 0, unchanged: 0, errors: 0, last_at: null, last_error: null };
|
||||||
|
const attempts = new Map();
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
const CONFIG = {
|
||||||
|
idle_min_ms: IDLE_MIN_MS,
|
||||||
|
idle_max_ms: IDLE_MAX_MS,
|
||||||
|
request_timeout_ms: REQUEST_TIMEOUT_MS,
|
||||||
|
max_input_chars: MAX_INPUT_CHARS,
|
||||||
|
min_text_chars: MIN_TEXT_CHARS,
|
||||||
|
max_attempts: MAX_ATTEMPTS,
|
||||||
|
model: MODEL,
|
||||||
|
ai_url: AI_URL,
|
||||||
|
};
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const t = setTimeout(() => { wake = null; resolve(); }, ms);
|
||||||
|
wake = () => { clearTimeout(t); wake = null; resolve(); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
if (wake) wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isEnabled() {
|
||||||
|
const v = await getSetting('ai_autocheck_enabled', 'true');
|
||||||
|
return String(v) !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimNext() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`SELECT id, description, description_original FROM entries
|
||||||
|
WHERE ai_status = 'pending' AND deleted_at IS NULL
|
||||||
|
ORDER BY id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`
|
||||||
|
);
|
||||||
|
if (!rows.length) {
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await client.query(`UPDATE entries SET ai_status = 'processing' WHERE id = $1`, [rows[0].id]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return rows[0];
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveActiveProfile() {
|
||||||
|
try {
|
||||||
|
const active = await getSetting('ai_active_profile', 'native');
|
||||||
|
if (active && active !== 'native') {
|
||||||
|
const raw = await getSetting('ai_profiles', '[]');
|
||||||
|
const list = JSON.parse(raw || '[]');
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
return list.find(p => p && p.id === active && p.base_url && p.model) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOpenAiBase(base) {
|
||||||
|
let b = String(base || '').trim().replace(/\/+$/, '');
|
||||||
|
if (!/^https?:\/\//i.test(b)) return null;
|
||||||
|
if (!/\/v1$/i.test(b)) b += '/v1';
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callModel(text, systemPrompt) {
|
||||||
|
const profile = await resolveActiveProfile();
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
let url;
|
||||||
|
let model;
|
||||||
|
let maxTokens;
|
||||||
|
if (profile) {
|
||||||
|
const base = normalizeOpenAiBase(profile.base_url);
|
||||||
|
if (!base) throw new Error('Некорректный base_url профиля ИИ');
|
||||||
|
url = `${base}/chat/completions`;
|
||||||
|
model = profile.model;
|
||||||
|
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
|
||||||
|
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, text.length * 2 + 512));
|
||||||
|
} else {
|
||||||
|
url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
|
||||||
|
model = MODEL;
|
||||||
|
maxTokens = Math.min(256, Math.max(128, text.length + 64));
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: text },
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const choice = data.choices && data.choices[0];
|
||||||
|
const content = (choice && choice.message && choice.message.content || '').trim();
|
||||||
|
if (!content) {
|
||||||
|
throw new Error(`Модель вернула пустой ответ (finish_reason=${choice && choice.finish_reason || 'unknown'})`);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize(original, out) {
|
||||||
|
if (!out) return original;
|
||||||
|
const o = out.trim();
|
||||||
|
if (!o) return original;
|
||||||
|
if (o.length > original.length * 4 + 80) return original;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processOne(row) {
|
||||||
|
const original = String(row.description_original ?? row.description ?? '').trim();
|
||||||
|
if (original.length < MIN_TEXT_CHARS) {
|
||||||
|
await pool.query(`UPDATE entries SET ai_status = 'skipped', ai_checked_at = now() WHERE id = $1`, [row.id]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const prompt = (await getSetting('ai_prompt', DEFAULT_PROMPT)) || DEFAULT_PROMPT;
|
||||||
|
const input = original.length > MAX_INPUT_CHARS ? original.slice(0, MAX_INPUT_CHARS) : original;
|
||||||
|
try {
|
||||||
|
const out = await callModel(input, prompt);
|
||||||
|
const checked = sanitize(input, out);
|
||||||
|
const changed = checked !== input;
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE entries SET description_ai = $1, description = $1, ai_status = 'done', ai_error = NULL, ai_checked_at = now() WHERE id = $2`,
|
||||||
|
[changed ? checked : original, row.id]
|
||||||
|
);
|
||||||
|
attempts.delete(row.id);
|
||||||
|
stats.checks++;
|
||||||
|
if (changed) stats.corrected++; else stats.unchanged++;
|
||||||
|
stats.last_at = new Date().toISOString();
|
||||||
|
stats.last_error = null;
|
||||||
|
if (logAudit) await logAudit(null, 'entry.ai.auto-check', { id: row.id, changed });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||||
|
const tries = (attempts.get(row.id) || 0) + 1;
|
||||||
|
stats.last_at = new Date().toISOString();
|
||||||
|
stats.last_error = message;
|
||||||
|
if (tries < MAX_ATTEMPTS) {
|
||||||
|
attempts.set(row.id, tries);
|
||||||
|
await pool.query(`UPDATE entries SET ai_status = 'pending', ai_error = $1 WHERE id = $2`, [message, row.id]);
|
||||||
|
if (logAudit) await logAudit(null, 'entry.ai.retry', { id: row.id, attempt: tries, error: message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
attempts.delete(row.id);
|
||||||
|
stats.errors++;
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE entries SET ai_status = 'error', ai_error = $1, ai_checked_at = now() WHERE id = $2`,
|
||||||
|
[message, row.id]
|
||||||
|
);
|
||||||
|
if (logAudit) await logAudit(null, 'entry.ai.error', { id: row.id, error: message });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loop() {
|
||||||
|
while (!stopped) {
|
||||||
|
let row = null;
|
||||||
|
try {
|
||||||
|
if (!(await isEnabled())) {
|
||||||
|
await sleep(IDLE_MAX_MS);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
row = await claimNext();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AI auto-check claim error:', e);
|
||||||
|
await sleep(IDLE_MAX_MS);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!row) {
|
||||||
|
await sleep(idleMs);
|
||||||
|
idleMs = Math.min(idleMs * 2, IDLE_MAX_MS);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
idleMs = IDLE_MIN_MS;
|
||||||
|
processing = true;
|
||||||
|
currentId = row.id;
|
||||||
|
let ok = true;
|
||||||
|
try {
|
||||||
|
ok = await processOne(row);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AI auto-check process error:', e);
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
currentId = null;
|
||||||
|
}
|
||||||
|
if (!ok) await sleep(IDLE_MAX_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetStale() {
|
||||||
|
await pool.query(`UPDATE entries SET ai_status = 'pending' WHERE ai_status = 'processing'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
startedAt = Date.now();
|
||||||
|
resetStale()
|
||||||
|
.catch((e) => console.error('AI auto-check reset error:', e))
|
||||||
|
.finally(() => { loop().catch((e) => console.error('AI auto-check loop error:', e)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStats() {
|
||||||
|
return { ...stats, processing };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInfo() {
|
||||||
|
return {
|
||||||
|
started_at: startedAt ? new Date(startedAt).toISOString() : null,
|
||||||
|
uptime_ms: startedAt ? Date.now() - startedAt : 0,
|
||||||
|
processing,
|
||||||
|
current_id: currentId,
|
||||||
|
config: CONFIG,
|
||||||
|
...stats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, notify, getStats, getInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker };
|
||||||
Reference in New Issue
Block a user