Files
WhatIDo/public/journal.html
T

516 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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" style="max-width:480px">
<h3>Редактирование записи</h3>
<form id="editForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Фамилия и имя <span style="color:#ef4444">*</span></label>
<input type="text" id="editName" class="settings-input" list="studentEditList" required autocomplete="off">
<datalist id="studentEditList"></datalist>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="editGroup" class="settings-input" required></select>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Что делала(а) <span style="color:#ef4444">*</span></label>
<textarea id="editDesc" class="settings-input" required></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Прикрепить файлы</label>
<input type="file" id="editFiles" multiple accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.zip,.rar,.7z,.jpg,.jpeg,.png,.gif,.webp">
<div class="files-preview" id="editFilesPreview"></div>
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" onclick="closeEdit()">Отмена</button>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
</form>
</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>
<label>Истекает через (дни)</label>
<input type="number" id="linkExpiresDays" min="1" max="365" value="7" placeholder="7">
</div>
<div>
<label>
<input type="checkbox" id="linkAnonymizeNames">
Показывать только инициалы учеников (анонимизировать)
</label>
</div>
<div>
<label>Пароль для доступа (необязательно)</label>
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
</div>
<div class="actions">
<button class="cancel" 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');
document.getElementById('editFiles').value = '';
document.getElementById('editFilesPreview').innerHTML = '';
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) { const err = await res.json(); alert(err.error); return; }
const files = document.getElementById('editFiles').files;
if (files.length) {
const formData = new FormData();
for (const f of files) formData.append('files', f);
const uploadRes = await fetch(`${API}/api/entries/${editId}/files`, {
method: 'POST',
headers: { 'X-Admin-Token': token },
body: formData
});
if (!uploadRes.ok) {
const err = await uploadRes.json().catch(() => ({}));
alert(err.error || 'Ошибка загрузки файлов');
return;
}
}
closeEdit();
loadEntries();
showToast('Запись обновлена');
}
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);
});
document.getElementById('editForm').addEventListener('submit', (e) => {
e.preventDefault();
saveEdit();
});
document.getElementById('editFiles').addEventListener('change', (e) => {
const preview = document.getElementById('editFilesPreview');
preview.innerHTML = '';
Array.from(e.target.files).forEach((f, i) => {
const div = document.createElement('div');
div.className = 'file-preview-item';
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
div.innerHTML = `<span>${esc(f.name)}</span> <span style="color:var(--muted)">${(f.size/1024).toFixed(1)} KB</span> <button type="button" onclick="removeEditFile(${i})" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1">✕</button>`;
preview.appendChild(div);
});
});
function removeEditFile(index) {
const input = document.getElementById('editFiles');
const dt = new DataTransfer();
Array.from(input.files).forEach((f, i) => { if (i !== index) dt.items.add(f); });
input.files = dt.files;
input.dispatchEvent(new Event('change'));
}
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('linkExpiresDays').value = '7';
document.getElementById('linkAnonymizeNames').checked = false;
document.getElementById('linkPassword').value = '';
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;
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
const access_password = document.getElementById('linkPassword').value;
if (!name) { alert('Введите название'); return; }
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
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, anonymize_names, expires_at, access_password: access_password || 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>