feat(datetime): timezone/time_format в настройках + единый хелпер дат
Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью. Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше $n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на tzWall(). Новый public/js/datetime.js: три семейства хелперов (instant / чистая DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах включая публичные share/report/index, initDateTime() встроен в checkAuth(). Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены. Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js; починены off-by-one месяца в report.js и группировка по дате в share.js.
This commit is contained in:
+2
-1
@@ -111,7 +111,8 @@ const FIELD_LABELS = {
|
||||
};
|
||||
|
||||
function fmtTime(t) {
|
||||
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
||||
const f = fmtFull(t);
|
||||
return f || t || '';
|
||||
}
|
||||
|
||||
function parseTarget(t) {
|
||||
|
||||
+2
-2
@@ -41,8 +41,8 @@ function renderPage() {
|
||||
<tr>
|
||||
<td><b>${esc(b.ip)}</b></td>
|
||||
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
|
||||
<td>${new Date(b.created_at).toLocaleString('ru')}</td>
|
||||
<td>${new Date(b.banned_until).toLocaleString('ru')}</td>
|
||||
<td>${fmtFull(b.created_at)}</td>
|
||||
<td>${fmtFull(b.banned_until)}</td>
|
||||
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -30,22 +30,22 @@ function renderStats(s, disk) {
|
||||
|
||||
function last14days() {
|
||||
const out = [];
|
||||
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
|
||||
const base = todayIso();
|
||||
for (let i = 13; i >= 0; i--) out.push(isoAddDays(base, -i));
|
||||
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 days = last14days().map(iso => {
|
||||
return { iso, label: fmtDayMonthIso(iso), n: map[iso] || 0 };
|
||||
});
|
||||
const max = Math.max(1, ...days.map(x => x.n));
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const todayKey = todayIso();
|
||||
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 ${x.iso === todayKey ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
|
||||
<div class="bar-day">${x.label}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function renderRecent(rows) {
|
||||
: `<div class="thumb"><i data-lucide="graduation-cap"></i></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 class="r-meta">${esc(e.group_name)} · ${fmtDateTime(e.created_at)}</div>
|
||||
</div>
|
||||
<a class="r-link" href="journal.html" title="В журнал">→</a>
|
||||
</div>`).join('');
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
const DateTime = { tz: '', hour12: false, ready: false, listeners: [] };
|
||||
|
||||
const DT_FALLBACK_TZ = 'Europe/Moscow';
|
||||
const DT_CACHE = new Map();
|
||||
|
||||
function dtValidTz(tz) {
|
||||
if (!tz || typeof tz !== 'string') return false;
|
||||
try { new Intl.DateTimeFormat('ru-RU', { timeZone: tz }); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function dtBrowserTz() {
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || DT_FALLBACK_TZ; } catch { return DT_FALLBACK_TZ; }
|
||||
}
|
||||
|
||||
function dtFmt(opts) {
|
||||
const key = DateTime.tz + '|' + (DateTime.hour12 ? '12' : '24') + '|' + JSON.stringify(opts);
|
||||
let f = DT_CACHE.get(key);
|
||||
if (!f) {
|
||||
f = new Intl.DateTimeFormat('ru-RU', Object.assign({ timeZone: DateTime.tz, hour12: DateTime.hour12 }, opts));
|
||||
DT_CACHE.set(key, f);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
function dtDate(v) {
|
||||
if (!v) return null;
|
||||
const d = v instanceof Date ? v : new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function fmtDt(v, opts) {
|
||||
const d = dtDate(v);
|
||||
return d ? dtFmt(opts).format(d) : '';
|
||||
}
|
||||
|
||||
function dtFmtParts() {
|
||||
return { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' };
|
||||
}
|
||||
|
||||
function fmtFull(v) { return fmtDt(v, dtFmtParts()); }
|
||||
function fmtDateTime(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||||
function fmtDateTimeY(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); }
|
||||
function fmtDateFull(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: 'numeric' }); }
|
||||
function fmtDayMonth(v) { return fmtDt(v, { day: '2-digit', month: '2-digit' }); }
|
||||
function fmtDateShort(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: '2-digit' }); }
|
||||
function fmtDateLong(v) { return fmtDt(v, { day: 'numeric', month: 'long', year: 'numeric' }); }
|
||||
function fmtTimeOnly(v) { return fmtDt(v, { hour: '2-digit', minute: '2-digit' }); }
|
||||
|
||||
function dtIsoMatch(v) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v == null ? '' : v));
|
||||
return m ? { y: m[1], m: m[2], d: m[3] } : null;
|
||||
}
|
||||
|
||||
function dtPad(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function fmtDateOnlyIso(v) {
|
||||
const p = dtIsoMatch(v);
|
||||
return p ? `${p.d}.${p.m}.${p.y}` : '';
|
||||
}
|
||||
|
||||
function fmtDayMonthIso(v) {
|
||||
const p = dtIsoMatch(v);
|
||||
return p ? `${p.d}.${p.m}` : '';
|
||||
}
|
||||
|
||||
function fmtShortIso(v) {
|
||||
const p = dtIsoMatch(v);
|
||||
return p ? `${p.d}.${p.m}.${p.y.slice(2)}` : '';
|
||||
}
|
||||
|
||||
function fmtHmStr(v) {
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec(String(v == null ? '' : v));
|
||||
if (!m) return '';
|
||||
const h = Number(m[1]);
|
||||
const mm = m[2];
|
||||
if (!DateTime.hour12) return `${dtPad(h)}:${mm}`;
|
||||
const ap = h < 12 ? 'AM' : 'PM';
|
||||
const h12 = h % 12 === 0 ? 12 : h % 12;
|
||||
return `${h12}:${mm} ${ap}`;
|
||||
}
|
||||
|
||||
function fmtDateOnlyLongIso(v) {
|
||||
const p = dtIsoMatch(v);
|
||||
if (!p) return '';
|
||||
const months = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
|
||||
return `${Number(p.d)} ${months[Number(p.m) - 1] || ''} ${p.y}`;
|
||||
}
|
||||
|
||||
function dtZoneDate(d) {
|
||||
try { return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d); } catch { return ''; }
|
||||
}
|
||||
|
||||
function todayIso() { return dtZoneDate(new Date()); }
|
||||
|
||||
function todayDow() {
|
||||
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
try { return names.indexOf(new Intl.DateTimeFormat('en-US', { timeZone: DateTime.tz, weekday: 'short' }).format(new Date())); } catch { return new Date().getDay(); }
|
||||
}
|
||||
|
||||
function nowHm() {
|
||||
try { return new Intl.DateTimeFormat('en-GB', { timeZone: DateTime.tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()); } catch { return ''; }
|
||||
}
|
||||
|
||||
function nowYear() {
|
||||
try { return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric' }).format(new Date()); } catch { return String(new Date().getFullYear()); }
|
||||
}
|
||||
|
||||
function isoAddDays(iso, n) {
|
||||
const p = dtIsoMatch(iso);
|
||||
if (!p) return '';
|
||||
const d = new Date(Date.UTC(Number(p.y), Number(p.m) - 1, Number(p.d)));
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dtApply(s) {
|
||||
if (!s || typeof s !== 'object') return;
|
||||
if (dtValidTz(s.timezone)) DateTime.tz = s.timezone;
|
||||
DateTime.hour12 = s.time_format === '12h';
|
||||
DateTime.listeners.forEach(fn => { try { fn(DateTime); } catch {} });
|
||||
}
|
||||
|
||||
async function initDateTime() {
|
||||
DateTime.tz = dtBrowserTz();
|
||||
DateTime.hour12 = false;
|
||||
try {
|
||||
const res = await fetch('/api/public-settings');
|
||||
if (res.ok) dtApply(await res.json());
|
||||
} catch {}
|
||||
DateTime.ready = true;
|
||||
return DateTime;
|
||||
}
|
||||
|
||||
function onDateTimeChange(fn) {
|
||||
if (typeof fn === 'function') DateTime.listeners.push(fn);
|
||||
}
|
||||
+1
-1
@@ -51,7 +51,7 @@ function fileCardHTML(f) {
|
||||
<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 class="time">${fmtFull(f.created_at)}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
|
||||
</div>
|
||||
<div class="f-actions">${action}</div>
|
||||
</div>
|
||||
|
||||
+5
-6
@@ -67,7 +67,7 @@ async function loadGroups() {
|
||||
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
|
||||
<button class="btn-link" data-export-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</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 class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${fmtHmStr(g.time_start)}–${fmtHmStr(g.time_end)}` : 'Не настроено'}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') + `</div>`;
|
||||
@@ -162,11 +162,11 @@ async function loadGroupPhotos() {
|
||||
|
||||
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
|
||||
|
||||
function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; }
|
||||
function fmtPhotoDate(d) { return fmtDateOnlyIso(d); }
|
||||
|
||||
function photoHTML(p) {
|
||||
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
|
||||
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
|
||||
const uploaded = `Загружено: ${fmtDateFull(p.created_at)}`;
|
||||
const isCover = currentCover && p.photo_path === currentCover;
|
||||
return `
|
||||
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
|
||||
@@ -274,7 +274,7 @@ async function exportGroup(id, name) {
|
||||
e.student_name,
|
||||
e.group_name,
|
||||
e.description.replace(/\n/g, ' '),
|
||||
new Date(e.created_at).toLocaleString('ru'),
|
||||
fmtFull(e.created_at),
|
||||
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
|
||||
]);
|
||||
});
|
||||
@@ -318,8 +318,7 @@ function closeGroupFilesModal() {
|
||||
}
|
||||
|
||||
function todayISOLocal() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
return todayIso();
|
||||
}
|
||||
|
||||
const GROUP_FILES_PHOTO_BOXES = ['groupFilesMainPhoto', 'groupFilesGallery', 'groupFilesOriginals'];
|
||||
|
||||
+2
-1
@@ -166,7 +166,7 @@ function pasteFileName(file) {
|
||||
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
||||
const stamp = `${todayIso()} ${nowHm().replace(':', '-')}-${pad(d.getSeconds())}`;
|
||||
return `Вставка ${stamp}${extFromFile(file)}`;
|
||||
}
|
||||
|
||||
@@ -281,6 +281,7 @@ async function resolveModuleId(name) {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await initDateTime();
|
||||
try {
|
||||
const list = await fetchModules('', 50);
|
||||
if (list.length) {
|
||||
|
||||
@@ -124,7 +124,7 @@ function gridEntryHTML(e) {
|
||||
<div class="group">${esc(e.group_name)}${e.module_name ? ' · ' + esc(e.module_name) : ''}</div>
|
||||
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
|
||||
${filesHTML(e.files)}
|
||||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
||||
<div class="time">${fmtFull(e.created_at)}</div>
|
||||
</div>
|
||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
||||
@@ -148,7 +148,7 @@ function cardEntryHTML(e) {
|
||||
${filesHTML(e.files)}
|
||||
</div>
|
||||
<div class="foot">
|
||||
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
||||
<span>${fmtFull(e.created_at)}</span>
|
||||
<span>
|
||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
||||
@@ -220,7 +220,7 @@ function setView(v) {
|
||||
}
|
||||
|
||||
function setDefaultDates() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = todayIso();
|
||||
document.getElementById('dateFrom').value = today;
|
||||
document.getElementById('dateTo').value = today;
|
||||
}
|
||||
@@ -895,7 +895,7 @@ async function loadPhotoHistory() {
|
||||
</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)}
|
||||
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(new Date(time).toLocaleString('ru'))}</span>
|
||||
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(fmtFull(time))}</span>
|
||||
</div>
|
||||
${err}
|
||||
</div>
|
||||
@@ -1229,7 +1229,7 @@ function renderEditAiStatus(e) {
|
||||
el.style.display = 'block';
|
||||
const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original;
|
||||
const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`];
|
||||
if (e.ai_checked_at) parts.push(new Date(e.ai_checked_at).toLocaleString('ru'));
|
||||
if (e.ai_checked_at) parts.push(fmtFull(e.ai_checked_at));
|
||||
let html = `<span>${esc(parts.join(' · '))}</span>`;
|
||||
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
|
||||
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
|
||||
@@ -1416,7 +1416,7 @@ async function exportCSV() {
|
||||
e.group_name,
|
||||
e.module_name || '',
|
||||
e.description.replace(/\n/g, ' '),
|
||||
new Date(e.created_at).toLocaleString('ru'),
|
||||
fmtFull(e.created_at),
|
||||
e.photo_path ? `${API}${e.photo_path}` : ''
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -7,14 +7,12 @@ let groupsLoaded = false;
|
||||
function lessonDateText(iso) {
|
||||
const s = String(iso || '').slice(0, 10);
|
||||
if (!s) return '';
|
||||
const d = new Date(s + 'T00:00:00');
|
||||
if (Number.isNaN(d.getTime())) return s;
|
||||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||
return fmtShortIso(s) || s;
|
||||
}
|
||||
|
||||
function lessonWhenText(r) {
|
||||
const date = lessonDateText(r.lesson_date);
|
||||
const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
|
||||
const time = r.lesson_time ? fmtHmStr(r.lesson_time) : '';
|
||||
return time ? `${date}, ${time}` : date;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -41,10 +41,10 @@ async function loadLinks() {
|
||||
${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>` : ' · весь период'}
|
||||
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${new Date(l.expires_at).toLocaleDateString('ru')}</span>` : l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
||||
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${fmtDateFull(l.expires_at)}</span>` : l.expires_at ? ` · истекает ${fmtDateFull(l.expires_at)}` : ''}
|
||||
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
|
||||
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
||||
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
||||
· создана ${fmtFull(l.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ function photoCardHTML(p) {
|
||||
<span class="ph-badge">${esc(p.source_label)}</span>
|
||||
<div class="ph-title">${esc(p.title || 'Без описания')}</div>
|
||||
${meta.length ? `<div class="ph-meta">${meta.join(' · ')}</div>` : ''}
|
||||
<div class="ph-time">${new Date(p.created_at).toLocaleString('ru')}</div>
|
||||
<div class="ph-time">${fmtFull(p.created_at)}</div>
|
||||
${link ? `<a class="ph-link" href="${link}">Открыть источник →</a>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+21
-12
@@ -24,9 +24,10 @@ function loadBrand(){
|
||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
||||
function pad(n){return String(n).padStart(2,'0')}
|
||||
function toDate(v){if(!v)return null;const d=new Date(v);return isNaN(d.getTime())?null:d}
|
||||
function fmtDate(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+d.getFullYear():''}
|
||||
function fmtShort(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+String(d.getFullYear()).slice(2):''}
|
||||
function fmtLong(v){const d=toDate(v);return d?d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'}):''}
|
||||
function dtIsDateOnly(v){return typeof v==='string'&&/^\d{4}-\d{2}-\d{2}/.test(v)}
|
||||
function fmtDate(v){return dtIsDateOnly(v)?fmtDateOnlyIso(v):(v?fmtDateFull(v):'')}
|
||||
function fmtShort(v){return dtIsDateOnly(v)?fmtShortIso(v):(v?fmtDateShort(v):'')}
|
||||
function fmtLong(v){return dtIsDateOnly(v)?fmtDateOnlyLongIso(v):(v?fmtDateLong(v):'')}
|
||||
function plural(n,one,few,many){const m10=n%10,m100=n%100;if(m100>=11&&m100<=14)return many;if(m10===1)return one;if(m10>=2&&m10<=4)return few;return many}
|
||||
function words(n,one,few,many){return n+' '+plural(n,one,few,many)}
|
||||
function isImageFile(name){return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
|
||||
@@ -41,14 +42,19 @@ function hideEl(el,on){if(el)el.classList.toggle('rep-hidden',!!on)}
|
||||
function node(html){const t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstElementChild}
|
||||
function periodLabel(from,to){
|
||||
if(!from&&!to)return '';
|
||||
const a=toDate(from),b=toDate(to);
|
||||
if(a&&b)return fmtDate(a)+' — '+fmtDate(b);
|
||||
return fmtDate(a)||fmtDate(b);
|
||||
if(from&&to)return fmtDate(from)+' — '+fmtDate(to);
|
||||
return fmtDate(from)||fmtDate(to);
|
||||
}
|
||||
function dtIsoYear(v){
|
||||
if(!v)return null;
|
||||
if(dtIsDateOnly(v))return String(v).slice(0,4);
|
||||
const d=toDate(v);
|
||||
if(!d)return null;
|
||||
try{return new Intl.DateTimeFormat('en-CA',{timeZone:DateTime.tz,year:'numeric'}).format(d).slice(0,4)}catch{return String(d.getFullYear())}
|
||||
}
|
||||
function yearLabel(from,to){
|
||||
const a=toDate(from),b=toDate(to);
|
||||
if(!a&&!b)return '';
|
||||
const ay=a?a.getFullYear():null,by=b?b.getFullYear():null;
|
||||
if(!from&&!to)return '';
|
||||
const ay=dtIsoYear(from),by=dtIsoYear(to);
|
||||
if(ay&&by)return ay===by?String(ay):ay+' — '+String(by).slice(2);
|
||||
return String(ay||by);
|
||||
}
|
||||
@@ -268,12 +274,15 @@ function statSep(variant){
|
||||
const extra=variant===1?' list__item--u-ip5wdmggw':(variant===2?' list__item--u-ixebr6v8q':'');
|
||||
return '<div class="list__item list__item--u-isd3lg3iz'+extra+'"></div>';
|
||||
}
|
||||
function dtYearMonth(d){
|
||||
try{return new Intl.DateTimeFormat('en-CA',{timeZone:DateTime.tz,year:'numeric',month:'2-digit'}).format(d).slice(0,7)}catch{return d.getFullYear()+'-'+d.getMonth()}
|
||||
}
|
||||
function renderAbout(data,pool){
|
||||
const entries=data.entries||[];
|
||||
const photos=(data.photos||[]).length;
|
||||
const files=entries.reduce(function(n,e){return n+((e.files||[]).length)},0);
|
||||
const months={};
|
||||
entries.forEach(function(e){const d=toDate(e.created_at);if(d)months[d.getFullYear()+'-'+d.getMonth()]=1});
|
||||
entries.forEach(function(e){const d=toDate(e.created_at);if(d)months[dtYearMonth(d)]=1});
|
||||
const photosTotal=entries.filter(function(e){return e.photo_path}).length+photos;
|
||||
setText('aboutTitle',(data.name&&String(data.name).trim())||'Отчёт');
|
||||
setText('aboutLead',(data.message&&String(data.message).trim())||('Мы собрали '+words(entries.length,'запись','записи','записей')+' о работе за этот период — с фотографиями, файлами и описанием каждого занятия.'));
|
||||
@@ -461,7 +470,7 @@ function renderFooter(data,heading,pool){
|
||||
const label=btn.querySelector('.text-block-wrap-div');
|
||||
if(label)label.textContent='Перейти по ссылке';
|
||||
}
|
||||
setText('footCopy',state.systemName+' © '+new Date().getFullYear());
|
||||
setText('footCopy',state.systemName+' © '+nowYear());
|
||||
setText('footBrand',heading);
|
||||
const fbox=document.getElementById('footerPhotoBox');
|
||||
const fimg=document.getElementById('footerPhoto');
|
||||
@@ -563,5 +572,5 @@ function bindEvents(){
|
||||
|
||||
bindEvents();
|
||||
makeNavLinks();
|
||||
loadBrand().then(function(){loadReport()});
|
||||
initDateTime().then(function(){return loadBrand()}).then(function(){loadReport()});
|
||||
function closeLightbox(){const lb=document.getElementById('lightbox');if(lb)lb.classList.remove('is-open')}
|
||||
+86
-4
@@ -1,6 +1,6 @@
|
||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
||||
const DIRTY_FIELDS = ['systemName', 'timezone', 'timeFormat', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
||||
const NOTIFY_IDS = [];
|
||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-datetime', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
|
||||
const SPAM_MAX = 10080;
|
||||
const BAN_REASONS = {
|
||||
'honeypot': 'Антиспам-поле',
|
||||
@@ -14,6 +14,67 @@ let logoOriginal = '';
|
||||
let currentSystemName = '';
|
||||
let photoAiService = null;
|
||||
|
||||
function fillTimezoneOptions() {
|
||||
const sel = document.getElementById('timezone');
|
||||
if (!sel || sel.options.length) return;
|
||||
let zones = [];
|
||||
try { zones = Intl.supportedValuesOf('timeZone'); } catch { zones = []; }
|
||||
if (!zones.length) zones = [DateTime.tz || 'Europe/Moscow'];
|
||||
const browserTz = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return ''; } })();
|
||||
const current = sel.dataset.value || DateTime.tz;
|
||||
sel.innerHTML = zones.map(z => {
|
||||
const off = tzOffsetLabel(z);
|
||||
const label = off ? `${z} (UTC${off})` : z;
|
||||
return `<option value="${esc(z)}">${esc(label)}</option>`;
|
||||
}).join('');
|
||||
const extra = browserTz && !zones.includes(browserTz) ? [browserTz] : [];
|
||||
extra.forEach(z => { sel.insertAdjacentHTML('afterbegin', `<option value="${esc(z)}">${esc(z)}</option>`); });
|
||||
if (current) sel.value = current;
|
||||
}
|
||||
|
||||
function tzOffsetLabel(tz) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('ru-RU', { timeZone: tz, timeZoneName: 'longOffset' }).formatToParts(new Date());
|
||||
const name = parts.find(p => p.type === 'timeZoneName');
|
||||
return name ? String(name.value).replace('GMT', '') : '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function previewTz() {
|
||||
const sel = document.getElementById('timezone');
|
||||
return sel && dtValidTz(sel.value) ? sel.value : DateTime.tz;
|
||||
}
|
||||
|
||||
function previewHour12() {
|
||||
const el = document.getElementById('timeFormat');
|
||||
return !!el && el.value === '12h';
|
||||
}
|
||||
|
||||
function renderTimePreview() {
|
||||
const box = document.getElementById('timePreview');
|
||||
if (!box) return;
|
||||
const tz = previewTz();
|
||||
const h12 = previewHour12();
|
||||
const now = new Date();
|
||||
const sample = new Date(now.getTime() + 3661 * 1000);
|
||||
box.innerHTML = `<span class="chip">Сейчас: ${esc(fmtIn(sample, tz, h12))}</span>`
|
||||
+ `<span class="chip-sub">${esc(fmtIn(now, tz, h12))}</span>`
|
||||
+ `<span class="chip-sub">Расписание 18:30 → ${esc(fmtHmIn('18:30:00', h12))}</span>`
|
||||
+ `<span class="chip-sub">Отчёт за занятие 09:05 → ${esc(fmtHmIn('09:05:00', h12))}</span>`;
|
||||
}
|
||||
|
||||
function fmtIn(date, tz, h12) {
|
||||
return new Intl.DateTimeFormat('ru-RU', { timeZone: tz, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', hour12: h12 }).format(date);
|
||||
}
|
||||
|
||||
function fmtHmIn(v, h12) {
|
||||
const saved = DateTime.hour12;
|
||||
DateTime.hour12 = h12;
|
||||
const out = fmtHmStr(v);
|
||||
DateTime.hour12 = saved;
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderLogoPreview() {
|
||||
const img = document.getElementById('logoPreviewImg');
|
||||
const fb = document.getElementById('logoPreviewFallback');
|
||||
@@ -89,6 +150,15 @@ function humanizeMinutes(min) {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function bindTimePreview() {
|
||||
['timezone', 'timeFormat'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('change', renderTimePreview);
|
||||
el.addEventListener('input', renderTimePreview);
|
||||
});
|
||||
}
|
||||
|
||||
function updateDirty() {
|
||||
const dirtyCount = DIRTY_FIELDS.filter(id => {
|
||||
const el = document.getElementById(id);
|
||||
@@ -123,6 +193,7 @@ function revertChanges() {
|
||||
renderLogoPreview();
|
||||
clearFieldError('spamInterval');
|
||||
updateSpamUi();
|
||||
renderTimePreview();
|
||||
updateDirty();
|
||||
showToast('Изменения отменены');
|
||||
}
|
||||
@@ -189,6 +260,7 @@ async function renderNotifyTypes(s) {
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
fillTimezoneOptions();
|
||||
const [sRes, stRes] = await Promise.all([
|
||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||
fetch(`${API}/api/stats`, { headers: hdr() }),
|
||||
@@ -202,6 +274,11 @@ async function loadSettings() {
|
||||
renderLogoPreview();
|
||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||
document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30';
|
||||
const tzSel = document.getElementById('timezone');
|
||||
const storedTz = String(s.timezone || '');
|
||||
tzSel.value = Array.from(tzSel.options).some(o => o.value === storedTz) ? storedTz : DateTime.tz;
|
||||
document.getElementById('timeFormat').value = ['24h', '12h'].includes(s.time_format) ? s.time_format : '24h';
|
||||
renderTimePreview();
|
||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||
document.getElementById('footerRight').value = s.footer_right || '';
|
||||
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
||||
@@ -272,7 +349,7 @@ async function loadSystemInfo(btn) {
|
||||
const data = await res.json();
|
||||
renderSystemInfo(data);
|
||||
renderStackInfo(data.stack);
|
||||
const stamp = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||
const stamp = 'обновлено ' + fmtTimeOnly(new Date());
|
||||
const live = document.getElementById('systemLive');
|
||||
if (live) live.textContent = stamp;
|
||||
const stackLive = document.getElementById('stackLive');
|
||||
@@ -804,6 +881,8 @@ async function saveSettings() {
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ settings: {
|
||||
system_name: document.getElementById('systemName').value.trim(),
|
||||
timezone: document.getElementById('timezone').value,
|
||||
time_format: document.getElementById('timeFormat').value,
|
||||
spam_interval_min: String(Math.round(Number(v))),
|
||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||
footer_right: document.getElementById('footerRight').value.trim(),
|
||||
@@ -828,6 +907,8 @@ async function saveSettings() {
|
||||
} })
|
||||
});
|
||||
if (res.ok) {
|
||||
dtApply({ timezone: document.getElementById('timezone').value, time_format: document.getElementById('timeFormat').value });
|
||||
renderTimePreview();
|
||||
showToast('Настройки сохранены');
|
||||
logoOriginal = currentLogo;
|
||||
snapshotDirty();
|
||||
@@ -939,7 +1020,7 @@ async function loadBans() {
|
||||
el.innerHTML = bans.map(b => `
|
||||
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
|
||||
<b>${esc(b.ip)}</b>
|
||||
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span>
|
||||
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${fmtFull(b.banned_until)}</span>
|
||||
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
|
||||
</span>`).join('');
|
||||
}
|
||||
@@ -1091,6 +1172,7 @@ window.addEventListener('beforeunload', e => {
|
||||
try {
|
||||
await loadSettings();
|
||||
bindDirty();
|
||||
bindTimePreview();
|
||||
if (authStatus) authStatus.classList.add('loaded');
|
||||
} catch {
|
||||
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
||||
|
||||
+6
-7
@@ -240,13 +240,11 @@ function renderShare(data, password) {
|
||||
const showStudentMessage = !!data.show_student_message;
|
||||
const showEntryDate = !!data.show_entry_date;
|
||||
const dayKey = e => {
|
||||
const d = new Date(e.created_at);
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
};
|
||||
const dayLabel = k => {
|
||||
const [y,m,d] = k.split('-').map(Number);
|
||||
return new Date(y, m-1, d).toLocaleDateString('ru-RU', { day:'numeric', month:'long', year:'numeric' });
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(e.created_at));
|
||||
} catch { return ''; }
|
||||
};
|
||||
const dayLabel = k => fmtDateOnlyLongIso(k);
|
||||
const cardHTML = e => {
|
||||
const body = [
|
||||
!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '',
|
||||
@@ -262,7 +260,7 @@ function renderShare(data, password) {
|
||||
: `<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>
|
||||
${body ? `<div class="body">${body}</div>` : ''}
|
||||
${showEntryDate ? `<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>` : ''}
|
||||
${showEntryDate ? `<div class="foot">${fmtDateTimeY(e.created_at)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -288,6 +286,7 @@ document.addEventListener('click', e => {
|
||||
});
|
||||
|
||||
(async()=>{
|
||||
await initDateTime();
|
||||
await loadShare();
|
||||
})();
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ async function exportStudentProfile(id) {
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${new Date().toISOString().slice(0, 10)}.zip`);
|
||||
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${todayIso()}.zip`);
|
||||
showToast(`Готово: ${s.name}`);
|
||||
} catch (err) {
|
||||
showToast('Ошибка при формировании профиля');
|
||||
|
||||
+4
-4
@@ -32,7 +32,7 @@ function entryHTML(e) {
|
||||
<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 class="trash-time">Удалена: ${fmtFull(e.deleted_at)}</div>
|
||||
</div>
|
||||
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
||||
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">×</button>
|
||||
@@ -47,7 +47,7 @@ function groupHTML(g) {
|
||||
<div class="info">
|
||||
<div class="name">${esc(g.name)}</div>
|
||||
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||||
<div class="trash-time">Удалена: ${new Date(g.deleted_at).toLocaleString('ru')}</div>
|
||||
<div class="trash-time">Удалена: ${fmtFull(g.deleted_at)}</div>
|
||||
</div>
|
||||
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
|
||||
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">×</button>
|
||||
@@ -64,7 +64,7 @@ function pendingEntryHTML(e) {
|
||||
<div class="group">${esc(e.group_name)}</div>
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
${filesHTML(e.files)}
|
||||
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(e.purge_at).toLocaleString('ru')}</div>
|
||||
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${fmtFull(e.purge_at)}</div>
|
||||
</div>
|
||||
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
|
||||
</div>
|
||||
@@ -78,7 +78,7 @@ function pendingGroupHTML(g) {
|
||||
<div class="info">
|
||||
<div class="name">${esc(g.name)}</div>
|
||||
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||||
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(g.purge_at).toLocaleString('ru')}</div>
|
||||
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${fmtFull(g.purge_at)}</div>
|
||||
</div>
|
||||
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ function fmtDur(ms) {
|
||||
|
||||
function fmtTime(t) {
|
||||
if (!t) return '—';
|
||||
try { return new Date(t).toLocaleString('ru'); } catch { return String(t); }
|
||||
return fmtFull(t) || String(t);
|
||||
}
|
||||
|
||||
function ago(t) {
|
||||
|
||||
Reference in New Issue
Block a user