feat: Sessions UI overhaul and backend revocation fix
- 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>
This commit is contained in:
parent
a194844309
commit
d86642ce31
@ -296,8 +296,22 @@ sessionRoutes.delete("/api/sessions/:id", async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*");
|
||||||
|
|
||||||
const session = await sqlWrapper.sql`
|
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]);
|
`.then((res: any) => res[0]);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@ -95,7 +95,7 @@ Deno.test("Zero-Trust Scope Guards", async (t) => {
|
|||||||
setMockSql(
|
setMockSql(
|
||||||
(async (strings: any, ..._values: any[]) => {
|
(async (strings: any, ..._values: any[]) => {
|
||||||
const q = Array.isArray(strings) ? strings.join("?") : String(strings);
|
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 }];
|
return [{ id: mockSessionId }];
|
||||||
}
|
}
|
||||||
if (q.includes("DELETE FROM sessions")) {
|
if (q.includes("DELETE FROM sessions")) {
|
||||||
|
|||||||
@ -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 `<h1>` in `SessionsPage.tsx` from `Active Sessions & Passes` to
|
||||||
|
**`Sessions & Passes`**.
|
||||||
|
2. **Missing Section Headings:**
|
||||||
|
- Section 1: `<h2>Event Passes</h2>` (with compact density toggle).
|
||||||
|
- Section 2: Add a prominent `<h2>Sessions</h2>` 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 `<div class="card">` 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:**
|
||||||
|
`<h2 id="guestDrawerTitle" style="margin: 0; font-size: 1.25rem;">[Event Name] Guests</h2>`
|
||||||
|
- **Subheader Context Bar:**
|
||||||
|
`<div id="guestDrawerContext" style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem;">Event Pass · [N] / [Max] Claimed Seats · ⏳ [Xh Ym left] · (Expires [Time])</div>`
|
||||||
|
- **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_<slug>_<N>`) + 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.
|
||||||
@ -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 `<h1>` in `SessionsPage.tsx` from `Active Sessions & Passes` to **`Sessions & Passes`**.
|
|
||||||
2. **Missing Section Headings:**
|
|
||||||
- Section 1: `<h2>Event Passes</h2>` (with compact density toggle).
|
|
||||||
- Section 2: Add a prominent `<h2>Sessions</h2>` 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 `<div class="card">` 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:** `<h2 id="guestDrawerTitle" style="margin: 0; font-size: 1.25rem;">[Event Name] Guests</h2>`
|
|
||||||
- **Subheader Context Bar:** `<div id="guestDrawerContext" style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem;">Event Pass · [N] / [Max] Claimed Seats · ⏳ [Xh Ym left] · (Expires [Time])</div>`
|
|
||||||
- **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_<slug>_<N>`) + 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.
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
|
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
|
||||||
import { EventCockpitDeck } from "./sessions/EventCockpitDeck.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 { DirectPassDrawer } from "./sessions/DirectPassDrawer.tsx";
|
||||||
import { WorkshopDrawer } from "./sessions/WorkshopDrawer.tsx";
|
import { WorkshopDrawer } from "./sessions/WorkshopDrawer.tsx";
|
||||||
import { ScopeModal } from "./sessions/ScopeModal.tsx";
|
import { ScopeModal } from "./sessions/ScopeModal.tsx";
|
||||||
@ -37,7 +37,7 @@ export const SessionsPage = ({
|
|||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||||
Active Sessions & Passes
|
Sessions & Passes
|
||||||
</h1>
|
</h1>
|
||||||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||||||
Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch
|
Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch
|
||||||
@ -57,6 +57,9 @@ export const SessionsPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 1rem; color: var(--text-primary);">
|
||||||
|
Event Passes
|
||||||
|
</h2>
|
||||||
<EventCockpitDeck eventPasses={eventPasses} />
|
<EventCockpitDeck eventPasses={eventPasses} />
|
||||||
|
|
||||||
{/* Delegate Session Drawer */}
|
{/* Delegate Session Drawer */}
|
||||||
@ -108,10 +111,18 @@ export const SessionsPage = ({
|
|||||||
<WorkshopDrawer apps={apps} />
|
<WorkshopDrawer apps={apps} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EventAttendeesDrawer />
|
<EventGuestsDrawer />
|
||||||
|
|
||||||
<ScopeModal apps={apps} />
|
<ScopeModal apps={apps} />
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 0.25rem; color: var(--text-primary);">
|
||||||
|
Sessions
|
||||||
|
</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 1rem; font-size: 0.95rem;">
|
||||||
|
Direct device logins, passkey authentications, and delegated agent
|
||||||
|
tokens.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Desktop Table View (≥ 768px) */}
|
{/* Desktop Table View (≥ 768px) */}
|
||||||
<SessionTable
|
<SessionTable
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
export const EventAttendeesDrawer = () => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="attendeesDrawer"
|
|
||||||
class="drawer-overlay"
|
|
||||||
style="display: none;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="card"
|
|
||||||
style="border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
|
|
||||||
>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem;">
|
|
||||||
<div style="width: 100%;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
||||||
<h3
|
|
||||||
id="attendeesDrawerTitle"
|
|
||||||
style="margin: 0 0 0.25rem 0; color: var(--text-primary);"
|
|
||||||
>
|
|
||||||
Live Attendees
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="closeAttendeesDrawer()"
|
|
||||||
style="background: none; border: none; font-size: 1.3rem; color: var(--text-muted); cursor: pointer;"
|
|
||||||
aria-label="Close Drawer"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p style="color: var(--text-secondary); font-size: 0.85rem; margin: 0;">
|
|
||||||
Manage active guest sessions for{" "}
|
|
||||||
<strong id="attendeesDrawerEventName">...</strong>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="attendeesDrawerContent"
|
|
||||||
style="min-height: 200px; max-height: 400px; overflow-y: auto; padding-right: 0.5rem;"
|
|
||||||
>
|
|
||||||
{/* Populated dynamically via JS */}
|
|
||||||
<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">
|
|
||||||
Loading attendees...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -3,186 +3,342 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style="margin-bottom: 2rem;">
|
<div style="margin-bottom: 2rem;">
|
||||||
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0 0 1rem 0; color: var(--text-primary); border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.5rem;">
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.5rem; margin-bottom: 1rem;">
|
||||||
Event Passes
|
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0; color: var(--text-primary);">
|
||||||
</h2>
|
Event Passes
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem;">
|
</h2>
|
||||||
{eventPasses.map((event) => (
|
<div style="display: flex; background: var(--surface-muted); padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 2px;">
|
||||||
<div
|
<button
|
||||||
key={event.id}
|
type="button"
|
||||||
class="card"
|
id="viewModeGrid"
|
||||||
style="border-left: 4px solid var(--primary); margin: 0;"
|
class="view-mode-btn active"
|
||||||
|
onclick="setEventViewMode('grid')"
|
||||||
|
aria-label="Grid View"
|
||||||
>
|
>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
🗂️ Grid
|
||||||
<div>
|
</button>
|
||||||
<h3 style="margin: 0 0 0.25rem 0; font-size: 1.1rem; color: var(--text-primary);">
|
<button
|
||||||
{event.name}
|
type="button"
|
||||||
</h3>
|
id="viewModeCompact"
|
||||||
<div style="font-size: 0.8rem; color: var(--text-secondary);">
|
class="view-mode-btn"
|
||||||
Expires: {new Date(event.expires_at).toLocaleString()}
|
onclick="setEventViewMode('compact')"
|
||||||
</div>
|
aria-label="Compact View"
|
||||||
</div>
|
>
|
||||||
<span class="badge badge-success">Active</span>
|
📋 Compact
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
{/* Progress Bar for Seats */}
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<div style="display: flex; justify-content: space-between; font-size: 0.8rem; margin-bottom: 0.25rem; color: var(--text-secondary);">
|
|
||||||
<span>Seats Claimed</span>
|
|
||||||
<strong style="color: var(--text-primary);">
|
|
||||||
{event.seats_claimed} /{" "}
|
|
||||||
{event.max_seats === 0 ? "∞" : event.max_seats}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div style="width: 100%; height: 8px; background: var(--surface-muted); border-radius: 4px; overflow: hidden;">
|
|
||||||
<div
|
|
||||||
style={`height: 100%; background: var(--primary); width: ${
|
|
||||||
event.max_seats > 0
|
|
||||||
? Math.min(
|
|
||||||
(event.seats_claimed / event.max_seats) * 100,
|
|
||||||
100,
|
|
||||||
)
|
|
||||||
: 100
|
|
||||||
}%;`}
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Copy Snippets */}
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
|
||||||
PIN:
|
|
||||||
</span>
|
|
||||||
<code
|
|
||||||
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.9rem; text-align: center; letter-spacing: 2px;"
|
|
||||||
id={`pin-${event.id}`}
|
|
||||||
>
|
|
||||||
{event.pin_code}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
aria-label={`Copy PIN code for ${event.name}`}
|
|
||||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
|
||||||
onclick={`copyText(document.getElementById('pin-${event.id}').textContent.trim())`}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
|
||||||
Link:
|
|
||||||
</span>
|
|
||||||
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
|
||||||
/e/{event.slug}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
aria-label={`Copy direct link for ${event.name}`}
|
|
||||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
|
||||||
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
|
||||||
CLI:
|
|
||||||
</span>
|
|
||||||
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
|
||||||
curl -sSL {Deno.env.get("RP_ID")
|
|
||||||
? `https://${Deno.env.get("RP_ID")}`
|
|
||||||
: ""}/join/{event.slug}?format=env | source /dev/stdin
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
aria-label={`Copy CLI command for ${event.name}`}
|
|
||||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
|
||||||
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details style="font-size: 0.75rem; margin-top: 0.1rem;">
|
|
||||||
<summary style="cursor: pointer; color: var(--primary); font-weight: 600; user-select: none;">
|
|
||||||
▸ Expand full CLI command
|
|
||||||
</summary>
|
|
||||||
<textarea
|
|
||||||
readonly
|
|
||||||
rows={2}
|
|
||||||
style="width: 100%; margin-top: 0.4rem; padding: 0.4rem 0.5rem; font-family: monospace; font-size: 0.75rem; background: var(--surface-muted); color: var(--text-primary); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); resize: vertical; box-sizing: border-box;"
|
|
||||||
onclick="this.select()"
|
|
||||||
>
|
|
||||||
{`curl -sSL ${
|
|
||||||
Deno.env.get("RP_ID")
|
|
||||||
? `https://${Deno.env.get("RP_ID")}`
|
|
||||||
: ""
|
|
||||||
}/join/${event.slug}?format=env | source /dev/stdin`}
|
|
||||||
</textarea>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cockpit Actions */}
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
aria-label={`Manage attendees for ${event.name}`}
|
|
||||||
style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
|
||||||
onclick={`openAttendeesDrawer('${event.id}', '${
|
|
||||||
event.name.replace(/'/g, "\\'")
|
|
||||||
}')`}
|
|
||||||
>
|
|
||||||
👥 Manage Attendees ({event.seats_claimed})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
aria-label={`Add 5 seats to ${event.name}`}
|
|
||||||
style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
|
||||||
onclick={`expandSeats('${event.id}', 5)`}
|
|
||||||
>
|
|
||||||
+5 Seats
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem;">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
aria-label={`Rotate PIN code for ${event.name}`}
|
|
||||||
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
|
||||||
onclick={`rotatePin('${event.id}')`}
|
|
||||||
>
|
|
||||||
🔄 Rotate PIN
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline extend-event-btn"
|
|
||||||
data-event-id={event.id}
|
|
||||||
aria-label={`Extend lifespan by 1 hour for ${event.name}`}
|
|
||||||
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
|
||||||
>
|
|
||||||
+1h Extend
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger end-event-btn"
|
|
||||||
data-event-id={event.id}
|
|
||||||
aria-label={`End event ${event.name} and revoke all attendees`}
|
|
||||||
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
|
||||||
>
|
|
||||||
End Event
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="eventDeckContainer" class="grid-view">
|
||||||
|
{eventPasses.map((event) => {
|
||||||
|
const expDate = new Date(event.expires_at);
|
||||||
|
const timeString = expDate.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
class="card event-card"
|
||||||
|
style="border-left: 4px solid var(--primary); margin: 0;"
|
||||||
|
>
|
||||||
|
<div class="event-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 0.25rem 0; font-size: 1.1rem; color: var(--text-primary);">
|
||||||
|
{event.name}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
class="countdown-pill"
|
||||||
|
data-expires-at={event.expires_at}
|
||||||
|
>
|
||||||
|
⏳ 0h 0m left · (Expires {timeString})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-success status-badge">Active</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar for Seats */}
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 0.8rem; margin-bottom: 0.25rem; color: var(--text-secondary);">
|
||||||
|
<span>Seats Claimed</span>
|
||||||
|
<strong style="color: var(--text-primary);">
|
||||||
|
{event.seats_claimed} /{" "}
|
||||||
|
{event.max_seats === 0 ? "∞" : event.max_seats}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100%; height: 8px; background: var(--surface-muted); border-radius: 4px; overflow: hidden;">
|
||||||
|
<div
|
||||||
|
style={`height: 100%; background: var(--primary); width: ${
|
||||||
|
event.max_seats > 0
|
||||||
|
? Math.min(
|
||||||
|
(event.seats_claimed / event.max_seats) * 100,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
: 100
|
||||||
|
}%;`}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Copy Snippets */}
|
||||||
|
<div
|
||||||
|
class="event-card-snippets"
|
||||||
|
style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;"
|
||||||
|
>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||||
|
PIN:
|
||||||
|
</span>
|
||||||
|
<code
|
||||||
|
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.9rem; text-align: center; letter-spacing: 2px;"
|
||||||
|
id={`pin-${event.id}`}
|
||||||
|
>
|
||||||
|
{event.pin_code}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Copy PIN code for ${event.name}`}
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||||
|
onclick={`copyText(document.getElementById('pin-${event.id}').textContent.trim())`}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||||
|
Link:
|
||||||
|
</span>
|
||||||
|
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||||
|
/e/{event.slug}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Copy direct link for ${event.name}`}
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||||
|
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||||
|
CLI:
|
||||||
|
</span>
|
||||||
|
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||||
|
curl -sSL {Deno.env.get("RP_ID")
|
||||||
|
? `https://${Deno.env.get("RP_ID")}`
|
||||||
|
: ""}/join/{event.slug}?format=env | source /dev/stdin
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Copy CLI command for ${event.name}`}
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||||
|
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details style="font-size: 0.75rem; margin-top: 0.1rem;">
|
||||||
|
<summary style="cursor: pointer; color: var(--primary); font-weight: 600; user-select: none;">
|
||||||
|
▸ Expand full CLI command
|
||||||
|
</summary>
|
||||||
|
<textarea
|
||||||
|
readonly
|
||||||
|
rows={2}
|
||||||
|
style="width: 100%; margin-top: 0.4rem; padding: 0.4rem 0.5rem; font-family: monospace; font-size: 0.75rem; background: var(--surface-muted); color: var(--text-primary); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); resize: vertical; box-sizing: border-box;"
|
||||||
|
onclick="this.select()"
|
||||||
|
>
|
||||||
|
{`curl -sSL ${
|
||||||
|
Deno.env.get("RP_ID")
|
||||||
|
? `https://${Deno.env.get("RP_ID")}`
|
||||||
|
: ""
|
||||||
|
}/join/${event.slug}?format=env | source /dev/stdin`}
|
||||||
|
</textarea>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cockpit Actions */}
|
||||||
|
<div class="event-card-actions">
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Manage attendees for ${event.name}`}
|
||||||
|
style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`openAttendeesDrawer('${event.id}', '${
|
||||||
|
event.name.replace(/'/g, "\\'")
|
||||||
|
}')`}
|
||||||
|
>
|
||||||
|
👥 Manage Guests ({event.seats_claimed})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Add 5 seats to ${event.name}`}
|
||||||
|
style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||||
|
onclick={`expandSeats('${event.id}', 5)`}
|
||||||
|
>
|
||||||
|
+5 Seats
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
aria-label={`Rotate PIN code for ${event.name}`}
|
||||||
|
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||||
|
onclick={`rotatePin('${event.id}')`}
|
||||||
|
>
|
||||||
|
🔄 Rotate PIN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline extend-event-btn"
|
||||||
|
data-event-id={event.id}
|
||||||
|
aria-label={`Extend lifespan by 1 hour for ${event.name}`}
|
||||||
|
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||||
|
>
|
||||||
|
+1h Extend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger end-event-btn"
|
||||||
|
data-event-id={event.id}
|
||||||
|
aria-label={`End event ${event.name} and revoke all attendees`}
|
||||||
|
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||||
|
>
|
||||||
|
End Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact Actions (only visible in compact mode) */}
|
||||||
|
<div class="event-card-compact-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
|
onclick={`openAttendeesDrawer('${event.id}', '${
|
||||||
|
event.name.replace(/'/g, "\\'")
|
||||||
|
}')`}
|
||||||
|
>
|
||||||
|
👥 Guests ({event.seats_claimed})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
|
onclick="alert('Expand functionality for compact view to be implemented if needed')"
|
||||||
|
>
|
||||||
|
▸ Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.view-mode-btn {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.view-mode-btn.active {
|
||||||
|
background: var(--surface-card);
|
||||||
|
color: var(--primary);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid View Layout */
|
||||||
|
.grid-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.grid-view .event-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid-view .event-card-compact-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact View Layout */
|
||||||
|
.compact-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.compact-view .event-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.compact-view .event-card > div {
|
||||||
|
margin-bottom: 0 !important; /* Reset component margins */
|
||||||
|
}
|
||||||
|
.compact-view .event-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.compact-view .event-card-header h3 {
|
||||||
|
margin: 0 !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.compact-view .event-card-header > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.compact-view .event-card-snippets,
|
||||||
|
.compact-view .event-card-actions,
|
||||||
|
.compact-view .status-badge {
|
||||||
|
display: none; /* Hide heavy elements in compact */
|
||||||
|
}
|
||||||
|
.compact-view .event-card-compact-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.compact-view .countdown-pill {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dynamic Countdown Colors */
|
||||||
|
.countdown-pill {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.countdown-pill.status-green { color: var(--success); }
|
||||||
|
.countdown-pill.status-amber { color: var(--warning); }
|
||||||
|
.countdown-pill.status-red { color: var(--danger-text); }
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
88
ui/components/sessions/EventGuestsDrawer.tsx
Normal file
88
ui/components/sessions/EventGuestsDrawer.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
export const EventGuestsDrawer = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="attendeesDrawer"
|
||||||
|
class="drawer-overlay"
|
||||||
|
style="display: none; position: fixed; inset: 0; z-index: 1040; background: rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s ease;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="drawer-panel"
|
||||||
|
style="position: fixed; background: var(--surface-card); box-shadow: var(--shadow-lg); z-index: 1050; display: flex; flex-direction: column; transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);"
|
||||||
|
>
|
||||||
|
<div style="padding: 1.5rem; border-bottom: 1px solid var(--border-subtle); display: flex; flex-direction: column; gap: 0.25rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||||
|
<h2
|
||||||
|
id="guestDrawerTitle"
|
||||||
|
style="margin: 0; font-size: 1.25rem; color: var(--text-primary);"
|
||||||
|
>
|
||||||
|
[Event Name] Guests
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="closeAttendeesDrawer()"
|
||||||
|
style="background: none; border: none; font-size: 1.5rem; color: var(--text-muted); cursor: pointer; line-height: 1;"
|
||||||
|
aria-label="Close Drawer"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="guestDrawerContext"
|
||||||
|
style="font-size: 0.8rem; color: var(--text-secondary);"
|
||||||
|
>
|
||||||
|
Event Pass · <span id="guestDrawerClaimed">0</span> /{" "}
|
||||||
|
<span id="guestDrawerMax">0</span> Claimed Seats ·{" "}
|
||||||
|
<span id="guestDrawerCountdown">⏳ 0h 0m left</span> · (Expires{" "}
|
||||||
|
<span id="guestDrawerExpiresAt">Time</span>)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="attendeesDrawerContent"
|
||||||
|
style="flex: 1; overflow-y: auto; padding: 1.5rem;"
|
||||||
|
>
|
||||||
|
{/* Populated dynamically via JS */}
|
||||||
|
<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">
|
||||||
|
Loading attendees...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
/* Desktop Slide-Over Panel */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.drawer-panel {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 420px;
|
||||||
|
height: 100vh;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
.drawer-panel.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Mobile Bottom Sheet */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.drawer-panel {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 80vh;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
.drawer-panel.open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.drawer-overlay.open {
|
||||||
|
display: block !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -43,11 +43,23 @@ export const SessionDeck = (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAgent
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
? <span class="badge badge-info">Delegated</span>
|
{isAgent
|
||||||
: isCurrent
|
? <span class="badge badge-info">Delegated</span>
|
||||||
? <span class="badge badge-success">Active Now</span>
|
: isCurrent
|
||||||
: <span class="badge badge-secondary">Active</span>}
|
? <span class="badge badge-success">Active Now</span>
|
||||||
|
: <span class="badge badge-secondary">Active</span>}
|
||||||
|
{!isCurrent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger revoke-btn"
|
||||||
|
data-session-id={session.id}
|
||||||
|
style="padding: 0.15rem 0.5rem; font-size: 0.75rem; min-height: 24px; border-radius: var(--radius-sm);"
|
||||||
|
>
|
||||||
|
🗑️ Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;">
|
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;">
|
||||||
@ -58,8 +70,16 @@ export const SessionDeck = (
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<strong>Expires:</strong>{" "}
|
<span
|
||||||
{new Date(session.expires_at).toLocaleString()}
|
class="countdown-pill"
|
||||||
|
data-expires-at={session.expires_at}
|
||||||
|
>
|
||||||
|
⏳ 0h 0m left · (Expires{" "}
|
||||||
|
{new Date(session.expires_at).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})})
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{session.last_activity_action && (
|
{session.last_activity_action && (
|
||||||
<div>
|
<div>
|
||||||
@ -71,40 +91,28 @@ export const SessionDeck = (
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
{isAgent && (
|
||||||
{isAgent && (
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
|
|
||||||
onclick={`extendSession('${session.id}', 1)`}
|
|
||||||
>
|
|
||||||
+1h Extend
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-outline"
|
|
||||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
|
|
||||||
onclick={`openEditScopesModal('${session.id}', '${
|
|
||||||
session.label || "Delegated"
|
|
||||||
}', ${JSON.stringify(JSON.stringify(scopes))})`}
|
|
||||||
>
|
|
||||||
Scopes
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!isCurrent && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-danger revoke-btn"
|
class="btn-outline"
|
||||||
data-session-id={session.id}
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
|
||||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
onclick={`extendSession('${session.id}', 1)`}
|
||||||
>
|
>
|
||||||
Revoke
|
+1h Extend
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
|
||||||
|
onclick={`openEditScopesModal('${session.id}', '${
|
||||||
|
session.label || "Delegated"
|
||||||
|
}', ${JSON.stringify(JSON.stringify(scopes))})`}
|
||||||
|
>
|
||||||
|
Scopes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@ -93,7 +93,16 @@ export const SessionTable = (
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{new Date(session.expires_at).toLocaleString()}
|
<span
|
||||||
|
class="countdown-pill"
|
||||||
|
data-expires-at={session.expires_at}
|
||||||
|
>
|
||||||
|
⏳ 0h 0m left · (Expires{" "}
|
||||||
|
{new Date(session.expires_at).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})})
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display: flex; gap: 0.35rem; align-items: center;">
|
<div style="display: flex; gap: 0.35rem; align-items: center;">
|
||||||
|
|||||||
@ -314,14 +314,47 @@ export const SessionsScript = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeAttendeesDrawer() {
|
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) {
|
async function openAttendeesDrawer(eventId, eventName) {
|
||||||
document.getElementById('attendeesDrawerEventName').textContent = 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>';
|
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>';
|
||||||
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 {
|
try {
|
||||||
const res = await fetch('/api/events/' + eventId + '/attendees');
|
const res = await fetch('/api/events/' + eventId + '/attendees');
|
||||||
@ -329,6 +362,20 @@ export const SessionsScript = () => {
|
|||||||
|
|
||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
const attendees = data.attendees;
|
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) {
|
if (attendees.length === 0) {
|
||||||
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-muted);">No attendees currently active.</div>';
|
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-muted);">No attendees currently active.</div>';
|
||||||
return;
|
return;
|
||||||
@ -337,18 +384,36 @@ export const SessionsScript = () => {
|
|||||||
let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
|
let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
|
||||||
for (const att of attendees) {
|
for (const att of attendees) {
|
||||||
const isPaused = att.is_paused === true;
|
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 += \`
|
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 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>
|
||||||
<div style="font-weight: 600; font-size: 0.9rem; color: var(--text-primary); \${isPaused ? 'text-decoration: line-through;' : ''}">\${att.username}</div>
|
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
|
||||||
<div style="font-size: 0.75rem; color: var(--text-secondary);">Expires: \${new Date(att.expires_at).toLocaleString()}</div>
|
<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>
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<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}')">
|
<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 ? '▶️ Unpause' : '⏸️ Pause'}
|
\${isPaused ? '▶️ Resume' : '⏸️ Pause'}
|
||||||
</button>
|
</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">
|
<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
|
🗑️ Revoke
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -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);
|
||||||
|
});
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user