Move document history to separate history.html page
- history.html: standalone page with My Documents table + auth - js/history.js: renderHistory, loadHistory, deleteHistoryJob logic - js/app-history.js: MSAL auth init for history.html - index.html: remove history section, add 'My Documents' link in header - js/app.js: show historyLink after auth, open job from ?job_id= URL param - deploy.sh: include history.html in deploy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
373bf88a29
commit
62094f4dfa
6 changed files with 304 additions and 134 deletions
|
|
@ -158,12 +158,13 @@ sudo mkdir -p "${WEB_DIR}"
|
|||
|
||||
# Clean old frontend files (but preserve uploads, results, .env, logs)
|
||||
log "Cleaning old frontend files..."
|
||||
sudo rm -f "${WEB_DIR}/index.html"
|
||||
sudo rm -f "${WEB_DIR}/index.html" "${WEB_DIR}/history.html"
|
||||
sudo rm -rf "${WEB_DIR}/css" "${WEB_DIR}/js"
|
||||
sudo rm -f "${WEB_DIR}/api.php" "${WEB_DIR}/auth.php"
|
||||
|
||||
# Copy frontend files
|
||||
sudo cp "${REPO_DIR}/index.html" "${WEB_DIR}/"
|
||||
sudo cp "${REPO_DIR}/history.html" "${WEB_DIR}/"
|
||||
sudo cp -r "${REPO_DIR}/css" "${WEB_DIR}/"
|
||||
sudo cp -r "${REPO_DIR}/js" "${WEB_DIR}/"
|
||||
|
||||
|
|
|
|||
68
history.html
Normal file
68
history.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Documents — PDF Accessibility Checker</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
|
||||
<div id="msalConfig" hidden
|
||||
data-tenant-id="e519c2e6-bc6d-4fdf-8d9c-923c2f002385"
|
||||
data-client-id="9079054c-9620-4757-a256-23413042f1ef"
|
||||
data-redirect-uri="https://ai-sandbox.oliver.solutions/pdf-accessibility/history.html"></div>
|
||||
|
||||
<!-- Auth Overlay -->
|
||||
<div class="auth-overlay" id="authOverlay" role="dialog" aria-label="Sign in required" aria-modal="true" aria-describedby="authCardDesc">
|
||||
<div class="auth-card">
|
||||
<h2>PDF Accessibility Checker</h2>
|
||||
<p id="authCardDesc">Sign in with your organization account to continue.</p>
|
||||
<button class="btn-microsoft" onclick="loginWithMicrosoft()" aria-label="Sign in with Microsoft">
|
||||
<svg width="20" height="20" viewBox="0 0 21 21" aria-hidden="true"><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>
|
||||
Sign in with Microsoft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<div class="container">
|
||||
<div class="header-inner">
|
||||
<div>
|
||||
<h1>Enterprise PDF Accessibility Checker</h1>
|
||||
<p class="subtitle">Comprehensive WCAG 2.1 compliance validation with AI-powered analysis</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="index.html" class="btn btn-secondary" style="text-decoration:none;padding:8px 16px;font-size:13px;">⬆ New Check</a>
|
||||
<span class="user-info" id="userInfo"></span>
|
||||
<button id="logoutBtn" onclick="logout()" style="display:none;">Sign Out</button>
|
||||
<button id="themeToggle" onclick="toggleDarkMode()" aria-label="Toggle dark mode">Dark</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<div class="container" style="padding-top: 32px;">
|
||||
<div class="card" id="historySection" style="display:none;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
|
||||
<h2 style="margin:0;">My Documents</h2>
|
||||
<button class="btn btn-secondary" onclick="loadHistory()" aria-label="Refresh" style="padding:8px 16px;font-size:13px;">↺ Refresh</button>
|
||||
</div>
|
||||
<div id="historyTableWrap">
|
||||
<p style="color:var(--text-muted);font-size:14px;" id="historyEmpty">No documents checked yet. <a href="index.html">Upload a PDF</a> to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/history.js"></script>
|
||||
<script src="js/app-history.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
index.html
12
index.html
|
|
@ -38,6 +38,7 @@
|
|||
<p class="subtitle">Comprehensive WCAG 2.1 compliance validation with AI-powered analysis</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="history.html" id="historyLink" style="display:none;text-decoration:none;" class="btn btn-secondary" style="padding:8px 16px;font-size:13px;">📂 My Documents</a>
|
||||
<span class="user-info" id="userInfo"></span>
|
||||
<button id="logoutBtn" onclick="logout()" style="display:none;">Sign Out</button>
|
||||
<button id="themeToggle" onclick="toggleDarkMode()" aria-label="Toggle dark mode">Dark</button>
|
||||
|
|
@ -48,17 +49,6 @@
|
|||
<main id="main-content">
|
||||
<div class="container">
|
||||
|
||||
<!-- History Section (shown when authenticated) -->
|
||||
<div class="card" id="historySection" style="display:none;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
|
||||
<h2 style="margin:0;">My Documents</h2>
|
||||
<button class="btn btn-secondary" onclick="loadHistory()" id="historyRefreshBtn" aria-label="Refresh document history" style="padding:8px 16px;font-size:13px;">↺ Refresh</button>
|
||||
</div>
|
||||
<div id="historyTableWrap">
|
||||
<p style="color:var(--text-muted);font-size:14px;" id="historyEmpty">No documents checked yet. Upload a PDF to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Section -->
|
||||
<div class="card" id="uploadSection">
|
||||
<h2>Upload PDF Document</h2>
|
||||
|
|
|
|||
110
js/app-history.js
Normal file
110
js/app-history.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/* MSAL auth + init for history.html */
|
||||
|
||||
const msalConfig = {
|
||||
auth: {
|
||||
clientId: '',
|
||||
authority: '',
|
||||
redirectUri: window.location.origin + window.location.pathname
|
||||
},
|
||||
cache: { cacheLocation: 'localStorage', storeAuthStateInCookie: false }
|
||||
};
|
||||
|
||||
let msalInstance = null;
|
||||
window.msalToken = null;
|
||||
|
||||
function initMsal() {
|
||||
const el = document.getElementById('msalConfig');
|
||||
if (!el) return;
|
||||
const tenantId = el.dataset.tenantId;
|
||||
const clientId = el.dataset.clientId;
|
||||
const redirectUri = el.dataset.redirectUri;
|
||||
if (!tenantId || !clientId) return;
|
||||
|
||||
msalConfig.auth.clientId = clientId;
|
||||
msalConfig.auth.authority = `https://login.microsoftonline.com/${tenantId}`;
|
||||
if (redirectUri) msalConfig.auth.redirectUri = redirectUri;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/@azure/msal-browser@2/lib/msal-browser.min.js';
|
||||
script.onload = () => {
|
||||
msalInstance = new msal.PublicClientApplication(msalConfig);
|
||||
msalInstance.initialize().then(handleMsalRedirect);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
async function handleMsalRedirect() {
|
||||
try {
|
||||
const response = await msalInstance.handleRedirectPromise();
|
||||
if (response) {
|
||||
window.msalToken = response.accessToken;
|
||||
showAuthenticatedUI(response.account);
|
||||
return;
|
||||
}
|
||||
} catch (e) { console.error('MSAL redirect error:', e); }
|
||||
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length > 0) {
|
||||
try {
|
||||
const tokenResponse = await msalInstance.acquireTokenSilent({ scopes: ['User.Read'], account: accounts[0] });
|
||||
window.msalToken = tokenResponse.accessToken;
|
||||
showAuthenticatedUI(accounts[0]);
|
||||
} catch (e) { showLoginUI(); }
|
||||
} else {
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
showAuthenticatedUI(null);
|
||||
} else {
|
||||
showLoginUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showLoginUI() {
|
||||
const overlay = document.getElementById('authOverlay');
|
||||
if (overlay) overlay.classList.add('active');
|
||||
}
|
||||
|
||||
function showAuthenticatedUI(account) {
|
||||
const overlay = document.getElementById('authOverlay');
|
||||
if (overlay) overlay.classList.remove('active');
|
||||
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
if (userInfo && account) userInfo.textContent = account.name || account.username;
|
||||
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) logoutBtn.style.display = 'inline-block';
|
||||
|
||||
const historySection = document.getElementById('historySection');
|
||||
if (historySection) historySection.style.display = '';
|
||||
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
async function loginWithMicrosoft() {
|
||||
if (!msalInstance) return;
|
||||
try { await msalInstance.loginRedirect({ scopes: ['User.Read'] }); }
|
||||
catch (e) { console.error('Login failed:', e); alert('Login failed. Please try again.'); }
|
||||
}
|
||||
|
||||
function logout() {
|
||||
if (msalInstance) msalInstance.logoutRedirect();
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
const isDark = document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', isDark ? '1' : '0');
|
||||
document.getElementById('themeToggle').textContent = isDark ? 'Light' : 'Dark';
|
||||
}
|
||||
|
||||
function loadTheme() {
|
||||
if (localStorage.getItem('darkMode') === '1') {
|
||||
document.body.classList.add('dark-mode');
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) btn.textContent = 'Light';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTheme();
|
||||
initMsal();
|
||||
});
|
||||
129
js/app.js
129
js/app.js
|
|
@ -97,129 +97,14 @@ function showAuthenticatedUI(account) {
|
|||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) logoutBtn.style.display = 'inline-block';
|
||||
|
||||
// Show history section and load jobs
|
||||
const historySection = document.getElementById('historySection');
|
||||
if (historySection) historySection.style.display = '';
|
||||
loadHistory();
|
||||
}
|
||||
// Show My Documents link in header
|
||||
const historyLink = document.getElementById('historyLink');
|
||||
if (historyLink) historyLink.style.display = 'inline-block';
|
||||
|
||||
// ─── Document History ────────────────────────────────────────────────────────
|
||||
|
||||
async function loadHistory() {
|
||||
const wrap = document.getElementById('historyTableWrap');
|
||||
if (!wrap) return;
|
||||
|
||||
try {
|
||||
const data = await apiCall('list');
|
||||
const jobs = data?.data?.jobs || data?.jobs || [];
|
||||
renderHistory(jobs);
|
||||
} catch (e) {
|
||||
console.error('[history] failed to load:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory(jobs) {
|
||||
const wrap = document.getElementById('historyTableWrap');
|
||||
const empty = document.getElementById('historyEmpty');
|
||||
|
||||
if (!jobs.length) {
|
||||
if (empty) empty.style.display = '';
|
||||
// Remove table if it existed
|
||||
const old = wrap.querySelector('table');
|
||||
if (old) old.remove();
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
const rows = jobs.map(j => {
|
||||
const score = j.score != null ? j.score : '—';
|
||||
const grade = j.grade || '—';
|
||||
const scoreClass = j.score >= 90 ? 'history-score-a'
|
||||
: j.score >= 70 ? 'history-score-b'
|
||||
: j.score != null ? 'history-score-f' : '';
|
||||
const status = j.status === 'completed' ? '<span class="history-badge-done">Done</span>'
|
||||
: '<span class="history-badge-pending">Pending</span>';
|
||||
const critical = j.critical_count ?? 0;
|
||||
const errors = j.error_count ?? 0;
|
||||
const date = j.uploaded_at ? j.uploaded_at.replace('T', ' ').substring(0, 16) : '—';
|
||||
const name = escapeHtml(j.original_filename || j.job_id);
|
||||
|
||||
const openBtn = j.status === 'completed'
|
||||
? `<button class="history-action-btn" onclick="openHistoryJob('${j.job_id}')" title="Open report">Open</button>`
|
||||
: '';
|
||||
const htmlBtn = j.status === 'completed'
|
||||
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=html" target="_blank" title="Download HTML report">HTML</a>`
|
||||
: '';
|
||||
const pdfBtn = j.status === 'completed'
|
||||
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=pdf" target="_blank" title="Download PDF report">PDF</a>`
|
||||
: '';
|
||||
const jsonBtn = j.status === 'completed'
|
||||
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=json" target="_blank" title="Download JSON">JSON</a>`
|
||||
: '';
|
||||
const deleteBtn = `<button class="history-action-btn history-action-delete" onclick="deleteHistoryJob('${j.job_id}', this)" title="Delete">🗑</button>`;
|
||||
|
||||
return `<tr>
|
||||
<td class="history-filename" title="${escapeHtml(j.original_filename || '')}">${name}</td>
|
||||
<td>${date}</td>
|
||||
<td>${status}</td>
|
||||
<td><span class="history-score ${scoreClass}">${score}${j.score != null ? '<small>/100</small>' : ''}</span> <span class="history-grade">${grade}</span></td>
|
||||
<td>${critical > 0 ? `<span class="history-crit">${critical} crit</span>` : ''} ${errors > 0 ? `<span class="history-err">${errors} err</span>` : ''}${!critical && !errors ? '<span style="color:var(--success)">✓ Clean</span>' : ''}</td>
|
||||
<td class="history-actions">${openBtn}${htmlBtn}${pdfBtn}${jsonBtn}${deleteBtn}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Replace or create table
|
||||
const old = wrap.querySelector('table');
|
||||
if (old) old.remove();
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'history-table';
|
||||
table.setAttribute('aria-label', 'Document history');
|
||||
table.innerHTML = `
|
||||
<thead><tr>
|
||||
<th>Document</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Score</th>
|
||||
<th>Issues</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>`;
|
||||
wrap.appendChild(table);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function deleteHistoryJob(jobId, btn) {
|
||||
if (!confirm('Delete this document and its report?')) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('job_id', jobId);
|
||||
const data = await apiCall('delete', { method: 'POST', body: formData });
|
||||
if (data.success) {
|
||||
btn.closest('tr').remove();
|
||||
// Show empty state if no rows left
|
||||
const tbody = document.querySelector('.history-table tbody');
|
||||
if (tbody && !tbody.querySelector('tr')) {
|
||||
document.querySelector('.history-table').remove();
|
||||
const empty = document.getElementById('historyEmpty');
|
||||
if (empty) empty.style.display = '';
|
||||
}
|
||||
} else {
|
||||
alert('Delete failed: ' + (data.error || 'Unknown error'));
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Delete failed.');
|
||||
btn.disabled = false;
|
||||
}
|
||||
// If URL has ?job_id= open that report directly
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const jobId = params.get('job_id');
|
||||
if (jobId) openHistoryJob(jobId);
|
||||
}
|
||||
|
||||
async function openHistoryJob(jobId) {
|
||||
|
|
|
|||
116
js/history.js
Normal file
116
js/history.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/* Document history table — used on history.html */
|
||||
|
||||
async function loadHistory() {
|
||||
const wrap = document.getElementById('historyTableWrap');
|
||||
if (!wrap) return;
|
||||
|
||||
try {
|
||||
const data = await apiCall('list');
|
||||
const jobs = data?.data?.jobs || data?.jobs || [];
|
||||
renderHistory(jobs);
|
||||
} catch (e) {
|
||||
console.error('[history] failed to load:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory(jobs) {
|
||||
const wrap = document.getElementById('historyTableWrap');
|
||||
const empty = document.getElementById('historyEmpty');
|
||||
|
||||
if (!jobs.length) {
|
||||
if (empty) empty.style.display = '';
|
||||
const old = wrap.querySelector('table');
|
||||
if (old) old.remove();
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
const rows = jobs.map(j => {
|
||||
const score = j.score != null ? j.score : '—';
|
||||
const grade = j.grade || '—';
|
||||
const scoreClass = j.score >= 90 ? 'history-score-a'
|
||||
: j.score >= 70 ? 'history-score-b'
|
||||
: j.score != null ? 'history-score-f' : '';
|
||||
const status = j.status === 'completed'
|
||||
? '<span class="history-badge-done">Done</span>'
|
||||
: '<span class="history-badge-pending">Pending</span>';
|
||||
const critical = j.critical_count ?? 0;
|
||||
const errors = j.error_count ?? 0;
|
||||
const date = j.uploaded_at ? j.uploaded_at.replace('T', ' ').substring(0, 16) : '—';
|
||||
const name = escapeHtml(j.original_filename || j.job_id);
|
||||
|
||||
const openBtn = j.status === 'completed'
|
||||
? `<a class="history-action-btn" href="index.html?job_id=${j.job_id}" title="Open report">Open</a>`
|
||||
: '';
|
||||
const htmlBtn = j.status === 'completed'
|
||||
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=html" target="_blank">HTML</a>`
|
||||
: '';
|
||||
const pdfBtn = j.status === 'completed'
|
||||
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=pdf" target="_blank">PDF</a>`
|
||||
: '';
|
||||
const jsonBtn = j.status === 'completed'
|
||||
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=json" target="_blank">JSON</a>`
|
||||
: '';
|
||||
const deleteBtn = `<button class="history-action-btn history-action-delete" onclick="deleteHistoryJob('${j.job_id}', this)" title="Delete">🗑</button>`;
|
||||
|
||||
return `<tr>
|
||||
<td class="history-filename" title="${escapeHtml(j.original_filename || '')}">${name}</td>
|
||||
<td>${date}</td>
|
||||
<td>${status}</td>
|
||||
<td><span class="history-score ${scoreClass}">${score}${j.score != null ? '<small>/100</small>' : ''}</span> <span class="history-grade">${grade}</span></td>
|
||||
<td>${critical > 0 ? `<span class="history-crit">${critical} crit</span>` : ''} ${errors > 0 ? `<span class="history-err">${errors} err</span>` : ''}${!critical && !errors && j.status === 'completed' ? '<span style="color:var(--success)">✓ Clean</span>' : ''}</td>
|
||||
<td class="history-actions">${openBtn}${htmlBtn}${pdfBtn}${jsonBtn}${deleteBtn}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const old = wrap.querySelector('table');
|
||||
if (old) old.remove();
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'history-table';
|
||||
table.setAttribute('aria-label', 'Document history');
|
||||
table.innerHTML = `
|
||||
<thead><tr>
|
||||
<th>Document</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Score</th>
|
||||
<th>Issues</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>`;
|
||||
wrap.appendChild(table);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function deleteHistoryJob(jobId, btn) {
|
||||
if (!confirm('Delete this document and its report?')) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('job_id', jobId);
|
||||
const data = await apiCall('delete', { method: 'POST', body: formData });
|
||||
if (data.success) {
|
||||
btn.closest('tr').remove();
|
||||
const tbody = document.querySelector('.history-table tbody');
|
||||
if (tbody && !tbody.querySelector('tr')) {
|
||||
document.querySelector('.history-table').remove();
|
||||
const empty = document.getElementById('historyEmpty');
|
||||
if (empty) empty.style.display = '';
|
||||
}
|
||||
} else {
|
||||
alert('Delete failed: ' + (data.error || 'Unknown error'));
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Delete failed.');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue