tam
This commit is contained in:
@@ -430,6 +430,147 @@ body {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.exam-resources-hint {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.exam-viewer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10050;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
display: none;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.exam-viewer-overlay.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.exam-viewer-shell {
|
||||
width: min(1100px, 100%);
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
|
||||
max-height: calc(100vh - 1.5rem);
|
||||
}
|
||||
|
||||
.exam-viewer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exam-viewer-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exam-viewer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #e2e8f0;
|
||||
position: relative;
|
||||
height: calc(100vh - 6.5rem);
|
||||
}
|
||||
|
||||
.exam-viewer-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
background: #cbd5e1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.exam-viewer-content.is-ready {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.exam-viewer-loading,
|
||||
.exam-viewer-error {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
color: #475569;
|
||||
font-size: 0.92rem;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.exam-viewer-loading.is-active,
|
||||
.exam-viewer-error.is-active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.exam-viewer-error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.exam-viewer-error.is-active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.exam-pdf-page-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.exam-pdf-page {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.exam-view-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.exam-view-text {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.exam-resource-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
78
client/frontend/src/examViewer.js
Normal file
78
client/frontend/src/examViewer.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist/build/pdf';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.js?url';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
|
||||
function decodeBase64(b64) {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function detectExamViewKind(fileName, mime, bytes) {
|
||||
if (bytes && bytes.length >= 4) {
|
||||
if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return 'pdf';
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image';
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image';
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image';
|
||||
}
|
||||
const name = String(fileName || '').toLowerCase();
|
||||
const type = String(mime || '').toLowerCase();
|
||||
if (type.includes('pdf') || name.endsWith('.pdf')) return 'pdf';
|
||||
if (type.startsWith('image/') || /\.(png|jpe?g|gif|webp)$/.test(name)) return 'image';
|
||||
if (type.startsWith('text/') || name.endsWith('.txt')) return 'text';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
export async function renderSecurePdf(container, bytes) {
|
||||
container.innerHTML = '';
|
||||
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
|
||||
const pad = 16;
|
||||
const width = container.clientWidth || container.parentElement?.clientWidth || 900;
|
||||
const maxWidth = Math.max(360, width - pad * 2);
|
||||
|
||||
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
|
||||
const page = await pdf.getPage(pageNum);
|
||||
const base = page.getViewport({ scale: 1 });
|
||||
const scale = Math.min(1.6, maxWidth / base.width);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'exam-pdf-page-wrap';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'exam-pdf-page';
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
wrap.appendChild(canvas);
|
||||
container.appendChild(wrap);
|
||||
|
||||
await page.render({
|
||||
canvasContext: canvas.getContext('2d'),
|
||||
viewport,
|
||||
}).promise;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderSecureImage(container, bytes, mime) {
|
||||
container.innerHTML = '';
|
||||
const blob = new Blob([bytes], { type: mime || 'image/png' });
|
||||
const img = document.createElement('img');
|
||||
img.className = 'exam-view-image';
|
||||
img.alt = 'Tài nguyên';
|
||||
img.draggable = false;
|
||||
img.src = URL.createObjectURL(blob);
|
||||
container.appendChild(img);
|
||||
}
|
||||
|
||||
export function renderSecureText(container, bytes) {
|
||||
container.innerHTML = '';
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'exam-view-text';
|
||||
pre.textContent = new TextDecoder('utf-8').decode(bytes);
|
||||
container.appendChild(pre);
|
||||
}
|
||||
|
||||
export function decodeExamFilePayload(payload) {
|
||||
return decodeBase64(payload.data || payload);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import './style.css';
|
||||
import './app.css';
|
||||
import logoUrl from './assets/logo.jpeg';
|
||||
import {
|
||||
decodeExamFilePayload,
|
||||
detectExamViewKind,
|
||||
renderSecureImage,
|
||||
renderSecurePdf,
|
||||
renderSecureText,
|
||||
} from './examViewer.js';
|
||||
|
||||
const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`;
|
||||
|
||||
@@ -68,9 +75,12 @@ function init() {
|
||||
});
|
||||
window.runtime.EventsOn('exam:paper-sent', () => {
|
||||
if (loggedIn) {
|
||||
examPaperFiles = null;
|
||||
examPaperFilesRoomId = 0;
|
||||
lastExamPanelKey = '';
|
||||
window.go.main.App.GetStats().then((s) => {
|
||||
stats = { ...stats, ...s };
|
||||
updateExamPanel();
|
||||
updateExamPanel(true);
|
||||
}).catch(console.error);
|
||||
}
|
||||
});
|
||||
@@ -87,6 +97,7 @@ async function checkLogin() {
|
||||
startStatsTicker();
|
||||
ensureChatWidget();
|
||||
updateChatBadge();
|
||||
if (stats.monitorMode === 'exam') updateExamPanel();
|
||||
} else {
|
||||
loggedIn = false;
|
||||
renderLoginPrompt();
|
||||
@@ -179,36 +190,197 @@ function renderShiftsTable(shifts) {
|
||||
}
|
||||
|
||||
let examPaperFiles = null;
|
||||
let examPaperFilesRoomId = 0;
|
||||
let examPaperFilesLoading = false;
|
||||
let lastExamPanelKey = '';
|
||||
let examViewerObjectUrl = null;
|
||||
|
||||
function wireExamViewerGuards(overlay) {
|
||||
overlay.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if ((e.ctrlKey || e.metaKey) && (key === 'p' || key === 's')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureExamViewer() {
|
||||
let overlay = document.getElementById('exam-viewer-overlay');
|
||||
if (overlay) return overlay;
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'exam-viewer-overlay';
|
||||
overlay.className = 'exam-viewer-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="exam-viewer-shell">
|
||||
<div class="exam-viewer-bar">
|
||||
<button type="button" class="btn btn-secondary" id="exam-viewer-close">← Quay lại</button>
|
||||
<span class="exam-viewer-title" id="exam-viewer-title"></span>
|
||||
</div>
|
||||
<div class="exam-viewer-body" id="exam-viewer-body">
|
||||
<div class="exam-viewer-content" id="exam-viewer-content"></div>
|
||||
<div class="exam-viewer-loading" id="exam-viewer-loading">Đang tải...</div>
|
||||
<div class="exam-viewer-error" id="exam-viewer-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelector('#exam-viewer-close').addEventListener('click', closeExamViewer);
|
||||
wireExamViewerGuards(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function setExamViewerLoading(loading) {
|
||||
const loadingEl = document.getElementById('exam-viewer-loading');
|
||||
const contentEl = document.getElementById('exam-viewer-content');
|
||||
const errorEl = document.getElementById('exam-viewer-error');
|
||||
if (loadingEl) loadingEl.classList.toggle('is-active', loading);
|
||||
if (contentEl) contentEl.classList.toggle('is-ready', !loading);
|
||||
if (errorEl) errorEl.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function setExamViewerError(message) {
|
||||
const loadingEl = document.getElementById('exam-viewer-loading');
|
||||
const contentEl = document.getElementById('exam-viewer-content');
|
||||
const errorEl = document.getElementById('exam-viewer-error');
|
||||
if (loadingEl) loadingEl.classList.remove('is-active');
|
||||
if (contentEl) contentEl.classList.remove('is-ready');
|
||||
if (errorEl) {
|
||||
errorEl.classList.add('is-active');
|
||||
errorEl.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function resetExamViewerPanels() {
|
||||
const loadingEl = document.getElementById('exam-viewer-loading');
|
||||
const contentEl = document.getElementById('exam-viewer-content');
|
||||
const errorEl = document.getElementById('exam-viewer-error');
|
||||
if (loadingEl) loadingEl.classList.remove('is-active');
|
||||
if (contentEl) contentEl.classList.remove('is-ready');
|
||||
if (errorEl) errorEl.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function clearExamViewerContent() {
|
||||
if (examViewerObjectUrl) {
|
||||
URL.revokeObjectURL(examViewerObjectUrl);
|
||||
examViewerObjectUrl = null;
|
||||
}
|
||||
const contentEl = document.getElementById('exam-viewer-content');
|
||||
if (contentEl) contentEl.innerHTML = '';
|
||||
}
|
||||
|
||||
async function showExamViewer(url, title, fileName = '') {
|
||||
const overlay = ensureExamViewer();
|
||||
document.getElementById('exam-viewer-title').textContent = title || 'Xem trong app';
|
||||
overlay.classList.add('is-open');
|
||||
overlay.focus();
|
||||
clearExamViewerContent();
|
||||
resetExamViewerPanels();
|
||||
setExamViewerLoading(true);
|
||||
|
||||
const contentEl = document.getElementById('exam-viewer-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
try {
|
||||
const payload = await window.go.main.App.LoadExamViewFile(url);
|
||||
const bytes = decodeExamFilePayload(payload);
|
||||
const mime = payload?.mime || '';
|
||||
const kind = detectExamViewKind(fileName || title, mime, bytes);
|
||||
|
||||
if (kind === 'pdf') {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await renderSecurePdf(contentEl, bytes);
|
||||
} else if (kind === 'image') {
|
||||
renderSecureImage(contentEl, bytes, mime);
|
||||
const img = contentEl.querySelector('img');
|
||||
if (img?.src?.startsWith('blob:')) examViewerObjectUrl = img.src;
|
||||
if (img) {
|
||||
await new Promise((resolve, reject) => {
|
||||
if (img.complete) resolve();
|
||||
else {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('Không hiển thị được ảnh'));
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (kind === 'text') {
|
||||
renderSecureText(contentEl, bytes);
|
||||
} else {
|
||||
setExamViewerError('Chỉ hỗ trợ xem PDF, ảnh hoặc file text trong app.');
|
||||
return;
|
||||
}
|
||||
setExamViewerLoading(false);
|
||||
} catch (e) {
|
||||
setExamViewerError(e?.message || e || 'Không mở được file');
|
||||
}
|
||||
}
|
||||
|
||||
function closeExamViewer() {
|
||||
const overlay = document.getElementById('exam-viewer-overlay');
|
||||
if (!overlay) return;
|
||||
overlay.classList.remove('is-open');
|
||||
clearExamViewerContent();
|
||||
resetExamViewerPanels();
|
||||
}
|
||||
|
||||
function renderExamResources() {
|
||||
if (!examPaperFiles?.resources?.length) return '';
|
||||
const rows = examPaperFiles.resources.map((r) => `
|
||||
const ex = stats.exam;
|
||||
if (!ex?.paperSent) return '';
|
||||
|
||||
if (examPaperFilesLoading && !examPaperFiles) {
|
||||
return `
|
||||
<div class="exam-resources">
|
||||
<div class="exam-resources-title">📎 Tài nguyên kèm đề</div>
|
||||
<p class="exam-resources-hint">Đang tải danh sách tài nguyên...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const resources = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources : [];
|
||||
if (!resources.length) {
|
||||
return `
|
||||
<div class="exam-resources">
|
||||
<div class="exam-resources-title">📎 Tài nguyên kèm đề</div>
|
||||
<p class="exam-resources-hint">Gói đề này không có tài nguyên đính kèm.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const rows = resources.map((r) => `
|
||||
<div class="exam-resource-row">
|
||||
<span class="exam-resource-name" title="${escapeHtml(r.fileName || '')}">${escapeHtml(r.fileName || 'Tài nguyên')}</span>
|
||||
<div class="exam-resource-btns">
|
||||
<button type="button" class="btn btn-secondary btn-sm btn-exam-res-view" data-id="${r.id}">Xem</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm btn-exam-res-dl" data-id="${r.id}">Tải về</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm btn-exam-res-dl" data-id="${r.id}">Tải về</button>
|
||||
</div>
|
||||
`).join('');
|
||||
return `
|
||||
<div class="exam-resources">
|
||||
<div class="exam-resources-title">Tài nguyên kèm đề</div>
|
||||
<div class="exam-resources-title">📎 Tài nguyên kèm đề (${resources.length})</div>
|
||||
${rows}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadExamPaperFiles() {
|
||||
async function loadExamPaperFiles(force = false) {
|
||||
const ex = stats.exam;
|
||||
if (!ex || stats.monitorMode !== 'exam' || !ex.paperSent) {
|
||||
examPaperFiles = null;
|
||||
examPaperFilesRoomId = 0;
|
||||
examPaperFilesLoading = false;
|
||||
return;
|
||||
}
|
||||
if (!force && examPaperFiles && examPaperFilesRoomId === ex.examRoomId) {
|
||||
return;
|
||||
}
|
||||
examPaperFilesLoading = true;
|
||||
try {
|
||||
examPaperFiles = await window.go.main.App.GetExamPaperFiles();
|
||||
} catch {
|
||||
examPaperFiles = null;
|
||||
examPaperFilesRoomId = ex.examRoomId;
|
||||
} catch (err) {
|
||||
console.error('GetExamPaperFiles failed:', err);
|
||||
examPaperFiles = { resources: [] };
|
||||
examPaperFilesRoomId = ex.examRoomId;
|
||||
} finally {
|
||||
examPaperFilesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +401,7 @@ function renderExamPanel() {
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
|
||||
</div>
|
||||
<p class="exam-panel-desc">Bạn đang trong giờ thi. Đề, link thi và tài nguyên mở trong app — không dùng trình duyệt ngoài.</p>
|
||||
<p class="exam-panel-desc">Đề PDF và tài nguyên xem ngay trong app. Link trắc nghiệm mở trang thi — xong thì menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p>
|
||||
<div class="exam-panel-actions">
|
||||
${paperBtn}
|
||||
${quizBtn}
|
||||
@@ -241,9 +413,15 @@ function renderExamPanel() {
|
||||
}
|
||||
|
||||
function wireExamPanel() {
|
||||
const ex = stats.exam;
|
||||
const paper = document.getElementById('btn-exam-paper');
|
||||
if (paper) paper.addEventListener('click', () => {
|
||||
window.go.main.App.OpenExamPaper().catch((e) => alert(e?.message || e));
|
||||
if (paper) paper.addEventListener('click', async () => {
|
||||
try {
|
||||
const url = await window.go.main.App.GetExamPaperViewURL();
|
||||
showExamViewer(url, ex?.paperTitle || 'Đề thi', ex?.paperTitle || 'de.pdf');
|
||||
} catch (e) {
|
||||
alert(e?.message || e || 'Không mở được đề');
|
||||
}
|
||||
});
|
||||
const quiz = document.getElementById('btn-exam-quiz');
|
||||
if (quiz) quiz.addEventListener('click', () => {
|
||||
@@ -262,18 +440,11 @@ function wireExamPanel() {
|
||||
alert(e?.message || e || 'Nộp bài thất bại');
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('.btn-exam-res-view').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = Number(btn.getAttribute('data-id'));
|
||||
window.go.main.App.OpenExamResource(id).catch((e) => alert(e?.message || e));
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.btn-exam-res-dl').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = Number(btn.getAttribute('data-id'));
|
||||
try {
|
||||
const path = await window.go.main.App.DownloadExamResource(id);
|
||||
alert(`Đã tải về:\n${path}`);
|
||||
await window.go.main.App.DownloadExamResource(id);
|
||||
} catch (e) {
|
||||
alert(e?.message || e || 'Tải thất bại');
|
||||
}
|
||||
@@ -281,14 +452,26 @@ function wireExamPanel() {
|
||||
});
|
||||
}
|
||||
|
||||
async function updateExamPanel() {
|
||||
async function updateExamPanel(forceReloadFiles = false) {
|
||||
const host = document.getElementById('exam-panel-host');
|
||||
if (!host) return;
|
||||
await loadExamPaperFiles();
|
||||
if (stats.monitorMode !== 'exam' || !stats.exam) {
|
||||
host.innerHTML = '';
|
||||
lastExamPanelKey = '';
|
||||
return;
|
||||
}
|
||||
await loadExamPaperFiles(forceReloadFiles);
|
||||
host.innerHTML = renderExamPanel();
|
||||
wireExamPanel();
|
||||
}
|
||||
|
||||
function examPanelKey() {
|
||||
const ex = stats.exam;
|
||||
if (!ex) return '';
|
||||
const resCount = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources.length : -1;
|
||||
return `${ex.examRoomId}:${ex.paperSent}:${ex.submitted}:${resCount}:${examPaperFilesLoading}`;
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
if (!studentInfo) return;
|
||||
|
||||
@@ -629,7 +812,17 @@ function startStatsTicker() {
|
||||
}
|
||||
}
|
||||
|
||||
updateExamPanel();
|
||||
if (stats.monitorMode === 'exam' && stats.exam) {
|
||||
const panelKey = examPanelKey();
|
||||
if (panelKey !== lastExamPanelKey) {
|
||||
lastExamPanelKey = panelKey;
|
||||
updateExamPanel();
|
||||
}
|
||||
} else if (lastExamPanelKey !== '') {
|
||||
lastExamPanelKey = '';
|
||||
const host = document.getElementById('exam-panel-host');
|
||||
if (host) host.innerHTML = '';
|
||||
}
|
||||
|
||||
const onlineEl = document.getElementById('clock-online');
|
||||
if (onlineEl) onlineEl.innerText = formatDuration(stats.onlineSecs || 0);
|
||||
|
||||
Reference in New Issue
Block a user