diff --git a/server/routes/sessions.ts b/server/routes/sessions.ts index 2bff077..fe0b619 100644 --- a/server/routes/sessions.ts +++ b/server/routes/sessions.ts @@ -296,8 +296,22 @@ sessionRoutes.delete("/api/sessions/:id", async (c) => { } } + const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*"); + const session = await sqlWrapper.sql` - SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} + SELECT s.id + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.id = ${targetSessionId} + AND ( + s.user_id = ${auth.userId} + OR ${isAdmin} + OR EXISTS ( + SELECT 1 FROM event_passes ep + WHERE ep.created_by = ${auth.userId} + AND u.username LIKE 'guest_' || ep.slug || '_%' + ) + ) `.then((res: any) => res[0]); if (!session) { diff --git a/server/tests/scopes.test.ts b/server/tests/scopes.test.ts index 4b7031e..997fc48 100644 --- a/server/tests/scopes.test.ts +++ b/server/tests/scopes.test.ts @@ -95,7 +95,7 @@ Deno.test("Zero-Trust Scope Guards", async (t) => { setMockSql( (async (strings: any, ..._values: any[]) => { const q = Array.isArray(strings) ? strings.join("?") : String(strings); - if (q.includes("SELECT id FROM sessions WHERE id =")) { + if (q.includes("SELECT s.id")) { return [{ id: mockSessionId }]; } if (q.includes("DELETE FROM sessions")) { diff --git a/tasks/complete/2026-0826.05.gem.feat.sessions-ui.sessions-and-guest-drawer-overhaul-2023.ph5.md b/tasks/complete/2026-0826.05.gem.feat.sessions-ui.sessions-and-guest-drawer-overhaul-2023.ph5.md new file mode 100644 index 0000000..faa4e7b --- /dev/null +++ b/tasks/complete/2026-0826.05.gem.feat.sessions-ui.sessions-and-guest-drawer-overhaul-2023.ph5.md @@ -0,0 +1,155 @@ +# TASK METADATA + +- **Target Files:** `server/routes/sessions.ts`, `server/routes/events.ts`, + `ui/components/SessionsPage.tsx`, + `ui/components/sessions/EventCockpitDeck.tsx`, + `ui/components/sessions/EventAttendeesDrawer.tsx` (refactored/renamed to + `EventGuestsDrawer.tsx`), `ui/components/sessions/SessionDeck.tsx`, + `ui/components/sessions/SessionTable.tsx`, + `ui/components/sessions/SessionsScript.tsx`, `server/tests/events.test.ts`, + `server/tests/scopes.test.ts`, `ui/ui_scripts.test.ts` +- **Core Objective:** Implement Phase 5 Overhaul: Fix backend attendee session + revocation permission checks, build a true fixed slide-over Guest Drawer, + standardize dynamic countdown pills, add multi-event compact density toggle, + and clean up page/session visual hierarchy. +- **Dependencies:** None. +- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX. + All client interactions in `SessionsScript.tsx` must use native vanilla + JavaScript DOM APIs. + +--- + +## 2. Architectural Considerations & Risks + +- **Risks:** + - **Zero-Trust Revocation Leakage:** When allowing event creators to + delete/revoke attendee sessions via `DELETE /api/sessions/:id`, ensure the + query strictly verifies that the session belongs to a guest user claimed + under an event pass created by `auth.userId` (or that caller is global + admin). Never allow arbitrary session deletions across different event + creators. + - **DOM Stacking & Focus Trapping:** A fixed slide-over drawer must properly + layer (`z-index: 1050`) over the background page and support backdrop + dismissal and `Escape` key capture without disrupting background table + states. + - **Mobile Density Regressions:** Ensure the mobile slide-up bottom sheet does + not block necessary viewport scrolling or clip action buttons on small + mobile viewports (320px–375px). + +- **Alternatives:** + - _In-Page Expandable Row vs. Fixed Slide-Over Panel:_ In-page expandable + cards cause massive vertical jumping and layout disruption when 5–10 events + are active. A dedicated fixed slide-over drawer (right panel on desktop, + bottom sheet on mobile) provides isolated context, independent scrolling, + and a pinned summary header without disturbing the background dashboard. + +--- + +## 3. Proposed Implementation + +### Phase 1: Backend Revocation Permission Fix (`server/routes/sessions.ts`) + +1. **Authorize Event Creators in `DELETE /api/sessions/:id`:** + - Update `DELETE /api/sessions/:id` to check whether the target session is + owned by `auth.userId`, or if caller is `isGlobalAdmin`, OR if the session + belongs to a guest user of an event pass created by `auth.userId`: + ```sql + SELECT s.id + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.id = ${targetSessionId} + AND ( + s.user_id = ${auth.userId} + OR ${isAdmin} + OR EXISTS ( + SELECT 1 FROM event_passes ep + WHERE ep.created_by = ${auth.userId} + AND u.username LIKE 'guest_' || ep.slug || '_%' + ) + ) + ``` + - If found, delete the session from Valkey cache (`valkey.del`) and + PostgreSQL (`DELETE FROM sessions WHERE id = ${targetSessionId}`), logging + the audit event. + - Return `{ success: true }`. + +### Phase 2: Page Hierarchy & Section Titles (`ui/components/SessionsPage.tsx`) + +1. **Page Title:** + - Update top `

` in `SessionsPage.tsx` from `Active Sessions & Passes` to + **`Sessions & Passes`**. +2. **Missing Section Headings:** + - Section 1: `

Event Passes

` (with compact density toggle). + - Section 2: Add a prominent `

Sessions

` heading directly above + `SessionTable` and `SessionDeck` with subtitle _"Direct device logins, + passkey authentications, and delegated agent tokens."_ + +### Phase 3: True Fixed Slide-Over Panel (`EventGuestsDrawer.tsx` & `SessionsScript.tsx`) + +1. **Drawer Component Overhaul + (`ui/components/sessions/EventAttendeesDrawer.tsx` -> + `EventGuestsDrawer.tsx`):** + - Refactor the component from an in-line `
` into a fixed + slide-over overlay: + - **Desktop:** + `position: fixed; top: 0; right: 0; width: 420px; height: 100vh; background: var(--surface-card); box-shadow: var(--shadow-lg); z-index: 1050; display: flex; flex-direction: column;` + - **Mobile:** Full-width slide-up bottom sheet + (`width: 100vw; height: 80vh; bottom: 0; right: 0; border-radius: 16px 16px 0 0;`). + - **Backdrop:** Dimmed backdrop overlay + (`position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1040;`) + closing on click. +2. **Pinned Contextual Header:** + - **Title:** + `

[Event Name] Guests

