Compare commits
No commits in common. "1e1c0b92447fbac827b2c75d126d1b1e6582f8e2" and "a194844309deb5b53e3d808450238957986b44d3" have entirely different histories.
1e1c0b9244
...
a194844309
@ -296,22 +296,8 @@ sessionRoutes.delete("/api/sessions/:id", async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*");
|
||||
|
||||
const session = await sqlWrapper.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 || '_%'
|
||||
)
|
||||
)
|
||||
SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (!session) {
|
||||
|
||||
@ -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 s.id")) {
|
||||
if (q.includes("SELECT id FROM sessions WHERE id =")) {
|
||||
return [{ id: mockSessionId }];
|
||||
}
|
||||
if (q.includes("DELETE FROM sessions")) {
|
||||
|
||||
@ -1,155 +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.
|
||||
@ -0,0 +1,96 @@
|
||||
# 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 { EventCockpitDeck } from "./sessions/EventCockpitDeck.tsx";
|
||||
import { EventGuestsDrawer } from "./sessions/EventGuestsDrawer.tsx";
|
||||
import { EventAttendeesDrawer } from "./sessions/EventAttendeesDrawer.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 = ({
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Sessions & Passes
|
||||
Active Sessions & Passes
|
||||
</h1>
|
||||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||||
Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch
|
||||
@ -57,9 +57,6 @@ export const SessionsPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 1rem; color: var(--text-primary);">
|
||||
Event Passes
|
||||
</h2>
|
||||
<EventCockpitDeck eventPasses={eventPasses} />
|
||||
|
||||
{/* Delegate Session Drawer */}
|
||||
@ -111,18 +108,10 @@ export const SessionsPage = ({
|
||||
<WorkshopDrawer apps={apps} />
|
||||
</div>
|
||||
|
||||
<EventGuestsDrawer />
|
||||
<EventAttendeesDrawer />
|
||||
|
||||
<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) */}
|
||||
<SessionTable
|
||||
sessions={sessions}
|
||||
|
||||
49
ui/components/sessions/EventAttendeesDrawer.tsx
Normal file
49
ui/components/sessions/EventAttendeesDrawer.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
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,342 +3,186 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
|
||||
return (
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<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;">
|
||||
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0; color: var(--text-primary);">
|
||||
Event Passes
|
||||
</h2>
|
||||
<div style="display: flex; background: var(--surface-muted); padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 2px;">
|
||||
<button
|
||||
type="button"
|
||||
id="viewModeGrid"
|
||||
class="view-mode-btn active"
|
||||
onclick="setEventViewMode('grid')"
|
||||
aria-label="Grid View"
|
||||
<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;">
|
||||
Event Passes
|
||||
</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem;">
|
||||
{eventPasses.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
class="card"
|
||||
style="border-left: 4px solid var(--primary); margin: 0;"
|
||||
>
|
||||
🗂️ Grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="viewModeCompact"
|
||||
class="view-mode-btn"
|
||||
onclick="setEventViewMode('compact')"
|
||||
aria-label="Compact View"
|
||||
>
|
||||
📋 Compact
|
||||
</button>
|
||||
</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 style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
||||
<div>
|
||||
<h3 style="margin: 0 0 0.25rem 0; font-size: 1.1rem; color: var(--text-primary);">
|
||||
{event.name}
|
||||
</h3>
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
Expires: {new Date(event.expires_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge badge-success">Active</span>
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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, "\\'")
|
||||
}')`}
|
||||
<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
|
||||
}%;`}
|
||||
>
|
||||
👥 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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,88 +0,0 @@
|
||||
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,23 +43,11 @@ export const SessionDeck = (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
{isAgent
|
||||
? <span class="badge badge-info">Delegated</span>
|
||||
: isCurrent
|
||||
? <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>
|
||||
{isAgent
|
||||
? <span class="badge badge-info">Delegated</span>
|
||||
: isCurrent
|
||||
? <span class="badge badge-success">Active Now</span>
|
||||
: <span class="badge badge-secondary">Active</span>}
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;">
|
||||
@ -70,16 +58,8 @@ export const SessionDeck = (
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<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>
|
||||
<strong>Expires:</strong>{" "}
|
||||
{new Date(session.expires_at).toLocaleString()}
|
||||
</div>
|
||||
{session.last_activity_action && (
|
||||
<div>
|
||||
@ -91,28 +71,40 @@ export const SessionDeck = (
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAgent && (
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
{isAgent && (
|
||||
<>
|
||||
<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
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
|
||||
onclick={`extendSession('${session.id}', 1)`}
|
||||
class="btn-danger revoke-btn"
|
||||
data-session-id={session.id}
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
>
|
||||
+1h Extend
|
||||
Revoke
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@ -93,16 +93,7 @@ export const SessionTable = (
|
||||
)}
|
||||
</td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
<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>
|
||||
{new Date(session.expires_at).toLocaleString()}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.35rem; align-items: center;">
|
||||
|
||||
@ -314,47 +314,14 @@ export const SessionsScript = () => {
|
||||
}
|
||||
|
||||
function closeAttendeesDrawer() {
|
||||
const drawer = document.getElementById('attendeesDrawer');
|
||||
if (drawer) {
|
||||
drawer.classList.remove('open');
|
||||
const panel = drawer.querySelector('.drawer-panel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
// Delay hiding until animation finishes
|
||||
setTimeout(() => {
|
||||
drawer.style.display = 'none';
|
||||
}, 250);
|
||||
}
|
||||
document.getElementById('attendeesDrawer').style.display = 'none';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const drawer = document.getElementById('attendeesDrawer');
|
||||
if (drawer && drawer.classList.contains('open')) {
|
||||
closeAttendeesDrawer();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const drawer = document.getElementById('attendeesDrawer');
|
||||
if (drawer && drawer.classList.contains('open') && e.target === drawer) {
|
||||
closeAttendeesDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
async function openAttendeesDrawer(eventId, eventName) {
|
||||
document.getElementById('guestDrawerTitle').textContent = eventName + ' Guests';
|
||||
document.getElementById('attendeesDrawerEventName').textContent = eventName;
|
||||
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">Loading attendees...</div>';
|
||||
|
||||
const drawer = document.getElementById('attendeesDrawer');
|
||||
if (drawer) {
|
||||
drawer.style.display = 'block';
|
||||
// Force reflow
|
||||
drawer.offsetHeight;
|
||||
drawer.classList.add('open');
|
||||
const panel = drawer.querySelector('.drawer-panel');
|
||||
if (panel) panel.classList.add('open');
|
||||
}
|
||||
document.getElementById('attendeesDrawer').style.display = 'block';
|
||||
document.getElementById('attendeesDrawer').scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/events/' + eventId + '/attendees');
|
||||
@ -362,20 +329,6 @@ 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 = '<div style="padding: 1rem; text-align: center; color: var(--text-muted);">No attendees currently active.</div>';
|
||||
return;
|
||||
@ -384,36 +337,18 @@ export const SessionsScript = () => {
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
|
||||
for (const att of attendees) {
|
||||
const isPaused = att.is_paused === true;
|
||||
// e.g. guest_deno-lab_1 -> Seat #1
|
||||
const usernameParts = att.username.split('_');
|
||||
const seatNumber = usernameParts.length > 2 ? usernameParts[usernameParts.length - 1] : '?';
|
||||
|
||||
// Compute relative join time (approximate based on created_at)
|
||||
const createdDate = new Date(att.created_at);
|
||||
const now = new Date();
|
||||
const diffMs = now - createdDate;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const joinText = diffMins < 1 ? 'Joined just now' : 'Joined ' + diffMins + 'm ago';
|
||||
|
||||
html += \`
|
||||
<div class="card" style="padding: 0.75rem; margin: 0; display: flex; justify-content: space-between; align-items: center; border-left: 3px solid \${isPaused ? 'var(--warning)' : 'var(--success)'}; opacity: \${isPaused ? '0.7' : '1'};">
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
|
||||
<div style="font-weight: 600; font-size: 0.95rem; color: var(--text-primary); \${isPaused ? 'text-decoration: line-through;' : ''}">Seat #\${seatNumber}</div>
|
||||
<div style="font-size: 0.75rem; padding: 2px 6px; border-radius: 4px; background: \${isPaused ? 'var(--warning-light)' : 'var(--success-light)'}; color: \${isPaused ? 'var(--warning)' : 'var(--success)'};">
|
||||
\${isPaused ? '⏸️ Paused' : '🟢 Active'}
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary);" title="\${att.username}">
|
||||
\${joinText} · \${att.username}
|
||||
</div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem; color: var(--text-primary); \${isPaused ? 'text-decoration: line-through;' : ''}">\${att.username}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary);">Expires: \${new Date(att.expires_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="button" class="btn-outline" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;" onclick="toggleSessionPause('\${att.id}', \${!isPaused}, '\${eventId}', '\${eventName}')">
|
||||
\${isPaused ? '▶️ Resume' : '⏸️ Pause'}
|
||||
\${isPaused ? '▶️ Unpause' : '⏸️ Pause'}
|
||||
</button>
|
||||
<button type="button" class="btn-danger revoke-btn" data-session-id="\${att.id}" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;" aria-label="Revoke">
|
||||
🗑️ Revoke
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -561,86 +496,6 @@ 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