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 = ` +
+
${esc(userName)}
+
${roleLabel}
+
На главную @@ -49,6 +76,7 @@ function buildSidebar(active) { // --- Common UI --- function showToast(msg) { const t = document.getElementById('toast'); + if (!t) return; t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 3000); @@ -61,7 +89,7 @@ function showImg(src) { } async function fillAutocomplete() { - const res = await fetch(`${API}/api/students`); + const res = await fetch(`${API}/api/students`, { headers: hdr() }); const students = await res.json(); const names = students.map(s => s.name); const dlEdit = document.getElementById('studentEditList'); diff --git a/public/files.html b/public/files.html index 6f6e37f..943019f 100644 --- a/public/files.html +++ b/public/files.html @@ -26,7 +26,7 @@
- +
diff --git a/public/js/branches.js b/public/js/branches.js index 243a61a..96c26af 100644 --- a/public/js/branches.js +++ b/public/js/branches.js @@ -110,7 +110,7 @@ document.getElementById('branchesGrid').addEventListener('click', e => { }); (async () => { - if (await checkAuth()) { + if (await requireAdminPage()) { buildSidebar(document.body.dataset.page); loadBranches(); } diff --git a/public/js/dashboard.js b/public/js/dashboard.js index c10241a..243b1ca 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -6,13 +6,25 @@ function plural(n, one, few, many) { return many; } -function renderStats(s) { +function renderStats(s, disk) { + let diskHtml = ''; + if (disk) { + const pct = disk.used_pct; + const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a'; + diskHtml = ` +
+
${disk.used} / ${disk.total}
+
Диск: свободно ${disk.free} (${100 - pct}%)
+
+
`; + } document.getElementById('stats').innerHTML = `
${s.entries}
Всего записей
${s.students}
Учеников
${s.groups}
Групп
${s.today}
Сегодня
${s.trash}
В корзине →
+ ${diskHtml} `; } @@ -92,7 +104,7 @@ async function loadDashboard() { const res = await fetch(`${API}/api/dashboard`, { headers: hdr() }); if (!res.ok) { document.getElementById('stats').innerHTML = '
Не удалось загрузить данные
'; return; } const d = await res.json(); - renderStats(d.stats); + renderStats(d.stats, d.disk); renderActivity(d.activity); renderRecent(d.recent_entries); renderActive(d.active_groups); diff --git a/public/js/files.js b/public/js/files.js index e9a7884..81b8d50 100644 --- a/public/js/files.js +++ b/public/js/files.js @@ -159,6 +159,9 @@ document.getElementById('grid').addEventListener('click', e => { (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); }); diff --git a/public/js/groups.js b/public/js/groups.js index 6e2e999..49cc281 100644 --- a/public/js/groups.js +++ b/public/js/groups.js @@ -23,7 +23,7 @@ async function loadGroups() { list.innerHTML = `
` + groups.map(g => { const schedSet = g.day_of_week != null && g.time_start && g.time_end; return ` -
+
${g.cover_path ? `` : `
📷
`} @@ -149,7 +149,8 @@ function photoHTML(p) { const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`; const isCover = currentCover && p.photo_path === currentCover; return ` -
+
+
⠿
${esc(p.caption || '')}
${taken ? taken + ' · ' : ''}${uploaded}
@@ -236,6 +237,8 @@ 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(); @@ -306,6 +309,73 @@ document.getElementById('groupList').addEventListener('click', e => { } }); +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 name = g ? g.name : currentGroupName; + 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.dataset.groupId = String(currentGroupId); + el.dataset.groupName = name; + el.title = 'Фото группы (хронология)'; + 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) { @@ -320,9 +390,11 @@ document.getElementById('photoGrid').addEventListener('click', async e => { 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(); - loadGroups(); } else { const err = await res.json(); alert(err.error || 'Ошибка'); diff --git a/public/js/login.js b/public/js/login.js index 05ccb02..43877d5 100644 --- a/public/js/login.js +++ b/public/js/login.js @@ -1,20 +1,31 @@ const API = location.origin; async function doLogin() { - const pwd = document.getElementById('pwdInput').value; - if (!pwd) return; + 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/stats`, { headers: { 'X-Admin-Token': pwd } }); - if (res.ok) { - sessionStorage.setItem('adminToken', pwd); + const res = await fetch(`${API}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.token) { + sessionStorage.setItem('authToken', data.token); location.href = 'dashboard.html'; } else { - document.getElementById('loginErr').style.display = 'block'; + err.textContent = data.error || 'Неверный логин или пароль'; + err.style.display = 'block'; } } catch { - document.getElementById('loginErr').style.display = 'block'; + 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); \ No newline at end of file diff --git a/public/js/settings.js b/public/js/settings.js index 99f43c7..d258236 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -102,9 +102,9 @@ function renderSystemInfo(si) {
Фото
-
Фото групп${photos.group_photos?.count || 0}
-
Фото в записях${photos.entry_photos?.count || 0}
-
Всего${photos.total_count || 0}
+
Фото групп${photos.group_photos?.count || 0} · ${photos.group_photos?.size || '0 B'}
+
Фото в записях${photos.entry_photos?.count || 0} · ${photos.entry_photos?.size || '0 B'}
+
Всего${photos.total_count || 0} · ${photos.total_size || '0 B'}
Файлы
@@ -213,7 +213,7 @@ document.getElementById('restoreBtn').addEventListener('click', () => document.g document.getElementById('saveBtn').addEventListener('click', saveSettings); (async () => { - if (await checkAuth()) { + if (await requireAdminPage()) { buildSidebar(document.body.dataset.page); bindDirty(); loadSettings(); diff --git a/public/links.html b/public/links.html index 5e07c37..2d15a08 100644 --- a/public/links.html +++ b/public/links.html @@ -17,47 +17,61 @@