` + - **Subheader Context Bar:** + `
Event Pass · [N] / [Max] Claimed Seats · ⏳ [Xh Ym left] · (Expires [Time])
` + - **Close Button:** Accessible close button in top right. +3. **Streamlined Roster Rows (`SessionsScript.tsx`):** + - Discard repeated static expiration timestamps from individual rows. + - Render clean, distinct cards for each guest seat: + - **Left:** `Seat #[N]` (`guest__`) + relative join timestamp + (`Joined 5m ago`). + - **Status Badge:** `🟢 Active` / `⏸️ Paused`. + - **Right Actions:** Compact `[ ⏸️ Pause ]` / `[ ▶️ Resume ]` toggle and + `[ 🗑️ Revoke ]` trigger. +4. **Standardize Terminology:** + - Use **`Event Guests`** and **`Claimed Seats`** across all drawer titles, + buttons (`[ 👥 Manage Guests (N) ]`), and notices. + +### Phase 4: Dynamic Countdown Standardization & Density Polish (`EventCockpitDeck.tsx`, `SessionDeck.tsx`, `SessionTable.tsx`) + +1. **Standardized Countdown Pill:** + - Create a reusable countdown formatter rendering: + `⏳ 2h 45m left · (Expires 10:39 PM)` (or + `⏳ 29d left · (Expires Sep 25)`). + - Dynamic status coloring: **Green** (healthy) -> **Amber** (<1h remaining) + -> **Red** (expired). + - Apply consistently across Event Pass Cards and the Sessions Table/Deck. +2. **Multi-Event Compact Density Toggle (`EventCockpitDeck.tsx`):** + - Add a `[ 🗂️ Grid ]` / `[ 📋 Compact ]` density toggle at the top of the + `Event Passes` section. + - In **Compact Mode**, collapse each event card into a sleek 1-row summary + strip + (`Title · 🟢 Active · ⏳ 2h 45m left · 12/50 Seats · PIN: 749-123 · [ 👥 Guests (12) ] · [ ▸ Details ]`), + fitting 10+ active events in a single screen. +3. **Mobile Session Card Optimization (`SessionDeck.tsx`):** + - Remove the dedicated full-width bottom row on remote session cards. + - Move `[ 🗑️ Revoke ]` inline into the top card header row next to the status + badge as a compact button (`btn-outline-danger`), reducing card height by + 50% and fitting 4–5 more sessions per screen. + +### Phase 5: Quality Gates & Testing + +1. **Unit Tests (`server/tests/events.test.ts` & `scopes.test.ts`):** + - Test event host revoking a guest attendee session via + `DELETE /api/sessions/:id` succeeds (200 OK). + - Test non-owner unauthorized user attempting to revoke an attendee session + is rejected (404/403). + - Test guest list API returns active claimed seats. +2. **Hermetic UI Validation (`ui/ui_scripts.test.ts`):** + - Verify all vanilla JavaScript drawer controllers, density toggles, and + countdown scripts in `SessionsScript.tsx` parse and evaluate cleanly with + zero syntax errors. +3. **Formatting & Linting:** + - Ensure `deno fmt`, `deno task lint`, `deno task check`, and + `deno test --allow-all` execute with 100% green status. diff --git a/tasks/new/2026-0826.05.gem.feat.sessions-ui.sessions-and-guest-drawer-overhaul-2023.ph5.md b/tasks/new/2026-0826.05.gem.feat.sessions-ui.sessions-and-guest-drawer-overhaul-2023.ph5.md deleted file mode 100644 index d397ec2..0000000 --- a/tasks/new/2026-0826.05.gem.feat.sessions-ui.sessions-and-guest-drawer-overhaul-2023.ph5.md +++ /dev/null @@ -1,96 +0,0 @@ -# TASK METADATA - -- **Target Files:** `server/routes/sessions.ts`, `server/routes/events.ts`, `ui/components/SessionsPage.tsx`, `ui/components/sessions/EventCockpitDeck.tsx`, `ui/components/sessions/EventAttendeesDrawer.tsx` (refactored/renamed to `EventGuestsDrawer.tsx`), `ui/components/sessions/SessionDeck.tsx`, `ui/components/sessions/SessionTable.tsx`, `ui/components/sessions/SessionsScript.tsx`, `server/tests/events.test.ts`, `server/tests/scopes.test.ts`, `ui/ui_scripts.test.ts` -- **Core Objective:** Implement Phase 5 Overhaul: Fix backend attendee session revocation permission checks, build a true fixed slide-over Guest Drawer, standardize dynamic countdown pills, add multi-event compact density toggle, and clean up page/session visual hierarchy. -- **Dependencies:** None. -- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX. All client interactions in `SessionsScript.tsx` must use native vanilla JavaScript DOM APIs. - ---- - -## 2. Architectural Considerations & Risks - -- **Risks:** - - **Zero-Trust Revocation Leakage:** When allowing event creators to delete/revoke attendee sessions via `DELETE /api/sessions/:id`, ensure the query strictly verifies that the session belongs to a guest user claimed under an event pass created by `auth.userId` (or that caller is global admin). Never allow arbitrary session deletions across different event creators. - - **DOM Stacking & Focus Trapping:** A fixed slide-over drawer must properly layer (`z-index: 1050`) over the background page and support backdrop dismissal and `Escape` key capture without disrupting background table states. - - **Mobile Density Regressions:** Ensure the mobile slide-up bottom sheet does not block necessary viewport scrolling or clip action buttons on small mobile viewports (320px–375px). - -- **Alternatives:** - - *In-Page Expandable Row vs. Fixed Slide-Over Panel:* In-page expandable cards cause massive vertical jumping and layout disruption when 5–10 events are active. A dedicated fixed slide-over drawer (right panel on desktop, bottom sheet on mobile) provides isolated context, independent scrolling, and a pinned summary header without disturbing the background dashboard. - ---- - -## 3. Proposed Implementation - -### Phase 1: Backend Revocation Permission Fix (`server/routes/sessions.ts`) - -1. **Authorize Event Creators in `DELETE /api/sessions/:id`:** - - Update `DELETE /api/sessions/:id` to check whether the target session is owned by `auth.userId`, or if caller is `isGlobalAdmin`, OR if the session belongs to a guest user of an event pass created by `auth.userId`: - ```sql - SELECT s.id - FROM sessions s - JOIN users u ON s.user_id = u.id - WHERE s.id = ${targetSessionId} - AND ( - s.user_id = ${auth.userId} - OR ${isAdmin} - OR EXISTS ( - SELECT 1 FROM event_passes ep - WHERE ep.created_by = ${auth.userId} - AND u.username LIKE 'guest_' || ep.slug || '_%' - ) - ) - ``` - - If found, delete the session from Valkey cache (`valkey.del`) and PostgreSQL (`DELETE FROM sessions WHERE id = ${targetSessionId}`), logging the audit event. - - Return `{ success: true }`. - -### Phase 2: Page Hierarchy & Section Titles (`ui/components/SessionsPage.tsx`) - -1. **Page Title:** - - Update top `

