feat: detect duplicates in csv

This commit is contained in:
2026-06-08 04:35:22 +01:00
parent 304892552f
commit a0bff2beb1
11 changed files with 390 additions and 118 deletions
+1
View File
@@ -1,2 +1,3 @@
FROM php:8.3-apache FROM php:8.3-apache
RUN docker-php-ext-install pdo pdo_mysql RUN docker-php-ext-install pdo pdo_mysql
COPY ./web /var/www/html/
+17 -2
View File
@@ -90,7 +90,7 @@
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);"> <div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;"> <div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i> <i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span> <span class="nav-username" style="font-size:0.82rem;">User</span>
</div> </div>
</div> </div>
</div> </div>
@@ -245,7 +245,21 @@
async function loadAdmin() { async function loadAdmin() {
try { try {
const data = await fetch('/api/admin.php').then(r => r.json()); const r = await fetch('/api/admin.php', { redirect: 'manual' });
if (r.type === 'opaqueredirect') { window.location.reload(); return; }
if (!r.ok) { window.location.replace('index.html'); return; }
const data = await r.json();
if (!data.is_admin) {
window.location.replace('index.html');
return;
}
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.remove('d-none'));
const name = data.name || data.username;
if (name) {
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
}
document.getElementById('sandboxToggle').checked = data.sandbox_mode; document.getElementById('sandboxToggle').checked = data.sandbox_mode;
document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none'; document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';
@@ -359,6 +373,7 @@
// ─── Init ──────────────────────────────────────────────────────────────────── // ─── Init ────────────────────────────────────────────────────────────────────
loadAdmin(); loadAdmin();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') loadAdmin(); });
</script> </script>
</body> </body>
+22
View File
@@ -32,8 +32,17 @@ function handle_get(): never
FROM delivery_log" FROM delivery_log"
)->fetch(); )->fetch();
$groups_raw = $_SERVER['HTTP_X_AUTHENTIK_GROUPS'] ?? '';
$groups = ($groups_raw !== '') ? explode('|', $groups_raw) : [];
$is_admin = in_array('imf_sms_admin', $groups, true);
$username = $_SERVER['HTTP_X_AUTHENTIK_USERNAME'] ?? '';
$name = $_SERVER['HTTP_X_AUTHENTIK_NAME'] ?? $username;
json_out([ json_out([
'sandbox_mode' => $sandbox_mode, 'sandbox_mode' => $sandbox_mode,
'is_admin' => $is_admin,
'username' => $username,
'name' => $name,
'delivery_log_total' => (int) ($counts['total'] ?? 0), 'delivery_log_total' => (int) ($counts['total'] ?? 0),
'delivery_log_sandbox' => (int) ($counts['sandbox_count'] ?? 0), 'delivery_log_sandbox' => (int) ($counts['sandbox_count'] ?? 0),
'delivery_log_real_sent' => (int) ($counts['real_sent'] ?? 0), 'delivery_log_real_sent' => (int) ($counts['real_sent'] ?? 0),
@@ -44,10 +53,23 @@ function handle_get(): never
]); ]);
} }
// ─── Admin guard ─────────────────────────────────────────────────────────────
function require_admin(): void
{
$groups_raw = $_SERVER['HTTP_X_AUTHENTIK_GROUPS'] ?? '';
$groups = ($groups_raw !== '') ? explode('|', $groups_raw) : [];
if (!in_array('imf_sms_admin', $groups, true)) {
error_out('Forbidden — requires imf_sms_admin group', 403);
}
}
// ─── POST /api/admin.php ────────────────────────────────────────────────────── // ─── POST /api/admin.php ──────────────────────────────────────────────────────
function handle_post(): never function handle_post(): never
{ {
require_admin();
$body = json_body(); $body = json_body();
$action = (string) ($body['action'] ?? ''); $action = (string) ($body['action'] ?? '');
+148 -90
View File
@@ -121,6 +121,129 @@ function handle_post(): never
$mode = 'clear'; $mode = 'clear';
} }
// Resolutions map: mobile_number => chosen group index (0-based)
// Sent on the second submission after the user resolves duplicates in the UI.
$resolutions = [];
if (!empty($_POST['resolutions'])) {
$dec = json_decode((string) $_POST['resolutions'], true);
if (is_array($dec)) {
$resolutions = $dec;
}
}
// ─── Phase 1: Parse all valid rows into memory ────────────────────────────
$validRows = [];
$skipped = 0;
$skipReasons = ['not_attending' => 0, 'no_mobile' => 0];
$rejected = [];
while (($row = fgetcsv($handle, 0, $delim)) !== false) {
$row = array_map(fn($v) => trim((string) $v, " \t\n\r\0\x0B\""), $row);
$firstName = trim((string) ($row[$col['first_name']] ?? ''));
$lastName = trim((string) ($row[$col['last_name']] ?? ''));
$email = trim((string) ($row[$col['email']] ?? ''));
$rawMobile = trim((string) ($row[$col['mobile_number']] ?? ''), " \t\n\r\0\x0B\"'");
$rawStatus = $col['status'] !== null ? trim((string) ($row[$col['status']] ?? '')) : '';
if ($col['status'] !== null) {
$status = strtolower($rawStatus);
if ($status !== '' && $status !== 'attending') {
$skipped++;
$skipReasons['not_attending']++;
$rejected[] = [
'first_name' => $firstName, 'last_name' => $lastName,
'email' => $email, 'raw_mobile' => $rawMobile,
'status' => $rawStatus, 'reason' => 'not_attending',
];
continue;
}
}
$mobile = sanitise_mobile($rawMobile);
if ($mobile === '') {
$skipped++;
$skipReasons['no_mobile']++;
$rejected[] = [
'first_name' => $firstName, 'last_name' => $lastName,
'email' => $email, 'raw_mobile' => $rawMobile,
'status' => $rawStatus, 'reason' => classify_mobile_rejection($rawMobile),
];
continue;
}
// Parse workshop columns up-front (needed for duplicate display and import)
$wsName = $hasWorkshops ? trim((string) ($row[$col['workshop_name']] ?? '')) : '';
$wsTime = ($hasWorkshops && $col['workshop_time'] !== null)
? trim((string) ($row[$col['workshop_time']] ?? ''))
: '';
$dt = null;
if ($hasWorkshops && $wsName !== '') {
if ($wsTime === '') {
$wsTime = extract_time_from_string($wsName) ?? '';
$wsName = trim_time_from_string($wsName);
}
if ($wsTime !== '') {
$parsed = strtotime($wsTime);
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
}
}
$validRows[] = [
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'mobile_number' => $mobile,
'workshop_name' => $wsName,
'workshop_time' => $dt,
];
}
fclose($handle);
// ─── Phase 2: Detect intra-CSV duplicate mobile numbers ───────────────────
$byMobile = [];
foreach ($validRows as $i => $vr) {
$byMobile[$vr['mobile_number']][] = $i;
}
$duplicateGroups = [];
foreach ($byMobile as $mobile => $indices) {
if (count($indices) > 1) {
$duplicateGroups[] = [
'mobile' => $mobile,
'rows' => array_values(array_map(fn($i) => $validRows[$i], $indices)),
];
}
}
// If duplicates exist and the user hasn't resolved them yet, pause and ask.
if (!empty($duplicateGroups) && empty($resolutions)) {
json_out(['status' => 'needs_resolution', 'duplicates' => $duplicateGroups]);
}
// Apply resolutions — keep only the chosen row for each duplicate mobile.
if (!empty($resolutions)) {
$indicesToRemove = [];
foreach ($byMobile as $mobile => $indices) {
if (!isset($resolutions[$mobile]) || count($indices) < 2) {
continue;
}
$chosenGroupPos = (int) $resolutions[$mobile];
foreach ($indices as $groupPos => $rowIdx) {
if ($groupPos !== $chosenGroupPos) {
$indicesToRemove[$rowIdx] = true;
}
}
}
$validRows = array_values(
array_filter($validRows, fn($_, $i) => !isset($indicesToRemove[$i]), ARRAY_FILTER_USE_BOTH)
);
}
// ─── Phase 3: Import ──────────────────────────────────────────────────────
$db = db(); $db = db();
$db->beginTransaction(); $db->beginTransaction();
@@ -132,7 +255,7 @@ function handle_post(): never
} }
} }
// Pre-populate workshop cache from existing sessions (for overwrite/append) // Pre-populate workshop cache from existing sessions (for overwrite mode)
$workshopCache = []; $workshopCache = [];
if ($hasWorkshops && $mode !== 'clear') { if ($hasWorkshops && $mode !== 'clear') {
foreach ($db->query('SELECT id, workshop_name, workshop_time FROM workshop_sessions')->fetchAll() as $ws) { foreach ($db->query('SELECT id, workshop_name, workshop_time FROM workshop_sessions')->fetchAll() as $ws) {
@@ -140,76 +263,39 @@ function handle_post(): never
} }
} }
$workshopStmt = $db->prepare( $workshopStmt = $db->prepare(
'INSERT INTO workshop_sessions (workshop_name, workshop_time) VALUES (:name, :time)' 'INSERT INTO workshop_sessions (workshop_name, workshop_time) VALUES (:name, :time)'
); );
$regStmt = $db->prepare( $regStmt = $db->prepare(
'INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:attendee_id, :ws_id)' 'INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:attendee_id, :ws_id)'
); );
$insertStmt = $db->prepare( $insertStmt = $db->prepare(
'INSERT INTO attendees (first_name, last_name, email, mobile_number) 'INSERT INTO attendees (first_name, last_name, email, mobile_number)
VALUES (:first_name, :last_name, :email, :mobile_number)' VALUES (:first_name, :last_name, :email, :mobile_number)
ON DUPLICATE KEY UPDATE
first_name = VALUES(first_name),
last_name = VALUES(last_name),
email = VALUES(email),
id = LAST_INSERT_ID(id)'
); );
// Match by email — most stable identifier
$findByEmailStmt = $db->prepare( $findByEmailStmt = $db->prepare(
"SELECT id FROM attendees WHERE email = :email AND email != ''" "SELECT id FROM attendees WHERE email = :email AND email != ''"
); );
// Update all fields including mobile (e.g. number may have changed, matched by email) $updateStmt = $db->prepare(
$updateStmt = $db->prepare(
'UPDATE attendees SET first_name=:first_name, last_name=:last_name, email=:email, mobile_number=:mobile_number WHERE id=:id' 'UPDATE attendees SET first_name=:first_name, last_name=:last_name, email=:email, mobile_number=:mobile_number WHERE id=:id'
); );
$deleteRegsStmt = $db->prepare( $deleteRegsStmt = $db->prepare(
'DELETE FROM registrations WHERE attendee_id = :attendee_id' 'DELETE FROM registrations WHERE attendee_id = :attendee_id'
); );
$imported = 0; $imported = 0;
$updated = 0; $updated = 0;
$skipped = 0;
$skipReasons = ['not_attending' => 0, 'no_mobile' => 0];
$rejected = [];
while (($row = fgetcsv($handle, 0, $delim)) !== false) { foreach ($validRows as $vr) {
// Strip leading/trailing double-quotes that some spreadsheet exports add around field values $firstName = $vr['first_name'];
$row = array_map(fn($v) => trim((string) $v, " \t\n\r\0\x0B\""), $row); $lastName = $vr['last_name'];
$email = $vr['email'];
$firstName = trim((string) ($row[$col['first_name']] ?? '')); $mobile = $vr['mobile_number'];
$lastName = trim((string) ($row[$col['last_name']] ?? ''));
$email = trim((string) ($row[$col['email']] ?? ''));
$rawMobile = trim((string) ($row[$col['mobile_number']] ?? ''), " \t\n\r\0\x0B\"'");
$rawStatus = $col['status'] !== null ? trim((string) ($row[$col['status']] ?? '')) : '';
// Skip non-attending rows when a status column is present
if ($col['status'] !== null) {
$status = strtolower($rawStatus);
if ($status !== '' && $status !== 'attending') {
$skipped++;
$skipReasons['not_attending']++;
$rejected[] = [
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'raw_mobile' => $rawMobile,
'status' => $rawStatus,
'reason' => 'not_attending',
];
continue;
}
}
$mobile = sanitise_mobile($rawMobile);
if ($mobile === '') {
$skipped++;
$skipReasons['no_mobile']++;
$rejected[] = [
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'raw_mobile' => $rawMobile,
'status' => $rawStatus,
'reason' => classify_mobile_rejection($rawMobile),
];
continue;
}
if ($mode === 'clear') { if ($mode === 'clear') {
$insertStmt->execute([ $insertStmt->execute([
@@ -219,9 +305,9 @@ function handle_post(): never
':mobile_number' => $mobile, ':mobile_number' => $mobile,
]); ]);
$attendeeId = (int) $db->lastInsertId(); $attendeeId = (int) $db->lastInsertId();
$imported++; // rowCount: 1 = new insert, 2 = ON DUPLICATE KEY UPDATE fired
if ($insertStmt->rowCount() >= 2) { $updated++; } else { $imported++; }
} else { } else {
// Match by email only
$matches = []; $matches = [];
if ($email !== '') { if ($email !== '') {
$findByEmailStmt->execute([':email' => $email]); $findByEmailStmt->execute([':email' => $email]);
@@ -229,7 +315,6 @@ function handle_post(): never
} }
if (count($matches) === 0) { if (count($matches) === 0) {
// Not found — insert
$insertStmt->execute([ $insertStmt->execute([
':first_name' => $firstName, ':first_name' => $firstName,
':last_name' => $lastName, ':last_name' => $lastName,
@@ -239,7 +324,6 @@ function handle_post(): never
$attendeeId = (int) $db->lastInsertId(); $attendeeId = (int) $db->lastInsertId();
$imported++; $imported++;
} else { } else {
// overwrite: update all fields (incl. mobile), collapse any duplicates
$attendeeId = (int) $matches[0]['id']; $attendeeId = (int) $matches[0]['id'];
$updateStmt->execute([ $updateStmt->execute([
':first_name' => $firstName, ':first_name' => $firstName,
@@ -259,42 +343,16 @@ function handle_post(): never
} }
} }
// Assign to a workshop session (runs for all modes) if ($hasWorkshops && $vr['workshop_name'] !== '') {
if ($hasWorkshops) { $cacheKey = $vr['workshop_name'] . '|' . ($vr['workshop_time'] ?? '');
$wsName = trim((string) ($row[$col['workshop_name']] ?? '')); if (!isset($workshopCache[$cacheKey])) {
$workshopStmt->execute([':name' => $vr['workshop_name'], ':time' => $vr['workshop_time']]);
if ($wsName !== '') { $workshopCache[$cacheKey] = (int) $db->lastInsertId();
// Try separate time column first; otherwise extract time from the name
$wsTime = $col['workshop_time'] !== null
? trim((string) ($row[$col['workshop_time']] ?? ''))
: '';
if ($wsTime === '') {
$wsTime = extract_time_from_string($wsName) ?? '';
// Strip the extracted time token from the workshop name
$wsName = trim_time_from_string($wsName);
}
$dt = null;
if ($wsTime !== '') {
$parsed = strtotime($wsTime);
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
}
$cacheKey = $wsName . '|' . ($dt ?? '');
if (!isset($workshopCache[$cacheKey])) {
$workshopStmt->execute([':name' => $wsName, ':time' => $dt]);
$workshopCache[$cacheKey] = (int) $db->lastInsertId();
}
$regStmt->execute([
':attendee_id' => $attendeeId,
':ws_id' => $workshopCache[$cacheKey],
]);
} }
$regStmt->execute([':attendee_id' => $attendeeId, ':ws_id' => $workshopCache[$cacheKey]]);
} }
} }
fclose($handle);
$db->commit(); $db->commit();
json_out(['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, 'skip_reasons' => $skipReasons, 'workshops_imported' => $hasWorkshops, 'rejected' => $rejected]); json_out(['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, 'skip_reasons' => $skipReasons, 'workshops_imported' => $hasWorkshops, 'rejected' => $rejected]);
+7
View File
@@ -46,6 +46,13 @@ function handle_get(): never
function handle_post(): never function handle_post(): never
{ {
// Only admins may write settings
$groups_raw = $_SERVER['HTTP_X_AUTHENTIK_GROUPS'] ?? '';
$groups = ($groups_raw !== '') ? explode('|', $groups_raw) : [];
if (!in_array('imf_sms_admin', $groups, true)) {
error_out('Forbidden — requires imf_sms_admin group', 403);
}
$body = json_decode((string) file_get_contents('php://input'), true); $body = json_decode((string) file_get_contents('php://input'), true);
$key = trim((string) ($body['key'] ?? '')); $key = trim((string) ($body['key'] ?? ''));
+123 -5
View File
@@ -93,7 +93,7 @@
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);"> <div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;"> <div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i> <i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span> <span class="nav-username" style="font-size:0.82rem;">User</span>
</div> </div>
</div> </div>
</div> </div>
@@ -240,6 +240,9 @@
<script> <script>
Dropzone.autoDiscover = false; Dropzone.autoDiscover = false;
let pendingFile = null;
let currentDuplicates = [];
const dz = new Dropzone("#csvDropzone", { const dz = new Dropzone("#csvDropzone", {
url: "/api/attendees.php", url: "/api/attendees.php",
acceptedFiles: ".csv", acceptedFiles: ".csv",
@@ -270,8 +273,14 @@
} }
dz.on("success", (file, response) => { dz.on("success", (file, response) => {
dz.removeFile(file);
const r = typeof response === 'string' ? JSON.parse(response) : response; const r = typeof response === 'string' ? JSON.parse(response) : response;
if (r.status === 'needs_resolution') {
pendingFile = file;
currentDuplicates = r.duplicates;
showDuplicateModal(r.duplicates);
return;
}
dz.removeFile(file);
const resultEl = document.getElementById('importResult'); const resultEl = document.getElementById('importResult');
const parts = []; const parts = [];
if (r.imported) parts.push(`<span class="text-success">${r.imported} added</span>`); if (r.imported) parts.push(`<span class="text-success">${r.imported} added</span>`);
@@ -589,6 +598,83 @@
} }
} }
function showDuplicateModal(duplicates) {
const container = document.getElementById('dupGroups');
container.innerHTML = duplicates.map((dup, gi) => {
const rowHtml = dup.rows.map((row, ri) => `
<div class="form-check mb-1">
<input class="form-check-input" type="radio" name="dup_${gi}" id="dup_${gi}_${ri}" value="${ri}"${ri === 0 ? ' checked' : ''}>
<label class="form-check-label" for="dup_${gi}_${ri}">
<strong>${esc(row.first_name)} ${esc(row.last_name)}</strong>
${row.email ? `<span class="text-muted ms-1">· ${esc(row.email)}</span>` : ''}
${row.workshop_name ? `<span class="badge bg-secondary ms-1">${esc(row.workshop_name)}</span>` : ''}
</label>
</div>`).join('');
return `
<div class="mb-3 p-3 rounded" style="background:var(--bg-card);border:1px solid var(--border);">
<div class="mb-2 fw-semibold" style="font-size:0.85rem;color:var(--text-muted);">
<i class="fa-solid fa-phone fa-fw me-1 text-warning"></i>${esc(dup.mobile)}
</div>
${rowHtml}
</div>`;
}).join('');
new bootstrap.Modal(document.getElementById('dupModal')).show();
}
function cancelDuplicates() {
bootstrap.Modal.getInstance(document.getElementById('dupModal')).hide();
if (pendingFile) { dz.removeFile(pendingFile); pendingFile = null; }
currentDuplicates = [];
}
async function confirmDuplicates() {
const resolutions = {};
currentDuplicates.forEach((dup, gi) => {
const sel = document.querySelector(`input[name="dup_${gi}"]:checked`);
if (sel) resolutions[dup.mobile] = parseInt(sel.value, 10);
});
const btn = document.getElementById('dupConfirmBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin fa-fw"></i> Importing…';
try {
const fd = new FormData();
fd.append('file', pendingFile, pendingFile.name);
fd.append('mode', document.querySelector('input[name="importMode"]:checked').value);
fd.append('resolutions', JSON.stringify(resolutions));
const res = await fetch('/api/attendees.php', { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Import failed');
bootstrap.Modal.getInstance(document.getElementById('dupModal')).hide();
dz.removeFile(pendingFile);
pendingFile = null;
currentDuplicates = [];
const resultEl = document.getElementById('importResult');
const parts = [];
if (data.imported) parts.push(`<span class="text-success">${data.imported} added</span>`);
if (data.updated) parts.push(`<span class="text-info">${data.updated} updated</span>`);
if (data.skipped) {
const reasons = [];
if (data.skip_reasons?.not_attending) reasons.push(`${data.skip_reasons.not_attending} not attending`);
if (data.skip_reasons?.no_mobile) reasons.push(`${data.skip_reasons.no_mobile} invalid/missing mobile`);
const detail = reasons.length ? ` (${reasons.join(', ')})` : '';
parts.push(`<span class="text-muted">${data.skipped} skipped${detail}</span>`);
}
resultEl.innerHTML = parts.length ? 'Import complete: ' + parts.join(', ') + '.' : 'Import complete.';
if (data.rejected && data.rejected.length > 0) {
showRejectedRecords(data.rejected);
} else {
rejectedRows = [];
renderRejectedTable();
}
loadAttendees();
} catch (e) {
document.getElementById('importResult').innerHTML = `<span class="text-danger">Import failed: ${esc(e.message)}</span>`;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-check fa-fw"></i> Confirm Import';
}
}
</script> </script>
<!-- ─── Edit attendee modal ─── --> <!-- ─── Edit attendee modal ─── -->
@@ -639,13 +725,45 @@
</div> </div>
</div> </div>
<!-- ─── Duplicate resolution modal ─── -->
<div class="modal fade" id="dupModal" tabindex="-1" data-bs-backdrop="static" aria-labelledby="dupModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="dupModalLabel">
<i class="fa-solid fa-triangle-exclamation fa-fw me-2 text-warning"></i>Duplicate Mobile Numbers
</h5>
</div>
<div class="modal-body">
<p class="text-muted mb-3" style="font-size:0.85rem;">The following mobile numbers appear more than once in your CSV. Choose which entry to import for each:</p>
<div id="dupGroups"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="cancelDuplicates()">Cancel</button>
<button type="button" class="btn btn-primary" id="dupConfirmBtn" onclick="confirmDuplicates()">
<i class="fa-solid fa-check fa-fw"></i> Confirm Import
</button>
</div>
</div>
</div>
</div>
<script> <script>
(async function () { async function checkAuth() {
try { try {
const d = await fetch('/api/admin.php').then(r => r.json()); const r = await fetch('/api/admin.php', { redirect: 'manual' });
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
const d = await r.json();
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none'; document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
const name = d.name || d.username;
if (name) {
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
}
} catch { } } catch { }
}()); }
checkAuth();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
</script> </script>
</body> </body>
+13 -4
View File
@@ -90,7 +90,7 @@
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);"> <div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;"> <div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i> <i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span> <span class="nav-username" style="font-size:0.82rem;">User</span>
</div> </div>
</div> </div>
</div> </div>
@@ -245,12 +245,21 @@
loadLog(); loadLog();
</script> </script>
<script> <script>
(async function () { async function checkAuth() {
try { try {
const d = await fetch('/api/admin.php').then(r => r.json()); const r = await fetch('/api/admin.php', { redirect: 'manual' });
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
const d = await r.json();
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none'; document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
const name = d.name || d.username;
if (name) {
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
}
} catch { } } catch { }
}()); }
checkAuth();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
</script> </script>
</body> </body>
+20 -5
View File
@@ -89,7 +89,7 @@
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);"> <div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;"> <div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i> <i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span> <span class="nav-username" style="font-size:0.82rem;">User</span>
</div> </div>
</div> </div>
</div> </div>
@@ -197,10 +197,12 @@
</a> </a>
</div> </div>
<!-- Start Admin Settings --> <!-- Start Admin Settings -->
<div id="adminSection" class="d-none col-12">
<hr class="mt-4 mb-3"> <hr class="mt-4 mb-3">
<div class="page-header mt-0 mb-0"> <div class="page-header mt-0 mb-4">
<p><em>You're seeing these options because you're an admin. Proceed with caution!</em></p> <p><em>You're seeing these options because you're an admin. Proceed with caution!</em></p>
</div> </div>
<div class="row g-3">
<div class="col-12 col-sm-6"> <div class="col-12 col-sm-6">
<a href="admin.html" class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3"> <a href="admin.html" class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
<i class="fa-solid fa-user-shield fa-lg" style="color:var(--accent);"></i> <i class="fa-solid fa-user-shield fa-lg" style="color:var(--accent);"></i>
@@ -223,6 +225,8 @@
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i> <i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
</button> </button>
</div> </div>
</div><!-- /.row (admin cards) -->
</div><!-- /#adminSection -->
</div> </div>
</div> </div>
</div> </div>
@@ -332,12 +336,23 @@
loadStats(); loadStats();
</script> </script>
<script> <script>
(async function () { async function checkAuth() {
try { try {
const d = await fetch('/api/admin.php').then(r => r.json()); const r = await fetch('/api/admin.php', { redirect: 'manual' });
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
const d = await r.json();
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none'; document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
const adminSection = document.getElementById('adminSection');
if (adminSection) adminSection.classList.toggle('d-none', !d.is_admin);
const name = d.name || d.username;
if (name) {
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
}
} catch { } } catch { }
}()); }
checkAuth();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
</script> </script>
</body> </body>
+13 -4
View File
@@ -104,7 +104,7 @@
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);"> <div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;"> <div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i> <i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span> <span class="nav-username" style="font-size:0.82rem;">User</span>
</div> </div>
</div> </div>
</div> </div>
@@ -501,12 +501,21 @@
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
</script> </script>
<script> <script>
(async function () { async function checkAuth() {
try { try {
const d = await fetch('/api/admin.php').then(r => r.json()); const r = await fetch('/api/admin.php', { redirect: 'manual' });
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
const d = await r.json();
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none'; document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
const name = d.name || d.username;
if (name) {
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
}
} catch { } } catch { }
}()); }
checkAuth();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
</script> </script>
</body> </body>
+13 -4
View File
@@ -90,7 +90,7 @@
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);"> <div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;"> <div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i> <i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span> <span class="nav-username" style="font-size:0.82rem;">User</span>
</div> </div>
</div> </div>
</div> </div>
@@ -369,12 +369,21 @@
loadTemplatePicker(); loadTemplatePicker();
</script> </script>
<script> <script>
(async function () { async function checkAuth() {
try { try {
const d = await fetch('/api/admin.php').then(r => r.json()); const r = await fetch('/api/admin.php', { redirect: 'manual' });
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
const d = await r.json();
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none'; document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
const name = d.name || d.username;
if (name) {
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
}
} catch { } } catch { }
}()); }
checkAuth();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
</script> </script>
</body> </body>
+13 -4
View File
@@ -90,7 +90,7 @@
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);"> <div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;"> <div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i> <i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span> <span class="nav-username" style="font-size:0.82rem;">User</span>
</div> </div>
</div> </div>
</div> </div>
@@ -296,12 +296,21 @@
loadTemplates(); loadTemplates();
</script> </script>
<script> <script>
(async function () { async function checkAuth() {
try { try {
const d = await fetch('/api/admin.php').then(r => r.json()); const r = await fetch('/api/admin.php', { redirect: 'manual' });
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
const d = await r.json();
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none'; document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
const name = d.name || d.username;
if (name) {
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
}
} catch { } } catch { }
}()); }
checkAuth();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
</script> </script>
</body> </body>