batch add students: add optional group selection in batch modal
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
uploads
|
||||
.git
|
||||
.env
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
ENV TZ=Europe/Moscow
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
RUN mkdir -p uploads certs
|
||||
RUN apk add --no-cache openssl && \
|
||||
openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
|
||||
-keyout certs/key.pem -out certs/cert.pem \
|
||||
-subj "/CN=whatido.local" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
|
||||
EXPOSE 3000 3443
|
||||
CMD ["node", "server.js"]
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL UNIQUE,
|
||||
group_id INT REFERENCES groups(id),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_name VARCHAR(150) NOT NULL,
|
||||
group_id INT NOT NULL REFERENCES groups(id),
|
||||
description TEXT NOT NULL,
|
||||
photo_path VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token VARCHAR(40) UNIQUE NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
group_id INT REFERENCES groups(id),
|
||||
date_from DATE,
|
||||
date_to DATE,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO groups (name) VALUES
|
||||
('Beginner'),
|
||||
('Intermediate'),
|
||||
('Advanced')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token VARCHAR(40) UNIQUE NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
group_id INT REFERENCES groups(id),
|
||||
date_from DATE,
|
||||
date_to DATE,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
INSERT INTO students (name)
|
||||
SELECT DISTINCT student_name FROM entries WHERE student_name IS NOT NULL AND student_name <> ''
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
END $$;
|
||||
@@ -0,0 +1,39 @@
|
||||
name: whatido
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: whereldo
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: app
|
||||
TZ: Europe/Moscow
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U app -d whereldo"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3443:3443"
|
||||
environment:
|
||||
DATABASE_URL: postgres://app:app@db:5432/whereldo
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
TZ: Europe/Moscow
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
uploads:
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "whereldo",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.13.0",
|
||||
"tar": "^7.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px;--sidebar:240px}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);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 .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)}
|
||||
.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: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 .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 .bottom a:hover{color:var(--text)}
|
||||
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
|
||||
|
||||
.main{flex:1;margin-left:var(--sidebar);padding:32px}
|
||||
.main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px}
|
||||
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:32px}
|
||||
.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}
|
||||
|
||||
.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 label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px}
|
||||
.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
|
||||
.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:hover{border-color:var(--accent)}
|
||||
.pager button.active{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||
.pager button:disabled{opacity:.4;cursor:not-allowed}
|
||||
.pager .info{color:var(--muted);font-size:.8rem}
|
||||
|
||||
.grid{display:flex;flex-direction:column;gap:12px}
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;display:flex;gap:16px;align-items:flex-start}
|
||||
.card img{width:80px;height:80px;border-radius:8px;object-fit:cover;flex-shrink:0;cursor:pointer}
|
||||
.card .info{flex:1;min-width:0}
|
||||
.card .name{font-weight:600;font-size:.95rem}
|
||||
.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 .time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
||||
.card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px;flex-shrink:0}
|
||||
.card .del:hover{color:#ef4444}
|
||||
.card .edit{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px;flex-shrink:0}
|
||||
.card .edit:hover{color:var(--accent)}
|
||||
.empty{text-align:center;color:var(--muted);padding:48px 0}
|
||||
|
||||
.view-toggle{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden}
|
||||
.view-toggle button{background:var(--card);border:none;color:var(--muted);padding:9px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
|
||||
.view-toggle button:hover{color:var(--text)}
|
||||
.view-toggle button.active{background:var(--accent);color:#fff}
|
||||
.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 img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:pointer;display:block;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 *{fill:currentColor}
|
||||
.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 .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 .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:hover{color:var(--accent)}
|
||||
.journal-card .foot .del:hover{color:#ef4444}
|
||||
@media(max-width:1200px){.journal-cards{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:900px){.journal-cards{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:640px){.journal-cards{grid-template-columns:1fr}}
|
||||
|
||||
.add-row{display:flex;gap:8px;margin-bottom:24px}
|
||||
.add-row input{flex:1;background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:12px 16px;font-size:.95rem;outline:none}
|
||||
.add-row input:focus{border-color:var(--accent)}
|
||||
.add-row button{background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:12px 20px;font-weight:600;cursor:pointer}
|
||||
.add-row .btn-ghost{background:var(--card);border:1px solid var(--border);color:var(--accent)}
|
||||
.add-row .btn-ghost:hover{border-color:var(--accent)}
|
||||
.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;transition:opacity .15s}
|
||||
.btn-link:hover{border-color:var(--accent);opacity:.85}
|
||||
.list{display:flex;flex-direction:column;gap:8px}
|
||||
.item{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.item .name{font-weight:500}
|
||||
.item .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.2rem;padding:4px}
|
||||
.item .del:hover{color:#ef4444}
|
||||
.item .name-input{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.95rem;font-weight:500;outline:none;width:100%;max-width:300px}
|
||||
.item .name-input:focus{border-color:var(--accent)}
|
||||
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
|
||||
.item .group-select:focus{border-color:var(--accent)}
|
||||
.attach-list{max-height:280px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;border:1px solid var(--border);border-radius:8px;padding:8px}
|
||||
.attach-list label{display:flex;gap:8px;align-items:center;font-size:.85rem;color:var(--text);margin:0;cursor:pointer}
|
||||
.attach-list .cur{color:var(--muted);font-size:.75rem;margin-left:auto}
|
||||
.student-actions{display:flex;gap:6px;align-items:center;flex-shrink:0}
|
||||
.student-actions button{border-radius:8px;padding:8px 14px;font-size:.8rem;font-weight:600;cursor:pointer;border:none;transition:opacity .15s}
|
||||
.student-actions button:hover{opacity:.85}
|
||||
.student-actions .btn-save{background:var(--accent);color:#fff}
|
||||
.student-actions .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent)}
|
||||
.student-actions .btn-link:hover{border-color:var(--accent)}
|
||||
.student-actions .btn-del{background:none;color:var(--muted);font-size:1.2rem;padding:4px 8px}
|
||||
.student-actions .btn-del:hover{color:#ef4444;opacity:1}
|
||||
|
||||
.group-schedule{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:8px}
|
||||
.group-schedule select,.group-schedule input[type=time]{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem}
|
||||
.group-schedule label{font-size:.75rem;color:var(--muted)}
|
||||
.group-schedule .sched-status{font-size:.75rem;color:var(--muted)}
|
||||
.group-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||
.group-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;display:flex;flex-direction:column;gap:12px}
|
||||
.group-card .group-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.group-card .name-input{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 10px;font-size:.95rem;font-weight:600;outline:none;width:100%}
|
||||
.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:hover{color:#ef4444}
|
||||
.group-card .group-schedule{display:grid;grid-template-columns: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 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 .save-sched{background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
|
||||
.group-card .save-sched:hover{opacity:.85}
|
||||
.group-card .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
|
||||
.group-card .btn-link:hover{border-color:var(--accent)}
|
||||
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:640px}
|
||||
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
|
||||
.settings-row label{font-weight:600;display:block;margin-bottom:4px}
|
||||
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
|
||||
.settings-card .btn-link{background:var(--accent);color:#fff;border:none;padding:10px 20px;font-size:.9rem}
|
||||
.settings-card .btn-link:hover{opacity:.85;border-color:transparent}
|
||||
.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:640px){.group-grid{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.open{display:flex}
|
||||
.modal-overlay img{max-width:90%;max-height:90dvh;border-radius:var(--radius)}
|
||||
.edit-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:420px;width:90%;display:flex;flex-direction:column;gap:16px}
|
||||
.edit-modal h3{font-size:1.1rem;font-weight:600}
|
||||
.edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px}
|
||||
.edit-modal input,.edit-modal select,.edit-modal textarea{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:10px 12px;font-size:.9rem;outline:none;font-family:inherit}
|
||||
.edit-modal input:focus,.edit-modal select:focus,.edit-modal textarea:focus{border-color:var(--accent)}
|
||||
.edit-modal textarea{min-height:100px;resize:vertical}
|
||||
.edit-modal .actions{display:flex;gap:8px}
|
||||
.edit-modal .actions button{flex:1;padding:12px;border:none;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer}
|
||||
.edit-modal .save{background:var(--accent);color:#fff}
|
||||
.edit-modal .cancel{background:var(--border);color:var(--text)}
|
||||
.edit-modal .actions button:hover{opacity:.85}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
|
||||
.toast.show{opacity:1}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Админ-панель</title>
|
||||
<script>location.replace('dashboard.html')</script>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
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 }; }
|
||||
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; }
|
||||
return true;
|
||||
} catch { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; }
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
sessionStorage.removeItem('adminToken');
|
||||
location.href = 'login.html';
|
||||
}
|
||||
|
||||
// --- Sidebar ---
|
||||
const NAV = [
|
||||
{ page: 'dashboard', label: 'Дашборд' },
|
||||
{ page: 'journal', label: 'Журнал' },
|
||||
{ page: 'links', label: 'Ссылки' },
|
||||
{ page: 'students', label: 'Ученики' },
|
||||
{ page: 'groups', label: 'Группы' },
|
||||
{ page: 'settings', label: 'Настройки' }
|
||||
];
|
||||
function buildSidebar(active) {
|
||||
const el = document.getElementById('sidebar');
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div class="logo">Админ-панель</div>
|
||||
<nav>
|
||||
${NAV.map(n => `<a href="${n.page}.html" class="${n.page === active ? 'active' : ''}">${n.label}</a>`).join('')}
|
||||
</nav>
|
||||
<div class="bottom">
|
||||
<a href="/">На главную</a>
|
||||
<a class="logout" onclick="doLogout()">Выйти</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --- Common UI ---
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
function showImg(src) {
|
||||
const img = document.getElementById('imgModalSrc');
|
||||
const m = document.getElementById('imgModal');
|
||||
if (img && m) { img.src = src; m.classList.add('open'); }
|
||||
}
|
||||
|
||||
async function fillAutocomplete() {
|
||||
const res = await fetch(`${API}/api/students`);
|
||||
const students = await res.json();
|
||||
const names = students.map(s => s.name);
|
||||
const dlEdit = document.getElementById('studentEditList');
|
||||
if (dlEdit) {
|
||||
dlEdit.innerHTML = '';
|
||||
names.forEach(n => { const o = document.createElement('option'); o.value = n; dlEdit.appendChild(o); });
|
||||
}
|
||||
const dlAdd = document.getElementById('studentAddList');
|
||||
if (dlAdd) {
|
||||
dlAdd.innerHTML = '';
|
||||
names.forEach(n => { const o = document.createElement('option'); o.value = n; dlAdd.appendChild(o); });
|
||||
}
|
||||
}
|
||||
|
||||
function copyStudentLink(name) {
|
||||
const url = `${location.origin}/?nameInput=${encodeURIComponent(name)}`;
|
||||
const done = () => showToast('Ссылка скопирована: ' + url);
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(url).then(done).catch(() => fallbackCopy(url, done));
|
||||
} else {
|
||||
fallbackCopy(url, done);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text, done) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); done(); } catch { prompt('Скопируйте вручную:', text); }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
const done = () => showToast('Скопировано: ' + text);
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done));
|
||||
} else {
|
||||
fallbackCopy(text, done);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPager(el, page, totalPages, onPage) {
|
||||
if (!el) return;
|
||||
if (totalPages <= 1) { el.innerHTML = ''; return; }
|
||||
let html = `<button ${page === 1 ? 'disabled' : ''} onclick="(${onPage})(${page - 1})">‹</button>`;
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (totalPages > 9 && i > 2 && i < totalPages - 1 && Math.abs(i - page) > 2) {
|
||||
if (i === 3) html += `<button disabled>…</button>`;
|
||||
continue;
|
||||
}
|
||||
html += `<button class="${i === page ? 'active' : ''}" onclick="(${onPage})(${i})">${i}</button>`;
|
||||
}
|
||||
html += `<button ${page === totalPages ? 'disabled' : ''} onclick="(${onPage})(${page + 1})">›</button>`;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!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="dashboard">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<h2>Дашборд</h2>
|
||||
<div class="stats" id="stats"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
async function loadDashboard() {
|
||||
const res = await fetch(`${API}/api/stats`, { headers: hdr() });
|
||||
const s = await res.json();
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadDashboard();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,134 @@
|
||||
<!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="groups">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<h2>Группы</h2>
|
||||
<div class="add-row">
|
||||
<input type="text" id="newGroup" placeholder="Название группы">
|
||||
<button onclick="addGroup()">Добавить</button>
|
||||
</div>
|
||||
<div class="list" id="groupList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<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">
|
||||
<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="btn-link" onclick="exportGroup(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Экспортировать записи группы">CSV</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();
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadGroups();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Что мы узнали на занятии</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
: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;flex-direction:column;align-items:center;padding:24px 16px}
|
||||
h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em}
|
||||
.sub{color:var(--muted);font-size:.85rem;margin-bottom:32px}
|
||||
.form{width:100%;max-width:480px;display:flex;flex-direction:column;gap:20px}
|
||||
.field{display:flex;flex-direction:column;gap:6px}
|
||||
label{font-size:.8rem;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
||||
input,select,textarea{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:14px 16px;font-size:.95rem;outline:none;transition:border .15s}
|
||||
input:focus,select:focus,textarea:focus{border-color:var(--accent)}
|
||||
.fixed-name{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:14px 16px;font-size:.95rem;font-weight:600;text-align:center}
|
||||
textarea{resize:vertical;min-height:160px;font-family:inherit;line-height:1.5}
|
||||
|
||||
.photo-btns{display:flex;gap:8px}
|
||||
.photo-btns button{flex:1;border-radius:var(--radius);padding:12px;font-size:.85rem;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--text);transition:border .15s}
|
||||
.photo-btns button:hover{border-color:var(--accent)}
|
||||
.photo-btns .cam{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||
.photo-btns .cam:hover{opacity:.85}
|
||||
|
||||
.preview{max-width:200px;border-radius:var(--radius);margin-top:8px;display:none}
|
||||
.remove-photo{background:none;border:none;color:#ef4444;font-size:.8rem;cursor:pointer;margin-top:4px;display:none}
|
||||
|
||||
.btn{background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:16px;font-size:1rem;font-weight:600;cursor:pointer;transition:opacity .15s}
|
||||
.btn:hover{opacity:.85}
|
||||
.btn:disabled{opacity:.4;cursor:not-allowed}
|
||||
.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.error{background:#dc2626}
|
||||
nav{position:fixed;top:16px;right:16px;display:flex;gap:8px}
|
||||
nav a{color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 12px;border-radius:8px;transition:color .15s}
|
||||
nav a:hover{color:var(--text)}
|
||||
|
||||
.modal{position:fixed;inset:0;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;z-index:100;display:none}
|
||||
.modal.open{display:flex}
|
||||
.modal-box{background:var(--card);border-radius:16px;padding:16px;max-width:400px;width:90%;display:flex;flex-direction:column;gap:12px}
|
||||
.modal-box video{width:100%;border-radius:var(--radius);background:#000}
|
||||
.modal-actions{display:flex;gap:8px}
|
||||
.modal-actions button{flex:1;padding:12px;border:none;border-radius:var(--radius);font-size:.9rem;font-weight:600;cursor:pointer}
|
||||
.modal-actions .capture{background:var(--accent);color:#fff}
|
||||
.modal-actions .cancel{background:var(--border);color:var(--text)}
|
||||
.modal-actions button:hover{opacity:.85}
|
||||
#camCanvas{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="admin.html">Админка</a>
|
||||
</nav>
|
||||
<h1 id="title">Что мы узнали на занятии</h1>
|
||||
<p class="sub">Отметь, что ты делал(а) на занятии</p>
|
||||
|
||||
<form class="form" id="form">
|
||||
<div class="field">
|
||||
<label>Фото</label>
|
||||
<div class="photo-btns">
|
||||
<button type="button" class="cam" onclick="openCam()">Камера</button>
|
||||
</div>
|
||||
<img class="preview" id="preview">
|
||||
<button type="button" class="remove-photo" id="removePhotoBtn" onclick="removePhoto()">Убрать фото</button>
|
||||
</div>
|
||||
|
||||
<div class="field" id="nameField">
|
||||
<label>Фамилия и имя</label>
|
||||
<input type="text" id="nameInput" list="studentList" placeholder="Например, Иванов Иван" required>
|
||||
</div>
|
||||
<datalist id="studentList"></datalist>
|
||||
|
||||
<div class="field">
|
||||
<label>Группа</label>
|
||||
<select id="groupInput" required>
|
||||
<option value="" disabled selected>Выбери группу</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Что сделал</label>
|
||||
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn" type="submit" id="submitBtn">Отправить</button>
|
||||
</form>
|
||||
|
||||
<div class="modal" id="camModal">
|
||||
<div class="modal-box">
|
||||
<video id="camVideo" autoplay playsinline></video>
|
||||
<canvas id="camCanvas"></canvas>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel" onclick="closeCam()">Отмена</button>
|
||||
<button class="capture" onclick="capture()">Снять</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast">Готово!</div>
|
||||
|
||||
<script>
|
||||
const API = location.origin;
|
||||
const form = document.getElementById('form');
|
||||
const preview = document.getElementById('preview');
|
||||
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 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>';
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
function showPreview(src) {
|
||||
preview.src = src;
|
||||
preview.style.display = 'block';
|
||||
removePhotoBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
function removePhoto() {
|
||||
preview.style.display = 'none';
|
||||
removePhotoBtn.style.display = 'none';
|
||||
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;
|
||||
camCanvas.getContext('2d').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');
|
||||
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();
|
||||
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>
|
||||
</html>
|
||||
@@ -0,0 +1,383 @@
|
||||
<!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="journal">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<h2>Журнал</h2>
|
||||
<div class="filters">
|
||||
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
|
||||
<div><label>Студент</label><select id="studentFilter"><option value="">Все</option></select></div>
|
||||
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
||||
<div><label>С</label><input type="date" id="dateFrom"></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 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>
|
||||
<div class="view-toggle" id="viewToggle">
|
||||
<button data-view="list" onclick="setView('list')">Список</button>
|
||||
<button data-view="cards" onclick="setView('cards')">Карточки</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||
<div class="pager" id="pager"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
||||
<img id="imgModalSrc">
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="editModal" onclick="if(event.target===this)closeEdit()">
|
||||
<div class="edit-modal">
|
||||
<h3>Редактирование записи</h3>
|
||||
<div>
|
||||
<label>Фамилия и имя</label>
|
||||
<input type="text" id="editName" list="studentEditList">
|
||||
<datalist id="studentEditList"></datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label>Группа</label>
|
||||
<select id="editGroup"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Что делала(а)</label>
|
||||
<textarea id="editDesc"></textarea>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" onclick="closeEdit()">Отмена</button>
|
||||
<button class="save" onclick="saveEdit()">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="linkModal" onclick="if(event.target===this)closeLinkCreate()">
|
||||
<div class="edit-modal">
|
||||
<div id="linkCreateView">
|
||||
<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>
|
||||
<label>Название ссылки</label>
|
||||
<input type="text" id="linkNameInput">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" onclick="closeLinkCreate()">Отмена</button>
|
||||
<button class="save" onclick="createLink()">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="linkResultView" style="display:none">
|
||||
<h3>Ссылка готова</h3>
|
||||
<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)">
|
||||
<div class="actions">
|
||||
<a class="cancel" href="links.html" style="text-decoration:none">К списку ссылок</a>
|
||||
<button class="save" onclick="copyLink()">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<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 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>
|
||||
<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>
|
||||
</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 sel = document.getElementById('studentFilter');
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||||
const names = await res.json();
|
||||
names.forEach(n => {
|
||||
const o = document.createElement('option');
|
||||
o.value = n; o.textContent = n;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
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() });
|
||||
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 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();
|
||||
}
|
||||
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>`;
|
||||
const dfl = df.split('-').reverse().join('.');
|
||||
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
|
||||
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 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, 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>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!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="links">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<h2>Ссылки</h2>
|
||||
<p style="color:var(--muted);font-size:.85rem;margin-bottom:20px;max-width:680px">Каждая ссылка открывает публичную страницу с карточками записей выбранной группы за указанный период — без входа и выбора параметров.</p>
|
||||
<div class="list" id="linksList"><div class="empty">Загрузка...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="linkModal" onclick="if(event.target===this)closeLinkModal()">
|
||||
<div class="edit-modal">
|
||||
<h3 id="linkModalTitle">Новая ссылка</h3>
|
||||
<div>
|
||||
<label>Название</label>
|
||||
<input type="text" id="linkName" placeholder="Например: СБ1400 05.09">
|
||||
</div>
|
||||
<div>
|
||||
<label>Группа</label>
|
||||
<select id="linkGroup"></select>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px">
|
||||
<div style="flex:1">
|
||||
<label>С</label>
|
||||
<input type="date" id="linkDateFrom">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label>По</label>
|
||||
<input type="date" id="linkDateTo">
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" onclick="closeLinkModal()">Отмена</button>
|
||||
<button class="save" onclick="saveLink()">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
let links = [];
|
||||
let groups = [];
|
||||
let editingId = null;
|
||||
|
||||
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">
|
||||
Группа: <b>${esc(l.group_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 = groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
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 = groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
document.getElementById('linkGroup').value = l.group_id || '';
|
||||
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 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, 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>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Вход — Админ-панель</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;display:flex;align-items:center;justify-content:center}
|
||||
.login{max-width:320px;width:100%;text-align:center;background:var(--card);border:1px solid var(--border);border-radius:16px;padding:40px 32px}
|
||||
.login h1{font-size:1.3rem;font-weight:600;margin-bottom:24px}
|
||||
.login input{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:14px 16px;font-size:.95rem;outline:none;margin-bottom:12px;text-align:center}
|
||||
.login input:focus{border-color:var(--accent)}
|
||||
.login button{width:100%;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:14px;font-size:1rem;font-weight:600;cursor:pointer}
|
||||
.login button:hover{opacity:.85}
|
||||
.login .err{color:#ef4444;font-size:.85rem;margin-top:8px;display:none}
|
||||
.login .back{margin-top:16px}
|
||||
.login .back a{color:var(--muted);text-decoration:none;font-size:.8rem}
|
||||
.login .back a:hover{color:var(--text)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login">
|
||||
<h1>Админ-панель</h1>
|
||||
<input type="password" id="pwdInput" placeholder="Пароль" autofocus>
|
||||
<button onclick="doLogin()">Войти</button>
|
||||
<div class="err" id="loginErr">Неверный пароль</div>
|
||||
<div class="back"><a href="/">На главную</a></div>
|
||||
</div>
|
||||
<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>
|
||||
</html>
|
||||
@@ -0,0 +1,107 @@
|
||||
<!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="settings">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<h2>Настройки</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<label for="spamInterval">Защита от повторов (минут)</label>
|
||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение этого времени. 0 — отключить.</p>
|
||||
</div>
|
||||
<input type="number" id="spamInterval" min="0" max="1440" style="width:120px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-size:.9rem">
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;margin-top:24px">
|
||||
<button class="btn-link" onclick="saveSettings()">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 style="margin:0 0 8px">Резервная копия</h3>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:16px">
|
||||
<button class="btn-link" onclick="downloadBackup()">Скачать бэкап</button>
|
||||
<button class="btn-link" onclick="document.getElementById('restoreFile').click()">Восстановить из файла</button>
|
||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||
</div>
|
||||
<div id="restoreStatus" class="hint" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
async function loadSettings() {
|
||||
const res = await fetch(`${API}/api/settings`, { headers: hdr() });
|
||||
const s = await res.json();
|
||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const v = document.getElementById('spamInterval').value;
|
||||
if (v === '' || Number(v) < 0) { alert('Введите число от 0'); return; }
|
||||
const res = await fetch(`${API}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ settings: { spam_interval_min: String(Math.round(Number(v))) } })
|
||||
});
|
||||
if (res.ok) showToast('Настройки сохранены');
|
||||
else { const e = await res.json(); alert(e.error); }
|
||||
}
|
||||
|
||||
async function downloadBackup() {
|
||||
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('Бэкап скачан');
|
||||
}
|
||||
|
||||
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');
|
||||
st.textContent = 'Восстановление...';
|
||||
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;
|
||||
}
|
||||
st.textContent = '';
|
||||
if (res.ok) showToast('Данные восстановлены');
|
||||
else { const t = await res.text(); alert('Ошибка: ' + t); }
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadSettings();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,97 @@
|
||||
<!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="#F7C90E">
|
||||
<title>Записи занятий</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--yellow:#F7C90E;--dark:#211E22;--gray:#9C9C9C;--page:#f6f6f6;--card-bg:#fff;--radius:30px}
|
||||
body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background:var(--page);color:var(--dark);min-height:100dvh}
|
||||
.hero{background:var(--yellow);padding:44px 20px 52px;text-align:center;position:relative;overflow:hidden}
|
||||
.hero h1{font-size:clamp(1.6rem,4.5vw,2.9rem);font-weight:800;line-height:1.05;letter-spacing:-.02em;color:var(--dark);max-width:900px;margin:0 auto}
|
||||
.hero .chips{margin-top:20px;display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
|
||||
.hero .chip{background:#fff;color:var(--dark);border-radius:999px;padding:8px 18px;font-size:.88rem;font-weight:600;box-shadow:5px 5px 10px rgba(216,173,0,.25)}
|
||||
.wrap{max-width:1200px;margin:0 auto;padding:48px 28px 70px}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:28px}
|
||||
.card{background:var(--card-bg);border-radius:var(--radius);padding:10px 10px 14px;box-shadow:0 1px 3px rgba(20,30,40,.06);transition:transform .18s ease,box-shadow .18s ease;display:flex;flex-direction:column}
|
||||
.card:hover{transform:translateY(-4px);box-shadow:0 14px 30px rgba(20,30,40,.14)}
|
||||
.card .photo{border-radius:24px;overflow:hidden;background:#E7E7E7}
|
||||
.card .photo img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;cursor:zoom-in}
|
||||
.card .no-photo{aspect-ratio:1/1;background:#E7E7E7;display:flex;align-items:center;justify-content:center;color:var(--gray)}
|
||||
.card .no-photo svg{width:52%;height:52%}
|
||||
.card .no-photo svg *{fill:currentColor}
|
||||
.card .body{padding:14px 6px 4px;display:flex;flex-direction:column;gap:6px;flex:1}
|
||||
.card .name{font-size:1.02rem;font-weight:700;color:var(--dark);letter-spacing:-.01em}
|
||||
.card .group{display:inline-block;background:var(--yellow);color:var(--dark);font-size:.72rem;font-weight:700;padding:4px 11px;border-radius:999px;align-self:flex-start}
|
||||
.card .desc{font-size:.9rem;line-height:1.45;color:#4a4f5a;white-space:pre-wrap;word-break:break-word}
|
||||
.card .foot{margin-top:auto;padding:8px 6px 2px;font-size:.78rem;color:var(--gray)}
|
||||
.empty{grid-column:1/-1;background:#fff;border-radius:var(--radius);padding:48px 24px;text-align:center;color:var(--gray);box-shadow:0 1px 3px rgba(20,30,40,.06)}
|
||||
.empty .big{font-size:2rem;margin-bottom:10px}
|
||||
.loading{grid-column:1/-1;text-align:center;color:var(--gray);padding:40px}
|
||||
.lightbox{position:fixed;inset:0;background:rgba(12,14,20,.92);display:none;align-items:center;justify-content:center;z-index:100;cursor:zoom-out;padding:24px}
|
||||
.lightbox.open{display:flex}
|
||||
.lightbox img{max-width:100%;max-height:100%;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.5)}
|
||||
@media(max-width:1000px){.grid{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:760px){.grid{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:480px){.grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="hero">
|
||||
<h1 id="title">Загрузка...</h1>
|
||||
<div class="chips" id="chips"></div>
|
||||
</section>
|
||||
<main class="wrap">
|
||||
<div class="grid" id="grid"><div class="loading">Карточки загружаются...</div></div>
|
||||
</main>
|
||||
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')"><img id="lightboxImg" alt=""></div>
|
||||
|
||||
<script>
|
||||
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')}
|
||||
|
||||
(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 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 class="body">
|
||||
<div class="name">${esc(e.student_name)}</div>
|
||||
<div>${e.group_name ? `<span class="group">${esc(e.group_name)}</span>` : ''}</div>
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
</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>
|
||||
</html>
|
||||
@@ -0,0 +1,285 @@
|
||||
<!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="students">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<h2>Ученики</h2>
|
||||
<div class="add-row">
|
||||
<input type="text" id="newStudent" placeholder="Фамилия Имя" list="studentAddList">
|
||||
<datalist id="studentAddList"></datalist>
|
||||
<button onclick="addStudent()">Добавить</button>
|
||||
<button class="btn-ghost" onclick="openBatchAdd()">Пакетное добавление</button>
|
||||
<button class="btn-ghost" onclick="openBatchAttach()">Прикрепить к группе</button>
|
||||
</div>
|
||||
<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">
|
||||
<select id="studentGroupFilter" class="search-input" style="padding:10px 14px"><option value="">Все группы</option></select>
|
||||
<button class="btn-link" onclick="exportStudentsCSV()">Экспорт CSV</button>
|
||||
</div>
|
||||
<div class="list" id="studentList"></div>
|
||||
<div class="pager" id="pager"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="batchModal" onclick="if(event.target===this)closeBatchAdd()">
|
||||
<div class="edit-modal">
|
||||
<h3>Пакетное добавление учеников</h3>
|
||||
<div>
|
||||
<label>Имена — каждое с новой строки</label>
|
||||
<textarea id="batchText" placeholder="Иванов Иван Петров Пётр Сидоров Сидор"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label>Группа (необязательно)</label>
|
||||
<select id="batchGroup"><option value="">— без группы —</option></select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" onclick="closeBatchAdd()">Отмена</button>
|
||||
<button class="save" onclick="batchAdd()">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="attachModal" onclick="if(event.target===this)closeBatchAttach()">
|
||||
<div class="edit-modal">
|
||||
<h3>Прикрепить к группе</h3>
|
||||
<div>
|
||||
<label>Группа</label>
|
||||
<select id="attachGroup"><option value="">— выберите группу —</option></select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Ученики</label>
|
||||
<input type="text" id="attachSearch" class="search-input" placeholder="Поиск по имени..." style="width:100%;margin-bottom:8px">
|
||||
<div class="attach-list" id="attachList"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" onclick="closeBatchAttach()">Отмена</button>
|
||||
<button class="save" onclick="batchAttach()">Прикрепить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<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}`);
|
||||
}
|
||||
|
||||
// --- 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>
|
||||
</html>
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Создаёт архив переноса: БД (dump) + фотографии (uploads).
|
||||
# Использование: ./scripts/backup.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||
mkdir -p backups
|
||||
|
||||
echo ">> Экспорт БД"
|
||||
docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz
|
||||
|
||||
echo ">> Копирование фотографий"
|
||||
rm -rf backups/_uploads
|
||||
mkdir -p backups/_uploads
|
||||
docker cp "whatido-app-1:/app/uploads/." "backups/_uploads/"
|
||||
|
||||
echo ">> Сборка архива"
|
||||
tar -czf "backups/whatido-backup-$STAMP.tar.gz" -C backups db.sql.gz _uploads
|
||||
rm -rf backups/_uploads backups/db.sql.gz
|
||||
|
||||
echo "Готово: backups/whatido-backup-$STAMP.tar.gz"
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Восстанавливает данные из архива переноса на новом устройстве.
|
||||
# Порядок: скопировать папку проекта + архив, затем ./scripts/restore.sh <архив>
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
BK="${1:?Использование: $0 <backup.tar.gz>}"
|
||||
[ -f "$BK" ] || { echo "Файл не найден: $BK"; exit 1; }
|
||||
|
||||
echo ">> Сборка и запуск стека"
|
||||
docker compose up -d --build
|
||||
|
||||
echo ">> Ожидание БД"
|
||||
for _ in $(seq 1 60); do
|
||||
docker compose exec -T db pg_isready -U app -d whereldo >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
echo ">> Распаковка архива"
|
||||
tar -xzf "$BK" -C "$TMP"
|
||||
|
||||
echo ">> Остановка приложения"
|
||||
docker compose stop app
|
||||
|
||||
echo ">> Восстановление БД (чистая схема)"
|
||||
docker compose exec -T db psql -U app -d whereldo -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null
|
||||
gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo
|
||||
|
||||
echo ">> Восстановление фотографий"
|
||||
docker cp "$TMP/_uploads/." "whatido-app-1:/app/uploads/"
|
||||
|
||||
echo ">> Запуск приложения"
|
||||
docker compose start app
|
||||
|
||||
echo "Готово. Проверка: https://localhost:3443 (админка /dashboard.html, пароль из .env)"
|
||||
@@ -0,0 +1,570 @@
|
||||
const express = require('express');
|
||||
const { Pool, types } = require('pg');
|
||||
const multer = require('multer');
|
||||
const cors = require('cors');
|
||||
const https = require('https');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
types.setTypeParser(1082, v => v);
|
||||
|
||||
const app = express();
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin';
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = req.headers['x-admin-token'];
|
||||
if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' });
|
||||
next();
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_, __, cb) => {
|
||||
fs.mkdirSync('uploads', { recursive: true });
|
||||
cb(null, 'uploads');
|
||||
},
|
||||
filename: (_, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.jpg';
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||
else cb(new Error('Only images'));
|
||||
},
|
||||
});
|
||||
|
||||
async function getSetting(key, def) {
|
||||
const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]);
|
||||
return rows.length ? rows[0].value : def;
|
||||
}
|
||||
|
||||
function removeUpload(file) {
|
||||
if (!file) return;
|
||||
const fp = path.join(__dirname, file.path);
|
||||
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
app.get('/api/settings', requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
||||
const out = {};
|
||||
rows.forEach(r => { out[r.key] = r.value; });
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
app.put('/api/settings', requireAdmin, async (req, res) => {
|
||||
const { settings } = req.body;
|
||||
if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'settings required' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await client.query(
|
||||
`INSERT INTO settings (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[key, String(value ?? '')]
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
||||
const out = {};
|
||||
rows.forEach(r => { out[r.key] = r.value; });
|
||||
res.json(out);
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Backup / Restore ---
|
||||
const gunzipAsync = require('util').promisify(require('zlib').gunzip);
|
||||
const tar = require('tar');
|
||||
const os = require('os');
|
||||
const uploadBackup = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 300 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const SAFE_NAME = /^[\w,.()-]+$/;
|
||||
|
||||
app.get('/api/backup', requireAdmin, async (_, res) => {
|
||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||
try {
|
||||
const [g, s, e, st] = await Promise.all([
|
||||
pool.query('SELECT * FROM groups ORDER BY id'),
|
||||
pool.query('SELECT * FROM students ORDER BY id'),
|
||||
pool.query('SELECT * FROM entries ORDER BY id'),
|
||||
pool.query('SELECT key, value FROM settings'),
|
||||
]);
|
||||
const settings = {};
|
||||
st.rows.forEach(r => { settings[r.key] = r.value; });
|
||||
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings };
|
||||
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
||||
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
|
||||
const dir = path.join(__dirname, 'uploads');
|
||||
if (fs.existsSync(dir)) {
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
const fp = path.join(dir, f);
|
||||
if (fs.statSync(fp).isFile() && SAFE_NAME.test(f)) fs.copyFileSync(fp, path.join(staging, 'uploads', f));
|
||||
}
|
||||
}
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
||||
const outPath = path.join(os.tmpdir(), `whatido-backup-${stamp}.tar.gz`);
|
||||
await tar.c({ gzip: true, file: outPath, cwd: staging }, ['data.json', 'uploads']);
|
||||
const buf = fs.readFileSync(outPath);
|
||||
fs.unlinkSync(outPath);
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="whatido-backup-${stamp}.tar.gz"`);
|
||||
res.send(buf);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'backup file required' });
|
||||
let data;
|
||||
let legacyPhotos = [];
|
||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-rst-'));
|
||||
try {
|
||||
const gunz = await gunzipAsync(req.file.buffer).catch(() => null);
|
||||
if (gunz && gunz[0] === 0x7b) {
|
||||
data = JSON.parse(gunz.toString('utf8'));
|
||||
legacyPhotos = data.photos || [];
|
||||
} else {
|
||||
const inPath = path.join(staging, 'input.tar.gz');
|
||||
fs.writeFileSync(inPath, req.file.buffer);
|
||||
await tar.x({ file: inPath, cwd: staging });
|
||||
data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8'));
|
||||
}
|
||||
} catch {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
return res.status(400).json({ error: 'Неверный файл бэкапа' });
|
||||
}
|
||||
if (!data || data.version !== 1 || !Array.isArray(data.groups)) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
return res.status(400).json({ error: 'Неверный формат бэкапа' });
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM entries');
|
||||
await client.query('DELETE FROM students');
|
||||
await client.query('DELETE FROM groups');
|
||||
for (const x of data.groups) {
|
||||
await client.query(
|
||||
'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[x.id, x.name, x.created_at, x.day_of_week ?? null, x.time_start ?? null, x.time_end ?? null]
|
||||
);
|
||||
}
|
||||
for (const x of data.students) {
|
||||
await client.query(
|
||||
'INSERT INTO students (id, name, created_at, group_id) VALUES ($1,$2,$3,$4)',
|
||||
[x.id, x.name, x.created_at, x.group_id ?? null]
|
||||
);
|
||||
}
|
||||
for (const x of data.entries) {
|
||||
await client.query(
|
||||
'INSERT INTO entries (id, student_name, group_id, description, photo_path, created_at) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[x.id, x.student_name, x.group_id, x.description, x.photo_path ?? null, x.created_at]
|
||||
);
|
||||
}
|
||||
for (const [k, v] of Object.entries(data.settings || {})) {
|
||||
await client.query(
|
||||
'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value',
|
||||
[k, String(v ?? '')]
|
||||
);
|
||||
}
|
||||
for (const tbl of ['groups', 'students', 'entries']) {
|
||||
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
|
||||
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
const dir = path.join(__dirname, 'uploads');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
if (legacyPhotos.length) {
|
||||
for (const p of legacyPhotos) {
|
||||
if (!p.path || !SAFE_NAME.test(p.path)) continue;
|
||||
fs.writeFileSync(path.join(dir, p.path), Buffer.from(p.data, 'base64'));
|
||||
}
|
||||
} else {
|
||||
const src = path.join(staging, 'uploads');
|
||||
if (fs.existsSync(src)) {
|
||||
for (const f of fs.readdirSync(src)) {
|
||||
if (!SAFE_NAME.test(f)) continue;
|
||||
const fp = path.join(src, f);
|
||||
if (fs.statSync(fp).isFile()) fs.copyFileSync(fp, path.join(dir, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Share links ---
|
||||
function normDates(o) {
|
||||
if (o && o.date_from) o.date_from = o.date_from instanceof Date ? o.date_from.toISOString().slice(0, 10) : String(o.date_from).slice(0, 10);
|
||||
if (o && o.date_to) o.date_to = o.date_to instanceof Date ? o.date_to.toISOString().slice(0, 10) : String(o.date_to).slice(0, 10);
|
||||
return o;
|
||||
}
|
||||
app.get('/api/links', requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT l.*, g.name AS group_name FROM share_links l
|
||||
LEFT JOIN groups g ON g.id = l.group_id ORDER BY l.created_at DESC`
|
||||
);
|
||||
rows.forEach(normDates);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.post('/api/links', requireAdmin, async (req, res) => {
|
||||
const { name, group_id, date_from, date_to } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO share_links (token, name, group_id, date_from, date_to)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[token, name.trim(), group_id || null, date_from || null, date_to || null]
|
||||
);
|
||||
res.status(201).json(normDates(rows[0]));
|
||||
});
|
||||
|
||||
app.put('/api/links/:id', requireAdmin, async (req, res) => {
|
||||
const { name, group_id, date_from, date_to } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE share_links SET
|
||||
name = $1, group_id = $2, date_from = $3, date_to = $4
|
||||
WHERE id = $5 RETURNING *`,
|
||||
[name.trim(), group_id || null, date_from || null, date_to || null, req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||
res.json(normDates(rows[0]));
|
||||
});
|
||||
|
||||
app.delete('/api/links/:id', requireAdmin, async (req, res) => {
|
||||
await pool.query('DELETE FROM share_links WHERE id = $1', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/share/:token', async (req, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT l.*, g.name AS group_name FROM share_links l
|
||||
LEFT JOIN groups g ON g.id = l.group_id WHERE l.token = $1`,
|
||||
[req.params.token]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Ссылка не найдена' });
|
||||
normDates(rows[0]);
|
||||
const l = rows[0];
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
||||
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||||
const { rows: entries } = await pool.query(
|
||||
`SELECT e.*, g.name AS group_name FROM entries e
|
||||
JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`,
|
||||
params
|
||||
);
|
||||
res.json({
|
||||
name: l.name,
|
||||
group_name: l.group_name,
|
||||
group_id: l.group_id,
|
||||
date_from: l.date_from,
|
||||
date_to: l.date_to,
|
||||
created_at: l.created_at,
|
||||
entries,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/s/:token', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'share.html'));
|
||||
});
|
||||
|
||||
// --- Groups CRUD ---
|
||||
app.get('/api/groups', async (_, res) => {
|
||||
const { rows } = await pool.query('SELECT * FROM groups ORDER BY id');
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.get('/api/groups/active', async (_, res) => {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT * FROM groups
|
||||
WHERE day_of_week IS NOT NULL
|
||||
AND time_start IS NOT NULL
|
||||
AND time_end IS NOT NULL
|
||||
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
|
||||
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end
|
||||
ORDER BY id
|
||||
`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.put('/api/groups/:id', requireAdmin, async (req, res) => {
|
||||
const { name, day_of_week, time_start, time_end } = req.body;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE groups SET
|
||||
name = COALESCE($1, name),
|
||||
day_of_week = $2,
|
||||
time_start = $3,
|
||||
time_end = $4
|
||||
WHERE id = $5 RETURNING *`,
|
||||
[name,
|
||||
day_of_week === undefined || day_of_week === null || day_of_week === '' ? null : day_of_week,
|
||||
time_start || null, time_end || null, req.params.id]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate name' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/groups', requireAdmin, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'INSERT INTO groups (name) VALUES ($1) RETURNING *',
|
||||
[name.trim()]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/groups/:id', requireAdmin, async (req, res) => {
|
||||
await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Students CRUD ---
|
||||
app.get('/api/students', async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.*, g.name AS group_name FROM students s
|
||||
LEFT JOIN groups g ON g.id = s.group_id ORDER BY s.name`
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.get('/api/students/names', requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT student_name AS name FROM entries WHERE student_name IS NOT NULL AND student_name <> \'\' ORDER BY student_name'
|
||||
);
|
||||
res.json(rows.map(r => r.name));
|
||||
});
|
||||
|
||||
app.post('/api/students', requireAdmin, async (req, res) => {
|
||||
const { name, group_id } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
const gid = group_id ? Number(group_id) : null;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'INSERT INTO students (name, group_id) VALUES ($1, $2) RETURNING *',
|
||||
[name.trim(), gid]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/students/:id', requireAdmin, async (req, res) => {
|
||||
const { name, group_id } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE students SET
|
||||
name = $1,
|
||||
group_id = $2
|
||||
WHERE id = $3 RETURNING *`,
|
||||
[name.trim(),
|
||||
group_id === undefined || group_id === null || group_id === '' ? null : group_id,
|
||||
req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/students/batch-group', requireAdmin, async (req, res) => {
|
||||
const { group_id, student_ids } = req.body;
|
||||
if (!group_id || !Array.isArray(student_ids) || !student_ids.length) {
|
||||
return res.status(400).json({ error: 'group_id and student_ids required' });
|
||||
}
|
||||
const ids = [...new Set(student_ids.map(Number).filter(Boolean))];
|
||||
if (!ids.length) return res.status(400).json({ error: 'No valid students' });
|
||||
const params = [group_id, ...ids];
|
||||
const placeholders = ids.map((_, i) => `$${i + 2}`).join(',');
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE students SET group_id = $1 WHERE id IN (${placeholders}) RETURNING id`,
|
||||
params
|
||||
);
|
||||
res.json({ ok: true, updated: rows.length });
|
||||
});
|
||||
|
||||
app.delete('/api/students/:id', requireAdmin, async (req, res) => {
|
||||
await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Entries ---
|
||||
app.get('/api/entries', requireAdmin, async (req, res) => {
|
||||
const { group_id, date_from, date_to, student_name, search, limit, offset } = req.query;
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
||||
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
||||
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||||
if (search) { params.push(`%${search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); }
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||||
const { rows: crows } = await pool.query(
|
||||
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`,
|
||||
params
|
||||
);
|
||||
const total = crows[0].n;
|
||||
let q = `SELECT e.*, g.name AS group_name FROM entries e
|
||||
JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`;
|
||||
const qparams = params.slice();
|
||||
const lim = parseInt(limit, 10);
|
||||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||||
const off = parseInt(offset, 10);
|
||||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||||
const { rows } = await pool.query(q, qparams);
|
||||
res.json({ entries: rows, total });
|
||||
});
|
||||
|
||||
app.get('/api/stats', requireAdmin, async (_, res) => {
|
||||
const [entries, groups, students, today] = await Promise.all([
|
||||
pool.query('SELECT count(*)::int AS n FROM entries'),
|
||||
pool.query('SELECT count(*)::int AS n FROM groups'),
|
||||
pool.query('SELECT count(DISTINCT student_name)::int AS n FROM entries'),
|
||||
pool.query("SELECT count(*)::int AS n FROM entries WHERE created_at >= now()::date"),
|
||||
]);
|
||||
res.json({
|
||||
entries: entries.rows[0].n,
|
||||
groups: groups.rows[0].n,
|
||||
students: students.rows[0].n,
|
||||
today: today.rows[0].n,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/entries', upload.single('photo'), async (req, res) => {
|
||||
const { student_name, group_id, description } = req.body;
|
||||
if (!student_name?.trim() || !group_id || !description?.trim()) {
|
||||
removeUpload(req.file);
|
||||
return res.status(400).json({ error: 'All fields required' });
|
||||
}
|
||||
const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0;
|
||||
if (intervalMin > 0) {
|
||||
const dup = await pool.query(
|
||||
'SELECT count(*)::int AS n FROM entries WHERE student_name = $1 AND created_at >= now() - ($2 || \' minutes\')::interval',
|
||||
[student_name.trim(), intervalMin]
|
||||
);
|
||||
if (dup.rows[0].n > 0) {
|
||||
removeUpload(req.file);
|
||||
return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` });
|
||||
}
|
||||
}
|
||||
const photo_path = req.file ? `/uploads/${req.file.filename}` : null;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
'INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
||||
[student_name.trim()]
|
||||
);
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO entries (student_name, group_id, description, photo_path)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[student_name.trim(), group_id, description.trim(), photo_path]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/entries/:id', requireAdmin, async (req, res) => {
|
||||
const { student_name, group_id, description } = req.body;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE entries SET
|
||||
student_name = COALESCE($1, student_name),
|
||||
group_id = COALESCE($2, group_id),
|
||||
description = COALESCE($3, description)
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[
|
||||
student_name ? student_name.trim() : null,
|
||||
group_id || null,
|
||||
description ? description.trim() : null,
|
||||
req.params.id,
|
||||
]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
app.delete('/api/entries/:id', requireAdmin, async (req, res) => {
|
||||
const { rows } = await pool.query(
|
||||
'DELETE FROM entries WHERE id = $1 RETURNING photo_path',
|
||||
[req.params.id]
|
||||
);
|
||||
if (rows[0]?.photo_path) {
|
||||
const fp = path.join(__dirname, rows[0].photo_path);
|
||||
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
|
||||
|
||||
const certPath = path.join(__dirname, 'certs', 'cert.pem');
|
||||
const keyPath = path.join(__dirname, 'certs', 'key.pem');
|
||||
|
||||
const redirect = express();
|
||||
redirect.use((req, res) => {
|
||||
res.redirect(301, `https://${req.headers.host.split(':')[0]}:${HTTPS_PORT}${req.originalUrl}`);
|
||||
});
|
||||
redirect.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (redirect → HTTPS)`));
|
||||
|
||||
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app)
|
||||
.listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`));
|
||||
} else {
|
||||
console.log('HTTPS: no certs found, skipping');
|
||||
}
|
||||
Reference in New Issue
Block a user