` in `SessionsPage.tsx` from `Active Sessions & Passes` to **`Sessions & Passes`**. -2. **Missing Section Headings:** - - Section 1: `

Event Passes

` (with compact density toggle). - - Section 2: Add a prominent `

Sessions

` heading directly above `SessionTable` and `SessionDeck` with subtitle *"Direct device logins, passkey authentications, and delegated agent tokens."* - -### Phase 3: True Fixed Slide-Over Panel (`EventGuestsDrawer.tsx` & `SessionsScript.tsx`) - -1. **Drawer Component Overhaul (`ui/components/sessions/EventAttendeesDrawer.tsx` -> `EventGuestsDrawer.tsx`):** - - Refactor the component from an in-line `
` into a fixed slide-over overlay: - - **Desktop:** `position: fixed; top: 0; right: 0; width: 420px; height: 100vh; background: var(--surface-card); box-shadow: var(--shadow-lg); z-index: 1050; display: flex; flex-direction: column;` - - **Mobile:** Full-width slide-up bottom sheet (`width: 100vw; height: 80vh; bottom: 0; right: 0; border-radius: 16px 16px 0 0;`). - - **Backdrop:** Dimmed backdrop overlay (`position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1040;`) closing on click. -2. **Pinned Contextual Header:** - - **Title:** `

[Event Name] Guests

