When a video that already has a barrier_opening row is loaded, a thin bar now appears below the player with a red vertical line at the marked opening_s. A tooltip shows the timestamp, the analyst's initials, and the bad_rois flag. Clicking the line snaps the player to the exact opening; clicking elsewhere on the bar seeks proportionally. Bar is hidden for un-annotated videos. API change: each queue item now carries an `existing` field with the opening_s, bad_rois, analyst, etc. from barrier_opening.csv when available, so the frontend can paint the mark without extra requests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
516 lines
22 KiB
HTML
516 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Cupido — barrier-opening picker</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
background: #1a1a1a; color: #e8e8e8; }
|
|
header { padding: 0.6rem 1rem; background: #111; border-bottom: 1px solid #333;
|
|
display: flex; align-items: center; gap: 1.5rem; }
|
|
h1 { margin: 0; font-size: 1rem; font-weight: 500; color: #ccc; }
|
|
#status { font-family: ui-monospace, "SF Mono", monospace; font-size: 0.85rem;
|
|
color: #9aa; }
|
|
#info { font-family: ui-monospace, monospace; font-size: 0.85rem; color: #cce; }
|
|
#meta { display: flex; gap: 1.5rem; flex-wrap: wrap; margin: 0.6rem 0 0.4rem;
|
|
font-size: 0.85rem; color: #aab; max-width: 1400px; width: 100%;
|
|
justify-content: center; }
|
|
#meta .pair { font-family: ui-monospace, monospace; }
|
|
#meta .pair .k { color: #678; }
|
|
#meta .pair .v { color: #def; margin-left: 0.25rem; }
|
|
#meta .role-training { color: #cd6 !important; }
|
|
#meta .role-testing { color: #6cd !important; }
|
|
main { display: flex; flex-direction: column; align-items: center; padding: 1rem; }
|
|
video { width: 100%; max-width: 1400px; height: auto; background: #000;
|
|
border-radius: 4px; }
|
|
#controls { margin-top: 1rem; display: flex; gap: 1rem; flex-wrap: wrap;
|
|
justify-content: center; }
|
|
button { padding: 0.7rem 1.4rem; font-size: 0.95rem; border: 1px solid #444;
|
|
background: #2a2a2a; color: #eee; border-radius: 4px; cursor: pointer;
|
|
transition: all 80ms; }
|
|
button:hover { background: #383838; border-color: #666; }
|
|
button:active { transform: translateY(1px); }
|
|
button.primary { background: #2d5; color: #053; border-color: #1a4;
|
|
font-weight: 600; }
|
|
button.primary:hover { background: #3e6; }
|
|
button.warn { background: #d84; color: #311; border-color: #b62; }
|
|
button.warn:hover { background: #ea5; }
|
|
button.muted { background: #2a2a2a; color: #888; }
|
|
.mute-divider { width: 1px; background: #333; margin: 0 0.3rem; }
|
|
#help { margin-top: 1rem; font-size: 0.8rem; color: #889; text-align: center;
|
|
font-family: ui-monospace, monospace; }
|
|
kbd { background: #222; border: 1px solid #444; border-radius: 3px;
|
|
padding: 0.1rem 0.4rem; font-size: 0.8rem; color: #ddd;
|
|
font-family: ui-monospace, monospace; }
|
|
#progress { font-size: 0.85rem; color: #889; margin-left: auto; }
|
|
#flash { position: fixed; top: 0.5rem; right: 0.5rem; padding: 0.5rem 1rem;
|
|
border-radius: 4px; opacity: 0; transition: opacity 200ms;
|
|
font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
|
#flash.show { opacity: 1; }
|
|
#flash.ok { background: #2d5; color: #042; }
|
|
#flash.err { background: #d44; color: white; }
|
|
|
|
/* Welcome modal */
|
|
#help-btn { background: transparent; border: 1px solid #444; color: #aab;
|
|
font-size: 0.85rem; padding: 0.3rem 0.7rem; }
|
|
#help-btn:hover { background: #2a2a2a; }
|
|
#modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85);
|
|
display: none; align-items: center; justify-content: center;
|
|
z-index: 100; padding: 1rem; }
|
|
#modal-backdrop.show { display: flex; }
|
|
#modal { background: #222; border: 1px solid #444; border-radius: 6px;
|
|
padding: 1.6rem 2rem; max-width: 640px; width: 100%;
|
|
max-height: 90vh; overflow-y: auto; color: #ddd; line-height: 1.55; }
|
|
#modal h2 { margin: 0 0 0.6rem; color: #fff; font-size: 1.2rem; }
|
|
#modal h3 { margin: 1.2rem 0 0.4rem; color: #cce; font-size: 0.95rem;
|
|
font-weight: 600; }
|
|
#modal p { margin: 0.5rem 0; }
|
|
#modal ol { padding-left: 1.4rem; margin: 0.5rem 0; }
|
|
#modal li { margin: 0.6rem 0; }
|
|
#modal .button-tag { display: inline-block; padding: 0.05rem 0.4rem;
|
|
background: #2d5; color: #053; border-radius: 3px;
|
|
font-weight: 600; font-size: 0.85rem; }
|
|
#modal-close { margin-top: 1.4rem; width: 100%; padding: 0.7rem;
|
|
background: #2d5; color: #053; font-weight: 600;
|
|
border-radius: 4px; cursor: pointer; border: 1px solid #1a4;
|
|
font-size: 0.95rem; }
|
|
#modal-close:hover { background: #3e6; }
|
|
|
|
/* User badge in header */
|
|
#user-badge { background: #2a3; color: #042; font-weight: 700;
|
|
padding: 0.2rem 0.6rem; border-radius: 12px;
|
|
font-family: ui-monospace, monospace; font-size: 0.85rem;
|
|
cursor: pointer; user-select: none; }
|
|
#user-badge:hover { background: #3b4; }
|
|
#user-badge.empty { background: #d84; color: #311; }
|
|
|
|
/* Login modal — narrower than the welcome modal */
|
|
#login-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85);
|
|
display: none; align-items: center; justify-content: center;
|
|
z-index: 200; }
|
|
#login-backdrop.show { display: flex; }
|
|
#login-box { background: #222; border: 1px solid #444; border-radius: 6px;
|
|
padding: 1.6rem 2rem; max-width: 380px; width: 90%;
|
|
color: #ddd; }
|
|
#login-box h2 { margin: 0 0 0.6rem; color: #fff; font-size: 1.1rem; }
|
|
#login-box p { margin: 0.4rem 0 1rem; color: #aab; font-size: 0.9rem; }
|
|
#login-input { width: 100%; padding: 0.6rem 0.8rem; font-size: 1.1rem;
|
|
background: #111; color: #fff; border: 1px solid #444;
|
|
border-radius: 4px; text-align: center;
|
|
font-family: ui-monospace, monospace; letter-spacing: 0.2em;
|
|
text-transform: uppercase; }
|
|
#login-input:focus { outline: none; border-color: #6c5; }
|
|
#login-submit { width: 100%; margin-top: 1rem; padding: 0.7rem;
|
|
background: #2d5; color: #053; font-weight: 600;
|
|
border-radius: 4px; cursor: pointer; border: 1px solid #1a4;
|
|
font-size: 0.95rem; }
|
|
#login-submit:hover { background: #3e6; }
|
|
#login-submit:disabled { background: #444; color: #888; cursor: not-allowed; }
|
|
|
|
/* Annotation bar (existing-mark indicator below the video) */
|
|
#annotation-bar { position: relative; width: 100%; max-width: 1400px;
|
|
height: 18px; background: #181818; border: 1px solid #333;
|
|
border-radius: 3px; margin-top: 0.5rem; cursor: pointer; }
|
|
#annotation-bar.empty { background: transparent; border-color: transparent;
|
|
cursor: default; height: 0.6rem; }
|
|
#annotation-bar .mark-line { position: absolute; top: -2px; bottom: -2px;
|
|
width: 3px; background: #f44;
|
|
box-shadow: 0 0 4px rgba(255,80,80,0.7); }
|
|
#annotation-bar .mark-line:hover { background: #f88; }
|
|
#annotation-bar .mark-tooltip { position: absolute; top: 100%;
|
|
transform: translateX(-50%);
|
|
margin-top: 4px; background: #f44;
|
|
color: white; padding: 2px 6px;
|
|
font-size: 0.75rem; border-radius: 3px;
|
|
font-family: ui-monospace, monospace;
|
|
white-space: nowrap; pointer-events: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Cupido — barrier picker</h1>
|
|
<span id="info">loading…</span>
|
|
<span id="progress"></span>
|
|
<span id="user-badge" title="click to change initials">…</span>
|
|
<button id="help-btn" title="show the help modal">?</button>
|
|
</header>
|
|
|
|
<div id="login-backdrop">
|
|
<div id="login-box">
|
|
<h2>Who are you?</h2>
|
|
<p>Enter your initials so we can record who annotated each video.
|
|
(Just letters, e.g. <code>GG</code>.)</p>
|
|
<input id="login-input" maxlength="4" autocomplete="off" autofocus />
|
|
<button id="login-submit" disabled>Continue</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="modal-backdrop">
|
|
<div id="modal">
|
|
<h2>Welcome to the Cupido barrier picker</h2>
|
|
<p>For each tracked video you'll mark the moment the barrier between
|
|
the two halves of the arena is removed and the flies start interacting.
|
|
That timestamp is what every downstream "post-opening" analysis needs.</p>
|
|
|
|
<h3>How to annotate one video</h3>
|
|
<ol>
|
|
<li><strong>Drag the seekbar to the end of the video</strong> and check
|
|
whether <em>all</em> ROIs are open or only the upper or lower half.
|
|
This determines which button you'll click later.</li>
|
|
|
|
<li><strong>Drag back toward the beginning</strong> to roughly find
|
|
the moment the barrier(s) lift. The opening can be anywhere from a
|
|
few seconds to several minutes in.</li>
|
|
|
|
<li><strong>Use the arrow keys</strong> to pin down the exact frame:
|
|
<kbd>←</kbd>/<kbd>→</kbd> jump ±5 s, <kbd>Shift</kbd>+arrow jump ±30 s.
|
|
Pause the player on the opening frame.</li>
|
|
|
|
<li><strong>Click the button</strong> matching what opened — the
|
|
playhead time is recorded, ROI inclusion is set accordingly, and
|
|
the next video loads automatically.</li>
|
|
</ol>
|
|
|
|
<h3>The three buttons</h3>
|
|
<p>
|
|
<span class="button-tag">All barriers open</span> — every ROI usable<br>
|
|
<span class="button-tag">Upper barrier opens</span> — only ROIs 1, 3, 5 (top row); lower row will be excluded from analysis<br>
|
|
<span class="button-tag">Lower barrier opens</span> — only ROIs 2, 4, 6 (bottom row); upper row excluded
|
|
</p>
|
|
|
|
<h3>Other controls</h3>
|
|
<p>
|
|
<kbd>Space</kbd> play/pause ·
|
|
<kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> the three buttons ·
|
|
<kbd>s</kbd> skip · <kbd>u</kbd> mark unusable
|
|
</p>
|
|
<p style="font-size: 0.85rem; color: #889;">Each click saves to
|
|
<code>barrier_opening.csv</code> immediately, so closing the tab
|
|
and coming back resumes from where you left off.</p>
|
|
|
|
<button id="modal-close">Got it — let's start</button>
|
|
</div>
|
|
</div>
|
|
|
|
<main>
|
|
<div id="meta"></div>
|
|
<video id="player" controls preload="auto"></video>
|
|
<div id="annotation-bar" class="empty"></div>
|
|
<div id="controls">
|
|
<button class="primary" data-mode="all">All barriers open <kbd>1</kbd></button>
|
|
<button class="primary" data-mode="upper">Upper barrier opens <kbd>2</kbd></button>
|
|
<button class="primary" data-mode="lower">Lower barrier opens <kbd>3</kbd></button>
|
|
<span class="mute-divider"></span>
|
|
<button class="muted" data-mode="skip">Skip <kbd>s</kbd></button>
|
|
<button class="warn" data-mode="unusable">Unusable <kbd>u</kbd></button>
|
|
<span class="mute-divider"></span>
|
|
<button class="muted" id="prev">◀ Previous</button>
|
|
<button class="muted" id="next">Next ▶</button>
|
|
</div>
|
|
<div id="help">
|
|
<kbd>Space</kbd> play/pause ·
|
|
<kbd>←</kbd> / <kbd>→</kbd> ±5 s ·
|
|
<kbd>Shift</kbd>+arrows ±30 s ·
|
|
drag the seekbar for finer control
|
|
</div>
|
|
<div id="flash"></div>
|
|
</main>
|
|
|
|
<script>
|
|
const player = document.getElementById('player');
|
|
const info = document.getElementById('info');
|
|
const meta = document.getElementById('meta');
|
|
const progress = document.getElementById('progress');
|
|
const flash = document.getElementById('flash');
|
|
const userBadge = document.getElementById('user-badge');
|
|
const loginBackdrop = document.getElementById('login-backdrop');
|
|
const loginInput = document.getElementById('login-input');
|
|
const loginSubmit = document.getElementById('login-submit');
|
|
|
|
// ─── Analyst identity (persisted in localStorage) ───────────────────
|
|
function getAnalyst() {
|
|
try { return localStorage.getItem('cupido.analyst') || ''; }
|
|
catch (e) { return ''; }
|
|
}
|
|
function setAnalyst(s) {
|
|
try { localStorage.setItem('cupido.analyst', s); } catch (e) {}
|
|
renderUserBadge();
|
|
}
|
|
function renderUserBadge() {
|
|
const a = getAnalyst();
|
|
userBadge.textContent = a || 'sign in';
|
|
userBadge.classList.toggle('empty', !a);
|
|
}
|
|
|
|
function showLogin() {
|
|
loginBackdrop.classList.add('show');
|
|
loginInput.value = getAnalyst();
|
|
loginSubmit.disabled = !loginInput.value.trim();
|
|
setTimeout(() => loginInput.focus(), 50);
|
|
}
|
|
function hideLogin() { loginBackdrop.classList.remove('show'); }
|
|
|
|
function submitLogin() {
|
|
const v = loginInput.value.trim().toUpperCase();
|
|
if (!v) return;
|
|
setAnalyst(v);
|
|
hideLogin();
|
|
// After first login, show the welcome modal if not yet seen.
|
|
try {
|
|
if (localStorage.getItem('cupido.welcomed') !== '1') {
|
|
showModal(); // welcome modal — defined below
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
loginInput.addEventListener('input', () => {
|
|
loginSubmit.disabled = !loginInput.value.trim();
|
|
});
|
|
loginInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && loginInput.value.trim()) submitLogin();
|
|
});
|
|
loginSubmit.addEventListener('click', submitLogin);
|
|
userBadge.addEventListener('click', showLogin);
|
|
|
|
let queue = [];
|
|
let cursor = 0;
|
|
|
|
function showFlash(msg, kind = 'ok') {
|
|
flash.textContent = msg;
|
|
flash.className = 'show ' + kind;
|
|
setTimeout(() => { flash.className = ''; }, 1800);
|
|
}
|
|
|
|
function updateProgress() {
|
|
const done = queue.filter(q => q.done).length;
|
|
progress.textContent = `${done}/${queue.length} done`;
|
|
}
|
|
|
|
function renderAnnotationBar(item) {
|
|
const bar = document.getElementById('annotation-bar');
|
|
bar.innerHTML = '';
|
|
const dur = item.duration_s;
|
|
const ex = item.existing;
|
|
// Always allow click-to-seek if we have a duration; only show as a real
|
|
// bar (with marks) when there's an existing annotation to display.
|
|
if (!dur) {
|
|
bar.classList.add('empty');
|
|
bar.onclick = null;
|
|
return;
|
|
}
|
|
if (!ex || ex.opening_s == null || isNaN(ex.opening_s)) {
|
|
bar.classList.add('empty');
|
|
bar.onclick = null;
|
|
return;
|
|
}
|
|
bar.classList.remove('empty');
|
|
// Click anywhere on the bar = seek to that fractional position.
|
|
bar.onclick = (e) => {
|
|
const rect = bar.getBoundingClientRect();
|
|
const t = (e.clientX - rect.left) / rect.width * dur;
|
|
player.currentTime = Math.max(0, Math.min(dur, t));
|
|
};
|
|
// Mark line at opening_s
|
|
const pct = (ex.opening_s / dur) * 100;
|
|
const line = document.createElement('div');
|
|
line.className = 'mark-line';
|
|
line.style.left = `calc(${pct}% - 1px)`;
|
|
// Click on the line specifically = snap to exact opening_s
|
|
line.onclick = (e) => {
|
|
e.stopPropagation();
|
|
player.currentTime = Math.max(0, Math.min(dur, ex.opening_s));
|
|
};
|
|
const tooltip = document.createElement('div');
|
|
tooltip.className = 'mark-tooltip';
|
|
const m = Math.floor(ex.opening_s / 60);
|
|
const s = (ex.opening_s % 60).toFixed(1);
|
|
const tag = ex.bad_rois ? ` · bad ROIs ${ex.bad_rois}` : '';
|
|
const who = ex.analyst ? ` · ${ex.analyst}` : '';
|
|
tooltip.textContent = `${m}:${s.padStart(4,'0')}${who}${tag}`;
|
|
// Position tooltip; if mark is near right edge, anchor right.
|
|
tooltip.style.left = pct < 90 ? `${pct}%` : `${pct}%`;
|
|
if (pct < 8) tooltip.style.transform = 'translateX(0)';
|
|
else if (pct > 92) tooltip.style.transform = 'translateX(-100%)';
|
|
bar.appendChild(line);
|
|
bar.appendChild(tooltip);
|
|
}
|
|
|
|
function renderMeta(m) {
|
|
meta.innerHTML = '';
|
|
if (!m) return;
|
|
const fields = [
|
|
['role', m.session_role,
|
|
m.session_role === 'training' ? 'role-training'
|
|
: m.session_role === 'testing' ? 'role-testing' : ''],
|
|
['species', m.species],
|
|
['memory', m.memory],
|
|
['training (hr)', m.training_length_hr],
|
|
['consol. (hr)', m.consolidation_length_hr],
|
|
['age (d)', m.age],
|
|
['flies', (m.n_trained || m.n_naive)
|
|
? `${m.n_trained || 0} trained · ${m.n_naive || 0} naive`
|
|
: null],
|
|
['training time', m.training_date_time],
|
|
['testing time', m.testing_date_time],
|
|
];
|
|
for (const [label, value, cls] of fields) {
|
|
if (value === undefined || value === null || value === '') continue;
|
|
const span = document.createElement('span');
|
|
span.className = 'pair';
|
|
span.innerHTML = `<span class="k">${label}:</span><span class="v ${cls||''}">${value}</span>`;
|
|
meta.appendChild(span);
|
|
}
|
|
}
|
|
|
|
function loadCursor() {
|
|
if (queue.length === 0) {
|
|
info.textContent = 'queue empty';
|
|
return;
|
|
}
|
|
cursor = ((cursor % queue.length) + queue.length) % queue.length;
|
|
const item = queue[cursor];
|
|
info.textContent =
|
|
`[${cursor + 1}/${queue.length}] ${item.machine_name} ${item.session_date} ${item.session_time} ` +
|
|
(item.duration_s ? `(${(item.duration_s/60).toFixed(1)} min)` : '') +
|
|
(item.done ? ' — already done' : '');
|
|
renderMeta(item.metadata);
|
|
renderAnnotationBar(item);
|
|
player.src = `/api/video/${item.idx}`;
|
|
player.load();
|
|
}
|
|
|
|
async function fetchQueue() {
|
|
const resp = await fetch('/api/queue');
|
|
queue = await resp.json();
|
|
// Jump to first not-yet-done
|
|
const firstUndone = queue.findIndex(q => !q.done);
|
|
cursor = firstUndone === -1 ? 0 : firstUndone;
|
|
updateProgress();
|
|
loadCursor();
|
|
}
|
|
|
|
async function submit(mode) {
|
|
if (queue.length === 0) return;
|
|
// Require initials before any picking action (skip is OK without).
|
|
if (mode !== 'skip' && !getAnalyst()) {
|
|
showLogin();
|
|
return;
|
|
}
|
|
const item = queue[cursor];
|
|
const payload = {
|
|
idx: item.idx,
|
|
time_s: (mode === 'skip' || mode === 'unusable') ? null : player.currentTime,
|
|
mode: mode,
|
|
analyst: getAnalyst(),
|
|
notes: '',
|
|
};
|
|
try {
|
|
const resp = await fetch('/api/submit', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
showFlash('error: ' + (err.detail || resp.status), 'err');
|
|
return;
|
|
}
|
|
const result = await resp.json();
|
|
if (result.status === 'saved') {
|
|
item.done = true;
|
|
updateProgress();
|
|
const t = result.row.opening_s;
|
|
const bad = result.row.bad_rois;
|
|
showFlash(
|
|
`saved ${item.machine_name} ${item.session_date} ${item.session_time}: ` +
|
|
(Number.isNaN(t) || t === null ? 'unusable' :
|
|
`${t.toFixed(1)}s${bad ? ' (bad ROIs: ' + bad + ')' : ''}`)
|
|
);
|
|
} else if (result.status === 'skipped') {
|
|
showFlash(`skipped ${item.machine_name} ${item.session_date} ${item.session_time}`);
|
|
}
|
|
cursor = (cursor + 1) % queue.length;
|
|
loadCursor();
|
|
} catch (e) {
|
|
showFlash('network error: ' + e.message, 'err');
|
|
}
|
|
}
|
|
|
|
// Button handlers
|
|
document.querySelectorAll('button[data-mode]').forEach(btn => {
|
|
btn.addEventListener('click', () => submit(btn.dataset.mode));
|
|
});
|
|
document.getElementById('prev').addEventListener('click', () => {
|
|
cursor = (cursor - 1 + queue.length) % queue.length;
|
|
loadCursor();
|
|
});
|
|
document.getElementById('next').addEventListener('click', () => {
|
|
cursor = (cursor + 1) % queue.length;
|
|
loadCursor();
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
// Don't react to picker shortcuts while either modal is open.
|
|
if (modalBackdrop.classList.contains('show')) return;
|
|
if (loginBackdrop.classList.contains('show')) return;
|
|
// Prevent the browser default (e.g. video focus side effects on space).
|
|
const stop = () => { e.preventDefault(); e.stopPropagation(); };
|
|
switch (e.key) {
|
|
case ' ':
|
|
stop();
|
|
if (player.paused) player.play(); else player.pause();
|
|
break;
|
|
case 'ArrowLeft':
|
|
stop();
|
|
player.currentTime -= e.shiftKey ? 30 : 5;
|
|
break;
|
|
case 'ArrowRight':
|
|
stop();
|
|
player.currentTime += e.shiftKey ? 30 : 5;
|
|
break;
|
|
case '1': stop(); submit('all'); break;
|
|
case '2': stop(); submit('upper'); break;
|
|
case '3': stop(); submit('lower'); break;
|
|
case 's': stop(); submit('skip'); break;
|
|
case 'u': stop(); submit('unusable'); break;
|
|
}
|
|
});
|
|
|
|
// Welcome modal — show first time, dismissable; ? button reopens it.
|
|
const modalBackdrop = document.getElementById('modal-backdrop');
|
|
const modalClose = document.getElementById('modal-close');
|
|
const helpBtn = document.getElementById('help-btn');
|
|
|
|
function showModal() { modalBackdrop.classList.add('show'); }
|
|
function hideModal() {
|
|
modalBackdrop.classList.remove('show');
|
|
try { localStorage.setItem('cupido.welcomed', '1'); } catch (e) {}
|
|
}
|
|
modalClose.addEventListener('click', hideModal);
|
|
helpBtn.addEventListener('click', showModal);
|
|
modalBackdrop.addEventListener('click', (e) => {
|
|
if (e.target === modalBackdrop) hideModal();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modalBackdrop.classList.contains('show')) {
|
|
e.stopPropagation(); hideModal();
|
|
}
|
|
});
|
|
|
|
// On first visit, login first (mandatory) then welcome modal.
|
|
renderUserBadge();
|
|
if (!getAnalyst()) {
|
|
showLogin();
|
|
} else {
|
|
let welcomed = false;
|
|
try { welcomed = localStorage.getItem('cupido.welcomed') === '1'; } catch (e) {}
|
|
if (!welcomed) showModal();
|
|
}
|
|
|
|
fetchQueue();
|
|
</script>
|
|
</body>
|
|
</html>
|