master #1

Closed
admin wants to merge 19 commits from master into main
28 changed files with 4323 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
uploads
.git
.env
+1
View File
@@ -0,0 +1 @@
ADMIN_PASSWORD=ByrByrPo1!!
+1
View File
@@ -0,0 +1 @@
uploads/
+13
View File
@@ -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"]
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
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),
deleted_at TIMESTAMPTZ,
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),
student_name VARCHAR(150),
date_from DATE,
date_to DATE,
created_at TIMESTAMPTZ DEFAULT now()
);
INSERT INTO groups (name) VALUES
('Beginner'),
('Intermediate'),
('Advanced')
ON CONFLICT (name) DO NOTHING;
CREATE TABLE IF NOT EXISTS group_photos (
id SERIAL PRIMARY KEY,
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
caption TEXT,
taken_at DATE,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
CREATE TABLE IF NOT EXISTS project_files (
id SERIAL PRIMARY KEY,
entry_id INT REFERENCES entries(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
path VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
detached_at TIMESTAMPTZ
);
+62
View File
@@ -0,0 +1,62 @@
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),
student_name VARCHAR(150),
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 $$;
CREATE TABLE IF NOT EXISTS project_files (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
token VARCHAR(64),
path VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
UPDATE project_files SET token = md5(random()::text || clock_timestamp()::text) WHERE token IS NULL;
ALTER TABLE project_files ALTER COLUMN token SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS project_files_token_key ON project_files(token);
ALTER TABLE project_files ALTER COLUMN entry_id DROP NOT NULL;
ALTER TABLE project_files ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS group_photos (
id SERIAL PRIMARY KEY,
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
caption TEXT,
taken_at DATE,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
+38
View File
@@ -0,0 +1,38 @@
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:
+15
View File
@@ -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"
}
}
+299
View File
@@ -0,0 +1,299 @@
*{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)}
#studentFilter,#linkStudent{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
#studentFilter:focus,#linkStudent: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}
.entry-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.entry-files .f-item{display:inline-flex;align-items:center;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:2px}
.entry-files .f-item:hover{border-color:var(--accent)}
.entry-files .f-item a{display:inline-flex;align-items:center;gap:4px;color:var(--text);text-decoration:none;padding:2px 6px;font-size:.75rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.entry-files .f-item a:hover{color:var(--accent)}
.entry-files .f-item a::before{content:'📎'}
.entry-files .f-detach{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.75rem;line-height:1;padding:2px 5px;border-radius:4px}
.entry-files .f-detach:hover{color:#ef4444;background:rgba(239,68,68,.1)}
.entry-files a{display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);text-decoration:none;border-radius:6px;padding:4px 8px;font-size:.75rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.entry-files a:hover{border-color:var(--accent);color:var(--accent)}
.entry-files a::before{content:'📎'}
.f-actions{display:flex;align-items:center;flex-shrink:0}
.f-action{background:none;border:1px solid var(--border);color:var(--muted);cursor:pointer;border-radius:8px;padding:6px 10px;font-size:.75rem;white-space:nowrap}
.f-action:hover{color:var(--accent);border-color:var(--accent)}
.f-action.f-del:hover{color:#ef4444;border-color:#ef4444}
.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:14px;padding:24px;max-width:680px;display:flex;flex-direction:column;gap:16px}
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
.settings-row 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 .cancel-btn{display:flex;flex:1;align-items:center;justify-content:center;padding:12px;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer;text-decoration:none;background:var(--border);color:var(--text);transition:opacity .15s}
.edit-modal .cancel-btn:hover{opacity:.85}
.edit-modal .actions button:hover{opacity:.85}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
.toast.show{opacity:1}
/* --- Фото группы (хронология) --- */
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;cursor:pointer;display:block;background:var(--bg)}
.group-cover:hover{opacity:.9}
.empty-cover{background:var(--bg);border:1px dashed var(--border);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:1.6rem;cursor:pointer}
.empty-cover:hover{border-color:var(--accent);color:var(--accent)}
.photo-btn{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer;transition:all .15s}
.photo-btn:hover{border-color:var(--accent);opacity:.85}
.gallery-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:860px;width:94%;max-height:90dvh;display:flex;flex-direction:column;gap:16px;overflow:hidden}
.gallery-head{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}
.gallery-head h3{font-size:1.1rem;font-weight:600}
.gallery-head .upload-btn{background:var(--accent);color:#fff;border:none;border-radius:8px;padding:9px 16px;font-size:.85rem;font-weight:600;cursor:pointer;transition:opacity .15s}
.gallery-head .upload-btn:hover{opacity:.85}
.gallery-foot{display:flex;justify-content:flex-end}
.gallery-foot .cancel{background:var(--border);color:var(--text);border:none;border-radius:8px;padding:10px 20px;font-size:.9rem;font-weight:600;cursor:pointer;transition:opacity .15s}
.gallery-foot .cancel:hover{opacity:.85}
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:2px}
.photo-item{background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
.photo-item img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:pointer;display:block;transition:opacity .15s}
.photo-item img:hover{opacity:.9}
.photo-item .p-cap{padding:8px 10px;font-size:.78rem;color:var(--text);min-height:1.2em;white-space:pre-wrap;word-break:break-word}
.photo-item .p-date{font-size:.72rem;color:var(--muted);padding:0 10px 6px}
.photo-item .p-actions{display:flex;justify-content:flex-end;gap:4px;padding:6px 8px;border-top:1px solid var(--border)}
.photo-item .p-actions button{background:none;border:none;cursor:pointer;font-size:1rem;color:var(--muted);padding:4px}
.photo-item .p-actions .p-edit:hover{color:var(--accent)}
.photo-item .p-actions .p-del:hover{color:#ef4444}
.edit-modal input[type=file]{padding:8px;cursor:pointer}
.edit-modal input[type=file]::file-selector-button{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 12px;font-size:.8rem;font-weight:600;cursor:pointer;margin-right:10px;transition:border-color .15s}
.edit-modal input[type=file]::file-selector-button:hover{border-color:var(--accent)}
.photo-preview{margin-top:4px;text-align:center}
.photo-preview img{max-width:100%;max-height:220px;border-radius:8px;overflow:hidden}
/* --- Корзина --- */
.trash-head{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px}
.trash-muted{color:var(--muted);font-size:.85rem}
.danger-btn{background:#ef4444;color:#fff;border:none;border-radius:8px;padding:9px 16px;font-size:.85rem;font-weight:600;cursor:pointer;transition:opacity .15s}
.danger-btn:hover{opacity:.85}
.danger-btn:disabled{opacity:.4;cursor:not-allowed}
.restore-btn{background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 12px;font-size:.8rem;font-weight:600;cursor:pointer;white-space:nowrap;transition:opacity .15s}
.restore-btn:hover{opacity:.85}
.del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
.del-btn:hover{opacity:.8}
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
/* --- Настройки (UX/UI) --- */
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
.page-sub{color:var(--muted);font-size:.85rem}
.settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px}
.settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px}
.settings-card .card-head .ic{width:38px;height:38px;border-radius:11px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0}
.settings-card .card-head h3{font-size:1.05rem;font-weight:700;margin:0}
.settings-card .hint{font-size:.82rem;color:var(--muted);line-height:1.5;margin:0;max-width:none}
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
.settings-field{display:flex;flex-direction:column;gap:8px}
.settings-field label{font-size:.86rem;font-weight:600;color:var(--text)}
.settings-input{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:10px;padding:10px 14px;font-size:.9rem;outline:none;font-family:inherit;transition:border-color .15s,box-shadow .15s;width:100%}
.settings-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
.settings-input[type=number]{width:120px}
.unit-row{display:flex;align-items:center;gap:10px}
.unit{color:var(--muted);font-size:.85rem}
.btn-primary{display:inline-flex;align-items:center;gap:8px;background:var(--accent);color:#fff;border:none;border-radius:10px;padding:11px 20px;font-size:.9rem;font-weight:600;cursor:pointer;transition:opacity .15s;white-space:nowrap}
.btn-primary:hover{opacity:.85}
.btn-primary:disabled{opacity:.45;cursor:not-allowed}
.btn-primary.ghost{background:var(--card);border:1px solid var(--border);color:var(--accent)}
.btn-primary.ghost:hover{border-color:var(--accent);opacity:1}
.card-foot{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding-top:16px;border-top:1px solid var(--border)}
.data-stats{display:flex;flex-wrap:wrap;gap:10px}
.data-stats .chip{background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:6px 14px;font-size:.8rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
.data-stats .chip b{color:var(--accent);font-weight:700}
.data-stats .chip a{color:#ef4444;text-decoration:none;font-weight:600}
.data-stats .chip a:hover{text-decoration:underline}
.spinner{width:14px;height:14px;border:2px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;display:inline-block;flex-shrink:0}
.spinner.dark{border:2px solid rgba(37,99,235,.2);border-top-color:var(--accent)}
@keyframes spin{to{transform:rotate(360deg)}}
.restore-status{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.85rem;min-height:22px}
.restore-status.err{color:#ef4444}
.save-bar{position:fixed;left:calc(var(--sidebar) + 24px);right:24px;bottom:24px;z-index:60;display:none;align-items:center;justify-content:space-between;gap:16px;background:var(--card);border:1px solid var(--border);border-radius:14px;padding:12px 20px;box-shadow:0 10px 30px rgba(0,0,0,.14)}
.save-bar.show{display:flex}
.save-bar .hint{color:var(--muted);font-size:.85rem;display:inline-flex;align-items:center;gap:8px}
@media(max-width:640px){.save-bar{left:16px;right:16px}}
/* --- Дашборд --- */
.dash-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:20px;align-items:start}
.dash-col{display:flex;flex-direction:column;gap:20px;min-width:0}
.dash-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:20px;display:flex;flex-direction:column;gap:16px}
.dash-card h3{font-size:1rem;font-weight:700;display:flex;align-items:center;gap:9px;margin:0}
.dash-card h3 .ic{width:30px;height:30px;border-radius:9px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:.95rem;flex-shrink:0}
.dash-empty{color:var(--muted);font-size:.82rem;padding:8px 0}
.dash-link-row{margin-top:2px;text-align:right}
.dash-link-row a{color:var(--accent);text-decoration:none;font-size:.8rem;font-weight:600}
.dash-link-row a:hover{text-decoration:underline}
.chart{display:flex;align-items:flex-end;gap:6px;height:150px}
.bar-col{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;gap:6px;min-width:0}
.bar{width:100%;max-width:28px;background:var(--accent);opacity:.35;border-radius:4px 4px 0 0;min-height:2px;transition:opacity .15s}
.bar:hover{opacity:1}
.bar.today{opacity:1}
.bar-day{font-size:.62rem;color:var(--muted);white-space:nowrap}
.recent{display:flex;flex-direction:column;gap:2px}
.recent-item{display:flex;align-items:center;gap:12px;padding:8px 6px;border-radius:10px;transition:background .15s}
.recent-item:hover{background:var(--bg)}
.recent-item img{width:44px;height:44px;border-radius:8px;object-fit:cover;flex-shrink:0;cursor:pointer}
.recent-item .thumb{width:44px;height:44px;border-radius:8px;background:var(--bg);display:flex;align-items:center;justify-content:center;color:var(--muted);flex-shrink:0}
.recent-item .r-info{flex:1;min-width:0}
.recent-item .r-name{font-weight:600;font-size:.88rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.recent-item .r-meta{color:var(--muted);font-size:.75rem;margin-top:2px}
.recent-item .r-link{color:var(--accent);text-decoration:none;font-size:1.1rem;opacity:0;transition:opacity .15s}
.recent-item:hover .r-link{opacity:1}
.photos-grid-dash{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.dash-photo{position:relative;border-radius:8px;overflow:hidden}
.dash-photo img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;cursor:pointer;transition:opacity .15s}
.dash-photo img:hover{opacity:.85}
.dash-photo .pcap{position:absolute;left:0;right:0;bottom:0;padding:14px 6px 5px;font-size:.62rem;font-weight:600;color:#fff;background:linear-gradient(transparent,rgba(0,0,0,.55));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.actions-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.action-btn{display:flex;flex-direction:column;align-items:center;gap:8px;padding:16px 10px;background:var(--bg);border:1px solid var(--border);border-radius:12px;color:var(--text);text-decoration:none;font-size:.8rem;font-weight:600;transition:all .15s}
.action-btn .aic{font-size:1.3rem}
.action-btn:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-2px)}
.active-list{display:flex;flex-direction:column;gap:8px}
.active-item{display:flex;justify-content:space-between;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:9px 12px}
.active-item .a-name{font-weight:600;font-size:.86rem}
.active-item .a-right{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}
.active-item .a-time{color:var(--muted);font-size:.76rem}
.active-item .a-now{font-size:.68rem;font-weight:700;color:#16a34a;background:rgba(22,163,74,.12);border-radius:999px;padding:3px 9px}
.top-list{display:flex;flex-direction:column;gap:6px}
.top-item{display:flex;align-items:center;gap:10px;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:8px 12px}
.top-rank{width:22px;height:22px;border-radius:7px;background:var(--accent);color:#fff;font-size:.7rem;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.top-rank.alt{background:var(--border);color:var(--muted)}
.top-name{flex:1;min-width:0;font-size:.85rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.top-count{font-size:.8rem;font-weight:700;color:var(--accent)}
.stat .label a{color:var(--accent);text-decoration:none;font-weight:600}
.stat .label a:hover{text-decoration:underline}
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}}
+10
View File
@@ -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>
+148
View File
@@ -0,0 +1,148 @@
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: 'trash', label: 'Корзина' },
{ page: 'files', 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 studentUrlContent(name) {
const url = `${location.origin}/?nameInput=${encodeURIComponent(name)}`;
return `[InternetShortcut]\r\nURL=${url}\r\n`;
}
function safeWindowsName(name) {
return String(name).replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_');
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function exportStudentUrlZip(students, zipName) {
if (typeof JSZip === 'undefined') { alert('JSZip не загружен'); return; }
const zip = new JSZip();
students.forEach(s => {
zip.file(safeWindowsName(s.name) + '.url', studentUrlContent(s.name));
});
const blob = await zip.generateAsync({ type: 'blob' });
downloadBlob(blob, zipName);
}
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;
}
+179
View File
@@ -0,0 +1,179 @@
<!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">
<div class="page-head">
<h2>Дашборд</h2>
<p class="page-sub">Сводка по журналу и группам</p>
</div>
<div class="stats" id="stats"></div>
<div class="dash-grid">
<div class="dash-col">
<div class="dash-card">
<h3><span class="ic">📊</span> Динамика записей (14 дней)</h3>
<div class="chart" id="activityChart" title=""></div>
<div class="dash-link-row"><a href="journal.html">Открыть журнал →</a></div>
</div>
<div class="dash-card">
<h3><span class="ic">🕒</span> Последние записи</h3>
<div class="recent" id="recentList"></div>
<div class="dash-link-row"><a href="journal.html">Все записи →</a></div>
</div>
<div class="dash-card">
<h3><span class="ic">📸</span> Фото групп</h3>
<div class="photos-grid-dash" id="dashPhotos"></div>
<div class="dash-link-row"><a href="groups.html">Фото групп →</a></div>
</div>
</div>
<div class="dash-col">
<div class="dash-card">
<h3><span class="ic">⚡</span> Быстрые действия</h3>
<div class="actions-grid">
<a class="action-btn" href="journal.html"><span class="aic">📖</span>Журнал</a>
<a class="action-btn" href="files.html"><span class="aic">📎</span>Файлы</a>
<a class="action-btn" href="groups.html"><span class="aic">👥</span>Группы</a>
<a class="action-btn" href="links.html"><span class="aic">🔗</span>Ссылки</a>
<a class="action-btn" href="trash.html"><span class="aic">🗑️</span>Корзина</a>
<a class="action-btn" href="settings.html"><span class="aic">🛡️</span>Бэкап</a>
</div>
</div>
<div class="dash-card">
<h3><span class="ic">🟢</span> Активные сейчас группы</h3>
<div class="active-list" id="activeList"></div>
</div>
<div class="dash-card">
<h3><span class="ic">🏆</span> Топ учеников</h3>
<div class="top-list" id="topList"></div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
<img id="imgModalSrc" alt="">
</div>
<div class="toast" id="toast"></div>
<script src="admin.js"></script>
<script>
function plural(n, one, few, many) {
n = Math.abs(n) % 100; const n1 = n % 10;
if (n > 10 && n < 20) return many;
if (n1 > 1 && n1 < 5) return few;
if (n1 === 1) return one;
return many;
}
function renderStats(s) {
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="num">${s.entries}</div><div class="label">Всего записей</div></div>
<div class="stat"><div class="num">${s.students}</div><div class="label">Учеников</div></div>
<div class="stat"><div class="num">${s.groups}</div><div class="label">Групп</div></div>
<div class="stat"><div class="num">${s.today}</div><div class="label">Сегодня</div></div>
<div class="stat"><div class="num" style="color:${s.trash ? '#ef4444' : 'var(--muted)'}">${s.trash}</div><div class="label"><a href="trash.html">В корзине →</a></div></div>
`;
}
function last14days() {
const out = [];
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
return out;
}
function renderActivity(activity) {
const map = {};
activity.forEach(a => { map[a.d] = a.n; });
const days = last14days().map(d => {
const iso = d.toISOString().slice(0, 10);
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
});
const max = Math.max(1, ...days.map(x => x.n));
const todayIso = new Date().toISOString().slice(0, 10);
document.getElementById('activityChart').innerHTML = days.map(x => `
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
<div class="bar ${x.iso === todayIso ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
<div class="bar-day">${x.label}</div>
</div>`).join('');
}
function renderRecent(rows) {
const el = document.getElementById('recentList');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Записей пока нет</div>'; return; }
el.innerHTML = rows.map(e => `
<div class="recent-item">
${e.photo_path
? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">`
: `<div class="thumb">🎓</div>`}
<div class="r-info">
<div class="r-name">${esc(e.student_name)}</div>
<div class="r-meta">${esc(e.group_name)} · ${new Date(e.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
</div>
<a class="r-link" href="journal.html" title="В журнал">→</a>
</div>`).join('');
}
function renderActive(rows) {
const el = document.getElementById('activeList');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Сейчас нет идущих групп</div>'; return; }
el.innerHTML = rows.map(g => `
<div class="active-item">
<span class="a-name">${esc(g.name)}</span>
<span class="a-right">
<span class="a-time">${g.day_label} ${g.time_start}–${g.time_end}</span>
<span class="a-now">сейчас</span>
</span>
</div>`).join('');
}
function renderTop(rows) {
const el = document.getElementById('topList');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Пока нет данных</div>'; return; }
el.innerHTML = rows.map((x, i) => `
<div class="top-item">
<span class="top-rank ${i > 2 ? 'alt' : ''}">${i + 1}</span>
<span class="top-name">${esc(x.student_name)}</span>
<span class="top-count">${x.n}</span>
</div>`).join('');
}
function renderPhotos(rows) {
const el = document.getElementById('dashPhotos');
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Фото групп пока нет</div>'; return; }
el.innerHTML = rows.map(p => `
<div class="dash-photo" title="${esc(p.group_name)}">
<img src="${API}${p.photo_path}" onclick="showImg('${API}${p.photo_path}')" alt="">
${p.caption ? `<div class="pcap">${esc(p.caption)}</div>` : ''}
</div>`).join('');
}
async function loadDashboard() {
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
const d = await res.json();
renderStats(d.stats);
renderActivity(d.activity);
renderRecent(d.recent_entries);
renderActive(d.active_groups);
renderTop(d.top_students);
renderPhotos(d.photos);
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadDashboard();
}
})();
</script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ошибка — WhatIDo</title>
<link rel="stylesheet" href="admin.css">
<style>
.error-page{display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:24px;text-align:center}
.error-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:48px 40px;max-width:400px;width:100%}
.error-code{font-size:6rem;font-weight:700;color:var(--accent);line-height:1;margin-bottom:8px}
.error-title{font-size:1.5rem;font-weight:600;color:var(--text);margin-bottom:12px}
.error-message{color:var(--muted);font-size:1rem;line-height:1.6;margin-bottom:24px}
.error-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
.error-actions a,.error-actions button{background:var(--accent);color:#fff;border:none;border-radius:8px;padding:12px 24px;font-size:.9rem;font-weight:600;cursor:pointer;text-decoration:none;transition:opacity .15s}
.error-actions a:hover,.error-actions button:hover{opacity:.85}
.error-actions .ghost{background:var(--card);border:1px solid var(--border);color:var(--accent)}
.error-actions .ghost:hover{border-color:var(--accent);background:rgba(37,99,235,.06)}
.error-details{margin-top:24px;padding-top:24px;border-top:1px solid var(--border);font-size:.75rem;color:var(--muted);text-align:left}
.error-details pre{white-space:pre-wrap;word-break:break-word;font-family:monospace;background:var(--bg);padding:12px;border-radius:8px;margin-top:8px;max-height:200px;overflow:auto}
@media(max-width:480px){.error-code{font-size:4rem}.error-card{padding:32px 20px}}
</style>
</head>
<body>
<div class="error-page">
<div class="error-card">
<div class="error-code" id="errorCode">404</div>
<h1 class="error-title" id="errorTitle">Страница не найдена</h1>
<p class="error-message" id="errorMessage">Запрашиваемая страница не существует или была перемещена.</p>
<div class="error-actions">
<a href="/" class="ghost">На главную</a>
<button onclick="history.back()">Назад</button>
</div>
<div class="error-details" id="errorDetails" style="display:none">
<strong>Детали:</strong>
<pre id="errorStack"></pre>
</div>
</div>
</div>
<script>
(function(){
const params = new URLSearchParams(location.search);
const code = params.get('code') || '404';
const title = params.get('title') || '';
const message = params.get('message') || '';
const details = params.get('details') || '';
document.getElementById('errorCode').textContent = code;
document.getElementById('errorTitle').textContent = title || (code === '404' ? 'Страница не найдена' : 'Ошибка');
document.getElementById('errorMessage').textContent = message || (code === '404' ? 'Запрашиваемая страница не существует или была перемещена.' : 'Произошла ошибка на сервере.');
if (details) {
document.getElementById('errorDetails').style.display = 'block';
document.getElementById('errorStack').textContent = details;
}
})();
</script>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Файлы — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
<style>
.file-icon{width:80px;height:80px;border-radius:8px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:1.6rem;flex-shrink:0;color:var(--muted)}
.file-name{font-weight:600;font-size:.95rem}
.file-name a{color:var(--text);text-decoration:none}
.file-name a:hover{color:var(--accent)}
</style>
</head>
<body data-page="files">
<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><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
<div><label>С</label><input type="date" id="dateFrom"></div>
<div><label>По</label><input type="date" id="dateTo"></div>
<div><label>Тип</label>
<div class="view-toggle" id="tabToggle">
<button data-mode="attached" onclick="setMode('attached')">Прикреплённые</button>
<button data-mode="detached" onclick="setMode('detached')">Откреплённые</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="toast" id="toast"></div>
<script src="admin.js"></script>
<script>
const PAGE_SIZE = 20;
let page = 1;
let totalFiles = 0;
let currentFiles = [];
let groupsLoaded = false;
let studentsLoaded = false;
let searchTimer = null;
let mode = 'attached';
function setMode(m) {
mode = m === 'detached' ? 'detached' : 'attached';
document.querySelectorAll('#tabToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
const show = mode === 'attached';
const keep = ['searchInput', 'studentFilter', 'groupFilter', 'dateFrom', 'dateTo'].map(id => document.getElementById(id));
if (!show) { keep.forEach(el => { el.value = ''; }); }
keep.forEach(el => { el.parentElement.style.display = show ? '' : 'none'; });
page = 1;
loadFiles();
}
function isImageFile(name) {
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function humanSize(b) {
if (!b) return '';
if (b < 1024) return b + ' Б';
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
return (b / 1048576).toFixed(1) + ' МБ';
}
function fileCardHTML(f) {
const url = `${API}/api/files/${f.token}`;
const thumb = isImageFile(f.name)
? `<img src="${url}" onclick="showImg('${url}')" alt="">`
: `<div class="file-icon">📄</div>`;
const target = isImageFile(f.name)
? `href="#" onclick="showImg('${url}');return false;"`
: `href="${url}" download`;
const meta = mode === 'detached'
? `<div class="group" style="color:#f59e0b">Откреплён от записи, не привязан</div>`
: `<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>`;
const action = mode === 'detached'
? `<button class="f-action f-del" onclick="deleteFile(${f.id})" title="Удалить файл навсегда">Удалить</button>`
: `<button class="f-action" onclick="detachFile(${f.id})" title="Открепить файл от записи">Открепить</button>`;
return `
<div class="card">
${thumb}
<div class="info">
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
${meta}
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
</div>
<div class="f-actions">${action}</div>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!currentFiles.length) {
grid.className = 'grid';
grid.innerHTML = mode === 'detached'
? '<div class="empty">Откреплённых файлов нет</div>'
: '<div class="empty">Файлов не найдено</div>';
return;
}
grid.className = 'grid';
grid.innerHTML = currentFiles.map(fileCardHTML).join('');
}
async function detachFile(id) {
if (!confirm('Открепить файл от записи? Он переместится во вкладку «Откреплённые».')) return;
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
if (res.ok) { showToast('Файл откреплён'); loadFiles(); }
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
}
async function deleteFile(id) {
if (!confirm('Удалить файл навсегда? Отменить это действие нельзя.')) return;
const res = await fetch(`${API}/api/files/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Файл удалён'); loadFiles(); }
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
}
async function loadGroupOptions() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
while (sel.options.length > 1) sel.remove(1);
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id; o.textContent = g.name;
sel.appendChild(o);
});
groupsLoaded = true;
}
async function loadStudentOptions() {
if (studentsLoaded) return;
const dl = document.getElementById('studentFilterList');
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
async function loadFiles() {
const p = new URLSearchParams();
if (mode === 'attached') {
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
}
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/files${mode === 'detached' ? '/detached' : ''}?${p}`, { headers: hdr() });
const data = await res.json();
currentFiles = data.files;
totalFiles = data.total;
renderGrid();
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalFiles / PAGE_SIZE)), goPage);
}
function goPage(p) {
page = p;
loadFiles();
}
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadFiles(); });
document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadFiles(); }, 300);
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
document.querySelectorAll('#tabToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
loadGroupOptions();
loadStudentOptions();
loadFiles();
}
})();
</script>
</body>
</html>
+326
View File
@@ -0,0 +1,326 @@
<!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="modal-overlay" id="photosModal" onclick="if(event.target===this)closePhotos()">
<div class="gallery-modal">
<div class="gallery-head">
<h3 id="photosTitle">Фото</h3>
<button class="upload-btn" onclick="openAddPhoto()">Загрузить фото</button>
</div>
<div class="photo-grid" id="photoGrid"><div class="empty">Загрузка...</div></div>
<div class="pager" id="photosPager"></div>
<div class="gallery-foot">
<button class="cancel" onclick="closePhotos()">Закрыть</button>
</div>
</div>
</div>
<div class="modal-overlay" id="photoFormModal" onclick="if(event.target===this)closePhotoForm()">
<div class="edit-modal">
<h3 id="photoFormTitle">Добавить фото</h3>
<div>
<label>Выбрать файл (jpg/png и т.п.)</label>
<input type="file" id="photoFile" accept="image/*">
</div>
<div id="photoFormPreview" class="photo-preview" style="display:none"><img id="photoPreviewImg" alt=""></div>
<div>
<label>Подпись</label>
<textarea id="photoCaption" placeholder="Например: выступление, тренировка..."></textarea>
</div>
<div>
<label>Дата съёмки</label>
<input type="date" id="photoTakenAt">
</div>
<div class="actions">
<button class="cancel" onclick="closePhotoForm()">Отмена</button>
<button class="save" onclick="savePhotoForm()">Сохранить</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="admin.js"></script>
<script src="vendor/jszip.min.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">
${g.cover_path
? `<img class="group-cover" src="${API}${g.cover_path}" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Фото группы (хронология)" alt="">`
: `<div class="group-cover empty-cover" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Фото группы (хронология)">📷</div>`}
<div class="group-head">
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
<button class="del" onclick="delGroup(${g.id})" title="Удалить">&times;</button>
</div>
<div class="group-schedule">
<div><label>День</label>
<select id="dow-${g.id}">
<option value="">—</option>
${DAYS.map((d, i) => `<option value="${i}" ${g.day_of_week === i ? 'selected' : ''}>${d}</option>`).join('')}
</select>
</div>
<div><label>С</label>
<input type="time" id="ts-${g.id}" value="${g.time_start ? g.time_start.slice(0,5) : ''}">
</div>
<div><label>По</label>
<input type="time" id="te-${g.id}" value="${g.time_end ? g.time_end.slice(0,5) : ''}">
</div>
</div>
<div class="group-foot">
<div style="display:flex;gap:8px">
<button class="save-sched" onclick="saveGroup(${g.id})">Сохранить</button>
<button class="photo-btn" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Фото группы (хронология)">Фото</button>
<button class="btn-link" onclick="exportGroup(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Экспортировать записи группы">CSV</button>
<button class="btn-link" onclick="exportGroupUrls(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Скачать .url файлы учеников группы">URL</button>
</div>
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
</div>
</div>`;
}).join('') + `</div>`;
}
async function saveGroup(id) {
const name = document.getElementById(`name-${id}`).value.trim();
if (!name) return;
const dow = document.getElementById(`dow-${id}`).value;
const ts = document.getElementById(`ts-${id}`).value;
const te = document.getElementById(`te-${id}`).value;
const res = await fetch(`${API}/api/groups/${id}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ name, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
});
if (res.ok) {
loadGroups();
const dayLabel = dow !== '' ? DAYS[dow] : 'без расписания';
const timeLabel = ts && te ? `, ${ts}–${te}` : '';
showToast(`Сохранено: «${name}» — ${dayLabel}${timeLabel}`);
} else { const e = await res.json(); alert(e.error); }
}
async function addGroup() {
const input = document.getElementById('newGroup');
const name = input.value.trim();
if (!name) return;
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
if (res.ok) { input.value = ''; loadGroups(); }
else { const e = await res.json(); alert(e.error); }
}
async function delGroup(id) {
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
loadGroups();
}
// --- Фото группы (хронология) ---
const PHOTO_PAGE_SIZE = 24;
let currentGroupId = null;
let currentGroupName = '';
let photos = [];
let photoPage = 1;
let totalPhotos = 0;
let editingPhotoId = null;
async function openPhotos(groupId, groupName) {
currentGroupId = groupId;
currentGroupName = groupName;
photoPage = 1;
editingPhotoId = null;
document.getElementById('photosTitle').textContent = `Фото — ${groupName}`;
document.getElementById('photosModal').classList.add('open');
await loadGroupPhotos();
}
function closePhotos() {
document.getElementById('photosModal').classList.remove('open');
currentGroupId = null;
}
async function loadGroupPhotos() {
const p = new URLSearchParams();
p.set('limit', PHOTO_PAGE_SIZE);
p.set('offset', (photoPage - 1) * PHOTO_PAGE_SIZE);
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos?${p}`, { headers: hdr() });
const data = await res.json();
photos = data.photos;
totalPhotos = data.total;
document.getElementById('photoGrid').className = 'photo-grid';
document.getElementById('photoGrid').innerHTML = photos.length
? photos.map(photoHTML).join('')
: '<div class="empty" style="grid-column:1/-1">Фото пока нет. Загрузите первое фото.</div>';
renderPager(document.getElementById('photosPager'), photoPage, Math.max(1, Math.ceil(totalPhotos / PHOTO_PAGE_SIZE)), goPhotoPage);
}
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; }
function photoHTML(p) {
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
return `
<div class="photo-item">
<img src="${API}${p.photo_path}" onclick="showImg('${API}${p.photo_path}')" alt="">
<div class="p-cap">${esc(p.caption || '')}</div>
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
<div class="p-actions">
<button class="p-edit" onclick="openEditPhoto(${p.id})" title="Редактировать">✎</button>
<button class="p-del" onclick="deletePhoto(${p.id})" title="Удалить">&times;</button>
</div>
</div>
`;
}
function openAddPhoto() {
editingPhotoId = null;
document.getElementById('photoFormTitle').textContent = `Добавить фото — ${currentGroupName}`;
document.getElementById('photoFile').value = '';
document.getElementById('photoCaption').value = '';
document.getElementById('photoTakenAt').value = '';
document.getElementById('photoFormPreview').style.display = 'none';
document.getElementById('photoFormModal').classList.add('open');
}
async function openEditPhoto(id) {
const p = photos.find(x => x.id === id);
if (!p) return;
editingPhotoId = id;
document.getElementById('photoFormTitle').textContent = 'Редактировать фото';
document.getElementById('photoFile').value = '';
document.getElementById('photoCaption').value = p.caption || '';
document.getElementById('photoTakenAt').value = p.taken_at || '';
const prev = document.getElementById('photoFormPreview');
const img = document.getElementById('photoPreviewImg');
img.src = `${API}${p.photo_path}`;
prev.style.display = 'block';
document.getElementById('photoFormModal').classList.add('open');
}
function closePhotoForm() {
document.getElementById('photoFormModal').classList.remove('open');
editingPhotoId = null;
}
function previewPhotoFile() {
const file = document.getElementById('photoFile').files[0];
const prev = document.getElementById('photoFormPreview');
if (!file) { prev.style.display = 'none'; return; }
const img = document.getElementById('photoPreviewImg');
img.src = URL.createObjectURL(file);
prev.style.display = 'block';
}
async function savePhotoForm() {
const caption = document.getElementById('photoCaption').value.trim();
const taken_at = document.getElementById('photoTakenAt').value;
if (editingPhotoId) {
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${editingPhotoId}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ caption: caption || null, taken_at: taken_at || null })
});
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
showToast('Фото обновлено');
} else {
const file = document.getElementById('photoFile').files[0];
if (!file) { alert('Выберите файл'); return; }
const fd = new FormData();
fd.append('photo', file);
fd.append('caption', caption);
fd.append('taken_at', taken_at);
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos`, {
method: 'POST',
headers: hdr(),
body: fd
});
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
showToast('Фото добавлено в хронологию');
}
closePhotoForm();
loadGroupPhotos();
loadGroups();
}
async function deletePhoto(id) {
if (!confirm('Удалить это фото безвозвратно?')) return;
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${id}`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
showToast('Фото удалено');
loadGroupPhotos();
loadGroups();
}
async function exportGroup(id, name) {
const res = await fetch(`${API}/api/entries?group_id=${id}`, { headers: hdr() });
const { entries } = await res.json();
if (!entries.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия Имя', 'Группа', 'Описание', 'Дата', 'Ссылка на страницу']];
entries.forEach(e => {
rows.push([
e.student_name,
e.group_name,
e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'),
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
]);
});
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `group_${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast(`Экспортировано записей: ${entries.length}`);
}
async function exportGroupUrls(groupId, groupName) {
const res = await fetch(`${API}/api/students`, { headers: hdr() });
const students = await res.json();
const list = students.filter(s => s.group_id === groupId);
if (!list.length) { alert('В группе нет учеников'); return; }
await exportStudentUrlZip(list, `group_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
showToast(`Экспортировано: ${list.length}`);
}
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadGroups();
}
})();
</script>
</body>
</html>
+344
View File
@@ -0,0 +1,344 @@
<!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;padding:24px 16px}
h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text-align:center}
.sub{color:var(--muted);font-size:.85rem;margin-bottom:32px;text-align:center}
.form{width:100%;max-width:900px;margin:0 auto;display:grid;grid-template-columns:320px 1fr;gap:32px;align-items:start}
@media(max-width:760px){.form{grid-template-columns:1fr;max-width:480px}}
.photo-col{display:flex;flex-direction:column;gap:16px}
.photo-card{border-radius:var(--radius);padding:24px;display:flex;flex-direction:column;gap:16px;align-items:center}
.photo-btns{display:flex;flex-direction:column;gap:12px;width:100%}
.photo-btns button{width:100%;border-radius:var(--radius);padding:14px 16px;font-size:.95rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--text);transition:border .15s,background .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}
.photo-btns .files-btn p{font-size:.8rem;font-weight:500;margin:0}
.files-btn{width:100%;border-radius:var(--radius);padding:12px 16px;font-size:.9rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--text);transition:border .15s,background .15s}
.files-btn:hover{border-color:var(--accent)}
.files-card{width:100%;border:1px dashed var(--border);border-radius:var(--radius);padding:16px;display:flex;flex-direction:column;gap:12px;background:var(--bg)}
.files-card .files-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
.files-card label{font-size:.8rem;font-weight:600;color:var(--text);text-transform:none;letter-spacing:0}
.files-card .files-clear{background:none;border:none;color:#ef4444;font-size:.8rem;cursor:pointer;padding:0;display:none}
.files-card .files-clear.show{display:block}
.files-list{display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto}
.files-list:empty{display:none}
.files-list .file-row{display:flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:.8rem}
.files-list .file-row .f-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
.files-list .file-row button:hover{color:#ef4444}
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
.preview.has-photo{border-style:solid}
.remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
.remove-photo.show{display:block}
.fields-col{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}
.btn{width:100%;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:16px;font-size:1rem;font-weight:600;cursor:pointer;transition:opacity .15s;margin-top:8px}
.btn:hover{opacity:.85}
.btn:disabled{opacity:.4;cursor:not-allowed}
.footer{max-width:900px;margin:48px auto 0;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:16px;font-size:.85rem;color:var(--muted)}
.footer .f-left{flex:1;text-align:left}
.footer .f-right{flex:1;text-align:right}
@media(max-width:760px){.footer{flex-direction:column;text-align:center}.footer .f-left,.footer .f-right{text-align:center}}
.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;transform:scaleX(-1)}
.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="photo-col">
<div class="photo-card">
<div class="preview-wrap">
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
<button type="button" class="remove-photo" id="removePhotoBtn" onclick="removePhoto()">Убрать фото</button>
</div>
<div class="photo-btns">
<button type="button" class="cam" onclick="openCam()">Камера</button>
</div>
</div>
<div class="files-card">
<div class="files-head">
<label>Файлы проекта</label>
<button type="button" class="files-clear" id="filesClearBtn" onclick="clearFiles()">Очистить</button>
</div>
<button type="button" class="files-btn" onclick="document.getElementById('filesInput').click()">Выбрать файлы</button>
<input type="file" id="filesInput" multiple hidden>
<div class="files-list" id="filesList"></div>
</div>
</div>
<div class="fields-col">
<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>
</div>
</form>
<footer class="footer">
<div class="f-left" id="footerLeft"></div>
<div class="f-right" id="footerRight"></div>
</footer>
<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 placeholderSrc = preview.src;
const removePhotoBtn = document.getElementById('removePhotoBtn');
const groupInput = document.getElementById('groupInput');
const toast = document.getElementById('toast');
const camVideo = document.getElementById('camVideo');
const camCanvas = document.getElementById('camCanvas');
const camModal = document.getElementById('camModal');
let capturedBlob = null;
let stream = null;
const descInput = document.getElementById('descInput');
const filesInput = document.getElementById('filesInput');
const filesList = document.getElementById('filesList');
const filesClearBtn = document.getElementById('filesClearBtn');
let selectedFiles = [];
function humanSize(b) {
if (b < 1024) return b + ' Б';
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
return (b / 1048576).toFixed(1) + ' МБ';
}
function renderFiles() {
filesList.innerHTML = '';
selectedFiles.forEach((f, i) => {
const row = document.createElement('div');
row.className = 'file-row';
const name = document.createElement('span');
name.className = 'f-name';
name.textContent = f.name;
name.title = f.name;
const size = document.createElement('span');
size.className = 'f-size';
size.textContent = humanSize(f.size);
const del = document.createElement('button');
del.type = 'button';
del.textContent = '✕';
del.onclick = () => removeFile(i);
row.append(name, size, del);
filesList.appendChild(row);
});
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
}
function removeFile(i) {
selectedFiles.splice(i, 1);
renderFiles();
}
function clearFiles() {
selectedFiles = [];
filesInput.value = '';
renderFiles();
}
filesInput.addEventListener('change', () => {
const keep = 10 - selectedFiles.length;
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
filesInput.value = '';
renderFiles();
});
const params = new URLSearchParams(location.search);
let studentName = '';
function escName(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
if (params.get('nameInput')) {
studentName = params.get('nameInput');
document.getElementById('nameInput').value = studentName;
const field = document.getElementById('nameField');
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
}
(async () => {
const res = await fetch(`${API}/api/students`);
const students = await res.json();
const dl = document.getElementById('studentList');
students.forEach(s => {
const o = document.createElement('option');
o.value = s.name;
dl.appendChild(o);
});
const me = studentName ? students.find(s => s.name === studentName) : null;
if (me && me.group_id) {
groupInput.innerHTML = `<option value="${me.group_id}" selected>${escName(me.group_name || `Группа ${me.group_id}`)}</option>`;
groupInput.value = String(me.group_id);
} else {
const groupsRes = await fetch(`${API}/api/groups/active`);
const groups = await groupsRes.json();
groups.forEach(g => {
const opt = document.createElement('option');
opt.value = g.id;
opt.textContent = g.name;
groupInput.appendChild(opt);
});
if (!groups.length) {
groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>';
}
}
try {
const fRes = await fetch(`${API}/api/public-settings`);
const f = await fRes.json();
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
} catch (e) { /* ignore */ }
})();
function showPreview(src) {
preview.src = src;
preview.classList.add('has-photo');
removePhotoBtn.classList.add('show');
}
function removePhoto() {
preview.src = placeholderSrc;
preview.classList.remove('has-photo');
removePhotoBtn.classList.remove('show');
capturedBlob = null;
}
async function openCam() {
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
camVideo.srcObject = stream;
camModal.classList.add('open');
} catch (e) {
alert('Не удалось открыть камеру: ' + e.message);
}
}
function closeCam() {
camModal.classList.remove('open');
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
}
function capture() {
camCanvas.width = camVideo.videoWidth;
camCanvas.height = camVideo.videoHeight;
const ctx = camCanvas.getContext('2d');
ctx.translate(camCanvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(camVideo, 0, 0);
camCanvas.toBlob(blob => {
capturedBlob = blob;
showPreview(URL.createObjectURL(blob));
closeCam();
}, 'image/jpeg', 0.85);
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = 'Отправка...';
const fd = new FormData();
if (capturedBlob) fd.append('photo', capturedBlob, 'photo.jpg');
selectedFiles.forEach(f => fd.append('files', f, f.name));
fd.append('student_name', studentName || document.getElementById('nameInput').value);
fd.append('group_id', groupInput.value);
fd.append('description', document.getElementById('descInput').value);
try {
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
if (res.status === 429) {
const e = await res.json().catch(() => ({}));
toast.textContent = e.error || 'Уже ответили: подождите';
toast.classList.add('show', 'error');
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
return;
}
if (!res.ok) throw new Error(await res.text());
form.reset();
removePhoto();
clearFiles();
toast.textContent = 'Готово!';
toast.classList.remove('error');
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
} catch (err) {
alert('Ошибка: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Отправить';
}
});
</script>
</body>
</html>
+438
View File
@@ -0,0 +1,438 @@
<!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><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
<div><label>С</label><input type="date" id="dateFrom"></div>
<div><label>По</label><input type="date" id="dateTo"></div>
<div><label>&nbsp;</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>&nbsp;</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>&nbsp;</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>
<label>Группа (если выбрана)</label>
<input type="text" id="linkGroupPreview" readonly style="background:var(--bg);color:var(--muted);cursor:not-allowed">
</div>
<div>
<label>Ученик (необязательно)</label>
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
</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-btn" href="links.html">К списку ссылок</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 isImageFile(name) {
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function filesHTML(files) {
if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f => {
const link = isImageFile(f.name)
? `<a class="f-link" href="#" onclick="showImg('${API}/api/files/${f.token}');return false;" title="Открыть">${esc(f.name)}</a>`
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`;
return `<span class="f-item">${link}<button class="f-detach" onclick="detachFile(${f.id})" title="Открепить файл">✕</button></span>`;
}).join('')}</div>`;
}
function findFile(id) {
for (const e of currentEntries) {
const f = (e.files || []).find(x => x.id === id);
if (f) return f;
}
return null;
}
async function detachFile(id) {
const f = findFile(id);
if (!f) return;
if (!confirm(`Открепить файл «${f.name}» от записи? Он переместится в «Файлы → Откреплённые».`)) return;
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
if (res.ok) {
showToast('Файл откреплён');
loadEntries();
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка');
}
}
function gridEntryHTML(e) {
return `
<div class="card">
${e.photo_path ? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
</div>
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
<button class="del" onclick="delEntry(${e.id})" title="Удалить">&times;</button>
</div>
`;
}
function cardEntryHTML(e) {
return `
<div class="journal-card">
${e.photo_path
? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
<div class="body">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
</div>
<div class="foot">
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
<span>
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
<button class="del" onclick="delEntry(${e.id})" title="Удалить">&times;</button>
</span>
</div>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!currentEntries.length) {
grid.className = 'grid';
grid.innerHTML = '<div class="empty">Ничего не найдено</div>';
return;
}
if (view === 'cards') {
grid.className = 'journal-cards';
grid.innerHTML = currentEntries.map(cardEntryHTML).join('');
} else {
grid.className = 'grid';
grid.innerHTML = currentEntries.map(gridEntryHTML).join('');
}
}
function setView(v) {
view = v === 'cards' ? 'cards' : 'list';
localStorage.setItem('journalView', view);
document.querySelectorAll('#viewToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.view === view);
});
renderGrid();
}
function setDefaultDates() {
const today = new Date().toISOString().slice(0, 10);
document.getElementById('dateFrom').value = today;
document.getElementById('dateTo').value = today;
}
async function loadGroupOptions() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
while (sel.options.length > 1) sel.remove(1);
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id; o.textContent = g.name;
sel.appendChild(o);
});
groupsLoaded = true;
}
async function loadStudentOptions() {
if (studentsLoaded) return;
const dl = document.getElementById('studentFilterList');
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
async function loadEntries() {
const p = new URLSearchParams();
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
const data = await res.json();
currentEntries = data.entries;
totalEntries = data.total;
renderGrid();
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
}
function goPage(p) {
page = p;
loadEntries();
}
async function openEdit(id) {
const e = currentEntries.find(x => x.id === id);
if (!e) return;
editId = id;
fillAutocomplete();
document.getElementById('editName').value = e.student_name;
document.getElementById('editDesc').value = e.description;
const sel = document.getElementById('editGroup');
if (!editGroups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
editGroups = await res.json();
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
sel.value = e.group_id;
document.getElementById('editModal').classList.add('open');
}
function closeEdit() {
document.getElementById('editModal').classList.remove('open');
editId = null;
}
async function saveEdit() {
if (!editId) return;
const name = document.getElementById('editName').value.trim();
const group_id = document.getElementById('editGroup').value;
const description = document.getElementById('editDesc').value.trim();
if (!name || !group_id || !description) { alert('Заполните все поля'); return; }
const res = await fetch(`${API}/api/entries/${editId}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ student_name: name, group_id, description })
});
if (res.ok) {
closeEdit();
loadEntries();
showToast('Запись обновлена');
} else { const err = await res.json(); alert(err.error); }
}
async function delEntry(id) {
if (!confirm('Переместить запись в корзину? Файлы и фото сохранятся.')) return;
await fetch(`${API}/api/entries/${id}`, { method: 'DELETE', headers: hdr() });
showToast('Запись перемещена в корзину');
loadEntries();
}
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadEntries(); }, 300);
});
async function exportCSV() {
const p = new URLSearchParams();
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
const entries = (await res.json()).entries;
if (!entries.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия и имя', 'Группа', 'Описание', 'Дата', 'Фото']];
entries.forEach(e => {
rows.push([
e.student_name,
e.group_name,
e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'),
e.photo_path ? `${API}${e.photo_path}` : ''
]);
});
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
const bom = '\uFEFF';
const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const dateLabel = df && dt ? `${df}_${dt}` : 'all';
a.download = `journal_${dateLabel}.csv`;
a.click();
URL.revokeObjectURL(url);
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
document.querySelectorAll('#viewToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.view === view);
});
setDefaultDates();
loadGroupOptions();
loadStudentOptions();
loadEntries();
}
})();
// --- Ссылки ---
let linkGroups = [];
let linkStudentsLoaded = false;
let createdLinkUrl = '';
function fmtD(d) { return d ? d.split('-').reverse().join('.') : 'весь период'; }
async function openLinkCreate() {
const gid = document.getElementById('groupFilter').value;
if (!gid) { alert('Сначала выберите группу в фильтре'); return; }
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (!linkGroups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
linkGroups = await res.json();
}
if (!linkStudentsLoaded) {
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
const dl = document.getElementById('linkStudentList');
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
linkStudentsLoaded = true;
}
// Capture current student filter
const currentStudent = document.getElementById('studentFilter').value;
const g = linkGroups.find(x => String(x.id) === String(gid));
document.getElementById('linkPreview').innerHTML =
`Будут показаны записи: <b>${esc(g ? g.name : '')}</b><br>Период: <b>${fmtD(df)}</b> — <b>${fmtD(dt)}</b>`;
document.getElementById('linkGroupPreview').value = g ? g.name : '';
const dfl = df.split('-').reverse().join('.');
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
document.getElementById('linkStudent').value = currentStudent;
document.getElementById('linkCreateView').style.display = '';
document.getElementById('linkResultView').style.display = 'none';
document.getElementById('linkModal').classList.add('open');
}
function closeLinkCreate() {
document.getElementById('linkModal').classList.remove('open');
}
async function createLink() {
const name = document.getElementById('linkNameInput').value.trim();
const group_id = document.getElementById('groupFilter').value;
const student_name = document.getElementById('linkStudent').value;
const date_from = document.getElementById('dateFrom').value;
const date_to = document.getElementById('dateTo').value;
if (!name) { alert('Введите название'); return; }
const res = await fetch(`${API}/api/links`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null })
});
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
const link = await res.json();
createdLinkUrl = `${location.origin}/s/${link.token}`;
document.getElementById('linkUrlResult').value = createdLinkUrl;
document.getElementById('linkCreateView').style.display = 'none';
document.getElementById('linkResultView').style.display = '';
showToast('Ссылка создана');
}
function copyLink() { copyText(createdLinkUrl); }
</script>
</body>
</html>
+180
View File
@@ -0,0 +1,180 @@
<!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"><option value="">— без группы —</option></select>
</div>
<div>
<label>Ученик (необязательно)</label>
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
</div>
<div style="display:flex;gap:12px">
<div style="flex:1">
<label>С</label>
<input type="date" id="linkDateFrom">
</div>
<div 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;
let studentsLoaded = false;
function linkUrl(token) { return `${location.origin}/s/${token}`; }
async function loadLinks() {
const res = await fetch(`${API}/api/links`, { headers: hdr() });
links = await res.json();
const el = document.getElementById('linksList');
if (!links.length) { el.innerHTML = '<div class="empty">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
el.innerHTML = links.map(l => `
<div class="item">
<div style="min-width:0">
<div class="name">${esc(l.name)}</div>
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
· создана ${new Date(l.created_at).toLocaleString('ru')}
</div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
<a class="btn-link" style="text-decoration:none" href="/s/${l.token}" target="_blank" title="Открыть">Открыть</a>
<button class="btn-link" onclick="copyText('${linkUrl(l.token)}')" title="Копировать ссылку">Копировать</button>
<button class="btn-link" onclick="openLinkEdit(${l.id})" title="Редактировать">✎</button>
<button class="btn-link" style="color:var(--muted)" onclick="delLink(${l.id})" title="Удалить">&times;</button>
</div>
</div>
`).join('');
}
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
async function openLinkModal() {
editingId = null;
document.getElementById('linkModalTitle').textContent = 'Новая ссылка';
document.getElementById('linkName').value = '';
document.getElementById('linkDateFrom').value = '';
document.getElementById('linkDateTo').value = '';
if (!groups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
groups = await res.json();
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
if (!studentsLoaded) {
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
const dl = document.getElementById('linkStudentList');
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
document.getElementById('linkModal').classList.add('open');
}
async function openLinkEdit(id) {
const l = links.find(x => x.id === id);
if (!l) return;
editingId = id;
document.getElementById('linkModalTitle').textContent = 'Редактирование ссылки';
document.getElementById('linkName').value = l.name;
document.getElementById('linkDateFrom').value = l.date_from || '';
document.getElementById('linkDateTo').value = l.date_to || '';
if (!groups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
groups = await res.json();
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
document.getElementById('linkGroup').value = l.group_id || '';
if (!studentsLoaded) {
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
const names = await res.json();
const dl = document.getElementById('linkStudentList');
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
studentsLoaded = true;
}
document.getElementById('linkStudent').value = l.student_name || '';
document.getElementById('linkModal').classList.add('open');
}
function closeLinkModal() {
document.getElementById('linkModal').classList.remove('open');
editingId = null;
}
async function saveLink() {
const name = document.getElementById('linkName').value.trim();
const group_id = document.getElementById('linkGroup').value;
const student_name = document.getElementById('linkStudent').value;
const date_from = document.getElementById('linkDateFrom').value;
const date_to = document.getElementById('linkDateTo').value;
if (!name) { alert('Введите название'); return; }
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null });
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
method: editingId ? 'PUT' : 'POST',
headers: hdrJson(),
body
});
if (res.ok) {
closeLinkModal();
loadLinks();
showToast(editingId ? 'Ссылка обновлена' : 'Ссылка создана');
} else {
const err = await res.json();
alert(err.error || 'Ошибка');
}
}
async function delLink(id) {
if (!confirm('Удалить ссылку?')) return;
await fetch(`${API}/api/links/${id}`, { method: 'DELETE', headers: hdr() });
loadLinks();
showToast('Ссылка удалена');
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadLinks();
}
})();
</script>
</body>
</html>
+52
View File
@@ -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>
+232
View File
@@ -0,0 +1,232 @@
<!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">
<div class="page-head">
<h2>Настройки</h2>
<p class="page-sub">Параметры формы и данные системы</p>
</div>
<div class="settings-stack">
<div class="settings-card">
<div class="card-head">
<div class="ic">🛡️</div>
<h3>Защита от повторов</h3>
</div>
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. 0 — отключить.</p>
<div class="settings-row">
<div class="settings-field">
<label for="spamInterval">Интервал ожидания</label>
<div class="unit-row">
<input type="number" id="spamInterval" class="settings-input" min="0" max="1440" step="1">
<span class="unit">минут</span>
</div>
</div>
</div>
</div>
<div class="settings-card">
<div class="card-head">
<div class="ic">📄</div>
<h3>Футер на форме</h3>
</div>
<p class="hint">Тексты показываются внизу формы ответа ученика.</p>
<div class="settings-row">
<div class="settings-field">
<label for="footerLeft">Текст слева</label>
<input type="text" id="footerLeft" class="settings-input" placeholder="Например: Поле, руководитель">
</div>
<div class="settings-field">
<label for="footerRight">Текст справа</label>
<input type="text" id="footerRight" class="settings-input" placeholder="Например: Год, курс">
</div>
</div>
</div>
<div class="settings-card">
<div class="card-head">
<div class="ic">🗄️</div>
<h3>Резервное копирование</h3>
</div>
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
<div class="card-foot">
<button class="btn-primary ghost" id="backupBtn" onclick="downloadBackup()">⬇️ Скачать бэкап</button>
<button class="btn-primary ghost" id="restoreBtn" onclick="document.getElementById('restoreFile').click()">⬆️ Восстановить из файла</button>
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
</div>
<div class="restore-status" id="restoreStatus"></div>
</div>
</div>
</div>
</div>
<div class="save-bar" id="saveBar">
<span class="hint" id="saveHint">⚠️ Есть несохранённые изменения</span>
<button class="btn-primary" id="saveBtn" onclick="saveSettings()">Сохранить изменения</button>
</div>
<div class="toast" id="toast"></div>
<script src="admin.js"></script>
<script>
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight'];
function setBtnLoading(btn, on, spinnerClass) {
if (on) {
btn.dataset.label = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
} else {
btn.disabled = false;
btn.innerHTML = btn.dataset.label || btn.innerHTML;
}
}
function updateDirty() {
const dirty = DIRTY_FIELDS.some(id => {
const el = document.getElementById(id);
return String(el.dataset.original ?? '') !== el.value;
});
document.getElementById('saveBar').classList.toggle('show', dirty);
}
function bindDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).addEventListener('input', updateDirty);
});
}
function snapshotDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).dataset.original = document.getElementById(id).value;
});
updateDirty();
}
async function loadSettings() {
const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }),
fetch(`${API}/api/stats`, { headers: hdr() }),
]);
const s = await sRes.json();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || '';
snapshotDirty();
if (stRes.ok) {
const st = await stRes.json();
document.getElementById('dataStats').innerHTML = `
<span class="chip">Записей: <b>${st.entries}</b></span>
<span class="chip">Учеников: <b>${st.students}</b></span>
<span class="chip">Групп: <b>${st.groups}</b></span>
<span class="chip">Сегодня: <b>${st.today}</b></span>
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
`;
} else {
document.getElementById('dataStats').innerHTML = '';
}
}
async function saveSettings() {
const v = document.getElementById('spamInterval').value;
if (v === '' || Number(v) < 0) { alert('Введите число от 0'); return; }
const btn = document.getElementById('saveBtn');
btn.dataset.loadingText = 'Сохранение...';
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/settings`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ settings: {
spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
} })
});
if (res.ok) {
showToast('Настройки сохранены');
snapshotDirty();
} else { const e = await res.json(); alert(e.error); }
} finally {
setBtnLoading(btn, false);
}
}
async function downloadBackup() {
const btn = document.getElementById('backupBtn');
btn.dataset.loadingText = 'Формирование...';
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
showToast('Бэкап скачан');
} finally {
setBtnLoading(btn, false);
}
}
document.getElementById('restoreFile').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
const st = document.getElementById('restoreStatus');
const backupBtn = document.getElementById('backupBtn');
const restoreBtn = document.getElementById('restoreBtn');
st.classList.remove('err');
st.innerHTML = '<span class="spinner dark"></span> Восстановление...';
backupBtn.disabled = true;
restoreBtn.disabled = true;
const fd = new FormData();
fd.append('backup', file);
let res;
try {
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
} catch (err) {
st.textContent = '';
alert('Ошибка сети: ' + err.message);
e.target.value = '';
return;
} finally {
backupBtn.disabled = false;
restoreBtn.disabled = false;
}
if (res.ok) {
showToast('Данные восстановлены');
st.textContent = '';
loadSettings();
} else {
st.classList.add('err');
st.innerHTML = 'Ошибка восстановления';
const t = await res.text();
alert('Ошибка: ' + t);
}
e.target.value = '';
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
bindDirty();
loadSettings();
}
})();
</script>
</body>
</html>
+159
View File
@@ -0,0 +1,159 @@
<!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}
.files-block{margin-top:10px}
.files-label{font-size:.7rem;font-weight:800;color:var(--gray);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}
.card .files{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
.card .files a{display:flex;align-items:center;justify-content:center;width:44px;height:44px;background:#F6F6F6;border:1px solid #EBEBEB;color:#3a3f4a;text-decoration:none;border-radius:12px;transition:border .15s,color .15s,background .15s}
.card .files a:hover{border-color:var(--yellow);color:var(--dark);background:#fff}
.card .files .ficon{font-size:1.25rem;line-height:1;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:8px;flex-shrink:0}
.card .files a.ftype-image .ficon{background:#EAF4E8}
.card .files a.ftype-music .ficon{background:#EAEEF6}
.card .files a.ftype-document .ficon{background:#F6F0E0}
.card .files a.ftype-presentation .ficon{background:#F6E4E8}
.card .files a.ftype-other .ficon{background:#EFEFEF}
.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)}
#photosBlock{display:none;margin-bottom:44px}
.photos-label{font-size:.78rem;font-weight:800;color:var(--gray);text-transform:uppercase;letter-spacing:.05em;margin-bottom:14px}
.photos-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
.gphoto{position:relative;border-radius:18px;overflow:hidden;background:#fff;display:block;box-shadow:0 1px 3px rgba(20,30,40,.06);text-decoration:none}
.gphoto img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;cursor:zoom-in;transition:transform .18s ease}
.gphoto:hover img{transform:scale(1.03)}
.gcap{position:absolute;left:0;right:0;bottom:0;padding:22px 12px 9px;font-size:.74rem;font-weight:600;color:#fff;background:linear-gradient(transparent,rgba(0,0,0,.6));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@media(max-width:1000px){.grid{grid-template-columns:repeat(3,1fr)}.photos-grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:760px){.grid{grid-template-columns:repeat(2,1fr)}.photos-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:480px){.grid{grid-template-columns:1fr}.photos-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 id="photosBlock">
<div class="photos-label">Фото группы</div>
<div class="photos-grid" id="photosGrid"></div>
</div>
<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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
function isImageFile(name){return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
function fileType(name){
if(/\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image';
if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music';
if(/\.(pptx?|odp|key)$/i.test(name||'')) return 'presentation';
if(/\.(pdf|docx?|xlsx?|txt|rtf|odt|ods|csv|md)$/i.test(name||'')) return 'document';
return 'other';
}
function fileIcon(t){
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
return icons[t]||icons.other;
}
function filesHTML(files){
if(!files||!files.length)return '';
return `<div class="files-block">
<div class="files-label">Работы резидента</div>
<div class="files">${files.map(f=>{
const t = fileType(f.name);
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
?`href="#" onclick="showImg('/api/files/${f.token}');return false;"`
:`href="/api/files/${f.token}" download`}>
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
</a>`;
}).join('')}</div>
</div>`;
}
(async()=>{
const token = location.pathname.split('/').filter(Boolean).pop() || '';
try {
const res = await fetch('/api/share/' + token);
if (!res.ok) throw new Error();
const data = await res.json();
const heading = data.group_name || data.name || 'Записи занятий';
document.title = heading;
document.getElementById('title').textContent = heading;
const chips = [];
if (data.group_name) chips.push(`Группа: <b>${esc(data.group_name)}</b>`);
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
const pb = document.getElementById('photosBlock');
if (data.photos && data.photos.length) {
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
<a class="gphoto" href="#" onclick="showImg('${p.photo_path}');return false;">
<img src="${p.photo_path}" alt="${esc(p.caption || '')}" loading="lazy">
${p.caption ? `<div class="gcap">${esc(p.caption)}</div>` : ''}
</a>
`).join('');
pb.style.display = 'block';
} else {
pb.style.display = 'none';
}
const grid = document.getElementById('grid');
if (!data.entries.length) {
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
return;
}
grid.innerHTML = data.entries.map(e => `
<div class="card">
<div class="photo">
${e.photo_path
? `<img src="${e.photo_path}" onclick="showImg('${e.photo_path}')" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
</div>
<div 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>
${filesHTML(e.files)}
</div>
<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>
</div>
`).join('');
} catch {
document.getElementById('title').textContent = 'Ссылка не найдена';
document.getElementById('grid').innerHTML = '<div class="empty"><div class="big">🤔</div>Эта ссылка не существует или была удалена</div>';
}
})();
</script>
</body>
</html>
+296
View File
@@ -0,0 +1,296 @@
<!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>
<button class="btn-link" onclick="exportStudentsUrls()">Экспорт URL</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="Иванов Иван&#10;Петров Пётр&#10;Сидоров Сидор"></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 src="vendor/jszip.min.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, '&#39;')}')" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
<button class="btn-del" onclick="delStudent(${s.id})" title="Удалить">&times;</button>
</div>
</div>
`).join('');
slice.forEach(s => loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id));
}
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
}
function goStudentPage(p) {
sPage = p;
renderStudents();
}
async function addStudent() {
const input = document.getElementById('newStudent');
const name = input.value.trim();
if (!name) return;
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
if (res.ok) { input.value = ''; loadStudents(); fillAutocomplete(); showToast(`Добавлен: ${name}`); }
else { const e = await res.json(); alert(e.error); }
}
async function saveStudent(id) {
const name = document.getElementById(`sname-${id}`).value.trim();
if (!name) return;
const group_id = document.getElementById(`sgroup-${id}`).value;
const res = await fetch(`${API}/api/students/${id}`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ name, group_id }) });
if (res.ok) { loadStudents(); fillAutocomplete(); showToast(`Сохранено: ${name}`); }
else { const e = await res.json(); alert(e.error); }
}
async function delStudent(id) {
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
loadStudents();
fillAutocomplete();
}
function openBatchAdd() {
document.getElementById('batchText').value = '';
loadGroupOptions(document.getElementById('batchGroup'), '');
document.getElementById('batchModal').classList.add('open');
}
function closeBatchAdd() {
document.getElementById('batchModal').classList.remove('open');
}
async function batchAdd() {
const raw = document.getElementById('batchText').value;
const names = [...new Set(raw.split('\n').map(s => s.trim()).filter(Boolean))];
if (!names.length) return;
const group_id = document.getElementById('batchGroup').value;
let added = 0, dupes = 0;
for (const name of names) {
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, group_id: group_id || null }) });
if (res.ok) added++;
else if (res.status === 409) dupes++;
}
closeBatchAdd();
loadStudents();
fillAutocomplete();
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}`);
}
document.getElementById('newStudent').addEventListener('keydown', e => { if (e.key === 'Enter') addStudent(); });
document.getElementById('studentSearch').addEventListener('input', () => {
clearTimeout(sSearchTimer);
sSearchTimer = setTimeout(() => { sPage = 1; renderStudents(); }, 300);
});
document.getElementById('studentGroupFilter').addEventListener('change', () => { sPage = 1; renderStudents(); });
function exportStudentsCSV() {
const gid = document.getElementById('studentGroupFilter').value;
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
if (!list.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия Имя', 'Группа', 'Ссылка на страницу']];
list.forEach(s => {
rows.push([
s.name,
s.group_name || '',
`${location.origin}/?nameInput=${encodeURIComponent(s.name)}`
]);
});
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
a.download = `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast(`Экспортировано: ${list.length}`);
}
async function exportStudentsUrls() {
const gid = document.getElementById('studentGroupFilter').value;
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
if (!list.length) { alert('Нет данных для экспорта'); return; }
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
await exportStudentUrlZip(list, `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
showToast(`Экспортировано: ${list.length}`);
}
// --- Batch attach ---
async function openBatchAttach() {
const sel = document.getElementById('attachGroup');
if (!attachGroupLoaded) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
sel.innerHTML = `<option value="">— выберите группу —</option>` +
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
attachGroupLoaded = true;
}
document.getElementById('attachSearch').value = '';
renderAttachList();
document.getElementById('attachModal').classList.add('open');
}
function closeBatchAttach() {
document.getElementById('attachModal').classList.remove('open');
}
function renderAttachList() {
const q = document.getElementById('attachSearch').value.trim().toLowerCase();
const filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
const list = document.getElementById('attachList');
if (!filtered.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:16px">Никого не найдено</div>'; return; }
list.innerHTML = filtered.map(s => `
<label>
<input type="checkbox" value="${s.id}">
<span>${esc(s.name)}</span>
<span class="cur">${s.group_name ? esc(s.group_name) : ''}</span>
</label>
`).join('');
}
async function batchAttach() {
const group_id = document.getElementById('attachGroup').value;
const ids = [...document.querySelectorAll('#attachList input[type=checkbox]:checked')].map(c => c.value);
if (!group_id) { alert('Выберите группу'); return; }
if (!ids.length) { alert('Выберите учеников'); return; }
const res = await fetch(`${API}/api/students/batch-group`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ group_id, student_ids: ids })
});
if (res.ok) {
const r = await res.json();
closeBatchAttach();
loadStudents();
showToast(`Прикреплено: ${r.updated}`);
} else { const e = await res.json(); alert(e.error); }
}
document.getElementById('attachSearch').addEventListener('input', () => {
clearTimeout(aSearchTimer);
aSearchTimer = setTimeout(renderAttachList, 300);
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
fillAutocomplete();
loadGroupOptions();
loadStudents();
}
})();
</script>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
<!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="trash">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<h2>Корзина</h2>
<div class="trash-head">
<span class="trash-muted" id="trashInfo"></span>
<button class="danger-btn" id="emptyBtn" onclick="emptyTrash()">Очистить корзину</button>
</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="toast" id="toast"></div>
<script src="admin.js"></script>
<script>
const PAGE_SIZE = 20;
let currentEntries = [];
let page = 1;
let totalEntries = 0;
function isImageFile(name) {
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function filesHTML(files) {
if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f =>
isImageFile(f.name)
? `<a class="f-link" href="#" onclick="showImg('${API}/api/files/${f.token}');return false;" title="Открыть">${esc(f.name)}</a>`
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`
).join('')}</div>`;
}
function entryHTML(e) {
return `
<div class="card">
${e.photo_path ? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" onclick="restoreEntry(${e.id})" title="Восстановить">Восстановить</button>
<button class="del-btn" onclick="hardDelete(${e.id})" title="Удалить навсегда (вместе с файлами)">&times;</button>
</div>
`;
}
function renderGrid() {
const grid = document.getElementById('grid');
grid.className = 'grid';
grid.innerHTML = currentEntries.length
? currentEntries.map(entryHTML).join('')
: '<div class="empty">Корзина пуста</div>';
document.getElementById('trashInfo').textContent =
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
document.getElementById('emptyBtn').disabled = totalEntries === 0;
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
}
async function loadTrash() {
const p = new URLSearchParams();
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
const data = await res.json();
currentEntries = data.entries;
totalEntries = data.total;
renderGrid();
}
function goPage(p) { page = p; loadTrash(); }
async function restoreEntry(id) {
const res = await fetch(`${API}/api/entries/${id}/restore`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Запись восстановлена'); loadTrash(); }
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
}
async function hardDelete(id) {
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
showToast('Запись удалена навсегда');
loadTrash();
}
async function emptyTrash() {
if (totalEntries === 0) return;
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
loadTrash();
}
})();
</script>
</body>
</html>
+13
View File
File diff suppressed because one or more lines are too long
+22
View File
@@ -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"
+37
View File
@@ -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)"
+1004
View File
File diff suppressed because it is too large Load Diff