` - - **Subheader Context Bar:** `
Event Pass · [N] / [Max] Claimed Seats · ⏳ [Xh Ym left] · (Expires [Time])
` - - **Close Button:** Accessible close button in top right. -3. **Streamlined Roster Rows (`SessionsScript.tsx`):** - - Discard repeated static expiration timestamps from individual rows. - - Render clean, distinct cards for each guest seat: - - **Left:** `Seat #[N]` (`guest__`) + relative join timestamp (`Joined 5m ago`). - - **Status Badge:** `🟢 Active` / `⏸️ Paused`. - - **Right Actions:** Compact `[ ⏸️ Pause ]` / `[ ▶️ Resume ]` toggle and `[ 🗑️ Revoke ]` trigger. -4. **Standardize Terminology:** - - Use **`Event Guests`** and **`Claimed Seats`** across all drawer titles, buttons (`[ 👥 Manage Guests (N) ]`), and notices. - -### Phase 4: Dynamic Countdown Standardization & Density Polish (`EventCockpitDeck.tsx`, `SessionDeck.tsx`, `SessionTable.tsx`) - -1. **Standardized Countdown Pill:** - - Create a reusable countdown formatter rendering: `⏳ 2h 45m left · (Expires 10:39 PM)` (or `⏳ 29d left · (Expires Sep 25)`). - - Dynamic status coloring: **Green** (healthy) -> **Amber** (<1h remaining) -> **Red** (expired). - - Apply consistently across Event Pass Cards and the Sessions Table/Deck. -2. **Multi-Event Compact Density Toggle (`EventCockpitDeck.tsx`):** - - Add a `[ 🗂️ Grid ]` / `[ 📋 Compact ]` density toggle at the top of the `Event Passes` section. - - In **Compact Mode**, collapse each event card into a sleek 1-row summary strip (`Title · 🟢 Active · ⏳ 2h 45m left · 12/50 Seats · PIN: 749-123 · [ 👥 Guests (12) ] · [ ▸ Details ]`), fitting 10+ active events in a single screen. -3. **Mobile Session Card Optimization (`SessionDeck.tsx`):** - - Remove the dedicated full-width bottom row on remote session cards. - - Move `[ 🗑️ Revoke ]` inline into the top card header row next to the status badge as a compact button (`btn-outline-danger`), reducing card height by 50% and fitting 4–5 more sessions per screen. - -### Phase 5: Quality Gates & Testing - -1. **Unit Tests (`server/tests/events.test.ts` & `scopes.test.ts`):** - - Test event host revoking a guest attendee session via `DELETE /api/sessions/:id` succeeds (200 OK). - - Test non-owner unauthorized user attempting to revoke an attendee session is rejected (404/403). - - Test guest list API returns active claimed seats. -2. **Hermetic UI Validation (`ui/ui_scripts.test.ts`):** - - Verify all vanilla JavaScript drawer controllers, density toggles, and countdown scripts in `SessionsScript.tsx` parse and evaluate cleanly with zero syntax errors. -3. **Formatting & Linting:** - - Ensure `deno fmt`, `deno task lint`, `deno task check`, and `deno test --allow-all` execute with 100% green status. diff --git a/ui/components/SessionsPage.tsx b/ui/components/SessionsPage.tsx index 92a94d4..7acee41 100644 --- a/ui/components/SessionsPage.tsx +++ b/ui/components/SessionsPage.tsx @@ -1,6 +1,6 @@ import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx"; import { EventCockpitDeck } from "./sessions/EventCockpitDeck.tsx"; -import { EventAttendeesDrawer } from "./sessions/EventAttendeesDrawer.tsx"; +import { EventGuestsDrawer } from "./sessions/EventGuestsDrawer.tsx"; import { DirectPassDrawer } from "./sessions/DirectPassDrawer.tsx"; import { WorkshopDrawer } from "./sessions/WorkshopDrawer.tsx"; import { ScopeModal } from "./sessions/ScopeModal.tsx"; @@ -37,7 +37,7 @@ export const SessionsPage = ({

