Update project files
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=сложный-пароль-админки
|
ADMIN_PASSWORD=сложный-пароль-админки
|
||||||
DB_PASSWORD=случайная-длинная-строка
|
DB_PASSWORD=случайная-длинная-строка
|
||||||
@@ -70,6 +70,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()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -102,4 +102,6 @@ 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);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://app:${DB_PASSWORD}@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}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -58,5 +59,36 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- 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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
Binary file not shown.
+12
-3
@@ -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{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}
|
||||||
@@ -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{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)}
|
||||||
@@ -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-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;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{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}
|
||||||
|
|||||||
+50
-22
@@ -1,44 +1,71 @@
|
|||||||
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; }
|
||||||
|
|
||||||
// --- 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: 'branches', label: 'Филиалы' },
|
|
||||||
{ page: 'audit', 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: 'Дашборд' },
|
||||||
|
{ 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 = `
|
el.innerHTML = `
|
||||||
<div class="logo">Админ-панель</div>
|
<div class="logo">Админ-панель</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" class="${n.page === active ? 'active' : ''}">${n.label}</a>`).join('')}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
<a href="/">На главную</a>
|
<a href="/">На главную</a>
|
||||||
@@ -49,6 +76,7 @@ function buildSidebar(active) {
|
|||||||
// --- Common UI ---
|
// --- Common UI ---
|
||||||
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);
|
||||||
@@ -61,7 +89,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');
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
<div><label>Тип</label>
|
<div><label>Тип</label>
|
||||||
<div class="view-toggle" id="tabToggle">
|
<div class="view-toggle" id="tabToggle">
|
||||||
<button data-mode="attached" type="button">Прикреплённые</button>
|
<button data-mode="attached" type="button">Прикреплённые</button>
|
||||||
<button data-mode="detached" type="button">Откреплённые</button>
|
<button data-mode="detached" type="button" id="detachedTab">Откреплённые</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ document.getElementById('branchesGrid').addEventListener('click', e => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await requireAdminPage()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
loadBranches();
|
loadBranches();
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -6,13 +6,25 @@ function plural(n, one, few, many) {
|
|||||||
return 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 = `
|
||||||
|
<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 = `
|
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.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.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.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">${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>
|
<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}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +104,7 @@ async function loadDashboard() {
|
|||||||
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
|
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
|
||||||
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
|
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
renderStats(d.stats);
|
renderStats(d.stats, d.disk);
|
||||||
renderActivity(d.activity);
|
renderActivity(d.activity);
|
||||||
renderRecent(d.recent_entries);
|
renderRecent(d.recent_entries);
|
||||||
renderActive(d.active_groups);
|
renderActive(d.active_groups);
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ document.getElementById('grid').addEventListener('click', e => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await checkAuth()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
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 => {
|
document.querySelectorAll('#tabToggle button').forEach(b => {
|
||||||
b.classList.toggle('active', b.dataset.mode === mode);
|
b.classList.toggle('active', b.dataset.mode === mode);
|
||||||
});
|
});
|
||||||
|
|||||||
+75
-3
@@ -23,7 +23,7 @@ async function loadGroups() {
|
|||||||
list.innerHTML = `<div class="group-grid">` + groups.map(g => {
|
list.innerHTML = `<div class="group-grid">` + groups.map(g => {
|
||||||
const schedSet = g.day_of_week != null && g.time_start && g.time_end;
|
const schedSet = g.day_of_week != null && g.time_start && g.time_end;
|
||||||
return `
|
return `
|
||||||
<div class="group-card">
|
<div class="group-card" data-group-id="${g.id}">
|
||||||
${g.cover_path
|
${g.cover_path
|
||||||
? `<img class="group-cover" src="${API}${g.cover_path}" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)" alt="">`
|
? `<img class="group-cover" src="${API}${g.cover_path}" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)" alt="">`
|
||||||
: `<div class="group-cover empty-cover" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">📷</div>`}
|
: `<div class="group-cover empty-cover" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">📷</div>`}
|
||||||
@@ -149,7 +149,8 @@ function photoHTML(p) {
|
|||||||
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
|
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
|
||||||
const isCover = currentCover && p.photo_path === currentCover;
|
const isCover = currentCover && p.photo_path === currentCover;
|
||||||
return `
|
return `
|
||||||
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}">
|
<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="${API}${p.photo_path}" data-img="${API}${p.photo_path}" alt="">
|
<img src="${API}${p.photo_path}" data-img="${API}${p.photo_path}" alt="">
|
||||||
<div class="p-cap">${esc(p.caption || '')}</div>
|
<div class="p-cap">${esc(p.caption || '')}</div>
|
||||||
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
|
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
|
||||||
@@ -236,6 +237,8 @@ async function deletePhoto(id) {
|
|||||||
if (!confirm('Удалить это фото безвозвратно?')) return;
|
if (!confirm('Удалить это фото безвозвратно?')) return;
|
||||||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${id}`, { method: 'DELETE', headers: hdr() });
|
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; }
|
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('Фото удалено');
|
showToast('Фото удалено');
|
||||||
loadGroupPhotos();
|
loadGroupPhotos();
|
||||||
loadGroups();
|
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 => {
|
document.getElementById('photoGrid').addEventListener('click', async e => {
|
||||||
const coverBtn = e.target.closest('.p-cover');
|
const coverBtn = e.target.closest('.p-cover');
|
||||||
if (coverBtn) {
|
if (coverBtn) {
|
||||||
@@ -320,9 +390,11 @@ document.getElementById('photoGrid').addEventListener('click', async e => {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const g = await res.json();
|
const g = await res.json();
|
||||||
currentCover = g.cover_path || null;
|
currentCover = g.cover_path || null;
|
||||||
|
const grp = allGroups.find(x => String(x.id) === String(currentGroupId));
|
||||||
|
if (grp) grp.cover_path = currentCover;
|
||||||
showToast('Обложка обновлена');
|
showToast('Обложка обновлена');
|
||||||
|
updateGroupCoverUI(currentCover);
|
||||||
loadGroupPhotos();
|
loadGroupPhotos();
|
||||||
loadGroups();
|
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
alert(err.error || 'Ошибка');
|
alert(err.error || 'Ошибка');
|
||||||
|
|||||||
+18
-7
@@ -1,20 +1,31 @@
|
|||||||
const API = location.origin;
|
const API = location.origin;
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
const pwd = document.getElementById('pwdInput').value;
|
const username = document.getElementById('userInput').value.trim();
|
||||||
if (!pwd) return;
|
const password = document.getElementById('pwdInput').value;
|
||||||
|
if (!username || !password) return;
|
||||||
|
const err = document.getElementById('loginErr');
|
||||||
|
err.style.display = 'none';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/stats`, { headers: { 'X-Admin-Token': pwd } });
|
const res = await fetch(`${API}/api/auth/login`, {
|
||||||
if (res.ok) {
|
method: 'POST',
|
||||||
sessionStorage.setItem('adminToken', pwd);
|
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';
|
location.href = 'dashboard.html';
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('loginErr').style.display = 'block';
|
err.textContent = data.error || 'Неверный логин или пароль';
|
||||||
|
err.style.display = 'block';
|
||||||
}
|
}
|
||||||
} catch {
|
} 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('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);
|
document.querySelector('.login button').addEventListener('click', doLogin);
|
||||||
@@ -102,9 +102,9 @@ function renderSystemInfo(si) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="sys-card">
|
<div class="sys-card">
|
||||||
<div class="sys-title">Фото</div>
|
<div class="sys-title">Фото</div>
|
||||||
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0}</span></div>
|
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0} · ${photos.group_photos?.size || '0 B'}</span></div>
|
||||||
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0}</span></div>
|
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0} · ${photos.entry_photos?.size || '0 B'}</span></div>
|
||||||
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b></span></div>
|
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b> · <b>${photos.total_size || '0 B'}</b></span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sys-card">
|
<div class="sys-card">
|
||||||
<div class="sys-title">Файлы</div>
|
<div class="sys-title">Файлы</div>
|
||||||
@@ -213,7 +213,7 @@ document.getElementById('restoreBtn').addEventListener('click', () => document.g
|
|||||||
document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await requireAdminPage()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
bindDirty();
|
bindDirty();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
|||||||
+52
-38
@@ -17,47 +17,61 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="linkModal">
|
<div class="modal-overlay" id="linkModal">
|
||||||
<div class="edit-modal">
|
<div class="edit-modal" style="max-width:480px">
|
||||||
<h3 id="linkModalTitle">Новая ссылка</h3>
|
<h3 id="linkModalTitle">Новая ссылка</h3>
|
||||||
<div>
|
<div class="settings-stack" style="gap:12px">
|
||||||
<label>Название</label>
|
<div class="settings-row">
|
||||||
<input type="text" id="linkName" placeholder="Например: СБ1400 05.09">
|
<div class="settings-field" style="flex:1">
|
||||||
</div>
|
<label>Название <span style="color:#ef4444">*</span></label>
|
||||||
<div>
|
<input type="text" id="linkName" class="settings-input" required placeholder="Например: СБ1400 05.09">
|
||||||
<label>Группа</label>
|
</div>
|
||||||
<select id="linkGroup"><option value="">— без группы —</option></select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Ученик (необязательно)</label>
|
|
||||||
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:12px">
|
|
||||||
<div style="flex:1">
|
|
||||||
<label>С</label>
|
|
||||||
<input type="date" id="linkDateFrom">
|
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1">
|
<div class="settings-row">
|
||||||
<label>По</label>
|
<div class="settings-field" style="flex:1">
|
||||||
<input type="date" id="linkDateTo">
|
<label>Группа</label>
|
||||||
|
<select id="linkGroup" class="settings-input"><option value="">— без группы —</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Ученик (необязательно)</label>
|
||||||
|
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>С</label>
|
||||||
|
<input type="date" id="linkDateFrom" class="settings-input">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>По</label>
|
||||||
|
<input type="date" id="linkDateTo" class="settings-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Истекает через (дни)</label>
|
||||||
|
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="linkAnonymizeNames">
|
||||||
|
Показывать только инициалы учеников (анонимизировать)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Пароль для доступа (необязательно)</label>
|
||||||
|
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" 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>
|
|
||||||
<div>
|
|
||||||
<label>Истекает через (дни)</label>
|
|
||||||
<input type="number" id="linkExpiresDays" min="1" max="365" value="7" placeholder="7">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="linkAnonymizeNames">
|
|
||||||
Показывать только инициалы учеников (анонимизировать)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Пароль для доступа (необязательно)</label>
|
|
||||||
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="cancel" type="button" id="linkCancelBtn">Отмена</button>
|
|
||||||
<button class="save" type="button" id="linkSaveBtn">Сохранить</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-2
@@ -23,9 +23,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
<body>
|
<body>
|
||||||
<div class="login">
|
<div class="login">
|
||||||
<h1>Админ-панель</h1>
|
<h1>Админ-панель</h1>
|
||||||
<input type="password" id="pwdInput" placeholder="Пароль" autofocus>
|
<input type="text" id="userInput" placeholder="Логин" autocomplete="username" autofocus>
|
||||||
|
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
|
||||||
<button type="button" id="loginBtn">Войти</button>
|
<button type="button" id="loginBtn">Войти</button>
|
||||||
<div class="err" id="loginErr">Неверный пароль</div>
|
<div class="err" id="loginErr">Неверный логин или пароль</div>
|
||||||
<div class="back"><a href="/">На главную</a></div>
|
<div class="back"><a href="/">На главную</a></div>
|
||||||
</div>
|
</div>
|
||||||
<script src="js/login.js"></script>
|
<script src="js/login.js"></script>
|
||||||
|
|||||||
+11
-11
@@ -15,7 +15,17 @@
|
|||||||
<p class="page-sub">Параметры формы и данные системы</p>
|
<p class="page-sub">Параметры формы и данные системы</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-stack">
|
<div class="settings-stack settings-stack-grid">
|
||||||
|
<div class="settings-card wide">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic">📊</div>
|
||||||
|
<h3>Системная информация</h3>
|
||||||
|
</div>
|
||||||
|
<div id="systemInfo" class="sys-grid">
|
||||||
|
<div class="sys-card loading">Загрузка…</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">🛡️</div>
|
||||||
@@ -65,16 +75,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="restore-status" id="restoreStatus"></div>
|
<div class="restore-status" id="restoreStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<div class="ic">📊</div>
|
|
||||||
<h3>Системная информация</h3>
|
|
||||||
</div>
|
|
||||||
<div id="systemInfo" class="sys-grid">
|
|
||||||
<div class="sys-card loading">Загрузка…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!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="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>
|
||||||
@@ -68,6 +68,54 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|||||||
|
|
||||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiskInfo() {
|
||||||
|
const totalDisk = fs.statfsSync ? fs.statfsSync(__dirname) : null;
|
||||||
|
if (totalDisk) {
|
||||||
|
const blockSize = totalDisk.bsize || 4096;
|
||||||
|
const total = totalDisk.blocks * blockSize;
|
||||||
|
const free = totalDisk.bfree * blockSize;
|
||||||
|
const used = total - free;
|
||||||
|
return {
|
||||||
|
total: formatBytes(total),
|
||||||
|
total_bytes: total,
|
||||||
|
used: formatBytes(used),
|
||||||
|
used_bytes: used,
|
||||||
|
free: formatBytes(free),
|
||||||
|
free_bytes: free,
|
||||||
|
used_pct: Math.round((used / total) * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const out = execSync('df -B1 .', { encoding: 'utf8' });
|
||||||
|
const lines = out.trim().split('\n');
|
||||||
|
if (lines.length > 1) {
|
||||||
|
const parts = lines[1].split(/\s+/);
|
||||||
|
const total = parseInt(parts[1], 10);
|
||||||
|
const used = parseInt(parts[2], 10);
|
||||||
|
const free = parseInt(parts[3], 10);
|
||||||
|
return {
|
||||||
|
total: formatBytes(total),
|
||||||
|
total_bytes: total,
|
||||||
|
used: formatBytes(used),
|
||||||
|
used_bytes: used,
|
||||||
|
free: formatBytes(free),
|
||||||
|
free_bytes: free,
|
||||||
|
used_pct: Math.round((used / total) * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function safeUser(u) {
|
function safeUser(u) {
|
||||||
return {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
@@ -1189,7 +1237,7 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
|||||||
if (l.group_id) {
|
if (l.group_id) {
|
||||||
const pRes = await pool.query(
|
const pRes = await pool.query(
|
||||||
`SELECT id, photo_path, caption, taken_at, created_at FROM group_photos
|
`SELECT id, photo_path, caption, taken_at, created_at FROM group_photos
|
||||||
WHERE group_id = $1 ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 12`,
|
WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC LIMIT 12`,
|
||||||
[l.group_id]
|
[l.group_id]
|
||||||
);
|
);
|
||||||
photos = pRes.rows.map(r => ({ id: r.id, photo_path: r.photo_path, caption: r.caption, taken_at: r.taken_at, created_at: r.created_at }));
|
photos = pRes.rows.map(r => ({ id: r.id, photo_path: r.photo_path, caption: r.caption, taken_at: r.taken_at, created_at: r.created_at }));
|
||||||
@@ -1253,13 +1301,14 @@ app.get('/s/:token', (req, res) => {
|
|||||||
app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => {
|
app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => {
|
||||||
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
|
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT g.*, gp.photo_path AS cover_path, b.name AS branch_name
|
`SELECT g.*,
|
||||||
|
COALESCE(g.cover_path,
|
||||||
|
(SELECT photo_path FROM group_photos
|
||||||
|
WHERE group_id = g.id
|
||||||
|
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC
|
||||||
|
LIMIT 1)) AS cover_path,
|
||||||
|
b.name AS branch_name
|
||||||
FROM groups g
|
FROM groups g
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT photo_path FROM group_photos
|
|
||||||
WHERE group_id = g.id
|
|
||||||
ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 1
|
|
||||||
) gp ON true
|
|
||||||
LEFT JOIN branches b ON b.id = g.branch_id
|
LEFT JOIN branches b ON b.id = g.branch_id
|
||||||
WHERE 1=1${bw.where}
|
WHERE 1=1${bw.where}
|
||||||
ORDER BY g.id`,
|
ORDER BY g.id`,
|
||||||
@@ -1428,7 +1477,7 @@ app.get('/api/groups/:id/photos', requireAuth, async (req, res) => {
|
|||||||
);
|
);
|
||||||
const total = crows[0].n;
|
const total = crows[0].n;
|
||||||
let q = `SELECT * FROM group_photos WHERE group_id = $1
|
let q = `SELECT * FROM group_photos WHERE group_id = $1
|
||||||
ORDER BY taken_at DESC NULLS LAST, created_at DESC`;
|
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC`;
|
||||||
const qparams = [req.params.id];
|
const qparams = [req.params.id];
|
||||||
const lim = parseInt(limit, 10);
|
const lim = parseInt(limit, 10);
|
||||||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||||||
@@ -1464,8 +1513,10 @@ app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
await convertPhoto(req.file);
|
await convertPhoto(req.file);
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO group_photos (group_id, photo_path, caption, taken_at)
|
`INSERT INTO group_photos (group_id, photo_path, caption, taken_at, sort_order)
|
||||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
VALUES ($1, $2, $3, $4,
|
||||||
|
COALESCE((SELECT MIN(sort_order) - 1 FROM group_photos WHERE group_id = $1), 0))
|
||||||
|
RETURNING *`,
|
||||||
[req.params.id, `/uploads/${req.file.filename}`, caption?.trim() || null, taken_at || null]
|
[req.params.id, `/uploads/${req.file.filename}`, caption?.trim() || null, taken_at || null]
|
||||||
);
|
);
|
||||||
await logAudit(req, 'group.photo.create', { group_id: req.params.id, photo_path: rows[0].photo_path });
|
await logAudit(req, 'group.photo.create', { group_id: req.params.id, photo_path: rows[0].photo_path });
|
||||||
@@ -1477,6 +1528,49 @@ app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/groups/:id/photos/reorder', requireAuth, async (req, res) => {
|
||||||
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||||
|
}
|
||||||
|
const { order } = req.body;
|
||||||
|
if (!Array.isArray(order) || order.some(id => !Number.isInteger(Number(id)))) {
|
||||||
|
return res.status(400).json({ error: 'Некорректный порядок фото' });
|
||||||
|
}
|
||||||
|
const ids = order.map(id => Number(id));
|
||||||
|
if (new Set(ids).size !== ids.length) {
|
||||||
|
return res.status(400).json({ error: 'Порядок фото содержит дубликаты' });
|
||||||
|
}
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const { rows: owned } = await client.query(
|
||||||
|
'SELECT id FROM group_photos WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
const ownedIds = owned.map(r => r.id);
|
||||||
|
if (ids.some(id => !ownedIds.includes(id))) {
|
||||||
|
throw { http: 404, message: 'Фото не найдено' };
|
||||||
|
}
|
||||||
|
const rest = ownedIds.filter(id => !ids.includes(id));
|
||||||
|
const allIds = [...ids, ...rest];
|
||||||
|
for (let i = 0; i < allIds.length; i++) {
|
||||||
|
await client.query(
|
||||||
|
'UPDATE group_photos SET sort_order = $1 WHERE id = $2',
|
||||||
|
[i + 1, allIds[i]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
await logAudit(req, 'group.photo.reorder', { group_id: req.params.id, order: ids });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
if (e.http) return res.status(e.http).json({ error: e.message });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.put('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
|
app.put('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
|
||||||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||||||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||||
@@ -1504,6 +1598,7 @@ app.delete('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
|
|||||||
);
|
);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||||
safeUnlink(rows[0].photo_path);
|
safeUnlink(rows[0].photo_path);
|
||||||
|
await pool.query('UPDATE groups SET cover_path = NULL WHERE id = $1 AND cover_path = $2', [req.params.id, rows[0].photo_path]);
|
||||||
await pool.query('DELETE FROM group_photos WHERE id = $1', [req.params.photoId]);
|
await pool.query('DELETE FROM group_photos WHERE id = $1', [req.params.photoId]);
|
||||||
await logAudit(req, 'group.photo.delete', { group_id: req.params.id, photo_id: req.params.photoId });
|
await logAudit(req, 'group.photo.delete', { group_id: req.params.id, photo_id: req.params.photoId });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
@@ -1961,6 +2056,24 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
|
|||||||
WHERE photo_path IS NOT NULL AND deleted_at IS NULL
|
WHERE photo_path IS NOT NULL AND deleted_at IS NULL
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
function sumPhotoSizes(paths) {
|
||||||
|
let bytes = 0;
|
||||||
|
for (const p of paths) {
|
||||||
|
if (!p) continue;
|
||||||
|
const fp = path.join(__dirname, p);
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(fp);
|
||||||
|
if (st.isFile()) bytes += st.size;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupPhotoSizes = await pool.query('SELECT photo_path FROM group_photos');
|
||||||
|
const entryPhotoSizes = await pool.query('SELECT photo_path FROM entries WHERE photo_path IS NOT NULL AND deleted_at IS NULL');
|
||||||
|
const groupPhotoBytes = sumPhotoSizes(groupPhotoSizes.rows.map(r => r.photo_path));
|
||||||
|
const entryPhotoBytes = sumPhotoSizes(entryPhotoSizes.rows.map(r => r.photo_path));
|
||||||
|
|
||||||
const uploadsDir = path.join(__dirname, 'uploads');
|
const uploadsDir = path.join(__dirname, 'uploads');
|
||||||
let uploadsSize = 0;
|
let uploadsSize = 0;
|
||||||
let uploadsCount = 0;
|
let uploadsCount = 0;
|
||||||
@@ -1974,52 +2087,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalDisk = fs.statfsSync ? fs.statfsSync(__dirname) : null;
|
const diskInfo = getDiskInfo();
|
||||||
let diskInfo = null;
|
|
||||||
if (totalDisk) {
|
|
||||||
const blockSize = totalDisk.bsize || 4096;
|
|
||||||
const total = totalDisk.blocks * blockSize;
|
|
||||||
const free = totalDisk.bfree * blockSize;
|
|
||||||
const used = total - free;
|
|
||||||
diskInfo = {
|
|
||||||
total: formatBytes(total),
|
|
||||||
total_bytes: total,
|
|
||||||
used: formatBytes(used),
|
|
||||||
used_bytes: used,
|
|
||||||
free: formatBytes(free),
|
|
||||||
free_bytes: free,
|
|
||||||
used_pct: Math.round((used / total) * 100),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
const out = execSync('df -B1 .', { encoding: 'utf8' });
|
|
||||||
const lines = out.trim().split('\n');
|
|
||||||
if (lines.length > 1) {
|
|
||||||
const parts = lines[1].split(/\s+/);
|
|
||||||
const total = parseInt(parts[1], 10);
|
|
||||||
const used = parseInt(parts[2], 10);
|
|
||||||
const free = parseInt(parts[3], 10);
|
|
||||||
diskInfo = {
|
|
||||||
total: formatBytes(total),
|
|
||||||
total_bytes: total,
|
|
||||||
used: formatBytes(used),
|
|
||||||
used_bytes: used,
|
|
||||||
free: formatBytes(free),
|
|
||||||
free_bytes: free,
|
|
||||||
used_pct: Math.round((used / total) * 100),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
database: {
|
database: {
|
||||||
@@ -2032,9 +2100,11 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
photos: {
|
photos: {
|
||||||
group_photos: { count: photoStats.rows[0].count || 0 },
|
group_photos: { count: photoStats.rows[0].count || 0, size: formatBytes(groupPhotoBytes), size_bytes: groupPhotoBytes },
|
||||||
entry_photos: { count: entryPhotoStats.rows[0].count || 0 },
|
entry_photos: { count: entryPhotoStats.rows[0].count || 0, size: formatBytes(entryPhotoBytes), size_bytes: entryPhotoBytes },
|
||||||
total_count: (photoStats.rows[0].count || 0) + (entryPhotoStats.rows[0].count || 0),
|
total_count: (photoStats.rows[0].count || 0) + (entryPhotoStats.rows[0].count || 0),
|
||||||
|
total_size: formatBytes(groupPhotoBytes + entryPhotoBytes),
|
||||||
|
total_size_bytes: groupPhotoBytes + entryPhotoBytes,
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
project_files: { count: fileStats.rows[0].count || 0 },
|
project_files: { count: fileStats.rows[0].count || 0 },
|
||||||
@@ -2104,12 +2174,12 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
|||||||
pool.query(
|
pool.query(
|
||||||
`SELECT gp.id, gp.photo_path, gp.caption, gp.taken_at, g.name AS group_name
|
`SELECT gp.id, gp.photo_path, gp.caption, gp.taken_at, g.name AS group_name
|
||||||
FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
|
FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
|
||||||
ORDER BY gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8`,
|
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8`,
|
||||||
whereGroup ? whereGroupParams : []
|
whereGroup ? whereGroupParams : []
|
||||||
),
|
),
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT gp.photo_path, gp.taken_at FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
|
`SELECT gp.photo_path, gp.taken_at FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
|
||||||
ORDER BY gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`,
|
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`,
|
||||||
whereGroup ? whereGroupParams : []
|
whereGroup ? whereGroupParams : []
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -2128,6 +2198,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
|||||||
top_students: top.rows,
|
top_students: top.rows,
|
||||||
photos: photos.rows,
|
photos: photos.rows,
|
||||||
latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null,
|
latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null,
|
||||||
|
disk: getDiskInfo(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user