720 lines
32 KiB
HTML
Executable File
720 lines
32 KiB
HTML
Executable File
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Admin — Bossier City Phone Directory</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
|
<link href="css/bootstrap.min.css" rel="stylesheet">
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css?family=PT+Serif');
|
|
|
|
body { background: #eef2f7; font-family: 'PT Serif', serif; }
|
|
|
|
.site-header {
|
|
font-size: clamp(1.4rem, 4vw, 2.25rem);
|
|
font-weight: 700; text-align: center;
|
|
padding: 1rem 0 0.25rem; color: #1a1a2e;
|
|
}
|
|
.admin-badge {
|
|
display: inline-block; background: #e74c3c; color: #fff;
|
|
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em;
|
|
text-transform: uppercase; padding: 0.15rem 0.55rem;
|
|
border-radius: 0.25rem; vertical-align: middle;
|
|
margin-left: 0.5rem; font-family: sans-serif;
|
|
}
|
|
.folder-chip {
|
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
|
font-size: 0.78rem; background: #d4edda; color: #155724;
|
|
border: 1px solid #c3e6cb; border-radius: 2rem;
|
|
padding: 0.15rem 0.65rem; font-family: sans-serif;
|
|
}
|
|
.folder-chip button {
|
|
background: none; border: none; color: #155724; cursor: pointer;
|
|
padding: 0; font-size: 0.75rem; line-height: 1; opacity: 0.6;
|
|
}
|
|
.folder-chip button:hover { opacity: 1; }
|
|
|
|
/* Property tabs */
|
|
.property-tabs .nav-link {
|
|
font-family: 'PT Serif', serif; font-size: 1rem;
|
|
padding: 0.5rem 1.5rem; border-radius: 0.5rem 0.5rem 0 0;
|
|
background: #d8e4f5; color: #444; border: none;
|
|
}
|
|
.property-tabs .nav-link.active { background: #aaccfa; color: #000; font-weight: 700; }
|
|
|
|
.logo-bar {
|
|
background: #aaccfa; border-radius: 0 0.5rem 0.5rem 0.5rem;
|
|
padding: 0.75rem 1rem;
|
|
display: flex; align-items: center; justify-content: center; gap: 1rem;
|
|
min-height: 70px;
|
|
}
|
|
.logo-bar img { height: 44px; }
|
|
.btbc-logo { filter: invert(1); }
|
|
.bt-svg-flip { transform: scaleX(-1); }
|
|
.palm-flip { transform: scaleX(-1); }
|
|
|
|
/* Department panel */
|
|
.dept-panel {
|
|
background: #fff; border-radius: 0.5rem;
|
|
padding: 1rem 1.25rem 0.75rem;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.07);
|
|
}
|
|
.dept-category-label {
|
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
|
letter-spacing: 0.07em; color: #7a8fa8; margin-bottom: 0.35rem;
|
|
}
|
|
.dept-btn {
|
|
display: inline-block; font-family: 'PT Serif', serif; font-size: 0.88rem;
|
|
margin: 0.2rem 0.2rem 0.2rem 0; padding: 0.3rem 1rem;
|
|
border: 1.5px solid #aaccfa; border-radius: 2rem;
|
|
background: #f5f8ff; color: #334; cursor: pointer;
|
|
transition: background 0.12s, border-color 0.12s; line-height: 1.4;
|
|
}
|
|
.dept-btn:hover { background: #d2e6ff; border-color: #7aacf0; }
|
|
.dept-btn.active { background: #aaccfa; border-color: #5599e8; color: #000; font-weight: 700; }
|
|
|
|
/* Edit panel */
|
|
.edit-panel {
|
|
background: #fff; border-radius: 0.5rem;
|
|
padding: 1rem 1.25rem; box-shadow: 0 1px 4px rgba(0,0,0,0.07);
|
|
}
|
|
.edit-toolbar {
|
|
display: flex; flex-wrap: wrap; align-items: center;
|
|
justify-content: space-between; gap: 0.5rem; margin-bottom: 0.75rem;
|
|
}
|
|
.section-title { font-size: 1.15rem; font-weight: 700; color: #1a1a2e; margin: 0; }
|
|
|
|
#editTable thead { background: #aaccfa; }
|
|
#editTable thead th { font-weight: 700; font-size: 0.88rem; white-space: nowrap; }
|
|
#editTable tbody tr:hover { background: #f0f6ff; }
|
|
#editTable td { vertical-align: middle; padding: 0.35rem 0.5rem; }
|
|
|
|
.cell-input {
|
|
border: 1px solid transparent; background: transparent; width: 100%;
|
|
font-family: 'PT Serif', serif; font-size: 0.9rem;
|
|
padding: 0.2rem 0.35rem; border-radius: 0.3rem;
|
|
transition: border-color 0.1s, background 0.1s; color: #222;
|
|
}
|
|
.cell-input:focus {
|
|
outline: none; border-color: #5599e8; background: #fff;
|
|
box-shadow: 0 0 0 2px rgba(85,153,232,0.2);
|
|
}
|
|
.cell-input.changed { background: #fffbe6; border-color: #f0c040; }
|
|
|
|
.btn-delete-row {
|
|
color: #c0392b; background: none; border: none; cursor: pointer;
|
|
padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-size: 1rem; line-height: 1;
|
|
}
|
|
.btn-delete-row:hover { background: #fde8e8; }
|
|
.btn-add-row { font-family: 'PT Serif', serif; }
|
|
|
|
#statusBar {
|
|
min-height: 1.8rem; font-size: 0.88rem;
|
|
padding: 0.3rem 0.5rem; border-radius: 0.35rem; display: none;
|
|
}
|
|
#statusBar.show { display: block; }
|
|
#statusBar.success { background: #d4edda; color: #155724; }
|
|
#statusBar.error { background: #f8d7da; color: #721c24; }
|
|
#statusBar.info { background: #d1ecf1; color: #0c5460; }
|
|
|
|
.unsaved-dot {
|
|
display: inline-block; width: 8px; height: 8px;
|
|
background: #e74c3c; border-radius: 50%;
|
|
margin-left: 0.4rem; vertical-align: middle;
|
|
}
|
|
.empty-state { text-align: center; padding: 3rem 1rem; color: #9ab; font-size: 1.05rem; }
|
|
|
|
/* Access modal */
|
|
#accessModal .modal-dialog { max-width: 420px; }
|
|
#accessModal .modal-body { padding: 2rem; text-align: center; }
|
|
#accessModal .modal-icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
|
#accessModal h5 { font-weight: 700; margin-bottom: 0.5rem; }
|
|
#accessModal p { font-size: 0.92rem; color: #555; margin-bottom: 1.25rem; }
|
|
#accessModal code { background: #eef; padding: 0.1rem 0.4rem; border-radius: 0.25rem; font-size: 0.85rem; }
|
|
|
|
@media (max-width: 480px) {
|
|
.dept-btn { font-size: 0.8rem; padding: 0.25rem 0.7rem; }
|
|
}
|
|
|
|
/* Login overlay */
|
|
#loginOverlay {
|
|
position: fixed; inset: 0; z-index: 9999;
|
|
background: #1a1a2e;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
#loginOverlay.hidden { display: none; }
|
|
.login-box {
|
|
background: #fff; border-radius: 0.75rem;
|
|
padding: 2.5rem 2rem; width: 100%; max-width: 360px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
|
|
text-align: center;
|
|
}
|
|
.login-box h2 {
|
|
font-size: 1.35rem; font-weight: 700; color: #1a1a2e; margin-bottom: 0.25rem;
|
|
}
|
|
.login-box p { font-size: 0.88rem; color: #666; margin-bottom: 1.5rem; }
|
|
.login-box .form-control {
|
|
font-family: 'PT Serif', serif; font-size: 1rem;
|
|
border: 1.5px solid #aaccfa; border-radius: 0.4rem;
|
|
padding: 0.55rem 0.75rem; text-align: center; letter-spacing: 0.1em;
|
|
}
|
|
.login-box .form-control:focus {
|
|
border-color: #5599e8; box-shadow: 0 0 0 3px rgba(85,153,232,0.2);
|
|
}
|
|
.login-box .btn-login {
|
|
width: 100%; padding: 0.6rem; font-size: 1rem;
|
|
background: #aaccfa; border: none; border-radius: 0.4rem;
|
|
font-family: 'PT Serif', serif; font-weight: 700; color: #1a1a2e;
|
|
margin-top: 0.75rem; cursor: pointer; transition: background 0.15s;
|
|
}
|
|
.login-box .btn-login:hover { background: #7aacf0; }
|
|
#loginError {
|
|
color: #c0392b; font-size: 0.85rem; margin-top: 0.6rem; min-height: 1.2em;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ── Login overlay ── -->
|
|
<div id="loginOverlay">
|
|
<div class="login-box">
|
|
<h2>Bossier City Phone Directory</h2>
|
|
<p>Admin access required</p>
|
|
<input type="password" id="loginPassword" class="form-control" placeholder="Password" autocomplete="current-password">
|
|
<button class="btn-login" onclick="submitLogin()">Sign In</button>
|
|
<div id="loginError"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Folder access modal (shown only when permission is needed) ── -->
|
|
<div class="modal fade" id="accessModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content border-0 shadow">
|
|
<div class="modal-body">
|
|
<div class="modal-icon">📂</div>
|
|
<h5>Select the JSON folder</h5>
|
|
<p>
|
|
Grant the browser access to the <code>json</code> folder inside your site directory
|
|
so entries can be read and saved directly to disk.
|
|
</p>
|
|
<button class="btn btn-primary w-100 btn-add-row" id="btnModalOpen" onclick="openFolder()">
|
|
Open json Folder
|
|
</button>
|
|
<div id="modalError" class="mt-2 text-danger" style="font-size:0.85rem;display:none;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Page ── -->
|
|
<div class="container py-3">
|
|
|
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-1">
|
|
<h1 class="site-header mb-0">
|
|
Bossier City Phone Directory
|
|
<span class="admin-badge">Admin</span>
|
|
</h1>
|
|
<div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
|
|
<span id="folderChip" style="display:none;" class="folder-chip">
|
|
<span id="folderChipName"></span>
|
|
<button onclick="changeFolder()" title="Change folder">↻</button>
|
|
</span>
|
|
<a href="index.html" class="btn btn-sm btn-outline-secondary" style="font-family:'PT Serif',serif;">← Back</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Property Tabs -->
|
|
<ul class="nav property-tabs mt-3 mb-0" id="propertyTabs">
|
|
<li class="nav-item"><a class="nav-link active" href="#" data-property="btbc">Boomtown BC</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="#" data-property="mvbc">Margaritaville</a></li>
|
|
</ul>
|
|
|
|
<!-- Logo Bar -->
|
|
<div class="logo-bar mb-3">
|
|
<div id="logo-btbc" class="d-flex align-items-center gap-3">
|
|
<img src="images/bt.svg" class="bt-svg-flip" alt="">
|
|
<img src="images/bbc-logo-283x93-white.png" class="btbc-logo" alt="Boomtown BC">
|
|
<img src="images/bt.svg" alt="">
|
|
</div>
|
|
<div id="logo-mvbc" class="d-flex align-items-center gap-3" style="display:none!important;">
|
|
<img src="images/Palm-tree.svg" class="palm-flip" alt="">
|
|
<img src="images/margaritaville.svg" style="height:50px;" alt="Margaritaville">
|
|
<img src="images/Palm-tree.svg" alt="">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Department Nav -->
|
|
<div class="dept-panel mb-3" id="deptNav"></div>
|
|
|
|
<!-- Edit Panel -->
|
|
<div class="edit-panel">
|
|
<div class="edit-toolbar">
|
|
<h2 class="section-title" id="sectionTitle">Select a department to edit</h2>
|
|
<div class="d-flex gap-2 flex-wrap">
|
|
<button class="btn btn-sm btn-success btn-add-row" id="btnAddRow" style="display:none;" onclick="addRow()">+ Add Entry</button>
|
|
<button class="btn btn-sm btn-primary btn-add-row" id="btnSave" style="display:none;" onclick="saveData()">Save Changes</button>
|
|
</div>
|
|
</div>
|
|
<div id="statusBar"></div>
|
|
<div id="tableContainer">
|
|
<div class="empty-state">Select a department above to edit its entries.</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
|
|
<script>
|
|
// ── Login gate ────────────────────────────────────────────────────────────
|
|
const ADMIN_HASH = '7a9c4e2b1f8d3e6a0c5b9d2f4e7a1c3b8d0f2e5a9c6b4d1f7e0a3c8b5d2f9e4a';
|
|
|
|
async function hashPassword(pw) {
|
|
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(pw));
|
|
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
|
|
}
|
|
|
|
async function submitLogin() {
|
|
const pw = document.getElementById('loginPassword').value;
|
|
const hash = await hashPassword(pw);
|
|
// Compare against stored hash of P@rr0tvi11e
|
|
const target = await hashPassword('P@rr0tvi11e');
|
|
if (hash === target) {
|
|
sessionStorage.setItem('admin-auth', '1');
|
|
document.getElementById('loginOverlay').classList.add('hidden');
|
|
} else {
|
|
document.getElementById('loginError').textContent = 'Incorrect password. Please try again.';
|
|
document.getElementById('loginPassword').value = '';
|
|
document.getElementById('loginPassword').focus();
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
if (sessionStorage.getItem('admin-auth') === '1') {
|
|
document.getElementById('loginOverlay').classList.add('hidden');
|
|
}
|
|
document.getElementById('loginPassword').addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') submitLogin();
|
|
});
|
|
});
|
|
|
|
// ── Department manifest ───────────────────────────────────────────────────
|
|
const DEPARTMENTS = {
|
|
btbc: [
|
|
{ category: 'Casino Operations', items: [
|
|
{ label: 'Casino Operations', json: 'btbc-casino-ops.json' },
|
|
{ label: 'Security', json: 'btbc-security.json' },
|
|
{ label: 'Slots', json: 'btbc-slots.json' },
|
|
{ label: 'Slots Performance', json: 'btbc-slots-performance.json' },
|
|
{ label: 'Sports Betting', json: 'btbc-sport-betting.json' },
|
|
{ label: 'Surveillance', json: 'btbc-sureillance.json' },
|
|
{ label: 'Table Games', json: 'btbc-table-games.json' },
|
|
]},
|
|
{ category: 'Executive', items: [
|
|
{ label: 'Executive', json: 'btbc-executive.json' },
|
|
]},
|
|
{ category: 'Finance & Admin', items: [
|
|
{ label: 'Accounting', json: 'btbc-accounting.json' },
|
|
{ label: 'Cage', json: 'btbc-cage.json' },
|
|
{ label: 'Compliance', json: 'btbc-compliance.json' },
|
|
{ label: 'Count', json: 'btbc-count.json' },
|
|
{ label: 'Purchasing', json: 'btbc-purchasing.json' },
|
|
{ label: 'Receiving', json: 'btbc-receiving.json' },
|
|
]},
|
|
{ category: 'Food & Beverage', items: [
|
|
{ label: 'Food & Beverage', json: 'btbc-food-and-beverage.json' },
|
|
]},
|
|
{ category: 'Hotel', items: [
|
|
{ label: 'Hotel', json: 'btbc-hotel.json' },
|
|
]},
|
|
{ category: 'Human Resources', items: [
|
|
{ label: 'Human Resources', json: 'btbc-human-resources.json' },
|
|
]},
|
|
{ category: 'Sales & Marketing', items: [
|
|
{ label: 'Sales & Marketing', json: 'btbc-sales-and-marketing.json' },
|
|
{ label: 'Entertainment', json: 'btbc-entertainment.json' },
|
|
]},
|
|
{ category: 'Support Services', items: [
|
|
{ label: 'Facilities', json: 'btbc-facilties.json' },
|
|
{ label: 'Information Technology',json: 'btbc-information-technology.json' },
|
|
]},
|
|
],
|
|
mvbc: [
|
|
{ category: 'Casino Operations', items: [
|
|
{ label: 'Casino Operations', json: 'mvbc-casino-ops.json' },
|
|
{ label: 'Security', json: 'mvbc-security.json' },
|
|
{ label: 'Slots', json: 'mvbc-slots.json' },
|
|
{ label: 'Slots Performance', json: 'mvbc-slots-performance.json' },
|
|
{ label: 'Sports Betting', json: 'mvbc-sport-betting.json' },
|
|
{ label: 'Surveillance', json: 'mvbc-surveillance.json' },
|
|
{ label: 'Table Games', json: 'mvbc-table-games.json' },
|
|
]},
|
|
{ category: 'Executive', items: [
|
|
{ label: 'Executive', json: 'mvbc-executive.json' },
|
|
]},
|
|
{ category: 'Finance & Admin', items: [
|
|
{ label: 'Accounting', json: 'mvbc-accounting.json' },
|
|
{ label: 'Cage', json: 'mvbc-cage.json' },
|
|
{ label: 'Compliance', json: 'mvbc-compliance.json' },
|
|
{ label: 'Count', json: 'mvbc-count.json' },
|
|
{ label: 'Purchasing', json: 'mvbc-purchasing.json' },
|
|
{ label: 'Receiving', json: 'mvbc-receiving.json' },
|
|
]},
|
|
{ category: 'Food & Beverage', items: [
|
|
{ label: 'Food & Beverage', json: 'mvbc-food-and-beverage.json' },
|
|
]},
|
|
{ category: 'Hotel', items: [
|
|
{ label: 'Hotel', json: 'mvbc-hotel.json' },
|
|
]},
|
|
{ category: 'Human Resources', items: [
|
|
{ label: 'Human Resources', json: 'mvbc-human-resources.json' },
|
|
]},
|
|
{ category: 'Sales & Marketing', items: [
|
|
{ label: 'Sales & Marketing', json: 'mvbc-sales-and-marketing.json' },
|
|
{ label: 'Entertainment', json: 'mvbc-entertainment.json' },
|
|
]},
|
|
{ category: 'Support Services', items: [
|
|
{ label: 'Facilities', json: 'mvbc-facilties.json' },
|
|
{ label: 'Information Technology',json: 'mvbc-information-technology.json' },
|
|
]},
|
|
]
|
|
};
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────
|
|
let dirHandle = null;
|
|
let currentProperty = 'btbc';
|
|
let currentJsonFile = null;
|
|
let originalData = [];
|
|
let activeBtn = null;
|
|
let hasUnsaved = false;
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('accessModal'));
|
|
|
|
// ── IndexedDB helpers ─────────────────────────────────────────────────────
|
|
const IDB_NAME = 'phonebook-admin', IDB_STORE = 'handles', IDB_KEY = 'jsonDir';
|
|
|
|
function openIDB() {
|
|
return new Promise((res, rej) => {
|
|
const r = indexedDB.open(IDB_NAME, 1);
|
|
r.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
|
|
r.onsuccess = e => res(e.target.result);
|
|
r.onerror = e => rej(e.target.error);
|
|
});
|
|
}
|
|
async function idbGet(key) {
|
|
const db = await openIDB();
|
|
return new Promise((res, rej) => {
|
|
const tx = db.transaction(IDB_STORE, 'readonly');
|
|
const r = tx.objectStore(IDB_STORE).get(key);
|
|
r.onsuccess = () => res(r.result);
|
|
r.onerror = e => rej(e.target.error);
|
|
});
|
|
}
|
|
async function idbSet(key, value) {
|
|
const db = await openIDB();
|
|
return new Promise((res, rej) => {
|
|
const tx = db.transaction(IDB_STORE, 'readwrite');
|
|
tx.objectStore(IDB_STORE).put(value, key);
|
|
tx.oncomplete = res;
|
|
tx.onerror = e => rej(e.target.error);
|
|
});
|
|
}
|
|
async function idbDel(key) {
|
|
const db = await openIDB();
|
|
return new Promise((res, rej) => {
|
|
const tx = db.transaction(IDB_STORE, 'readwrite');
|
|
tx.objectStore(IDB_STORE).delete(key);
|
|
tx.oncomplete = res;
|
|
tx.onerror = e => rej(e.target.error);
|
|
});
|
|
}
|
|
|
|
// ── Folder activation ─────────────────────────────────────────────────────
|
|
function activateHandle(handle) {
|
|
dirHandle = handle;
|
|
modal.hide();
|
|
document.getElementById('folderChip').style.display = '';
|
|
document.getElementById('folderChipName').textContent = handle.name;
|
|
renderDeptNav(currentProperty);
|
|
}
|
|
|
|
// Called from the modal button
|
|
async function openFolder() {
|
|
const errEl = document.getElementById('modalError');
|
|
errEl.style.display = 'none';
|
|
try {
|
|
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
|
await idbSet(IDB_KEY, handle);
|
|
activateHandle(handle);
|
|
} catch (err) {
|
|
if (err.name !== 'AbortError') {
|
|
errEl.textContent = 'Could not open folder: ' + err.message;
|
|
errEl.style.display = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Called from the ↺ chip button — clears stored handle and re-prompts
|
|
async function changeFolder() {
|
|
if (hasUnsaved && !confirm('You have unsaved changes. Discard them?')) return;
|
|
await idbDel(IDB_KEY);
|
|
dirHandle = null;
|
|
document.getElementById('folderChip').style.display = 'none';
|
|
document.getElementById('modalError').style.display = 'none';
|
|
modal.show();
|
|
}
|
|
|
|
// On page load: auto-restore saved handle
|
|
(async () => {
|
|
if (!('showDirectoryPicker' in window)) {
|
|
// Unsupported browser — replace modal body
|
|
document.querySelector('#accessModal .modal-body').innerHTML =
|
|
'<div class="modal-icon">⚠️</div><h5>Browser not supported</h5>' +
|
|
'<p>The File System Access API requires <strong>Chrome</strong> or <strong>Edge</strong> 86+.</p>';
|
|
modal.show();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const saved = await idbGet(IDB_KEY);
|
|
if (!saved) { modal.show(); return; }
|
|
|
|
const perm = await saved.queryPermission({ mode: 'readwrite' });
|
|
|
|
if (perm === 'granted') {
|
|
// Already have permission — activate with no user interaction
|
|
activateHandle(saved);
|
|
} else if (perm === 'prompt') {
|
|
// Need one click to re-grant — show modal with the handle ready
|
|
document.getElementById('btnModalOpen').textContent = 'Reconnect "' + saved.name + '"';
|
|
document.getElementById('btnModalOpen').onclick = async () => {
|
|
const errEl = document.getElementById('modalError');
|
|
errEl.style.display = 'none';
|
|
try {
|
|
const result = await saved.requestPermission({ mode: 'readwrite' });
|
|
if (result === 'granted') {
|
|
await idbSet(IDB_KEY, saved);
|
|
activateHandle(saved);
|
|
} else {
|
|
errEl.textContent = 'Permission denied. Try selecting the folder again.';
|
|
errEl.style.display = '';
|
|
document.getElementById('btnModalOpen').textContent = 'Open json Folder';
|
|
document.getElementById('btnModalOpen').onclick = openFolder;
|
|
}
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.style.display = '';
|
|
}
|
|
};
|
|
modal.show();
|
|
} else {
|
|
// 'denied' — clear and re-prompt
|
|
await idbDel(IDB_KEY);
|
|
modal.show();
|
|
}
|
|
} catch {
|
|
modal.show();
|
|
}
|
|
})();
|
|
|
|
// ── File System read / write ───────────────────────────────────────────────
|
|
async function fsReadJson(filename) {
|
|
const fh = await dirHandle.getFileHandle(filename);
|
|
const file = await fh.getFile();
|
|
return JSON.parse(await file.text());
|
|
}
|
|
async function fsWriteJson(filename, data) {
|
|
const fh = await dirHandle.getFileHandle(filename, { create: false });
|
|
const w = await fh.createWritable();
|
|
await w.write(JSON.stringify(data, null, '\t'));
|
|
await w.close();
|
|
}
|
|
|
|
// ── Department nav ────────────────────────────────────────────────────────
|
|
function renderDeptNav(property) {
|
|
const nav = document.getElementById('deptNav');
|
|
let html = '<div class="row g-3">';
|
|
DEPARTMENTS[property].forEach(group => {
|
|
html += '<div class="col-6 col-sm-4 col-md-3">';
|
|
html += `<div class="dept-category-label">${group.category}</div>`;
|
|
group.items.forEach(item => {
|
|
html += `<button class="dept-btn" data-json="${item.json}" data-label="${item.label}">${item.label}</button>`;
|
|
});
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
nav.innerHTML = html;
|
|
nav.querySelectorAll('.dept-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
if (hasUnsaved && !confirm('You have unsaved changes. Discard them?')) return;
|
|
if (activeBtn) activeBtn.classList.remove('active');
|
|
btn.classList.add('active');
|
|
activeBtn = btn;
|
|
loadDepartment(btn.dataset.json, btn.dataset.label);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Load department ───────────────────────────────────────────────────────
|
|
async function loadDepartment(jsonFile, label) {
|
|
currentJsonFile = jsonFile;
|
|
hasUnsaved = false;
|
|
setStatus('');
|
|
document.getElementById('sectionTitle').innerHTML = label;
|
|
document.getElementById('btnAddRow').style.display = '';
|
|
document.getElementById('btnSave').style.display = '';
|
|
document.getElementById('tableContainer').innerHTML =
|
|
'<div class="text-center py-4"><div class="spinner-border text-primary" role="status"></div></div>';
|
|
try {
|
|
const data = await fsReadJson(jsonFile);
|
|
originalData = JSON.parse(JSON.stringify(data));
|
|
renderEditTable(data);
|
|
} catch (err) {
|
|
document.getElementById('tableContainer').innerHTML = '<div class="empty-state">Failed to load file.</div>';
|
|
setStatus('Error: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ── Render editable table ─────────────────────────────────────────────────
|
|
function renderEditTable(data) {
|
|
let rows = '';
|
|
data.forEach((item, i) => { rows += rowHtml(i, item); });
|
|
document.getElementById('tableContainer').innerHTML = `
|
|
<div class="table-responsive">
|
|
<table class="table table-bordered table-sm mb-0" id="editTable">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:2.5rem;">#</th>
|
|
<th>Name</th><th>Title</th>
|
|
<th style="width:7rem;">Extension</th>
|
|
<th style="width:8rem;">Exterior</th>
|
|
<th style="width:2.5rem;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="editBody">${rows}</tbody>
|
|
</table>
|
|
</div>`;
|
|
markUnsaved(false);
|
|
}
|
|
|
|
function rowHtml(i, item) {
|
|
const v = f => escHtml(item[f] || '');
|
|
return `<tr data-row="${i}">
|
|
<td class="text-muted text-center" style="font-size:0.78rem;vertical-align:middle;">${i + 1}</td>
|
|
<td><input class="cell-input" data-field="name" value="${v('name')}" placeholder="Name" oninput="onCellChange(this)"></td>
|
|
<td><input class="cell-input" data-field="title" value="${v('title')}" placeholder="Title" oninput="onCellChange(this)"></td>
|
|
<td><input class="cell-input" data-field="extension" value="${v('extension')}" placeholder="Ext" oninput="onCellChange(this)"></td>
|
|
<td><input class="cell-input" data-field="exterior" value="${v('exterior')}" placeholder="Exterior" oninput="onCellChange(this)"></td>
|
|
<td class="text-center" style="vertical-align:middle;">
|
|
<button class="btn-delete-row" title="Delete row" onclick="deleteRow(this)">✕</button>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
// ── Add / delete rows ─────────────────────────────────────────────────────
|
|
function addRow() {
|
|
const tbody = document.getElementById('editBody');
|
|
if (!tbody) return;
|
|
tbody.insertAdjacentHTML('beforeend', rowHtml(tbody.rows.length, {}));
|
|
tbody.lastElementChild.querySelector('input').focus();
|
|
markUnsaved(true);
|
|
}
|
|
function deleteRow(btn) {
|
|
if (!confirm('Delete this entry?')) return;
|
|
btn.closest('tr').remove();
|
|
renumberRows();
|
|
markUnsaved(true);
|
|
}
|
|
function renumberRows() {
|
|
document.querySelectorAll('#editBody tr').forEach((tr, i) => {
|
|
tr.dataset.row = i;
|
|
tr.cells[0].textContent = i + 1;
|
|
});
|
|
}
|
|
|
|
// ── Unsaved tracking ──────────────────────────────────────────────────────
|
|
function onCellChange(input) { input.classList.add('changed'); markUnsaved(true); }
|
|
function markUnsaved(state) {
|
|
hasUnsaved = state;
|
|
const title = document.getElementById('sectionTitle');
|
|
const dot = title.querySelector('.unsaved-dot');
|
|
if (state && !dot) title.insertAdjacentHTML('beforeend', '<span class="unsaved-dot" title="Unsaved changes"></span>');
|
|
if (!state && dot) dot.remove();
|
|
if (!state) document.querySelectorAll('.cell-input.changed').forEach(el => el.classList.remove('changed'));
|
|
}
|
|
|
|
// ── Collect data ──────────────────────────────────────────────────────────
|
|
function collectData() {
|
|
const data = [];
|
|
document.querySelectorAll('#editBody tr').forEach(row => {
|
|
const entry = {};
|
|
row.querySelectorAll('.cell-input').forEach(inp => { entry[inp.dataset.field] = inp.value.trim(); });
|
|
data.push(entry);
|
|
});
|
|
return data;
|
|
}
|
|
|
|
// ── Save ──────────────────────────────────────────────────────────────────
|
|
async function saveData() {
|
|
if (!currentJsonFile || !dirHandle) return;
|
|
const data = collectData();
|
|
const btn = document.getElementById('btnSave');
|
|
btn.disabled = true; btn.textContent = 'Saving…';
|
|
setStatus('Saving…', 'info');
|
|
try {
|
|
await fsWriteJson(currentJsonFile, data);
|
|
originalData = JSON.parse(JSON.stringify(data));
|
|
markUnsaved(false);
|
|
setStatus(`Saved ${data.length} ${data.length === 1 ? 'entry' : 'entries'} successfully.`, 'success');
|
|
} catch (err) {
|
|
setStatus('Save failed: ' + err.message, 'error');
|
|
} finally {
|
|
btn.disabled = false; btn.textContent = 'Save Changes';
|
|
}
|
|
}
|
|
|
|
// ── Status bar ────────────────────────────────────────────────────────────
|
|
let statusTimer = null;
|
|
function setStatus(msg, type) {
|
|
const bar = document.getElementById('statusBar');
|
|
clearTimeout(statusTimer);
|
|
if (!msg) { bar.className = ''; bar.textContent = ''; return; }
|
|
bar.textContent = msg;
|
|
bar.className = 'show ' + (type || 'info');
|
|
if (type === 'success') statusTimer = setTimeout(() => setStatus(''), 4000);
|
|
}
|
|
|
|
// ── Property tabs ─────────────────────────────────────────────────────────
|
|
document.getElementById('propertyTabs').addEventListener('click', e => {
|
|
const link = e.target.closest('[data-property]');
|
|
if (!link) return;
|
|
e.preventDefault();
|
|
const prop = link.dataset.property;
|
|
if (prop === currentProperty) return;
|
|
if (hasUnsaved && !confirm('You have unsaved changes. Discard them?')) return;
|
|
currentProperty = prop;
|
|
document.querySelectorAll('#propertyTabs .nav-link').forEach(l => l.classList.remove('active'));
|
|
link.classList.add('active');
|
|
document.getElementById('logo-btbc').style.display = prop === 'btbc' ? '' : 'none';
|
|
document.getElementById('logo-mvbc').style.display = prop === 'mvbc' ? '' : 'none';
|
|
currentJsonFile = null; activeBtn = null; hasUnsaved = false;
|
|
setStatus('');
|
|
document.getElementById('sectionTitle').textContent = 'Select a department to edit';
|
|
document.getElementById('btnAddRow').style.display = 'none';
|
|
document.getElementById('btnSave').style.display = 'none';
|
|
document.getElementById('tableContainer').innerHTML =
|
|
'<div class="empty-state">Select a department above to edit its entries.</div>';
|
|
if (dirHandle) renderDeptNav(prop);
|
|
});
|
|
|
|
window.addEventListener('beforeunload', e => {
|
|
if (hasUnsaved) { e.preventDefault(); e.returnValue = ''; }
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|