- Update `DELETE /api/sessions/:id` in `server/routes/sessions.ts` to allow event creators to delete guests' sessions. - Update page hierarchy and top headings in `ui/components/SessionsPage.tsx`. - Refactor `EventAttendeesDrawer.tsx` to `EventGuestsDrawer.tsx` as a fixed slide-over overlay. - Add multi-event compact view toggle with `localStorage` persistence in `EventCockpitDeck.tsx`. - Standardize dynamic countdown pills across `EventCockpitDeck.tsx`, `EventGuestsDrawer.tsx`, `SessionDeck.tsx`, and `SessionTable.tsx`. - Optimize mobile session deck in `SessionDeck.tsx`. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
650 lines
30 KiB
TypeScript
650 lines
30 KiB
TypeScript
export const SessionsScript = () => {
|
|
return (
|
|
<script
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
let lastMintedToken = "";
|
|
|
|
function showNotice(msg, isError) {
|
|
const banner = document.getElementById('status-banner');
|
|
banner.textContent = msg;
|
|
banner.style.display = 'block';
|
|
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
|
|
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
|
|
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
|
|
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
|
}
|
|
|
|
function openDelegateDrawer() {
|
|
document.getElementById('delegateDrawer').style.display = 'block';
|
|
document.getElementById('delegateDrawer').scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
|
|
function closeDelegateDrawer() {
|
|
document.getElementById('delegateDrawer').style.display = 'none';
|
|
// Reset WorkshopDrawer 2-State Machine
|
|
const eventCreateState = document.getElementById('eventCreateState');
|
|
if (eventCreateState) eventCreateState.style.display = 'block';
|
|
const eventHandoffState = document.getElementById('eventHandoffState');
|
|
if (eventHandoffState) eventHandoffState.style.display = 'none';
|
|
const eventForm = document.getElementById('eventForm');
|
|
if (eventForm) eventForm.reset();
|
|
const drawerTitle = document.getElementById('delegateDrawerTitle');
|
|
if (drawerTitle) drawerTitle.textContent = 'Create New Pass / Session';
|
|
}
|
|
|
|
function switchDelegateTab(tabId) {
|
|
document.getElementById('tabDirectPass').style.display = 'none';
|
|
document.getElementById('tabWorkshopPass').style.display = 'none';
|
|
document.getElementById('tabBtnDirectPass').classList.remove('active');
|
|
document.getElementById('tabBtnWorkshopPass').classList.remove('active');
|
|
|
|
document.getElementById(tabId).style.display = 'block';
|
|
if (tabId === 'tabDirectPass') {
|
|
document.getElementById('tabBtnDirectPass').classList.add('active');
|
|
} else {
|
|
document.getElementById('tabBtnWorkshopPass').classList.add('active');
|
|
}
|
|
}
|
|
|
|
function selectSeats(btn, count) {
|
|
document.querySelectorAll('.seat-pill').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById('eventMaxSeats').value = count;
|
|
}
|
|
|
|
function selectEventLifespan(btn, hours) {
|
|
document.querySelectorAll('.event-lifespan-pill').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById('eventLifespanHours').value = hours;
|
|
}
|
|
|
|
function handleTargetAppChange(appName) {
|
|
if (appName) {
|
|
const scopeVal = 'app:' + appName;
|
|
document.querySelectorAll('.scope-checkbox').forEach(cb => {
|
|
if (cb.value === scopeVal) cb.checked = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
function selectLifespan(btn, hours) {
|
|
document.querySelectorAll('.lifespan-pill').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById('delegateHours').value = hours;
|
|
}
|
|
|
|
function selectMode(btn, mode) {
|
|
document.querySelectorAll('.mode-pill').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById('delegateMode').value = mode;
|
|
|
|
const accordion = document.getElementById('customScopesSection');
|
|
if (mode === 'custom') {
|
|
accordion.open = true;
|
|
}
|
|
}
|
|
|
|
async function handleDelegateSession(e) {
|
|
e.preventDefault();
|
|
const label = document.getElementById('delegateLabel').value.trim();
|
|
const lifespanHours = parseInt(document.getElementById('delegateHours').value) || 1;
|
|
let mode = document.getElementById('delegateMode').value;
|
|
const targetApp = document.getElementById('delegateTargetApp') ? document.getElementById('delegateTargetApp').value : '';
|
|
|
|
const customScopes = [];
|
|
if (targetApp) {
|
|
customScopes.push('app:' + targetApp);
|
|
}
|
|
if (mode === 'custom' || targetApp) {
|
|
document.querySelectorAll('.scope-checkbox:checked').forEach(cb => {
|
|
if (!customScopes.includes(cb.value)) {
|
|
customScopes.push(cb.value);
|
|
}
|
|
});
|
|
if (targetApp && mode !== 'custom' && mode !== 'admin') {
|
|
mode = 'custom';
|
|
}
|
|
}
|
|
|
|
const btn = document.getElementById('submitDelegateBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Minting...';
|
|
|
|
try {
|
|
const res = await fetch('/api/sessions/delegate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ label, lifespanHours, mode, customScopes }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
lastMintedToken = data.token;
|
|
document.getElementById('handoffMagicLinkText').textContent = window.location.origin + '/pass?token=' + data.token;
|
|
document.getElementById('handoffCliText').textContent = 'export AUTH_YES_TOKEN="' + data.token + '"';
|
|
document.getElementById('handoffCurlText').textContent = '-H "Authorization: Bearer ' + data.token + '"';
|
|
document.getElementById('handoffModal').style.display = 'block';
|
|
showNotice('Delegated session created for: ' + data.label, false);
|
|
} else {
|
|
showNotice(data.error || 'Failed to delegate session', true);
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error delegating session', true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Mint & Delegate Session';
|
|
}
|
|
}
|
|
|
|
async function handleCreateEvent(e) {
|
|
e.preventDefault();
|
|
const name = document.getElementById('eventName').value.trim();
|
|
const appId = document.getElementById('eventAppId').value || null;
|
|
const role = document.getElementById('eventRole').value || 'viewer';
|
|
const maxSeats = parseInt(document.getElementById('eventMaxSeats').value) || 0;
|
|
const lifespanHours = parseInt(document.getElementById('eventLifespanHours').value) || 3;
|
|
const slug = document.getElementById('eventSlug').value.trim() || undefined;
|
|
const pinCode = document.getElementById('eventPinCode').value.trim() || undefined;
|
|
|
|
const btn = document.getElementById('submitEventBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Launching...';
|
|
|
|
try {
|
|
const res = await fetch('/api/events', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, appId, role, maxSeats, lifespanHours, slug, pinCode }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.success) {
|
|
const ev = data.event;
|
|
document.getElementById('createdEventTitle').textContent = ev.name + ' Live!';
|
|
document.getElementById('eventHandoffPin').textContent = ev.pin_code;
|
|
document.getElementById('eventHandoffLink').textContent = window.location.origin + '/e/' + ev.slug;
|
|
document.getElementById('eventHandoffCli').textContent = 'curl -sSL ' + window.location.origin + '/join/' + ev.slug + '?format=env | source /dev/stdin';
|
|
|
|
document.getElementById('eventCreateState').style.display = 'none';
|
|
document.getElementById('eventHandoffState').style.display = 'block';
|
|
document.getElementById('delegateDrawerTitle').textContent = 'Event Pass Active';
|
|
|
|
showNotice('Workshop pass created: ' + ev.name, false);
|
|
} else {
|
|
showNotice(data.error || 'Failed to create event pass', true);
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error creating event pass', true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = '🎟️ Launch Workshop Pass';
|
|
}
|
|
}
|
|
|
|
function copyEventHandoff(type) {
|
|
let text = '';
|
|
if (type === 'pin') text = document.getElementById('eventHandoffPin').textContent;
|
|
if (type === 'link') text = document.getElementById('eventHandoffLink').textContent;
|
|
if (type === 'cli') text = document.getElementById('eventHandoffCli').textContent;
|
|
navigator.clipboard.writeText(text);
|
|
showNotice('Copied to clipboard: ' + text, false);
|
|
}
|
|
|
|
function closeEventHandoffModal() {
|
|
closeDelegateDrawer();
|
|
window.location.reload();
|
|
}
|
|
|
|
function copyHandoff(type) {
|
|
let text = '';
|
|
if (type === 'link') text = document.getElementById('handoffLinkText').textContent;
|
|
if (type === 'cli') text = document.getElementById('handoffCliText').textContent;
|
|
if (type === 'curl') text = document.getElementById('handoffCurlText').textContent;
|
|
if (type === 'magic') text = document.getElementById('handoffMagicLinkText').textContent;
|
|
navigator.clipboard.writeText(text);
|
|
showNotice('Copied to clipboard: ' + text, false);
|
|
}
|
|
|
|
function closeHandoffModal() {
|
|
document.getElementById('handoffModal').style.display = 'none';
|
|
window.location.reload();
|
|
}
|
|
|
|
async function extendSession(sessionId, hours) {
|
|
try {
|
|
const res = await fetch('/api/sessions/' + sessionId + '/extend', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ extendHours: hours }),
|
|
});
|
|
if (res.ok) {
|
|
showNotice('Session extended by ' + hours + ' hour(s)!', false);
|
|
setTimeout(() => window.location.reload(), 600);
|
|
} else {
|
|
const data = await res.json();
|
|
showNotice(data.error || 'Failed to extend session', true);
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error extending session', true);
|
|
}
|
|
}
|
|
|
|
function openEditScopesModal(sessionId, label, scopesJson) {
|
|
const scopes = JSON.parse(scopesJson || '[]');
|
|
document.getElementById('editScopesSessionId').value = sessionId;
|
|
document.getElementById('editScopesLabel').textContent = label;
|
|
|
|
document.querySelectorAll('.edit-scope-chk').forEach(cb => {
|
|
cb.checked = scopes.includes(cb.value);
|
|
});
|
|
|
|
document.getElementById('editScopesModal').style.display = 'flex';
|
|
}
|
|
|
|
function closeEditScopesModal() {
|
|
document.getElementById('editScopesModal').style.display = 'none';
|
|
}
|
|
|
|
async function saveUpdatedScopes() {
|
|
const sessionId = document.getElementById('editScopesSessionId').value;
|
|
const customScopes = [];
|
|
document.querySelectorAll('.edit-scope-chk:checked').forEach(cb => {
|
|
customScopes.push(cb.value);
|
|
});
|
|
|
|
try {
|
|
const res = await fetch('/api/sessions/' + sessionId + '/scopes', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ customScopes }),
|
|
});
|
|
if (res.ok) {
|
|
showNotice('Session scopes updated!', false);
|
|
setTimeout(() => window.location.reload(), 600);
|
|
} else {
|
|
const data = await res.json();
|
|
showNotice(data.error || 'Failed to update scopes', true);
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error updating scopes', true);
|
|
}
|
|
}
|
|
|
|
function copyText(text) {
|
|
navigator.clipboard.writeText(text);
|
|
showNotice('Copied to clipboard!', false);
|
|
}
|
|
|
|
async function rotatePin(eventId) {
|
|
try {
|
|
const res = await fetch('/api/events/' + eventId + '/rotate-pin', {
|
|
method: 'POST'
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.success) {
|
|
const pinElem = document.getElementById('pin-' + eventId);
|
|
if (pinElem) {
|
|
pinElem.textContent = data.pinCode;
|
|
}
|
|
showNotice('Event PIN rotated successfully', false);
|
|
} else {
|
|
showNotice(data.error || 'Failed to rotate PIN', true);
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error rotating PIN', true);
|
|
}
|
|
}
|
|
|
|
async function expandSeats(eventId, count) {
|
|
try {
|
|
const res = await fetch('/api/events/' + eventId + '/expand', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ addSeats: count }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.success) {
|
|
showNotice('Expanded capacity by ' + count + ' seats', false);
|
|
setTimeout(() => window.location.reload(), 600);
|
|
} else {
|
|
showNotice(data.error || 'Failed to expand seats', true);
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error expanding seats', true);
|
|
}
|
|
}
|
|
|
|
function closeAttendeesDrawer() {
|
|
const drawer = document.getElementById('attendeesDrawer');
|
|
if (drawer) {
|
|
drawer.classList.remove('open');
|
|
const panel = drawer.querySelector('.drawer-panel');
|
|
if (panel) panel.classList.remove('open');
|
|
// Delay hiding until animation finishes
|
|
setTimeout(() => {
|
|
drawer.style.display = 'none';
|
|
}, 250);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
const drawer = document.getElementById('attendeesDrawer');
|
|
if (drawer && drawer.classList.contains('open')) {
|
|
closeAttendeesDrawer();
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
const drawer = document.getElementById('attendeesDrawer');
|
|
if (drawer && drawer.classList.contains('open') && e.target === drawer) {
|
|
closeAttendeesDrawer();
|
|
}
|
|
});
|
|
|
|
async function openAttendeesDrawer(eventId, eventName) {
|
|
document.getElementById('guestDrawerTitle').textContent = eventName + ' Guests';
|
|
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">Loading attendees...</div>';
|
|
|
|
const drawer = document.getElementById('attendeesDrawer');
|
|
if (drawer) {
|
|
drawer.style.display = 'block';
|
|
// Force reflow
|
|
drawer.offsetHeight;
|
|
drawer.classList.add('open');
|
|
const panel = drawer.querySelector('.drawer-panel');
|
|
if (panel) panel.classList.add('open');
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/events/' + eventId + '/attendees');
|
|
const data = await res.json();
|
|
|
|
if (res.ok && data.success) {
|
|
const attendees = data.attendees;
|
|
|
|
// Update Context Header
|
|
// In a real app we would get the true max seats and expires time from the event payload.
|
|
// For this UI, we can derive it or keep it simple.
|
|
document.getElementById('guestDrawerClaimed').textContent = attendees.length;
|
|
if (data.event) {
|
|
document.getElementById('guestDrawerMax').textContent = data.event.max_seats;
|
|
const expDate = new Date(data.event.expires_at);
|
|
document.getElementById('guestDrawerExpiresAt').textContent = expDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
|
document.getElementById('guestDrawerCountdown').setAttribute('data-expires-at', data.event.expires_at);
|
|
// Trigger countdown update
|
|
if (typeof updateAllCountdowns === 'function') updateAllCountdowns();
|
|
}
|
|
|
|
if (attendees.length === 0) {
|
|
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-muted);">No attendees currently active.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
|
|
for (const att of attendees) {
|
|
const isPaused = att.is_paused === true;
|
|
// e.g. guest_deno-lab_1 -> Seat #1
|
|
const usernameParts = att.username.split('_');
|
|
const seatNumber = usernameParts.length > 2 ? usernameParts[usernameParts.length - 1] : '?';
|
|
|
|
// Compute relative join time (approximate based on created_at)
|
|
const createdDate = new Date(att.created_at);
|
|
const now = new Date();
|
|
const diffMs = now - createdDate;
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const joinText = diffMins < 1 ? 'Joined just now' : 'Joined ' + diffMins + 'm ago';
|
|
|
|
html += \`
|
|
<div class="card" style="padding: 0.75rem; margin: 0; display: flex; justify-content: space-between; align-items: center; border-left: 3px solid \${isPaused ? 'var(--warning)' : 'var(--success)'}; opacity: \${isPaused ? '0.7' : '1'};">
|
|
<div>
|
|
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
|
|
<div style="font-weight: 600; font-size: 0.95rem; color: var(--text-primary); \${isPaused ? 'text-decoration: line-through;' : ''}">Seat #\${seatNumber}</div>
|
|
<div style="font-size: 0.75rem; padding: 2px 6px; border-radius: 4px; background: \${isPaused ? 'var(--warning-light)' : 'var(--success-light)'}; color: \${isPaused ? 'var(--warning)' : 'var(--success)'};">
|
|
\${isPaused ? '⏸️ Paused' : '🟢 Active'}
|
|
</div>
|
|
</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-secondary);" title="\${att.username}">
|
|
\${joinText} · \${att.username}
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 0.5rem;">
|
|
<button type="button" class="btn-outline" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;" onclick="toggleSessionPause('\${att.id}', \${!isPaused}, '\${eventId}', '\${eventName}')">
|
|
\${isPaused ? '▶️ Resume' : '⏸️ Pause'}
|
|
</button>
|
|
<button type="button" class="btn-danger revoke-btn" data-session-id="\${att.id}" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;" aria-label="Revoke">
|
|
🗑️ Revoke
|
|
</button>
|
|
</div>
|
|
</div>
|
|
\`;
|
|
}
|
|
html += '</div>';
|
|
document.getElementById('attendeesDrawerContent').innerHTML = html;
|
|
|
|
// Re-bind revoke buttons
|
|
document.getElementById('attendeesDrawerContent').querySelectorAll('.revoke-btn').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
if (!confirm('Revoke this session immediately?')) return;
|
|
const sessionId = e.currentTarget.getAttribute('data-session-id');
|
|
const originalText = e.currentTarget.textContent;
|
|
e.currentTarget.textContent = 'Revoking...';
|
|
e.currentTarget.disabled = true;
|
|
|
|
try {
|
|
const revokeRes = await fetch('/api/sessions/' + sessionId, { method: 'DELETE' });
|
|
if (revokeRes.ok) {
|
|
openAttendeesDrawer(eventId, eventName); // Refresh list
|
|
} else {
|
|
const revokeData = await revokeRes.json();
|
|
showNotice(revokeData.error || 'Failed to revoke session', true);
|
|
e.currentTarget.textContent = originalText;
|
|
e.currentTarget.disabled = false;
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error revoking session', true);
|
|
e.currentTarget.textContent = originalText;
|
|
e.currentTarget.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
} else {
|
|
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; color: var(--danger-text);">Failed to load attendees: ' + (data.error || 'Unknown error') + '</div>';
|
|
}
|
|
} catch (err) {
|
|
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; color: var(--danger-text);">Network error loading attendees.</div>';
|
|
}
|
|
}
|
|
|
|
async function toggleSessionPause(sessionId, shouldPause, eventId, eventName) {
|
|
try {
|
|
const res = await fetch('/api/sessions/' + sessionId + '/pause', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_paused: shouldPause }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.success) {
|
|
showNotice('Session ' + (shouldPause ? 'paused' : 'unpaused') + ' successfully', false);
|
|
openAttendeesDrawer(eventId, eventName); // Refresh list
|
|
} else {
|
|
showNotice(data.error || 'Failed to toggle pause state', true);
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error toggling pause state', true);
|
|
}
|
|
}
|
|
|
|
// Event Cockpit Actions
|
|
document.querySelectorAll('.extend-event-btn').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
const eventId = e.currentTarget.getAttribute('data-event-id');
|
|
e.currentTarget.disabled = true;
|
|
try {
|
|
const res = await fetch('/api/events/' + eventId + '/extend', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ extendHours: 1 })
|
|
});
|
|
if (res.ok) {
|
|
showNotice('Event extended by 1 hour!', false);
|
|
setTimeout(() => window.location.reload(), 600);
|
|
} else {
|
|
const data = await res.json();
|
|
showNotice(data.error || 'Failed to extend event', true);
|
|
e.currentTarget.disabled = false;
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error extending event', true);
|
|
e.currentTarget.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.end-event-btn').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
if (!confirm('Are you sure you want to end this workshop? This will immediately revoke ALL active guest sessions and they will lose access.')) return;
|
|
|
|
const eventId = e.currentTarget.getAttribute('data-event-id');
|
|
e.currentTarget.disabled = true;
|
|
e.currentTarget.textContent = 'Revoking...';
|
|
|
|
try {
|
|
const res = await fetch('/api/events/' + eventId + '/end', {
|
|
method: 'POST',
|
|
});
|
|
if (res.ok) {
|
|
showNotice('Workshop ended and all sessions revoked.', false);
|
|
setTimeout(() => window.location.reload(), 600);
|
|
} else {
|
|
const data = await res.json();
|
|
showNotice(data.error || 'Failed to end event', true);
|
|
e.currentTarget.disabled = false;
|
|
e.currentTarget.textContent = '🔴 End & Revoke All';
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error ending event', true);
|
|
e.currentTarget.disabled = false;
|
|
e.currentTarget.textContent = '🔴 End & Revoke All';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Revoke handler
|
|
document.querySelectorAll('.revoke-btn').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
if (!confirm('Revoke this session immediately?')) return;
|
|
|
|
const sessionId = e.currentTarget.getAttribute('data-session-id');
|
|
const originalText = e.currentTarget.textContent;
|
|
e.currentTarget.textContent = 'Revoking...';
|
|
e.currentTarget.disabled = true;
|
|
|
|
try {
|
|
const res = await fetch('/api/sessions/' + sessionId, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (res.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
const data = await res.json();
|
|
showNotice(data.error || 'Failed to revoke session', true);
|
|
e.currentTarget.textContent = originalText;
|
|
e.currentTarget.disabled = false;
|
|
}
|
|
} catch (err) {
|
|
showNotice('Network error revoking session', true);
|
|
e.currentTarget.textContent = originalText;
|
|
e.currentTarget.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
// Set Event View Mode (Grid vs Compact)
|
|
function setEventViewMode(mode) {
|
|
const container = document.getElementById('eventDeckContainer');
|
|
const gridBtn = document.getElementById('viewModeGrid');
|
|
const compactBtn = document.getElementById('viewModeCompact');
|
|
|
|
if (container && gridBtn && compactBtn) {
|
|
if (mode === 'compact') {
|
|
container.classList.remove('grid-view');
|
|
container.classList.add('compact-view');
|
|
gridBtn.classList.remove('active');
|
|
compactBtn.classList.add('active');
|
|
localStorage.setItem('auth_yes_event_view_mode', 'compact');
|
|
} else {
|
|
container.classList.remove('compact-view');
|
|
container.classList.add('grid-view');
|
|
compactBtn.classList.remove('active');
|
|
gridBtn.classList.add('active');
|
|
localStorage.setItem('auth_yes_event_view_mode', 'grid');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dynamic Countdown Updater
|
|
function updateAllCountdowns() {
|
|
const pills = document.querySelectorAll('.countdown-pill, #guestDrawerCountdown');
|
|
const now = new Date();
|
|
|
|
pills.forEach(pill => {
|
|
const expiresAtStr = pill.getAttribute('data-expires-at');
|
|
if (!expiresAtStr) return;
|
|
|
|
const expDate = new Date(expiresAtStr);
|
|
const diffMs = expDate - now;
|
|
|
|
pill.classList.remove('status-green', 'status-amber', 'status-red');
|
|
|
|
if (diffMs <= 0) {
|
|
pill.textContent = '⏳ Expired';
|
|
pill.classList.add('status-red');
|
|
} else {
|
|
const totalMins = Math.floor(diffMs / 60000);
|
|
const hours = Math.floor(totalMins / 60);
|
|
const mins = totalMins % 60;
|
|
|
|
let text = '';
|
|
if (hours > 72) { // more than 3 days
|
|
const days = Math.floor(hours / 24);
|
|
text = \`⏳ \${days}d left\`;
|
|
} else {
|
|
text = \`⏳ \${hours}h \${mins}m left\`;
|
|
}
|
|
|
|
// Add absolute time suffix if it's a standard pill (not the drawer context header which has it separate)
|
|
if (pill.id !== 'guestDrawerCountdown') {
|
|
const timeString = expDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
text += \` · (Expires \${timeString})\`;
|
|
}
|
|
|
|
pill.textContent = text;
|
|
|
|
if (hours < 1) {
|
|
pill.classList.add('status-amber');
|
|
} else {
|
|
pill.classList.add('status-green');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize on DOM load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// 1. Initialize Event View Mode
|
|
const storedViewMode = localStorage.getItem('auth_yes_event_view_mode') || 'grid';
|
|
setEventViewMode(storedViewMode);
|
|
|
|
// 2. Initialize Countdowns
|
|
updateAllCountdowns();
|
|
setInterval(updateAllCountdowns, 30000);
|
|
});
|
|
`,
|
|
}}
|
|
>
|
|
</script>
|
|
);
|
|
};
|