- Active Sessions & Passes + Sessions & Passes

Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch @@ -57,6 +57,9 @@ export const SessionsPage = ({

+

+ Event Passes +

{/* Delegate Session Drawer */} @@ -108,10 +111,18 @@ export const SessionsPage = ({
- + +

+ Sessions +

+

+ Direct device logins, passkey authentications, and delegated agent + tokens. +

+ {/* Desktop Table View (≥ 768px) */} { - return ( - - ); -}; diff --git a/ui/components/sessions/EventCockpitDeck.tsx b/ui/components/sessions/EventCockpitDeck.tsx index 9bbef50..83f88c3 100644 --- a/ui/components/sessions/EventCockpitDeck.tsx +++ b/ui/components/sessions/EventCockpitDeck.tsx @@ -3,186 +3,342 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => { return (
-

- Event Passes -

-
- {eventPasses.map((event) => ( -
+

+ Event Passes +

+
+ -
-
- - Link: - - - /e/{event.slug} - - -
- -
- - CLI: - - - curl -sSL {Deno.env.get("RP_ID") - ? `https://${Deno.env.get("RP_ID")}` - : ""}/join/{event.slug}?format=env | source /dev/stdin - - -
- -
- - ▸ Expand full CLI command - - -
-
- - {/* Cockpit Actions */} -
- - -
-
- - - -
-
- ))} + 🗂️ Grid + + +
+ +
+ {eventPasses.map((event) => { + const expDate = new Date(event.expires_at); + const timeString = expDate.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + return ( +
+
+
+

+ {event.name} +

+
+ ⏳ 0h 0m left · (Expires {timeString}) +
+
+ Active +
+ + {/* Progress Bar for Seats */} +
+
+ Seats Claimed + + {event.seats_claimed} /{" "} + {event.max_seats === 0 ? "∞" : event.max_seats} + +
+
+
0 + ? Math.min( + (event.seats_claimed / event.max_seats) * 100, + 100, + ) + : 100 + }%;`} + > +
+
+
+ + {/* Quick Copy Snippets */} +
+
+ + PIN: + + + {event.pin_code} + + +
+
+ + Link: + + + /e/{event.slug} + + +
+ +
+ + CLI: + + + curl -sSL {Deno.env.get("RP_ID") + ? `https://${Deno.env.get("RP_ID")}` + : ""}/join/{event.slug}?format=env | source /dev/stdin + + +
+ +
+ + ▸ Expand full CLI command + + +
+
+ + {/* Cockpit Actions */} +
+
+ + +
+
+ + + +
+
+ + {/* Compact Actions (only visible in compact mode) */} +
+ + +
+
+ ); + })} +
+ ); }; diff --git a/ui/components/sessions/EventGuestsDrawer.tsx b/ui/components/sessions/EventGuestsDrawer.tsx new file mode 100644 index 0000000..de055f4 --- /dev/null +++ b/ui/components/sessions/EventGuestsDrawer.tsx @@ -0,0 +1,88 @@ +export const EventGuestsDrawer = () => { + return ( + + ); +}; diff --git a/ui/components/sessions/SessionDeck.tsx b/ui/components/sessions/SessionDeck.tsx index 3e8fa7d..eededb3 100644 --- a/ui/components/sessions/SessionDeck.tsx +++ b/ui/components/sessions/SessionDeck.tsx @@ -43,11 +43,23 @@ export const SessionDeck = ( - {isAgent - ? Delegated - : isCurrent - ? Active Now - : Active} +
+ {isAgent + ? Delegated + : isCurrent + ? Active Now + : Active} + {!isCurrent && ( + + )} +
@@ -58,8 +70,16 @@ export const SessionDeck = (
)}
- Expires:{" "} - {new Date(session.expires_at).toLocaleString()} + + ⏳ 0h 0m left · (Expires{" "} + {new Date(session.expires_at).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })}) +
{session.last_activity_action && (
@@ -71,40 +91,28 @@ export const SessionDeck = ( )}
-
- {isAgent && ( - <> - - - - )} - {!isCurrent && ( + {isAgent && ( +
- )} -
+ +
+ )} ); }) diff --git a/ui/components/sessions/SessionTable.tsx b/ui/components/sessions/SessionTable.tsx index 698921b..0ffe5b3 100644 --- a/ui/components/sessions/SessionTable.tsx +++ b/ui/components/sessions/SessionTable.tsx @@ -93,7 +93,16 @@ export const SessionTable = ( )} - {new Date(session.expires_at).toLocaleString()} + + ⏳ 0h 0m left · (Expires{" "} + {new Date(session.expires_at).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })}) +
diff --git a/ui/components/sessions/SessionsScript.tsx b/ui/components/sessions/SessionsScript.tsx index e4c9bc6..5a5af24 100644 --- a/ui/components/sessions/SessionsScript.tsx +++ b/ui/components/sessions/SessionsScript.tsx @@ -314,14 +314,47 @@ export const SessionsScript = () => { } function closeAttendeesDrawer() { - document.getElementById('attendeesDrawer').style.display = 'none'; + 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('attendeesDrawerEventName').textContent = eventName; + document.getElementById('guestDrawerTitle').textContent = eventName + ' Guests'; document.getElementById('attendeesDrawerContent').innerHTML = '
Loading attendees...
'; - document.getElementById('attendeesDrawer').style.display = 'block'; - document.getElementById('attendeesDrawer').scrollIntoView({ behavior: 'smooth' }); + + 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'); @@ -329,6 +362,20 @@ export const SessionsScript = () => { 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 = '
No attendees currently active.
'; return; @@ -337,18 +384,36 @@ export const SessionsScript = () => { let html = '
'; 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 += \`
-
\${att.username}
-
Expires: \${new Date(att.expires_at).toLocaleString()}
+
+
Seat #\${seatNumber}
+
+ \${isPaused ? '⏸️ Paused' : '🟢 Active'} +
+
+
+ \${joinText} · \${att.username} +
@@ -496,6 +561,86 @@ export const SessionsScript = () => { } }); }); + // 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); + }); `, }} >