Пользователи
+Управление доступом и ролями
+| ID | +Логин | +Имя | +Роль | +Филиалы | +Статус | ++ |
|---|
diff --git a/.env.example b/.env.example index 97099ce..448397e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ +ADMIN_USERNAME=admin ADMIN_PASSWORD=сложный-пароль-админки DB_PASSWORD=случайная-длинная-строка \ No newline at end of file diff --git a/db/init.sql b/db/init.sql index 7a2f04f..016552b 100644 --- a/db/init.sql +++ b/db/init.sql @@ -70,6 +70,7 @@ CREATE TABLE IF NOT EXISTS group_photos ( photo_path VARCHAR(255) NOT NULL, caption TEXT, taken_at DATE, + sort_order INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now() ); diff --git a/db/migration.sql b/db/migration.sql index 39dfa31..5a65df1 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -102,4 +102,6 @@ CREATE TABLE IF NOT EXISTS group_photos ( 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); diff --git a/docker-compose.yml b/docker-compose.yml index d773501..263ac0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: environment: DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo ADMIN_PASSWORD: ${ADMIN_PASSWORD} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} NODE_ENV: production TZ: Europe/Moscow depends_on: @@ -58,5 +59,36 @@ services: depends_on: - app + + text-corrector: + image: ghcr.io/ggml-org/llama.cpp:server + container_name: text-corrector + restart: unless-stopped + + volumes: + - ./models:/models + + ports: + - "8080:8080" + + command: + - -m + - /models/qwen2.5-1.5b-instruct-q4_k_m.gguf + - --host + - 0.0.0.0 + - --port + - "8080" + - -c + - "4096" + - -n + - "256" + - -t + - "8" + - --temp + - "0.1" + + + + volumes: - pgdata: \ No newline at end of file + pgdata: diff --git a/models/qwen2.5-1.5b-instruct-q4_k_m.gguf b/models/qwen2.5-1.5b-instruct-q4_k_m.gguf new file mode 100644 index 0000000..20e20f9 Binary files /dev/null and b/models/qwen2.5-1.5b-instruct-q4_k_m.gguf differ diff --git a/public/admin.css b/public/admin.css index c7d9cf9..908407e 100644 --- a/public/admin.css +++ b/public/admin.css @@ -24,6 +24,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px} .stat .num{font-size:2rem;font-weight:700;color:var(--accent)} .stat .label{color:var(--muted);font-size:.8rem;margin-top:4px} +.stat .stat-sub{font-size:1rem;color:var(--muted);font-weight:600} +.stat-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:14px} +.stat-bar i{display:block;height:100%;border-radius:3px} .filters{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end} .filters select,.filters input[type=date]{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem} @@ -190,9 +193,12 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .gallery-foot .cancel{background:var(--border);color:var(--text);border:none;border-radius:8px;padding:10px 20px;font-size:.9rem;font-weight:600;cursor:pointer;transition:opacity .15s} .gallery-foot .cancel:hover{opacity:.85} .photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:2px} -.photo-item{background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column} -.photo-item img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:pointer;display:block;transition:opacity .15s} -.photo-item img:hover{opacity:.9} +.photo-item{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;cursor:grab} +.photo-item.dragging{opacity:.5;border-color:#8b5cf6;box-shadow:0 0 0 2px rgba(139,92,246,.3)} +.photo-item.drag-over{border-color:#8b5cf6} +.photo-item img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:grab;display:block;transition:opacity .15s} +.photo-item:hover img{opacity:.9} +.photo-item .drag-hint{position:absolute;top:6px;left:6px;z-index:2;background:rgba(0,0,0,.55);color:#fff;border-radius:6px;padding:2px 7px;font-size:.85rem;line-height:1.2;cursor:grab;user-select:none;pointer-events:none} .photo-item .p-cap{padding:8px 10px;font-size:.78rem;color:var(--text);min-height:1.2em;white-space:pre-wrap;word-break:break-word} .photo-item .p-date{font-size:.72rem;color:var(--muted);padding:0 10px 6px} .photo-item .p-actions{display:flex;justify-content:flex-end;gap:4px;padding:6px 8px;border-top:1px solid var(--border)} @@ -226,6 +232,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .page-head h2{font-size:1.3rem;font-weight:600;margin:0} .page-sub{color:var(--muted);font-size:.85rem} .settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px} +.settings-stack-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;max-width:1200px} +.settings-stack-grid > .settings-card{max-width:none;height:100%} +.settings-stack-grid > .settings-card.wide{grid-column:1/-1} .settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px} .settings-card .card-head .ic{width:38px;height:38px;border-radius:11px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0} .settings-card .card-head h3{font-size:1.05rem;font-weight:700;margin:0} diff --git a/public/admin.js b/public/admin.js index cd5c2b6..003cca1 100644 --- a/public/admin.js +++ b/public/admin.js @@ -1,44 +1,71 @@ const API = location.origin; -let token = sessionStorage.getItem('adminToken'); -function hdr() { return { 'X-Admin-Token': token }; } -function hdrJson() { return { 'Content-Type': 'application/json', 'X-Admin-Token': token }; } +let token = sessionStorage.getItem('authToken'); +let currentUser = null; +function hdr() { return { 'X-Auth-Token': token }; } +function hdrJson() { return { 'Content-Type': 'application/json', 'X-Auth-Token': token }; } function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // --- Auth check --- async function checkAuth() { if (!token) { location.href = 'login.html'; return false; } try { - const res = await fetch(`${API}/api/stats`, { headers: hdr() }); - if (!res.ok) { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; } + const res = await fetch(`${API}/api/auth/me`, { headers: hdr() }); + if (!res.ok) { cleanupSession(); return false; } + currentUser = await res.json(); return true; - } catch { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; } + } catch { cleanupSession(); return false; } } -function doLogout() { - sessionStorage.removeItem('adminToken'); +function cleanupSession() { + sessionStorage.removeItem('authToken'); + token = null; + currentUser = null; location.href = 'login.html'; } +function isAdmin() { return currentUser && currentUser.role === 'admin'; } + +async function requireAdminPage() { + if (!(await checkAuth())) return false; + if (!isAdmin()) { location.href = 'journal.html'; return false; } + return true; +} + +async function doLogout() { + try { await fetch(`${API}/api/auth/logout`, { method: 'POST', headers: hdr() }); } catch {} + cleanupSession(); +} + // --- Sidebar --- -const NAV = [ - { page: 'dashboard', label: 'Дашборд' }, - { page: 'journal', label: 'Журнал' }, - { page: 'trash', label: 'Корзина' }, - { page: 'files', label: 'Файлы' }, - { page: 'links', label: 'Ссылки' }, - { page: 'students', label: 'Ученики' }, - { page: 'groups', label: 'Группы' }, - { page: 'branches', label: 'Филиалы' }, - { page: 'audit', label: 'Аудит' }, - { page: 'settings', label: 'Настройки' } -]; function buildSidebar(active) { const el = document.getElementById('sidebar'); if (!el) return; + const base = [ + { page: 'dashboard', label: 'Дашборд' }, + { page: 'journal', label: 'Журнал' }, + { page: 'trash', label: 'Корзина' }, + { page: 'files', label: 'Файлы' }, + { page: 'links', label: 'Ссылки' }, + { page: 'students', label: 'Ученики' }, + { page: 'groups', label: 'Группы' }, + ]; + const adminNav = [ + { page: 'branches', label: 'Филиалы' }, + { page: 'users', label: 'Пользователи' }, + { page: 'audit', label: 'Аудит' }, + { page: 'settings', label: 'Настройки' }, + ]; + const nav = [...base, ...(isAdmin() ? adminNav : [])]; + const userName = currentUser?.name || currentUser?.username || ''; + const roleLabel = isAdmin() ? 'Администратор' : 'Тутор'; el.innerHTML = `