From 8c8df9ff98093315d70e349060e56abb034dd1f9 Mon Sep 17 00:00:00 2001 From: Simon Woolf Date: Wed, 3 Jun 2026 14:09:26 +0100 Subject: [PATCH 1/2] feat(examples): add pub-sub live-voting example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an interactive "Live voting" example at /examples/pub-sub-live-voting demonstrating message annotations: a poll is a message, each vote is a `vote:unique.v1` annotation, and Ably aggregates votes into a summary. The live widget shows voter + presenter panes side by side (d-pad poll auto-started via a sandbox-only bootstrap that seeds a few votes). The full app — admin/voter/presenter client plus an Express server (role-scoped JWT auth, shows API, static SHOWS_FILE and Postgres stores) — ships under the example so it can be cloned and run as a real voting app. Wiring: register the example, give its two preview panes per-role start routes and Voter/Presenter labels, and add the qrcode dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/package.json | 4 + .../pub-sub-live-voting/javascript/.gitignore | 3 + .../pub-sub-live-voting/javascript/README.md | 189 +++ .../javascript/data/demo-shows.json | 38 + .../database/migrations/001_poll_types.sql | 7 + .../migrations/002_suggest_poll_type.sql | 3 + .../javascript/database/seed.sql | 18 + .../javascript/database/setup.sql | 24 + .../pub-sub-live-voting/javascript/index.html | 19 + .../javascript/package.json | 11 + .../javascript/server/.env.example | 14 + .../javascript/server/package.json | 26 + .../javascript/server/src/db.ts | 177 +++ .../javascript/server/src/server.ts | 326 ++++ .../javascript/server/src/showStore.ts | 54 + .../javascript/server/src/staticStore.ts | 92 ++ .../javascript/server/tsconfig.json | 15 + .../javascript/src/admin.ts | 986 +++++++++++++ .../javascript/src/config.ts | 22 + .../javascript/src/demo-bootstrap.ts | 102 ++ .../javascript/src/demo-shows.json | 38 + .../javascript/src/presenter.ts | 316 ++++ .../javascript/src/script.ts | 25 + .../javascript/src/shared/ably.ts | 86 ++ .../javascript/src/shared/chart.ts | 60 + .../javascript/src/shared/clientId.ts | 16 + .../javascript/src/shared/controller.ts | 140 ++ .../javascript/src/shared/errors.ts | 22 + .../javascript/src/shared/format.ts | 13 + .../javascript/src/shared/sessionId.ts | 31 + .../javascript/src/shared/types.ts | 63 + .../javascript/src/styles.css | 1309 +++++++++++++++++ .../javascript/src/voter.ts | 442 ++++++ .../javascript/tailwind.config.ts | 9 + .../javascript/tsconfig.json | 5 + .../javascript/vite-env.d.ts | 9 + .../javascript/vite.config.ts | 16 + examples/yarn.lock | 145 +- gatsby-config.ts | 5 + src/components/Examples/ExamplesRenderer.tsx | 15 +- src/data/examples/index.ts | 18 + src/images/examples/pub-sub-live-voting.png | Bin 0 -> 30935 bytes 42 files changed, 4910 insertions(+), 3 deletions(-) create mode 100644 examples/pub-sub-live-voting/javascript/.gitignore create mode 100644 examples/pub-sub-live-voting/javascript/README.md create mode 100644 examples/pub-sub-live-voting/javascript/data/demo-shows.json create mode 100644 examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql create mode 100644 examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql create mode 100644 examples/pub-sub-live-voting/javascript/database/seed.sql create mode 100644 examples/pub-sub-live-voting/javascript/database/setup.sql create mode 100644 examples/pub-sub-live-voting/javascript/index.html create mode 100644 examples/pub-sub-live-voting/javascript/package.json create mode 100644 examples/pub-sub-live-voting/javascript/server/.env.example create mode 100644 examples/pub-sub-live-voting/javascript/server/package.json create mode 100644 examples/pub-sub-live-voting/javascript/server/src/db.ts create mode 100644 examples/pub-sub-live-voting/javascript/server/src/server.ts create mode 100644 examples/pub-sub-live-voting/javascript/server/src/showStore.ts create mode 100644 examples/pub-sub-live-voting/javascript/server/src/staticStore.ts create mode 100644 examples/pub-sub-live-voting/javascript/server/tsconfig.json create mode 100644 examples/pub-sub-live-voting/javascript/src/admin.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/config.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/demo-shows.json create mode 100644 examples/pub-sub-live-voting/javascript/src/presenter.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/script.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/shared/ably.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/shared/chart.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/shared/clientId.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/shared/controller.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/shared/errors.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/shared/format.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/shared/sessionId.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/shared/types.ts create mode 100644 examples/pub-sub-live-voting/javascript/src/styles.css create mode 100644 examples/pub-sub-live-voting/javascript/src/voter.ts create mode 100644 examples/pub-sub-live-voting/javascript/tailwind.config.ts create mode 100644 examples/pub-sub-live-voting/javascript/tsconfig.json create mode 100644 examples/pub-sub-live-voting/javascript/vite-env.d.ts create mode 100644 examples/pub-sub-live-voting/javascript/vite.config.ts create mode 100644 src/images/examples/pub-sub-live-voting.png diff --git a/examples/package.json b/examples/package.json index 2bc7219d0f..b8a58f6e4e 100644 --- a/examples/package.json +++ b/examples/package.json @@ -48,6 +48,7 @@ "pub-sub-rewind/react", "pub-sub-rewind/javascript", "pub-sub-message-annotations/javascript", + "pub-sub-live-voting/javascript", "spaces-avatar-stack/react", "spaces-avatar-stack/javascript", "spaces-component-locking/react", @@ -100,6 +101,7 @@ "pub-sub-rewind-javascript": "yarn workspace pub-sub-rewind-javascript dev", "pub-sub-rewind-react": "yarn workspace pub-sub-rewind-react dev", "pub-sub-message-annotations-javascript": "yarn workspace pub-sub-message-annotations-javascript dev", + "pub-sub-live-voting-javascript": "yarn workspace pub-sub-live-voting-javascript dev", "spaces-avatar-stack-javascript": "yarn workspace spaces-avatar-stack-javascript dev", "spaces-avatar-stack-react": "yarn workspace spaces-avatar-stack-react dev", "spaces-component-locking-javascript": "yarn workspace spaces-component-locking-javascript dev", @@ -119,6 +121,7 @@ "lodash": "^4.18.1", "minifaker": "^1.34.1", "nanoid": "^5.0.7", + "qrcode": "^1.5.4", "react": "^18", "react-dom": "^18", "react-icons": "^5.4.0", @@ -130,6 +133,7 @@ "@tailwindcss/postcss": "^4.0.14", "@types/express": "^5.0.0", "@types/node": "^20", + "@types/qrcode": "^1.5.5", "@types/react": "^18", "@types/react-dom": "^18", "@types/uikit": "^3.7.0", diff --git a/examples/pub-sub-live-voting/javascript/.gitignore b/examples/pub-sub-live-voting/javascript/.gitignore new file mode 100644 index 0000000000..804bd4a4ae --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.local diff --git a/examples/pub-sub-live-voting/javascript/README.md b/examples/pub-sub-live-voting/javascript/README.md new file mode 100644 index 0000000000..a8ebef68c8 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/README.md @@ -0,0 +1,189 @@ +# Build live voting with message annotations + +This is a live-voting app: an admin runs a show of polls, an audience votes +from their phones, and a big screen shows results updating in realtime. The +voter pane and presenter pane on the left are both live: cast a vote and watch +the presenter's heatmap and vote bubbles react instantly. + +The design pattern this example highlights is how a vote is represented. A poll +is a regular Ably **message**. Each vote is an **annotation** attached to that +message. Ably aggregates the annotations into a **summary**, attached to the +original poll message, and delivers it to subscribers. Votes do not need to be +stored in a database, and your own server is only needed to autenticate +clients, all the realtime low-latency vote distribution and counting is done by +Ably. + +## Why annotations? + +There are a few different patterns you could use to build a voting app. +Firstly, of course, you could just use regular messages. Secondly, you could +use [LiveObjects](https://ably.com/docs/liveobjects), an ably feature which +attaches a synchonized, conflict-free, concurrently-modifyable state object to +a channel. Either would be an excellent choice, but +[annotations](https://ably.com/docs/messages/annotations) have some advantages +that make it well-suited to this use-case: + +**Summaries and raw events are separate streams, so each view subscribes to +what it needs.** Voters subscribe to the annotation *summary* only — one +compact, regularly-rolled-up object (`{ optionId: { total } }`) with enough information to +show live percentages on every phone. A busy poll is one cheap summary update +per phone, not one event per vote. The presenter view, however, does want to +subscribe to individual annotation events, one per vote, so it can to animate a +bubble for each (together with the user id of the person who made that vote). +It opts into that higher-volume stream separately by specifying the +[`ANNOTATION_SUBSCRIBE` channel +mode](https://ably.com/docs/messages/annotations#individual-annotations) and +add a listener with with `annotations.subscribe()`. Same data, two consumption +modes, each scoped to the view that needs it. + +**Capabilities keep voters locked down.** A voter's token grants only the +`annotation-publish` capability on the channel. A voter can cast votes but +cannot publish poll messages, and cannot subscribe to other voters' raw +annotations, so it can't tamper with the poll or snoop on individual votes. See +the per-role capabilities in +[`server/src/server.ts`](https://github.com/ably/docs/tree/main/examples/pub-sub-live-voting/javascript/server/src/server.ts). + +**One vote per person, enforced by Ably.** Votes use the `unique` aggregation +(`vote:unique.v1`). In this aggregation mode, Ably keeps at most one vote per +`clientId`, and moving to another option moves that client's vote rather than +adding a second one. You get "one person, one vote" semantics for free. + +If building on [LiveObjects](/docs/liveobjects), you'd use a `LiveCounter` per +option, but there's no built-in one-vote-per-client constraint — any client can +call `increment` as many times as it likes. You could add a separate `LiveMap` +tracking `clientId → vote` and check it before allowing a vote, but there's no +atomic check-and-increment. Of course, LiveObjects have other advantages -- and +if some part of your app needs the features of liveobjects, you can of course +use a LiveObject on the same channel as well. + +## How it works + +1. The admin starts a poll by publishing a message: + + ```javascript + await channel.publish('poll', { pollId, question, type, options }); + ``` + +2. A voter attaches a `vote:unique.v1` annotation to that message's `serial`, + naming the chosen option: + + ```javascript + await channel.annotations.publish(pollSerial, { + type: 'vote:unique.v1', + name: optionId, + }); + ``` + +3. Ably aggregates the votes and delivers a summary on the poll message. Voters + read it to render live percentages: + + ```javascript + channel.subscribe((message) => { + const summary = message.annotations?.summary?.['vote:unique.v1']; + // summary[optionId].total === votes for that option + }); + ``` + +4. The presenter additionally subscribes to the individual events for its vote + and suggestion bubbles: + + ```javascript + channel.annotations.subscribe('vote:unique.v1', (annotation) => { + // one event per vote — annotation.name is the option, annotation.clientId the voter + }); + ``` + +The annotation type is written `namespace:summarization.version` — here +`vote`, `unique`, `v1`. Read more about the aggregation types (`unique`, +`distinct`, `multiple`, `total`, `flag`) in +[Message annotations](/docs/messages/annotations). + +The channel is attached with `rewind: 1`, so a phone that joins (or refreshes) +mid-poll immediately receives the current poll and its latest summary without +the admin re-publishing. + +## Unaggregated annotations + +Open-ended "suggest" polls (where voters type free text instead of picking an +option) use a second annotation type, `suggestion`, with no aggregation suffix. +Votes need a running tally, so they use `unique` and are read from the summary. +A suggestion is a one-off piece of text that floats across the presenter once +and is gone, with nothing to count. Since they are unaggregated, they are +received by anyone subscribing to annotations, and they are retrievable through +anyone using +[annotations.get()](https://ably.com/docs/messages/annotations#retrieve) to +retrieve a list of annotations for a given message, but they don't result in a +new message summary being generated. + +## Server-side batching + +For very high usecases, where individual votes might (say) overwhelm the +presenter view's message rate limits, you can turn on [server-side +batching](/docs/messages/batch) for the `voting` channel namespace so Ably +groups messages over a short interval (for example 100ms) before fanning them +out. Each batch counts as a single message, which keeps cost and rate-limits in +check during surges with only a small, configurable delay. Create the rule in +your app settings, or via the Control API / CLI: + +```shell +ably apps rules create --name "voting" --batching-enabled --batching-interval 100 +``` + +## Required channel rule + +Annotations require the *Message annotations, updates, deletes, and appends* +rule to be enabled on the channel or namespace. Enable it for the `voting` +namespace before running this against your own app. + +## Getting started + +The live demo above runs entirely in the browser. The full app — the +voter/presenter/admin client plus the backend (`server/`), the poll data +(`data/`) and the database schema (`database/`) — all lives under this +`javascript/` folder, so "Open in CodeSandbox" gives you the whole project. To +run it locally: + +1. Clone the [Ably docs](https://github.com/ably/docs) repository: + + ```sh + git clone git@github.com:ably/docs.git + cd docs/examples/pub-sub-live-voting/javascript + ``` + +2. Run the client. From the docs repo's `examples/` directory: + + ```sh + yarn install + yarn pub-sub-live-voting-javascript # Vite dev server on http://localhost:5173 + ``` + + Vite proxies `/auth` and `/api` to the server on port 3000. + +3. Run the server (in a separate terminal). It's a standalone sub-project: + + ```sh + cd examples/pub-sub-live-voting/javascript/server + cp .env.example .env # set ABLY_API_KEY (keyName:keySecret) and ADMIN_PASSWORD + npm install + npm run dev # auth + polls API on http://localhost:3000 + ``` + + By default the server reads polls from the static `data/demo-shows.json` file + (`SHOWS_FILE`); to use Postgres instead, unset `SHOWS_FILE` and create the + schema with the SQL in `database/`. + +4. Open the client. The default view is the voter; add `?role=admin` to drive + the show and `?role=presenter` for the big screen. The admin generates a + session and a QR code that points voters at the right `?s=` session. + +In this clone the client never sees an API key — it calls the server's `/auth` +endpoint for short-lived, role-scoped tokens. The hosted demo above has no +backend, so it uses a raw key embedded in the page; this is just for demo +purposes and should never be done in the real app. + +## Related + +- [Message annotations](/docs/messages/annotations) — the full feature reference. +- [Message annotations example](/examples/pub-sub-message-annotations) — a + lower-level tour of all five aggregation types. +- [Message batching](/docs/messages/batch) — scaling high-throughput channels. diff --git a/examples/pub-sub-live-voting/javascript/data/demo-shows.json b/examples/pub-sub-live-voting/javascript/data/demo-shows.json new file mode 100644 index 0000000000..ce877c4447 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/data/demo-shows.json @@ -0,0 +1,38 @@ +{ + "shows": [ + { + "id": 1, + "name": "Ably Live Voting demo", + "polls": [ + { + "id": 1, + "question": "Which real-time feature do you reach for most?", + "type": "list", + "options": [ + { "id": 101, "label": "Pub/Sub channels" }, + { "id": 102, "label": "Presence" }, + { "id": 103, "label": "Message history" }, + { "id": 104, "label": "Push notifications" } + ] + }, + { + "id": 2, + "question": "Rate this demo with the d-pad", + "type": "dpad", + "options": [ + { "id": 201, "label": "Love it", "direction": "up" }, + { "id": 202, "label": "It's neat", "direction": "right" }, + { "id": 203, "label": "Meh", "direction": "down" }, + { "id": 204, "label": "Confused", "direction": "left" } + ] + }, + { + "id": 3, + "question": "What should we build with Ably next?", + "type": "suggest", + "options": [] + } + ] + } + ] +} diff --git a/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql b/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql new file mode 100644 index 0000000000..0eaa99d8b7 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql @@ -0,0 +1,7 @@ +ALTER TABLE polls + ADD COLUMN IF NOT EXISTS type TEXT NOT NULL DEFAULT 'list' + CHECK (type IN ('list', 'dpad')); + +ALTER TABLE poll_options + ADD COLUMN IF NOT EXISTS direction TEXT + CHECK (direction IS NULL OR direction IN ('up', 'right', 'down', 'left')); diff --git a/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql b/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql new file mode 100644 index 0000000000..d2d180a737 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql @@ -0,0 +1,3 @@ +ALTER TABLE polls DROP CONSTRAINT IF EXISTS polls_type_check; +ALTER TABLE polls ADD CONSTRAINT polls_type_check + CHECK (type IN ('list', 'dpad', 'suggest')); diff --git a/examples/pub-sub-live-voting/javascript/database/seed.sql b/examples/pub-sub-live-voting/javascript/database/seed.sql new file mode 100644 index 0000000000..633bd85c27 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/database/seed.sql @@ -0,0 +1,18 @@ +-- Optional sample data for the Postgres store. The d-pad and suggest poll +-- types make the presenter view more expressive than list polls alone. +INSERT INTO shows (name) VALUES ('Ably Live Voting demo'); + +INSERT INTO polls (show_id, question, type, sort_order) VALUES + (1, 'Which real-time feature do you reach for most?', 'list', 0), + (1, 'Rate this demo with the d-pad', 'dpad', 1), + (1, 'What should we build with Ably next?', 'suggest', 2); + +INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES + (1, 'Pub/Sub channels', NULL, 0), + (1, 'Presence', NULL, 1), + (1, 'Message history', NULL, 2), + (1, 'Push notifications', NULL, 3), + (2, 'Love it', 'up', 0), + (2, 'It''s neat', 'right', 1), + (2, 'Meh', 'down', 2), + (2, 'Confused', 'left', 3); diff --git a/examples/pub-sub-live-voting/javascript/database/setup.sql b/examples/pub-sub-live-voting/javascript/database/setup.sql new file mode 100644 index 0000000000..d71ade40bc --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/database/setup.sql @@ -0,0 +1,24 @@ +CREATE TABLE shows ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL +); + +CREATE TABLE polls ( + id SERIAL PRIMARY KEY, + show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + question TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + type TEXT NOT NULL DEFAULT 'list' + CHECK (type IN ('list', 'dpad', 'suggest')) +); + +CREATE TABLE poll_options ( + id SERIAL PRIMARY KEY, + poll_id INTEGER NOT NULL REFERENCES polls(id) ON DELETE CASCADE, + label TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + direction TEXT + CHECK (direction IS NULL OR direction IN ( + 'up', 'right', 'down', 'left' + )) +); diff --git a/examples/pub-sub-live-voting/javascript/index.html b/examples/pub-sub-live-voting/javascript/index.html new file mode 100644 index 0000000000..cf21dc306f --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + Ably Live Voting + + + + + + + + diff --git a/examples/pub-sub-live-voting/javascript/package.json b/examples/pub-sub-live-voting/javascript/package.json new file mode 100644 index 0000000000..3425e78671 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/package.json @@ -0,0 +1,11 @@ +{ + "name": "pub-sub-live-voting-javascript", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + } +} diff --git a/examples/pub-sub-live-voting/javascript/server/.env.example b/examples/pub-sub-live-voting/javascript/server/.env.example new file mode 100644 index 0000000000..573433b69c --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/.env.example @@ -0,0 +1,14 @@ +# Your Ably API key, in "keyName:keySecret" form. Used to sign role-scoped JWTs. +ABLY_API_KEY= + +# Password the admin console must supply to drive a show. +ADMIN_PASSWORD=changeme + +# Read polls from a static JSON file (no database). Omit to use Postgres. +# Path is resolved relative to the server's working directory. +SHOWS_FILE=../data/demo-shows.json + +# Postgres connection string (only used when SHOWS_FILE is unset). +# DATABASE_URL=postgresql://localhost/ably_voting + +# PORT=3000 diff --git a/examples/pub-sub-live-voting/javascript/server/package.json b/examples/pub-sub-live-voting/javascript/server/package.json new file mode 100644 index 0000000000..eb1e196fd4 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/package.json @@ -0,0 +1,26 @@ +{ + "name": "pub-sub-live-voting-server", + "version": "1.0.0", + "type": "module", + "main": "src/server.ts", + "license": "MIT", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "tsx src/server.ts" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.0", + "jsonwebtoken": "^9.0.0", + "pg": "^8.20.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.0", + "@types/node": "^20", + "@types/pg": "^8.20.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/examples/pub-sub-live-voting/javascript/server/src/db.ts b/examples/pub-sub-live-voting/javascript/server/src/db.ts new file mode 100644 index 0000000000..47ac7be578 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/src/db.ts @@ -0,0 +1,177 @@ +import pg from 'pg'; +import type { OptionInput, ShowStore } from './showStore.js'; + +// Lazily constructed so that static (SHOWS_FILE) deployments never open — or +// even configure — a database connection. +let _pool: pg.Pool | null = null; +function pool(): pg.Pool { + if (!_pool) { + _pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL || 'postgresql://localhost/ably_voting', + }); + } + return _pool; +} + +async function getShows() { + const { rows } = await pool().query(` + SELECT s.id, s.name, COUNT(p.id)::int AS poll_count + FROM shows s + LEFT JOIN polls p ON p.show_id = s.id + GROUP BY s.id + ORDER BY s.id + `); + return rows; +} + +async function createShow(name: string) { + const { rows } = await pool().query( + 'INSERT INTO shows (name) VALUES ($1) RETURNING id, name', + [name], + ); + return rows[0]; +} + +async function updateShow(id: number, name: string) { + const { rowCount } = await pool().query( + 'UPDATE shows SET name = $1 WHERE id = $2', + [name, id], + ); + return rowCount! > 0; +} + +async function deleteShow(id: number) { + const { rowCount } = await pool().query('DELETE FROM shows WHERE id = $1', [id]); + return rowCount! > 0; +} + +async function getShowPolls(showId: number) { + const showResult = await pool().query('SELECT id, name FROM shows WHERE id = $1', [showId]); + if (showResult.rows.length === 0) return null; + + const pollRows = await pool().query( + 'SELECT id, question, type, sort_order FROM polls WHERE show_id = $1 ORDER BY sort_order, id', + [showId], + ); + + const optionRows = await pool().query( + `SELECT po.id, po.poll_id, po.label, po.direction, po.sort_order + FROM poll_options po + JOIN polls p ON p.id = po.poll_id + WHERE p.show_id = $1 + ORDER BY po.sort_order, po.id`, + [showId], + ); + + const optionsByPoll = new Map>(); + for (const opt of optionRows.rows) { + if (!optionsByPoll.has(opt.poll_id)) optionsByPoll.set(opt.poll_id, []); + optionsByPoll.get(opt.poll_id)!.push({ + id: opt.id, label: opt.label, direction: opt.direction, sort_order: opt.sort_order, + }); + } + + const polls = pollRows.rows.map((p) => ({ + id: p.id, + question: p.question, + type: p.type, + sort_order: p.sort_order, + options: optionsByPoll.get(p.id) ?? [], + })); + + return { show: showResult.rows[0], polls }; +} + +async function createPoll(showId: number, type: string, question: string, options: OptionInput[]) { + const client = await pool().connect(); + try { + await client.query('BEGIN'); + + const maxOrder = await client.query( + 'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM polls WHERE show_id = $1', + [showId], + ); + + const pollResult = await client.query( + 'INSERT INTO polls (show_id, question, type, sort_order) VALUES ($1, $2, $3, $4) RETURNING id', + [showId, question, type, maxOrder.rows[0].next], + ); + const pollId = pollResult.rows[0].id; + + for (let i = 0; i < options.length; i++) { + await client.query( + 'INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES ($1, $2, $3, $4)', + [pollId, options[i].label, options[i].direction ?? null, i], + ); + } + + await client.query('COMMIT'); + return pollId; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +async function updatePoll(pollId: number, type: string, question: string, options: OptionInput[]) { + const client = await pool().connect(); + try { + await client.query('BEGIN'); + + await client.query('UPDATE polls SET question = $1, type = $2 WHERE id = $3', [question, type, pollId]); + await client.query('DELETE FROM poll_options WHERE poll_id = $1', [pollId]); + + for (let i = 0; i < options.length; i++) { + await client.query( + 'INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES ($1, $2, $3, $4)', + [pollId, options[i].label, options[i].direction ?? null, i], + ); + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +async function deletePoll(pollId: number) { + const { rowCount } = await pool().query('DELETE FROM polls WHERE id = $1', [pollId]); + return rowCount! > 0; +} + +async function reorderPolls(showId: number, pollIds: number[]) { + const client = await pool().connect(); + try { + await client.query('BEGIN'); + for (let i = 0; i < pollIds.length; i++) { + await client.query( + 'UPDATE polls SET sort_order = $1 WHERE id = $2 AND show_id = $3', + [i, pollIds[i], showId], + ); + } + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +export const pgStore: ShowStore = { + readOnly: false, + getShows, + getShowPolls, + createShow, + updateShow, + deleteShow, + createPoll, + updatePoll, + deletePoll, + reorderPolls, +}; diff --git a/examples/pub-sub-live-voting/javascript/server/src/server.ts b/examples/pub-sub-live-voting/javascript/server/src/server.ts new file mode 100644 index 0000000000..a0b586fb57 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/src/server.ts @@ -0,0 +1,326 @@ +import express from 'express'; +import cors from 'cors'; +import jwt from 'jsonwebtoken'; +import path from 'path'; +import { existsSync } from 'fs'; +import { fileURLToPath } from 'url'; +import type { OptionInput, ShowStore } from './showStore.js'; +import { pgStore } from './db.js'; +import { createStaticStore } from './staticStore.js'; + +// The backend has two jobs: +// 1. Issue short-lived, role-scoped Ably tokens (GET /auth). This is the +// important bit for the tutorial — a voter token can publish annotations +// and nothing else. +// 2. Serve the poll definitions to the admin (GET /api/shows...), from either +// a static JSON file (SHOWS_FILE) or Postgres. +// It never touches votes — those live entirely as Ably annotations. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const ABLY_API_KEY = process.env.ABLY_API_KEY; +if (!ABLY_API_KEY) { + console.error('ABLY_API_KEY environment variable is required'); + process.exit(1); +} + +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; +if (!ADMIN_PASSWORD) { + console.error('ADMIN_PASSWORD environment variable is required'); + process.exit(1); +} + +const [keyName, keySecret] = ABLY_API_KEY.split(':'); + +// Data source: a JSON file (read-only, no database) when SHOWS_FILE is set, +// otherwise Postgres. Resolved relative to the working directory. +const SHOWS_FILE = process.env.SHOWS_FILE; +let store: ShowStore; +try { + store = SHOWS_FILE ? createStaticStore(path.resolve(SHOWS_FILE)) : pgStore; +} catch (err: any) { + console.error(err.message); + process.exit(1); +} +console.log(store.readOnly + ? `Using read-only static show data from ${SHOWS_FILE}` + : 'Using Postgres show store'); + +const app = express(); + +// The client is a separate Vite app. In production it can be served from the +// same origin (see the static block below); in development it runs on Vite's +// dev server and reaches the API through Vite's proxy. CORS keeps direct +// cross-origin calls working too. +app.use(cors({ origin: true, methods: ['GET', 'POST', 'PUT', 'DELETE'] })); +app.use(express.json()); + +function fail(res: express.Response, label: string, err: any) { + console.error(`${label} failed:`, err); + const message = err?.message || err?.code || String(err); + res.status(500).json({ error: message }); +} + +// Basic auth middleware for admin routes. Only the password half is checked — +// the client sends `Basic base64("admin:")`. +function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) { + const auth = req.headers.authorization; + if (!auth?.startsWith('Basic ')) { + res.set('WWW-Authenticate', 'Basic realm="Admin"'); + res.status(401).send('Authentication required'); + return; + } + const decoded = Buffer.from(auth.slice(6), 'base64').toString(); + const password = decoded.split(':').slice(1).join(':'); + if (password !== ADMIN_PASSWORD) { + res.set('WWW-Authenticate', 'Basic realm="Admin"'); + res.status(401).send('Invalid password'); + return; + } + next(); +} + +// Reject writes when the active store is read-only (static demo data). +function requireWritable(_req: express.Request, res: express.Response, next: express.NextFunction) { + if (store.readOnly) { + res.status(403).json({ error: 'This is a read-only demo — editing is disabled.' }); + return; + } + next(); +} + +// Lets the admin client discover whether editing is available (static vs db). +app.get('/api/config', (_req, res) => { + res.json({ readOnly: store.readOnly }); +}); + +// Admin API — all behind basic auth +app.get('/api/shows', requireAdmin, async (_req, res) => { + try { + res.json(await store.getShows()); + } catch (err: any) { + fail(res, 'GET /api/shows', err); + } +}); + +app.post('/api/shows', requireAdmin, requireWritable, async (req, res) => { + const { name } = req.body; + if (!name) { res.status(400).json({ error: 'Missing name' }); return; } + try { + res.json(await store.createShow(name)); + } catch (err: any) { + fail(res, 'POST /api/shows', err); + } +}); + +app.put('/api/shows/:id', requireAdmin, requireWritable, async (req, res) => { + const { name } = req.body; + if (!name) { res.status(400).json({ error: 'Missing name' }); return; } + try { + const ok = await store.updateShow(Number(req.params.id), name); + if (!ok) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ ok: true }); + } catch (err: any) { + fail(res, `PUT /api/shows/${req.params.id}`, err); + } +}); + +app.delete('/api/shows/:id', requireAdmin, requireWritable, async (req, res) => { + try { + const ok = await store.deleteShow(Number(req.params.id)); + if (!ok) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ ok: true }); + } catch (err: any) { + fail(res, `DELETE /api/shows/${req.params.id}`, err); + } +}); + +// Polls within a show — read endpoint is public (presenter needs it) +app.get('/api/shows/:showId/polls', async (req, res) => { + try { + const result = await store.getShowPolls(Number(req.params.showId)); + if (!result) { res.status(404).json({ error: 'Show not found' }); return; } + // Convert numeric option IDs to strings for Ably annotation compatibility + const polls = result.polls.map((p) => ({ + ...p, + options: p.options.map((o) => ({ + id: String(o.id), + label: o.label, + ...(o.direction ? { direction: o.direction } : {}), + })), + })); + res.json({ show: result.show, polls }); + } catch (err: any) { + fail(res, `GET /api/shows/${req.params.showId}/polls`, err); + } +}); + +const DPAD_SLOTS = new Set(['up', 'right', 'down', 'left']); + +const VALID_TYPES = new Set(['list', 'dpad', 'suggest']); +const ALL_SLOTS = new Set(DPAD_SLOTS); + +function validatePollInput(body: any): { type: string; question: string; options: OptionInput[] } | string { + const { question } = body; + const type = body.type ?? 'list'; + if (!VALID_TYPES.has(type)) return 'Invalid poll type'; + if (!question || typeof question !== 'string') return 'Missing question'; + + if (type === 'suggest') { + return { type, question, options: [] }; + } + + const options = body.options; + if (!Array.isArray(options) || options.length === 0) return 'Missing options'; + + const normalized: OptionInput[] = []; + const seenDirections = new Set(); + for (const o of options) { + if (typeof o === 'string') { + normalized.push({ label: o }); + } else if (o && typeof o.label === 'string') { + const direction = o.direction ?? null; + if (direction !== null) { + if (!ALL_SLOTS.has(direction)) return `Invalid direction: ${direction}`; + if (seenDirections.has(direction)) return `Duplicate direction: ${direction}`; + seenDirections.add(direction); + } + normalized.push({ label: o.label, direction }); + } else { + return 'Invalid option entry'; + } + } + + if (type === 'dpad') { + if (normalized.some((o) => !o.direction)) return 'Every d-pad option needs a slot'; + if (normalized.some((o) => o.direction && !DPAD_SLOTS.has(o.direction))) { + return 'Invalid slot for d-pad poll'; + } + if (normalized.length > DPAD_SLOTS.size) return `A d-pad poll has at most ${DPAD_SLOTS.size} options`; + } + + return { type, question, options: normalized }; +} + +app.post('/api/shows/:showId/polls', requireAdmin, requireWritable, async (req, res) => { + const parsed = validatePollInput(req.body); + if (typeof parsed === 'string') { res.status(400).json({ error: parsed }); return; } + try { + const pollId = await store.createPoll(Number(req.params.showId), parsed.type, parsed.question, parsed.options); + res.json({ id: pollId }); + } catch (err: any) { + fail(res, `POST /api/shows/${req.params.showId}/polls`, err); + } +}); + +app.put('/api/shows/:showId/polls/reorder', requireAdmin, requireWritable, async (req, res) => { + const { order } = req.body; + if (!Array.isArray(order)) { + res.status(400).json({ error: 'Missing order array' }); + return; + } + try { + await store.reorderPolls(Number(req.params.showId), order); + res.json({ ok: true }); + } catch (err: any) { + fail(res, `PUT /api/shows/${req.params.showId}/polls/reorder`, err); + } +}); + +app.put('/api/shows/:showId/polls/:pollId', requireAdmin, requireWritable, async (req, res) => { + const parsed = validatePollInput(req.body); + if (typeof parsed === 'string') { res.status(400).json({ error: parsed }); return; } + try { + await store.updatePoll(Number(req.params.pollId), parsed.type, parsed.question, parsed.options); + res.json({ ok: true }); + } catch (err: any) { + fail(res, `PUT /api/shows/${req.params.showId}/polls/${req.params.pollId}`, err); + } +}); + +app.delete('/api/shows/:showId/polls/:pollId', requireAdmin, requireWritable, async (req, res) => { + try { + const ok = await store.deletePoll(Number(req.params.pollId)); + if (!ok) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ ok: true }); + } catch (err: any) { + fail(res, `DELETE /api/shows/${req.params.showId}/polls/${req.params.pollId}`, err); + } +}); + +// ── Ably token auth ── +// +// The heart of the security model. Each role gets a JWT whose capabilities are +// scoped to exactly what that role needs on this one channel: +// - voter: annotation-publish only (cast votes; can't read others' votes +// or publish poll messages) +// - presenter: annotation-subscribe + subscribe (read-only) +// - admin: publish + subscribe + annotation-subscribe (drives the show) +// The clientId baked into the token is what makes `vote:unique.v1` enforce one +// vote per person. +app.get('/auth', (req, res) => { + const clientId = req.query.clientId as string; + const sessionId = req.query.sessionId as string; + const role = req.query.role as string; + + if (!clientId || !sessionId || !role) { + res.status(400).json({ error: 'Missing clientId, sessionId, or role' }); + return; + } + + if (role === 'admin') { + const password = req.query.password as string; + if (password !== ADMIN_PASSWORD) { + res.status(403).json('Invalid admin password'); + return; + } + } + + const channelName = `voting:${sessionId}`; + const capsByRole: Record = { + admin: ['publish', 'subscribe', 'annotation-subscribe'], + voter: ['subscribe', 'annotation-publish'], + presenter: ['subscribe', 'annotation-subscribe'], + }; + const caps = capsByRole[role]; + if (!caps) { + res.status(400).json({ error: 'Invalid role' }); + return; + } + const capabilities: Record = { [channelName]: caps }; + + const now = Math.floor(Date.now() / 1000); + const token = jwt.sign( + { + 'x-ably-capability': JSON.stringify(capabilities), + 'x-ably-clientId': clientId, + iat: now, + exp: now + 3600, + }, + keySecret, + { + header: { + typ: 'JWT', + alg: 'HS256', + kid: keyName, + }, + }, + ); + + res.set('Content-Type', 'application/jwt'); + res.send(token); +}); + +// Optionally serve the built client (../../dist, i.e. the javascript/ project +// root) from the same origin, so a production deploy needs only this one +// server. In development you'd run the Vite dev server instead and let it proxy +// the API here. +const clientDist = path.resolve(__dirname, '../../dist'); +if (existsSync(clientDist)) { + app.use(express.static(clientDist)); +} + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); diff --git a/examples/pub-sub-live-voting/javascript/server/src/showStore.ts b/examples/pub-sub-live-voting/javascript/server/src/showStore.ts new file mode 100644 index 0000000000..67c00ff7dc --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/src/showStore.ts @@ -0,0 +1,54 @@ +// Data layer behind the admin API. Two implementations: a Postgres store +// (db.ts) for full read/write, and a read-only static store (staticStore.ts) +// backed by a JSON file for DB-less demo deployments. The server picks one at +// startup based on the SHOWS_FILE env var. + +export type Slot = 'up' | 'right' | 'down' | 'left'; + +export interface OptionInput { + label: string; + direction?: Slot | null; +} + +export interface ShowSummary { + id: number; + name: string; + poll_count: number; +} + +export interface StoredOption { + id: number; + label: string; + direction: string | null; + sort_order: number; +} + +export interface StoredPoll { + id: number; + question: string; + type: string; + sort_order: number; + options: StoredOption[]; +} + +export interface ShowPolls { + show: { id: number; name: string }; + polls: StoredPoll[]; +} + +export interface ShowStore { + /** When true, the write methods are unavailable and the admin UI is read-only. */ + readonly readOnly: boolean; + + getShows(): Promise; + getShowPolls(showId: number): Promise; + + createShow(name: string): Promise<{ id: number; name: string }>; + updateShow(id: number, name: string): Promise; + deleteShow(id: number): Promise; + + createPoll(showId: number, type: string, question: string, options: OptionInput[]): Promise; + updatePoll(pollId: number, type: string, question: string, options: OptionInput[]): Promise; + deletePoll(pollId: number): Promise; + reorderPolls(showId: number, pollIds: number[]): Promise; +} diff --git a/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts b/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts new file mode 100644 index 0000000000..c22196795e --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'fs'; +import type { + ShowStore, ShowSummary, ShowPolls, StoredPoll, StoredOption, +} from './showStore.js'; + +// JSON authoring format (see ../../data/demo-shows.json): +// { "shows": [ { "id", "name", "polls": [ +// { "id", "question", "type", "options": [ { "id", "label", "direction"? } ] } +// ] } ] } +interface RawOption { id: number; label: string; direction?: string | null; } +interface RawPoll { id: number; question: string; type: string; options?: RawOption[]; } +interface RawShow { id: number; name: string; polls?: RawPoll[]; } + +const VALID_TYPES = new Set(['list', 'dpad', 'suggest']); + +function readonlyWrite(): never { + throw new Error('Static show store is read-only; editing is disabled.'); +} + +function parseShows(filePath: string): RawShow[] { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(filePath, 'utf-8')); + } catch (err: any) { + throw new Error(`Failed to read SHOWS_FILE "${filePath}": ${err.message}`); + } + + const shows = (parsed as { shows?: unknown })?.shows; + if (!Array.isArray(shows)) { + throw new Error(`SHOWS_FILE "${filePath}" must contain a top-level "shows" array.`); + } + + for (const show of shows as RawShow[]) { + if (typeof show.id !== 'number' || typeof show.name !== 'string') { + throw new Error('Each show needs a numeric "id" and a string "name".'); + } + for (const poll of show.polls ?? []) { + if (typeof poll.id !== 'number' || typeof poll.question !== 'string') { + throw new Error(`Show ${show.id}: each poll needs a numeric "id" and a string "question".`); + } + if (!VALID_TYPES.has(poll.type)) { + throw new Error(`Show ${show.id}, poll ${poll.id}: invalid type "${poll.type}".`); + } + for (const opt of poll.options ?? []) { + if (typeof opt.id !== 'number' || typeof opt.label !== 'string') { + throw new Error(`Show ${show.id}, poll ${poll.id}: each option needs a numeric "id" and a string "label".`); + } + } + } + } + + return shows as RawShow[]; +} + +export function createStaticStore(filePath: string): ShowStore { + const shows = parseShows(filePath); + const byId = new Map(shows.map((s) => [s.id, s])); + + return { + readOnly: true, + + async getShows(): Promise { + return shows.map((s) => ({ id: s.id, name: s.name, poll_count: (s.polls ?? []).length })); + }, + + async getShowPolls(showId: number): Promise { + const show = byId.get(showId); + if (!show) return null; + const polls: StoredPoll[] = (show.polls ?? []).map((p, pi) => ({ + id: p.id, + question: p.question, + type: p.type, + sort_order: pi, + options: (p.options ?? []).map((o, oi): StoredOption => ({ + id: o.id, + label: o.label, + direction: o.direction ?? null, + sort_order: oi, + })), + })); + return { show: { id: show.id, name: show.name }, polls }; + }, + + createShow: readonlyWrite, + updateShow: readonlyWrite, + deleteShow: readonlyWrite, + createPoll: readonlyWrite, + updatePoll: readonlyWrite, + deletePoll: readonlyWrite, + reorderPolls: readonlyWrite, + }; +} diff --git a/examples/pub-sub-live-voting/javascript/server/tsconfig.json b/examples/pub-sub-live-voting/javascript/server/tsconfig.json new file mode 100644 index 0000000000..2cb7b6f25c --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/pub-sub-live-voting/javascript/src/admin.ts b/examples/pub-sub-live-voting/javascript/src/admin.ts new file mode 100644 index 0000000000..59965423cc --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/admin.ts @@ -0,0 +1,986 @@ +import * as Ably from 'ably'; +import { createAblyClient, getChannel } from './shared/ably'; +import { renderChart } from './shared/chart'; +import { displayName } from './shared/clientId'; +import { showError } from './shared/errors'; +import { generateSessionId } from './shared/sessionId'; +import { IS_SANDBOX } from './config'; +import { + VOTE_ANNOTATION_TYPE, + SUGGESTION_ANNOTATION_TYPE, + SESSION_KEY, + AdminState, + PollMessage, + PollType, + ControlMessage, + PollOption, + PollDefinition, + ControllerKind, + SLOTS_BY_KIND, + isControllerKind, +} from './shared/types'; +import { slotGlyph } from './shared/controller'; + +// The operator console. It drives the show — it's the only role that *publishes +// poll messages*. Voters and the presenter only react to them. This view talks +// to the backend (the shows API + token auth), so it's not part of the +// in-browser sandbox demo; clone the repo and run the server to use it. + +const VIEW_HTML = ` +
+ + + + +
+
+
+

Shows

+
    +
    + + +
    +
    + +
    +

    Select a show to edit its polls.

    +
    +
    +
    + + +
    +
    +

    Run a Show

    +
    + + + +
    +
    +
    + + +
    +
    +
    +
    +

    Session:

    +

    Show:

    +

    +
    + +
    + + + + + +
    + +
    + + +
    +
    + Open Presenter +
    + +
    +
    + +
    +

    Ready to start

    +
    +

    +
    +
    +
    +
    +`; + +// ── State ── + +let state: AdminState = 'manage'; +let ablyClient: Ably.Realtime | null = null; +let channel: Ably.RealtimeChannel | null = null; +let sessionId: string; + +// Show runner state +let runningShowId: number | null = null; +let showPolls: PollDefinition[] = []; +let showName = ''; +let currentPollIndex = 0; +let currentPollSerial: string | null = null; +let currentPollId: number | null = null; +let currentPollType: PollType = 'list'; +let currentPollOptions: PollOption[] = []; +let currentSummary: Ably.SummaryUniqueValues | null = null; +let suggestListener: ((a: Ably.Annotation) => void) | null = null; + +// Poll editor state +let selectedShowId: number | null = null; + +// Whether the active show store is read-only (static demo data). Fetched from +// the server's /api/config at startup. When true, editing controls are hidden +// and a notice is shown; running a show still works fully. +let readOnly = false; + +const $ = (sel: string) => document.querySelector(sel) as HTMLElement; + +// Build a URL on the current origin that carries the session id. +// Path "/" is the voter URL (encoded into the QR); "/?role=presenter" is the +// presenter URL. Both flow from admin → voter/screen, so they share the shape. +function buildSessionUrl(path: string): string { + return `${path}?${SESSION_KEY}=${sessionId}`; +} + +// ── Admin password ── +// +// The shows API and the admin Ably token are both gated on a password. The +// server serves the client statically, so (unlike a server-rendered admin +// page) the password isn't injected — we prompt for it once and cache it for +// the tab. It's sent as HTTP Basic auth on API calls and as the `password` +// auth param when minting the admin Ably token. +const PASSWORD_KEY = 'admin-password'; + +function getAdminPassword(): string { + let pw = sessionStorage.getItem(PASSWORD_KEY); + if (!pw) { + pw = window.prompt('Admin password') || ''; + if (pw) sessionStorage.setItem(PASSWORD_KEY, pw); + } + return pw; +} + +function authHeader(): Record { + // requireAdmin only checks the password half of the Basic credentials. + return { Authorization: `Basic ${btoa(`admin:${getAdminPassword()}`)}` }; +} + +// ── API helpers ── + +async function api(method: string, path: string, body?: any) { + let res: Response; + try { + res = await fetch(path, { + method, + headers: { + ...authHeader(), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (err: any) { + const message = err?.message || String(err); + showError(`${method} ${path}: ${message}`); + throw err; + } + if (res.status === 401) { + // Wrong/blank password — drop the cached value so the next attempt re-prompts. + sessionStorage.removeItem(PASSWORD_KEY); + } + if (!res.ok) { + const err = await res.json().catch(() => ({})); + const message = err.error || res.statusText || `Request failed (${res.status})`; + showError(`${method} ${path} → ${res.status}: ${message}`); + throw new Error(message); + } + return res.json(); +} + +// ── Tab switching ── + +function setState(newState: AdminState) { + state = newState; + document.body.dataset.state = newState; + if (newState !== 'manage') { + updateShowControls(); + } + if (newState === 'manage' || newState === 'setup') { + clearSavedShowState(); + } else { + saveShowState(); + } +} + +const ADMIN_SHOW_STATE_KEY = 'admin-show-state'; + +interface SavedShowState { + showId: number; + sessionId: string; + state: AdminState; + currentPollIndex: number; +} + +function saveShowState() { + if (runningShowId === null || !sessionId) return; + try { + const data: SavedShowState = { + showId: runningShowId, + sessionId, + state, + currentPollIndex, + }; + sessionStorage.setItem(ADMIN_SHOW_STATE_KEY, JSON.stringify(data)); + } catch {} +} + +function clearSavedShowState() { + try { + sessionStorage.removeItem(ADMIN_SHOW_STATE_KEY); + } catch {} +} + +function loadSavedShowState(): SavedShowState | null { + try { + const raw = sessionStorage.getItem(ADMIN_SHOW_STATE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function initTabs() { + const tabManage = $('#tab-manage'); + const tabShow = $('#tab-show'); + + tabManage.addEventListener('click', () => { + tabManage.classList.add('active'); + tabManage.setAttribute('aria-selected', 'true'); + tabShow.classList.remove('active'); + tabShow.setAttribute('aria-selected', 'false'); + setState('manage'); + loadShowList(); + }); + + tabShow.addEventListener('click', async () => { + tabShow.classList.add('active'); + tabShow.setAttribute('aria-selected', 'true'); + tabManage.classList.remove('active'); + tabManage.setAttribute('aria-selected', 'false'); + if (channel) { + // Already running a show, restore the active state + setState(state === 'manage' ? 'ready' : state); + } else { + await populateShowSelect(); + setState('setup'); + } + }); + + tabManage.addEventListener('keydown', (e) => { + if (e.key === 'ArrowRight') { tabShow.focus(); tabShow.click(); } + }); + tabShow.addEventListener('keydown', (e) => { + if (e.key === 'ArrowLeft') { tabManage.focus(); tabManage.click(); } + }); +} + +// ── Poll Management ── + +async function loadShowList() { + const shows = await api('GET', '/api/shows'); + const list = $('#show-list'); + list.innerHTML = ''; + for (const show of shows) { + const li = document.createElement('li'); + li.role = 'option'; + li.tabIndex = 0; + li.dataset.showId = String(show.id); + li.classList.toggle('selected', show.id === selectedShowId); + li.innerHTML = ` + ${esc(show.name)} + ${show.poll_count} polls + `; + li.addEventListener('click', () => selectShow(show.id)); + li.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectShow(show.id); } + if (e.key === 'ArrowDown') { (li.nextElementSibling as HTMLElement)?.focus(); } + if (e.key === 'ArrowUp') { (li.previousElementSibling as HTMLElement)?.focus(); } + }); + list.appendChild(li); + } +} + +async function selectShow(showId: number) { + selectedShowId = showId; + // Highlight in list + document.querySelectorAll('#show-list li').forEach((li) => { + (li as HTMLElement).classList.toggle('selected', (li as HTMLElement).dataset.showId === String(showId)); + }); + await renderPollEditor(showId); +} + +async function renderPollEditor(showId: number) { + const data = await api('GET', `/api/shows/${showId}/polls`); + const main = $('#manage-main'); + + let html = `${readonlyBanner()}
    +
    +

    ${esc(data.show.name)}

    + +
    `; + + for (const poll of data.polls) { + html += renderPollCard(poll); + } + + html += `
    + +
    + + ${renderPollTypeSelect('new-poll-type', 'list')} +
    +
    + +
    + ${renderSlotInputs('dpad', 'new-poll', {}, true)} + + +
    `; + + main.innerHTML = html; + bindPollEditorEvents(showId, data.polls); +} + +const POLL_TYPE_LABELS: Record = { + list: 'List', + dpad: 'D-pad', + suggest: 'Suggest', +}; + +function renderPollTypeSelect(id: string, selected: PollType): string { + const opts = (Object.keys(POLL_TYPE_LABELS) as PollType[]).map((t) => + ``, + ).join(''); + return ``; +} + +function renderSlotInputs(kind: ControllerKind, idPrefix: string, bySlot: Record, startHidden: boolean): string { + const inputs = SLOTS_BY_KIND[kind].map((s) => + ``, + ).join(''); + const hiddenCls = startHidden ? ' hidden' : ''; + return `
    +
    ${inputs}
    +
    `; +} + +function renderPollCard(poll: any): string { + const type: PollType = poll.type ?? 'list'; + const listValue = poll.options.map((o: any) => esc(o.label)).join(', '); + const bySlot: Record = {}; + for (const o of poll.options) { + if (o.direction) bySlot[o.direction] = o.label; + } + const slotEditors = (['dpad'] as ControllerKind[]) + .map((k) => renderSlotInputs(k, `poll-${poll.id}`, k === type ? bySlot : {}, k !== type)) + .join(''); + return `
    +
    + + ${renderPollTypeSelect(`poll-${poll.id}-type`, type)} +
    + + + +
    +
    +
    + +
    + ${slotEditors} +
    +

    Voters will type their own suggestions. No options to set.

    +
    +
    + +
    +
    `; +} + +function readPollEditor(card: Element): { type: PollType; options: { label: string; direction?: string }[] } | null { + const type = (card.querySelector('.poll-type-select') as HTMLSelectElement).value as PollType; + if (type === 'suggest') { + return { type, options: [] }; + } + if (isControllerKind(type)) { + const options: { label: string; direction: string }[] = []; + const editor = card.querySelector(`.poll-options-${type}`); + editor?.querySelectorAll('.slot-input').forEach((inp) => { + const label = inp.value.trim(); + if (label) options.push({ label, direction: inp.dataset.direction! }); + }); + if (options.length === 0) return null; + return { type, options }; + } else { + const optionsStr = (card.querySelector('.poll-options-input') as HTMLInputElement).value; + const options = optionsStr.split(',').map((s) => ({ label: s.trim() })).filter((o) => o.label); + if (options.length === 0) return null; + return { type, options }; + } +} + +function bindPollTypeSwitch(card: Element) { + const select = card.querySelector('.poll-type-select') as HTMLSelectElement; + const sections: Array<[string, string]> = [ + ['.poll-options-list', 'list'], + ['.poll-options-dpad', 'dpad'], + ['.poll-options-suggest', 'suggest'], + ]; + select?.addEventListener('change', () => { + (card as HTMLElement).dataset.pollType = select.value; + for (const [sel, kind] of sections) { + card.querySelector(sel)?.classList.toggle('hidden', select.value !== kind); + } + }); +} + +function bindPollEditorEvents(showId: number, polls: any[]) { + // Show name editing + const nameEl = $('#show-name-edit'); + nameEl?.addEventListener('blur', async () => { + const newName = nameEl.textContent?.trim(); + if (newName) { + await api('PUT', `/api/shows/${showId}`, { name: newName }); + loadShowList(); + } + }); + nameEl?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); nameEl.blur(); } + }); + + // Delete show + $('#btn-delete-show')?.addEventListener('click', async () => { + if (!confirm('Delete this show and all its polls?')) return; + await api('DELETE', `/api/shows/${showId}`); + selectedShowId = null; + $('#manage-main').innerHTML = '

    Select a show to edit its polls.

    '; + loadShowList(); + }); + + // Per-poll save, delete, reorder + document.querySelectorAll('.poll-card').forEach((card, idx) => { + const pollId = Number((card as HTMLElement).dataset.pollId); + bindPollTypeSwitch(card); + + card.querySelector('.btn-save-poll')?.addEventListener('click', async () => { + const question = (card.querySelector('.poll-question-input') as HTMLInputElement).value.trim(); + const parsed = readPollEditor(card); + if (!question || !parsed) return; + await api('PUT', `/api/shows/${showId}/polls/${pollId}`, { + question, type: parsed.type, options: parsed.options, + }); + await renderPollEditor(showId); + }); + + card.querySelector('.btn-delete-poll')?.addEventListener('click', async () => { + await api('DELETE', `/api/shows/${showId}/polls/${pollId}`); + await renderPollEditor(showId); + }); + + card.querySelector('.btn-move-up')?.addEventListener('click', async () => { + if (idx === 0) return; + const order = polls.map((p: any) => p.id); + [order[idx - 1], order[idx]] = [order[idx], order[idx - 1]]; + await api('PUT', `/api/shows/${showId}/polls/reorder`, { order }); + await renderPollEditor(showId); + }); + + card.querySelector('.btn-move-down')?.addEventListener('click', async () => { + if (idx === polls.length - 1) return; + const order = polls.map((p: any) => p.id); + [order[idx], order[idx + 1]] = [order[idx + 1], order[idx]]; + await api('PUT', `/api/shows/${showId}/polls/reorder`, { order }); + await renderPollEditor(showId); + }); + }); + + // Add poll + const addCard = document.querySelector('.add-poll-card'); + if (addCard) bindPollTypeSwitch(addCard); + $('#btn-add-poll')?.addEventListener('click', async () => { + const question = ($('#new-poll-question') as HTMLInputElement).value.trim(); + if (!addCard) return; + const parsed = readPollEditor(addCard); + if (!question || !parsed) return; + await api('POST', `/api/shows/${showId}/polls`, { + question, type: parsed.type, options: parsed.options, + }); + await renderPollEditor(showId); + loadShowList(); + }); +} + +function esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +async function initManage() { + await loadShowList(); + + $('#btn-add-show').addEventListener('click', async () => { + const input = $('#new-show-name') as HTMLInputElement; + const name = input.value.trim(); + if (!name) return; + await api('POST', '/api/shows', { name }); + input.value = ''; + await loadShowList(); + }); + + // Allow Enter to submit + $('#new-show-name').addEventListener('keydown', (e) => { + if ((e as KeyboardEvent).key === 'Enter') { + e.preventDefault(); + ($('#btn-add-show') as HTMLButtonElement).click(); + } + }); +} + +// ── Show Runner ── + +async function populateShowSelect() { + const shows = await api('GET', '/api/shows'); + const select = $('#show-select') as HTMLSelectElement; + select.innerHTML = ''; + for (const show of shows) { + const opt = document.createElement('option'); + opt.value = String(show.id); + opt.textContent = `${show.name} (${show.poll_count} polls)`; + select.appendChild(opt); + } +} + +function updateShowControls() { + const totalPolls = showPolls.length; + + $('#poll-progress').textContent = (state === 'setup' || state === 'manage') + ? '' + : `Poll ${currentPollIndex + 1} of ${totalPolls}`; + + $('#btn-start').classList.toggle('hidden', state !== 'ready'); + $('#btn-close').classList.toggle('hidden', state !== 'poll-open'); + $('#btn-clear-poll').classList.toggle('hidden', state !== 'poll-closed'); + $('#btn-next').classList.toggle('hidden', state !== 'show-qr'); + $('#btn-clear-suggestions').classList.toggle( + 'hidden', + !(currentPollType === 'suggest' && (state === 'poll-open' || state === 'poll-closed')), + ); + + if (state === 'show-qr' && currentPollIndex >= totalPolls - 1) { + $('#btn-next').textContent = 'Finish'; + } else { + $('#btn-next').textContent = 'Next Poll'; + } +} + +function populateJumpSelect() { + const select = $('#jump-select') as HTMLSelectElement; + select.innerHTML = ''; + + const startOpt = document.createElement('option'); + startOpt.value = 'start'; + startOpt.textContent = 'Start (QR / waiting)'; + select.appendChild(startOpt); + + for (let i = 0; i < showPolls.length; i++) { + const opt = document.createElement('option'); + opt.value = String(i); + opt.textContent = `${i + 1}. ${showPolls[i].question}`; + select.appendChild(opt); + } +} + +function handleMessage(msg: Ably.Message) { + if (msg.name === 'poll') { + const data = msg.data as PollMessage; + const summary = msg.annotations?.summary?.[VOTE_ANNOTATION_TYPE] as Ably.SummaryUniqueValues | undefined; + + if (msg.serial !== currentPollSerial) { + currentPollSerial = msg.serial!; + currentPollId = data.pollId; + currentPollOptions = data.options; + currentPollType = data.type ?? 'list'; + + const idx = showPolls.findIndex((p) => p.id === data.pollId); + if (idx !== -1) currentPollIndex = idx; + + currentSummary = summary ?? null; + + $('#admin-question').textContent = data.question; + + unsubscribeAdminSuggestions(); + if (currentPollType === 'suggest') { + renderAdminSuggestList([]); + subscribeAdminSuggestions(); + } else { + renderChart($('#chart'), currentPollOptions, currentSummary, 'both'); + } + updateTotalVotes(); + updateShowControls(); + } else if (summary && currentPollType !== 'suggest') { + currentSummary = summary; + renderChart($('#chart'), currentPollOptions, currentSummary, 'both'); + updateTotalVotes(); + } + } else if (msg.name === 'control') { + const data = msg.data as ControlMessage; + if (data.action === 'clear-suggestions') { + renderAdminSuggestList([]); + return; + } + if (data.pollId === currentPollId) { + if (data.action === 'close') setState('poll-closed'); + else if (data.action === 'show-qr') setState('show-qr'); + } + } +} + +let adminSuggestList: { text: string; from: string }[] = []; + +function renderAdminSuggestList(items: { text: string; from: string }[]) { + adminSuggestList = items; + const chart = $('#chart'); + chart.innerHTML = ''; + const ul = document.createElement('ul'); + ul.className = 'admin-suggest-list'; + for (const item of items) { + const li = document.createElement('li'); + const t = document.createElement('span'); + t.className = 'admin-suggest-text'; + t.textContent = item.text; + const f = document.createElement('span'); + f.className = 'admin-suggest-from'; + f.textContent = item.from; + li.appendChild(t); + li.appendChild(f); + ul.appendChild(li); + } + chart.appendChild(ul); +} + +function subscribeAdminSuggestions() { + if (!channel || suggestListener) return; + const handler = (annotation: Ably.Annotation) => { + if (annotation.action === 'annotation.delete') return; + const text = annotation.name; + const cid = annotation.clientId; + if (!text || !cid) return; + renderAdminSuggestList([{ text, from: displayName(cid) }, ...adminSuggestList]); + }; + suggestListener = handler; + channel.annotations.subscribe(SUGGESTION_ANNOTATION_TYPE, handler); +} + +function unsubscribeAdminSuggestions() { + if (channel && suggestListener) { + channel.annotations.unsubscribe(SUGGESTION_ANNOTATION_TYPE, suggestListener); + } + suggestListener = null; + adminSuggestList = []; +} + +async function startPoll() { + if (!channel) return; + const poll = showPolls[currentPollIndex]; + currentPollOptions = poll.options; + currentPollId = poll.id; + currentSummary = null; + + try { + // Starting a poll is just publishing a message. Voters and the presenter + // attach their annotations to *this* message's serial. + await channel.publish('poll', { + pollId: poll.id, + question: poll.question, + type: poll.type, + options: poll.options, + } satisfies PollMessage); + } catch (err: any) { + showError(`Failed to start poll: ${err.message || err}`); + return; + } + + $('#admin-question').textContent = poll.question; + if (poll.type === 'suggest') { + renderAdminSuggestList([]); + } else { + renderChart($('#chart'), currentPollOptions, null, 'both'); + } + updateTotalVotes(); + setState('poll-open'); +} + +async function closeVoting() { + const pollId = currentPollId; + if (!channel || pollId == null) return; + try { + await channel.publish('control', { + action: 'close', + pollId, + } satisfies ControlMessage); + setState('poll-closed'); + } catch (err: any) { + showError(`Failed to close voting: ${err.message || err}`); + } +} + +async function clearPoll() { + const pollId = currentPollId; + if (!channel || pollId == null) return; + const voterUrl = buildSessionUrl(`${window.location.origin}/`); + try { + await channel.publish('control', { + action: 'show-qr', + pollId, + voterUrl, + } satisfies ControlMessage); + setState('show-qr'); + } catch (err: any) { + showError(`Failed to clear poll: ${err.message || err}`); + } +} + +function nextPoll() { + currentPollIndex++; + if (currentPollIndex < showPolls.length) { + startPoll(); + } else { + currentPollIndex = 0; + setState('ready'); + $('#admin-question').textContent = 'All polls complete!'; + $('#chart').innerHTML = ''; + $('#vote-count').textContent = ''; + } +} + +async function jumpToSection() { + const value = ($('#jump-select') as HTMLSelectElement).value; + if (value === 'start') { + if (!channel) return; + const voterUrl = buildSessionUrl(`${window.location.origin}/`); + try { + await channel.publish('control', { + action: 'reset', + voterUrl, + } satisfies ControlMessage); + } catch (err: any) { + showError(`Failed to jump to start: ${err.message || err}`); + return; + } + currentPollIndex = 0; + currentPollId = null; + currentPollSerial = null; + currentPollOptions = []; + currentSummary = null; + $('#admin-question').textContent = 'Ready to start'; + $('#chart').innerHTML = ''; + $('#vote-count').textContent = ''; + setState('ready'); + return; + } + currentPollIndex = parseInt(value, 10); + startPoll(); +} + +function updateTotalVotes() { + const total = currentPollOptions.reduce( + (sum, opt) => sum + (currentSummary?.[opt.id]?.total ?? 0), + 0, + ); + $('#vote-count').textContent = `${total} vote${total !== 1 ? 's' : ''}`; +} + +async function connectToShow( + showId: number, + opts?: { sessionId?: string; initialState?: AdminState; pollIndex?: number }, +) { + const data = await api('GET', `/api/shows/${showId}/polls`); + showPolls = data.polls; + showName = data.show.name; + runningShowId = showId; + + if (showPolls.length === 0) { + showError('This show has no polls. Add some in the Manage Polls tab.'); + return; + } + + sessionId = opts?.sessionId ?? generateSessionId(); + + if (opts?.pollIndex !== undefined) { + currentPollIndex = opts.pollIndex; + } + + $('#session-display').textContent = sessionId; + $('#show-display').textContent = showName; + const presenterUrl = `${window.location.origin}/?role=presenter&${SESSION_KEY}=${sessionId}`; + const presenterLink = $('#presenter-link') as HTMLAnchorElement; + presenterLink.href = presenterUrl; + // Open in a dedicated popup window (not just a tab) so the presenter view + // is visually separable for screen-sharing. Modifier-clicks fall through to + // the browser's normal new-tab behavior. The named target reuses the same + // window across repeat clicks rather than spawning more. + presenterLink.onclick = (e) => { + if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + window.open(presenterUrl, 'ably-voting-presenter', 'popup,width=1280,height=800'); + }; + + populateJumpSelect(); + + const password = getAdminPassword(); + + const clientId = `admin-${sessionId}`; + ablyClient = createAblyClient(clientId, sessionId, 'admin', { password }); + channel = getChannel(ablyClient, sessionId, 'admin'); + channel.subscribe(handleMessage); + + setState(opts?.initialState ?? 'ready'); +} + +function initShowRunner() { + const form = $('#setup-form') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const showId = parseInt(($('#show-select') as HTMLSelectElement).value, 10); + await connectToShow(showId); + }); + + $('#btn-start').addEventListener('click', startPoll); + $('#btn-close').addEventListener('click', closeVoting); + $('#btn-clear-poll').addEventListener('click', clearPoll); + $('#btn-next').addEventListener('click', nextPoll); + $('#btn-jump').addEventListener('click', jumpToSection); + $('#btn-end-show').addEventListener('click', endShow); + $('#btn-clear-suggestions').addEventListener('click', clearSuggestions); +} + +async function clearSuggestions() { + if (!channel) return; + try { + await channel.publish('control', { + action: 'clear-suggestions', + pollId: currentPollId!, + } satisfies ControlMessage); + renderAdminSuggestList([]); + } catch (err: any) { + showError(`Failed to clear: ${err.message || err}`); + } +} + +async function endShow() { + if (!confirm('End the show? Voters and the presenter will see a closing screen.')) return; + + if (channel) { + try { + await channel.publish('control', { action: 'end' } satisfies ControlMessage); + } catch (err: any) { + showError(`Failed to end show: ${err.message || err}`); + return; + } + } + + unsubscribeAdminSuggestions(); + if (ablyClient) { + try { ablyClient.close(); } catch {} + } + ablyClient = null; + channel = null; + + runningShowId = null; + showPolls = []; + showName = ''; + currentPollIndex = 0; + currentPollSerial = null; + currentPollId = null; + currentPollType = 'list'; + currentPollOptions = []; + currentSummary = null; + + await populateShowSelect(); + setState('setup'); +} + +// ── Init ── + +function readonlyBanner(): string { + return readOnly + ? '

    Read-only demo — these polls come from a static data file and can’t be edited here. Switch to “Run Show” to run them.

    ' + : ''; +} + +async function init() { + try { + const cfg = await api('GET', '/api/config'); + readOnly = cfg.readOnly === true; + } catch { + // Non-fatal: assume writable and let individual writes surface errors. + } + + if (readOnly) { + document.body.dataset.readonly = 'true'; + $('#manage-main').innerHTML = `${readonlyBanner()}

    Select a show to view its polls.

    `; + } + initTabs(); + initManage(); + initShowRunner(); + + const saved = loadSavedShowState(); + if (saved) { + activateShowTab(); + try { + await connectToShow(saved.showId, { + sessionId: saved.sessionId, + initialState: saved.state, + pollIndex: saved.currentPollIndex, + }); + } catch (err: any) { + showError(`Failed to resume show: ${err.message || err}`); + clearSavedShowState(); + } + } +} + +function activateShowTab() { + const tabManage = $('#tab-manage'); + const tabShow = $('#tab-show'); + tabShow.classList.add('active'); + tabShow.setAttribute('aria-selected', 'true'); + tabManage.classList.remove('active'); + tabManage.setAttribute('aria-selected', 'false'); +} + +export function mount() { + document.body.dataset.view = 'admin'; + document.body.dataset.state = 'manage'; + + // The admin console needs the backend (shows API + token auth), which the + // in-browser sandbox doesn't have. Clone the repo and run the server to use + // it; the live docs demo only exercises the voter and presenter. + if (IS_SANDBOX) { + document.body.innerHTML = ` +
    +
    +

    Admin runs against the backend

    +

    The operator console drives the show and talks to the shows API and token-auth + endpoint, so it isn't part of the in-browser demo. Clone the repo and run the server + (see the README) to use it.

    +
    +
    `; + return; + } + + document.body.innerHTML = VIEW_HTML; + init(); +} diff --git a/examples/pub-sub-live-voting/javascript/src/config.ts b/examples/pub-sub-live-voting/javascript/src/config.ts new file mode 100644 index 0000000000..a8d05a14be --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/config.ts @@ -0,0 +1,22 @@ +// Configuration and sandbox detection. +// +// In the hosted docs sandbox the build replaces `import.meta.env.VITE_ABLY_KEY` +// and `import.meta.env.VITE_NAME` with real string literals, so the demo runs +// entirely in the browser with no backend. In a normal clone these are +// undefined, and the app talks to the bundled server instead (see ../../server). +// +// Only reference env vars that the docs build injects (VITE_ABLY_KEY, +// VITE_NAME). Other `import.meta.env` lookups would survive into the sandbox +// bundle and break it — the server-backed config (auth URL, API base) is wired +// through same-origin paths and a Vite dev proxy instead (see vite.config.ts). + +export const SANDBOX_ABLY_KEY = import.meta.env.VITE_ABLY_KEY as string | undefined; + +// A shared channel seed. In the sandbox both preview panes get the same +// injected value, so the voter and presenter land on the same channel without +// exchanging a session id. In a clone this is undefined and the session id +// comes from the URL (`?s=...`) instead. +export const SANDBOX_CHANNEL_SEED = import.meta.env.VITE_NAME as string | undefined; + +/** True only inside the hosted docs sandbox (a raw key was injected). */ +export const IS_SANDBOX = Boolean(SANDBOX_ABLY_KEY); diff --git a/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts b/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts new file mode 100644 index 0000000000..7ee4bab9df --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts @@ -0,0 +1,102 @@ +import * as Ably from 'ably'; +import { SANDBOX_ABLY_KEY, SANDBOX_CHANNEL_SEED } from './config'; +import { votingChannelName } from './shared/ably'; +import demoShows from './demo-shows.json'; +import { PollMessage, PollOption, VOTE_ANNOTATION_TYPE } from './shared/types'; + +// ─── SANDBOX ONLY ─────────────────────────────────────────────────────────── +// None of this is part of the real app. In a real show the admin console +// starts the show and a live audience supplies the votes. The hosted docs +// demo has neither, so this module fakes a "host": it publishes one poll and +// seeds a handful of votes from throwaway clients, purely so the presenter +// pane is alive and expressive the moment the page loads. The real voter pane +// then adds its own live votes on top. +// +// Clone the repo and run the server (see the README) for the genuine flow — +// you'll never use this file there. + +// Throwaway clients that each cast one vote. Distinct clientIds matter: votes +// are `unique` annotations, deduped per clientId, so N distinct voters are what +// give the heatmap N counts to spread across the options. +const SEED_VOTERS = 6; + +const channelName = () => votingChannelName(SANDBOX_CHANNEL_SEED!); + +// Auto-start the d-pad poll — the most expressive presenter screen (a heatmap +// with a leader badge), as opposed to a plain bar chart or the suggest stage. +function pickDpadPoll(): PollMessage | null { + const show = (demoShows as { shows?: any[] }).shows?.[0]; + const polls: any[] = show?.polls ?? []; + const dpad = polls.find((p) => p.type === 'dpad') ?? polls[0]; + if (!dpad) return null; + return { + pollId: dpad.id, + question: dpad.question, + type: dpad.type, + // Option ids are strings on the wire (the server stringifies them); match that. + options: (dpad.options ?? []).map((o: any): PollOption => ({ + id: String(o.id), + label: o.label, + direction: o.direction, + })), + }; +} + +export async function runDemoHost() { + const poll = pickDpadPoll(); + if (!poll) return; + + const host = new Ably.Realtime({ key: SANDBOX_ABLY_KEY, clientId: 'demo-host' }); + const channel = host.channels.get(channelName(), { modes: ['publish', 'subscribe'] }); + + // Publishing returns void, so read the serial back from the echoed message — + // the seed votes are annotations and need the poll's serial to attach to. + channel.subscribe('poll', (msg: Ably.Message) => { + const data = msg.data as PollMessage; + if (msg.serial && data.pollId === poll.pollId) { + channel.unsubscribe('poll'); + seedVotes(poll, msg.serial); + } + }); + + try { + await channel.publish('poll', poll); + } catch { + /* best-effort demo seeding; ignore failures */ + } +} + +function seedVotes(poll: PollMessage, serial: string) { + // Stagger them so the presenter shows a trickle of vote bubbles rather than + // one synchronised burst. + for (let i = 0; i < SEED_VOTERS; i++) { + setTimeout(() => castSeedVote(poll, serial), 600 + i * 350); + } +} + +function weightedPick(options: PollOption[]): PollOption { + // Bias toward earlier options so a clear leader emerges on the heatmap. + const weights = options.map((_, i) => options.length - i); + const total = weights.reduce((a, b) => a + b, 0); + let r = Math.random() * total; + for (let i = 0; i < options.length; i++) { + r -= weights[i]; + if (r <= 0) return options[i]; + } + return options[0]; +} + +function castSeedVote(poll: PollMessage, serial: string) { + if (poll.options.length === 0) return; + const opt = weightedPick(poll.options); + const suffix = Math.random().toString(36).slice(2, 6); + // `Guest-xxxx` so the presenter's displayName() shows "Guest" on the bubble. + const bot = new Ably.Realtime({ key: SANDBOX_ABLY_KEY, clientId: `Guest-${suffix}` }); + const channel = bot.channels.get(channelName(), { modes: ['annotation_publish'] }); + channel.annotations + .publish(serial, { type: VOTE_ANNOTATION_TYPE, name: opt.id }) + .catch(() => {}) + // The vote persists in the summary after the bot leaves (annotations aren't + // presence), so we can disconnect to free the connection. + .finally(() => setTimeout(() => bot.close(), 2000)); +} diff --git a/examples/pub-sub-live-voting/javascript/src/demo-shows.json b/examples/pub-sub-live-voting/javascript/src/demo-shows.json new file mode 100644 index 0000000000..ce877c4447 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/demo-shows.json @@ -0,0 +1,38 @@ +{ + "shows": [ + { + "id": 1, + "name": "Ably Live Voting demo", + "polls": [ + { + "id": 1, + "question": "Which real-time feature do you reach for most?", + "type": "list", + "options": [ + { "id": 101, "label": "Pub/Sub channels" }, + { "id": 102, "label": "Presence" }, + { "id": 103, "label": "Message history" }, + { "id": 104, "label": "Push notifications" } + ] + }, + { + "id": 2, + "question": "Rate this demo with the d-pad", + "type": "dpad", + "options": [ + { "id": 201, "label": "Love it", "direction": "up" }, + { "id": 202, "label": "It's neat", "direction": "right" }, + { "id": 203, "label": "Meh", "direction": "down" }, + { "id": 204, "label": "Confused", "direction": "left" } + ] + }, + { + "id": 3, + "question": "What should we build with Ably next?", + "type": "suggest", + "options": [] + } + ] + } + ] +} diff --git a/examples/pub-sub-live-voting/javascript/src/presenter.ts b/examples/pub-sub-live-voting/javascript/src/presenter.ts new file mode 100644 index 0000000000..37b80ca728 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/presenter.ts @@ -0,0 +1,316 @@ +import * as Ably from 'ably'; +import QRCode from 'qrcode'; +import { createAblyClient, getChannel } from './shared/ably'; +import { renderChart } from './shared/chart'; +import { renderController } from './shared/controller'; +import { displayName } from './shared/clientId'; +import { isValidSessionId } from './shared/sessionId'; +import { IS_SANDBOX, SANDBOX_CHANNEL_SEED } from './config'; +import { + VOTE_ANNOTATION_TYPE, + SUGGESTION_ANNOTATION_TYPE, + SESSION_KEY, + PresenterState, + PollMessage, + PollType, + ControlMessage, + PollOption, + isControllerKind, +} from './shared/types'; + +// The big-screen view. The presenter is the consumer that wants *individual* +// annotation events, not just the summary: every vote and every suggestion +// becomes a floating bubble. That's a much higher-volume stream than the +// summary the voters get — which is exactly why annotations let each audience +// subscribe to the slice it needs. + +const VIEW_HTML = ` +
    Ably Live Voting
    +
    + +
    +
    +

    That session ID doesn't look right

    +

    Please double-check the URL.

    +
    +
    + + +
    +
    +

    +
    +
    +
    +
    + + +
    +
    +

    +
    +
    +
    + + +
    +
    +

    Scan to join the show →

    +
    +
    + + +
    +
    +

    That's all, folks!

    +

    Thanks for taking part.

    +
    +
    +
    + + + +`; + +let channel: Ably.RealtimeChannel | null = null; +let currentPollOptions: PollOption[] = []; +let currentPollType: PollType = 'list'; +let currentPollSerial: string | null = null; +let currentPollId: number | null = null; +let currentSummary: Ably.SummaryUniqueValues | null = null; +let suggestListener: ((a: Ably.Annotation) => void) | null = null; +let voteListener: ((a: Ably.Annotation) => void) | null = null; +let acceptingSuggestions = false; + +const $ = (sel: string) => document.querySelector(sel) as HTMLElement; + +function setState(newState: PresenterState) { + document.body.dataset.state = newState; +} + +function handleMessage(msg: Ably.Message) { + if (msg.name === 'poll') { + const data = msg.data as PollMessage; + const summary = msg.annotations?.summary?.[VOTE_ANNOTATION_TYPE] as Ably.SummaryUniqueValues | undefined; + + if (msg.serial !== currentPollSerial) { + currentPollOptions = data.options; + currentPollType = data.type ?? 'list'; + currentPollSerial = msg.serial!; + currentPollId = data.pollId; + currentSummary = summary ?? null; + + if (currentPollType === 'suggest') { + unsubscribeSuggestions(); + unsubscribeVotes(); + $('#suggest-question').textContent = data.question; + $('#bubble-layer').innerHTML = ''; + subscribeSuggestions(); + setState('suggesting'); + } else { + unsubscribeSuggestions(); + $('#question').textContent = data.question; + renderResults(); + setState('live'); + subscribeVotes(); + } + } else if (summary && currentPollType !== 'suggest') { + currentSummary = summary; + renderResults(); + } + } else if (msg.name === 'control') { + const data = msg.data as ControlMessage; + if (data.action === 'end') { + unsubscribeSuggestions(); + unsubscribeVotes(); + setState('ended'); + return; + } + if (data.action === 'reset') { + unsubscribeSuggestions(); + unsubscribeVotes(); + currentPollSerial = null; + currentPollId = null; + currentPollOptions = []; + currentSummary = null; + if (data.voterUrl) showQR(data.voterUrl); + return; + } + if (data.action === 'clear-suggestions') { + $('#bubble-layer').innerHTML = ''; + return; + } + if (data.pollId === currentPollId) { + if (data.action === 'close') { + if (currentPollType === 'suggest') { + acceptingSuggestions = false; + } else { + setState('results'); + } + } else if (data.action === 'show-qr') { + showQR(data.voterUrl!); + } + } + } +} + +async function showQR(voterUrl: string) { + const canvas = $('#qr-canvas') as HTMLCanvasElement; + await QRCode.toCanvas(canvas, voterUrl, { width: 1000, margin: 2 }); + // qrcode pins an inline 1000px width/height on the canvas, which would + // override our stylesheet sizing. Clear it so the CSS clamp() wins while + // the 1000px bitmap stays for crisp rendering. + canvas.style.width = ''; + canvas.style.height = ''; + setState('show-qr'); +} + +function renderResults() { + const chart = $('#chart'); + if (isControllerKind(currentPollType)) { + // The d-pad heatmap: each slot fills proportionally to its share of the + // leader, with a star badge on the winner — all driven by the summary. + chart.classList.add('presenter-controller'); + renderController(currentPollType, chart, currentPollOptions, currentSummary, { + showHeatmap: true, + showLeaderBadge: true, + display: 'percent', + }); + } else { + chart.classList.remove('presenter-controller', 'controller', 'controller-dpad'); + renderChart(chart, currentPollOptions, currentSummary, 'percent'); + } +} + +function subscribeSuggestions() { + if (!channel || suggestListener) return; + acceptingSuggestions = true; + const handler = (annotation: Ably.Annotation) => { + if (!acceptingSuggestions) return; + if (annotation.action === 'annotation.delete') return; + const text = annotation.name; + const cid = annotation.clientId; + if (!text || !cid) return; + spawnBubble($('#bubble-layer'), text, displayName(cid), { baseMs: 16000, decayPerAlive: 300, minMs: 3000 }); + }; + suggestListener = handler; + channel.annotations.subscribe(SUGGESTION_ANNOTATION_TYPE, handler); +} + +function unsubscribeSuggestions() { + acceptingSuggestions = false; + if (channel && suggestListener) { + channel.annotations.unsubscribe(SUGGESTION_ANNOTATION_TYPE, suggestListener); + } + suggestListener = null; +} + +function subscribeVotes() { + if (!channel || voteListener) return; + // One bubble per individual vote annotation. This is the high-volume stream + // the summary spares the voters from; the presenter opts in via + // annotation_subscribe. + const handler = (annotation: Ably.Annotation) => { + if (annotation.action === 'annotation.delete') return; + const optionId = annotation.name; + const cid = annotation.clientId; + if (!optionId || !cid) return; + const opt = currentPollOptions.find((o) => o.id === optionId); + if (!opt) return; + spawnBubble($('#vote-bubble-layer'), opt.label, displayName(cid), { baseMs: 1500, decayPerAlive: 0, minMs: 1500 }); + }; + voteListener = handler; + channel.annotations.subscribe(VOTE_ANNOTATION_TYPE, handler); +} + +function unsubscribeVotes() { + if (channel && voteListener) { + channel.annotations.unsubscribe(VOTE_ANNOTATION_TYPE, voteListener); + } + voteListener = null; +} + +interface BubbleLifetime { + baseMs: number; + decayPerAlive: number; + minMs: number; +} + +function spawnBubble(layer: HTMLElement, text: string, attribution: string, lifetime: BubbleLifetime) { + const el = document.createElement('div'); + el.className = 'bubble'; + el.innerHTML = ``; + el.querySelector('.bubble-text')!.textContent = text; + el.querySelector('.bubble-attribution')!.textContent = attribution; + + const { x, y } = pickBubblePosition(); + el.style.left = `${x}%`; + el.style.top = `${y}%`; + + const alive = layer.children.length; + const lifetimeMs = Math.max(lifetime.minMs, lifetime.baseMs - alive * lifetime.decayPerAlive); + el.style.setProperty('--lifetime', `${lifetimeMs}ms`); + el.addEventListener('animationend', () => el.remove(), { once: true }); + + layer.appendChild(el); +} + +function pickBubblePosition(): { x: number; y: number } { + // Polar around the centre, avoiding the question text in the middle. + const angle = Math.random() * Math.PI * 2; + const r = 25 + Math.random() * 20; // 25–45% of half-viewport + const x = 50 + Math.cos(angle) * r; + const y = 50 + Math.sin(angle) * r; + return { x, y }; +} + +function connect(sessionId: string) { + const clientId = `presenter-${crypto.randomUUID().slice(0, 8)}`; + const ably = createAblyClient(clientId, sessionId, 'presenter'); + channel = getChannel(ably, sessionId, 'presenter'); + channel.subscribe(handleMessage); +} + +function init() { + // Sandbox: join the shared demo channel and wait for the auto-started poll + // (published by demo-bootstrap). No QR — the voter pane is already connected. + if (IS_SANDBOX) { + document.body.dataset.sandbox = 'true'; + $('#show-qr-heading').textContent = 'Connecting…'; + setState('show-qr'); + connect(SANDBOX_CHANNEL_SEED!); + return; + } + + const sessionId = new URLSearchParams(window.location.search).get(SESSION_KEY); + if (!sessionId || !isValidSessionId(sessionId)) { + setState('invalid-session'); + return; + } + + connect(sessionId); + + // Carry the presenter's querystring (the session) straight into the voter + // URL — admin builds the presenter link with the same querystring, so they + // stay in sync. + const voterUrl = `${window.location.origin}/${window.location.search}`; + showQR(voterUrl); + + // Manual-entry fallback: the short URL displayed under the QR for people + // without a working camera. It already carries just the session, so it + // matches the QR target. + const qrLink = $('#presenter-qr-url') as HTMLAnchorElement; + qrLink.textContent = `${window.location.host}/?${SESSION_KEY}=${sessionId}`; + qrLink.href = voterUrl; +} + +export function mount() { + document.body.dataset.view = 'presenter'; + document.body.dataset.state = 'show-qr'; + document.body.innerHTML = VIEW_HTML; + init(); +} diff --git a/examples/pub-sub-live-voting/javascript/src/script.ts b/examples/pub-sub-live-voting/javascript/src/script.ts new file mode 100644 index 0000000000..ff71259394 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/script.ts @@ -0,0 +1,25 @@ +// Single entry point. The app has three roles — voter, presenter, admin — +// selected by the `?role=` query param. Splitting one app this way (rather +// than three HTML pages) lets the docs sandbox show two of the roles side by +// side in separate preview panes, each loaded with a different `?role=`. +import { mount as mountVoter } from './voter'; +import { mount as mountPresenter } from './presenter'; +import { mount as mountAdmin } from './admin'; +import { runDemoHost } from './demo-bootstrap'; +import { IS_SANDBOX } from './config'; + +const params = new URLSearchParams(window.location.search); +const role = params.get('role') ?? 'voter'; + +if (role === 'presenter') { + mountPresenter(); + // Sandbox only: `?demo=1` also makes this pane the host that auto-starts the + // show, since there's no admin running. See demo-bootstrap.ts. + if (IS_SANDBOX && params.get('demo') === '1') { + runDemoHost(); + } +} else if (role === 'admin') { + mountAdmin(); +} else { + mountVoter(); +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/ably.ts b/examples/pub-sub-live-voting/javascript/src/shared/ably.ts new file mode 100644 index 0000000000..9025e550f4 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/ably.ts @@ -0,0 +1,86 @@ +import * as Ably from 'ably'; +import { Role } from './types'; +import { showError, clearError } from './errors'; +import { IS_SANDBOX, SANDBOX_ABLY_KEY } from '../config'; + +export function createAblyClient( + clientId: string, + sessionId: string, + role: Role, + extraParams?: Record, +): Ably.Realtime { + // ── Authentication ── + // + // Real app: the client never sees an API key. It calls the server's `/auth` + // endpoint, which returns a short-lived JWT scoped to exactly this role's + // capabilities — a voter token can publish annotations but cannot publish + // messages or read other voters' raw annotations (see server/src/server.ts). + // This is the pattern you should use in production. + // + // Hosted docs sandbox ONLY: there is no server to call, so we fall back to a + // raw API key embedded in the page. Do not do this with a real app, it's used here + // purely so the live demo can run without a backend. + const client = new Ably.Realtime( + IS_SANDBOX + ? { key: SANDBOX_ABLY_KEY, clientId } + : { + authUrl: '/auth', + authParams: { clientId, sessionId, role, ...extraParams }, + clientId, + }, + ); + + client.connection.on('failed', (change) => { + const reason = change.reason; + const detail = reason?.cause?.message || reason?.message || 'unknown error'; + showError(`Connection failed: ${detail}`); + }); + + client.connection.on('connected', () => { + clearError(); + }); + + return client; +} + +// Channel modes are the client's declared intent, enforced by Ably. They're +// the second layer of least-privilege after the token capabilities: a voter +// attaches with annotation_publish only, so it can cast votes but never +// receive the (much higher-volume) individual annotation stream the presenter +// consumes, nor publish poll messages. +const MODES_BY_ROLE: Record = { + // Admin publishes poll/control messages and subscribes for echo + summaries. + // Also subscribes to per-annotation events to render the live suggest list. + admin: ['publish', 'subscribe', 'annotation_subscribe'], + // Voter only publishes annotations (votes, suggestions) and subscribes for + // poll/control messages. + voter: ['subscribe', 'annotation_publish'], + // Presenter is read-only on both messages and per-annotation events. + presenter: ['subscribe', 'annotation_subscribe'], +}; + +// The channel a session runs on. Always namespaced under `voting:` so the +// channel rules this app needs, such as Message annotations and serverside batching, can +// be configured against the `voting` namespace. In the sandbox `sessionId` is the +// injected channel name; in the real app it's the admin-generated session id. +export function votingChannelName(sessionId: string): string { + return `voting:${sessionId}`; +} + +export function getChannel(client: Ably.Realtime, sessionId: string, role: Role): Ably.RealtimeChannel { + // `rewind: 1` means a client that attaches mid-poll immediately receives the + // current poll message (and its latest annotation summary), so late joiners + // and page refreshes catch up without the admin re-publishing. + const channel = client.channels.get(votingChannelName(sessionId), { + params: { rewind: '1' }, + modes: MODES_BY_ROLE[role], + }); + + channel.on('failed', (change) => { + const reason = change.reason; + const detail = reason?.cause?.message || reason?.message || 'unknown error'; + showError(`Channel error: ${detail}`); + }); + + return channel; +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/chart.ts b/examples/pub-sub-live-voting/javascript/src/shared/chart.ts new file mode 100644 index 0000000000..e26e31235d --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/chart.ts @@ -0,0 +1,60 @@ +import * as Ably from 'ably'; +import { PollOption } from './types'; +import { CountDisplay, formatCount } from './format'; + +const COLORS = ['#ff5416', '#1a1a1a', '#6b6b6b', '#ffa07a', '#c9c5bd', '#ff7b47']; + +// Renders a horizontal bar per option from an annotation summary. The summary +// is Ably's server-side rollup of every `vote:unique.v1` annotation on the +// poll message — `summary[optionId].total` is the live vote count for that +// option, computed for us, not by tallying events on the client. +export function renderChart( + container: HTMLElement, + options: PollOption[], + summary: Ably.SummaryUniqueValues | null, + display: CountDisplay = 'count', +): void { + const totalVotes = options.reduce( + (sum, opt) => sum + (summary?.[opt.id]?.total ?? 0), + 0, + ); + + // Reuse existing rows if structure matches, otherwise rebuild + const existingRows = container.querySelectorAll('.chart-row'); + if (existingRows.length !== options.length) { + container.innerHTML = ''; + for (let i = 0; i < options.length; i++) { + const opt = options[i]; + const count = summary?.[opt.id]?.total ?? 0; + const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0; + + const row = document.createElement('div'); + row.className = 'chart-row'; + row.dataset.optionId = opt.id; + row.innerHTML = ` + ${opt.label} +
    +
    +
    + ${formatCount(count, totalVotes, display)} + `; + container.appendChild(row); + } + } else { + // Update in place for smooth transitions + for (let i = 0; i < options.length; i++) { + const opt = options[i]; + const row = existingRows[i] as HTMLElement; + const count = summary?.[opt.id]?.total ?? 0; + const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0; + + row.dataset.optionId = opt.id; + const labelEl = row.querySelector('.chart-label') as HTMLElement; + labelEl.textContent = opt.label; + const fill = row.querySelector('.chart-bar-fill') as HTMLElement; + fill.style.width = `${pct}%`; + const countEl = row.querySelector('.chart-count') as HTMLElement; + countEl.textContent = formatCount(count, totalVotes, display); + } + } +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/clientId.ts b/examples/pub-sub-live-voting/javascript/src/shared/clientId.ts new file mode 100644 index 0000000000..ff484cdae6 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/clientId.ts @@ -0,0 +1,16 @@ +/** ClientId format: `${name}-${random4}`. The random suffix keeps clientIds + * (and therefore votes) distinct even when two people pick the same username. + * + * The clientId matters: votes are `unique` annotations, which Ably dedupes + * per clientId. One clientId means one counted vote, no matter how many times + * that client taps. */ +export function voterClientId(name: string): string { + const suffix = Math.random().toString(36).slice(2, 6); + return `${name}-${suffix}`; +} + +/** Strip the trailing `-suffix` to get a presentable display name. */ +export function displayName(clientId: string): string { + const lastDash = clientId.lastIndexOf('-'); + return lastDash > 0 ? clientId.slice(0, lastDash) : clientId; +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/controller.ts b/examples/pub-sub-live-voting/javascript/src/shared/controller.ts new file mode 100644 index 0000000000..19e4e9b200 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/controller.ts @@ -0,0 +1,140 @@ +import * as Ably from 'ably'; +import { PollOption, Direction, ControllerKind, SLOTS_BY_KIND } from './types'; +import { CountDisplay, formatCount } from './format'; + +interface SlotStyle { + glyph: string; + color: string; +} + +const SLOT_STYLES: Record = { + up: { glyph: '▲', color: '#ff5416' }, + right: { glyph: '▶', color: '#ff5416' }, + down: { glyph: '▼', color: '#ff5416' }, + left: { glyph: '◀', color: '#ff5416' }, +}; + +export function slotGlyph(slot: Direction): string { + return SLOT_STYLES[slot].glyph; +} + +export interface ControllerRenderOpts { + clickable?: boolean; + onClick?: (optionId: string) => void; + /** Mark this option as "your vote". With invertSelected this swaps the + * slot to black-on-white; without it, fills the slot with its colour. */ + selectedId?: string | null; + /** Fill each button proportionally to its share of the leader. */ + showHeatmap?: boolean; + /** Place a star badge on the option(s) with the most votes. */ + showLeaderBadge?: boolean; + /** Render every populated slot in full colour by default. */ + defaultFilled?: boolean; + /** Selected slot inverts to black-bg / white-text instead of taking its + * slot colour. Pairs naturally with defaultFilled. */ + invertSelected?: boolean; + /** How to render the per-slot tally. Defaults to the raw count. */ + display?: CountDisplay; +} + +// Renders the d-pad: four positional buttons fed from the same annotation +// summary as the bar chart. On the presenter this drives the heatmap (fill +// proportional to each slot's share) and the leader star badge. +export function renderController( + kind: ControllerKind, + container: HTMLElement, + options: PollOption[], + summary: Ably.SummaryUniqueValues | null, + opts: ControllerRenderOpts = {}, +): void { + const slots = SLOTS_BY_KIND[kind]; + + const optionBySlot = new Map(); + for (const o of options) { + if (o.direction) optionBySlot.set(o.direction, o); + } + + const counts = new Map(); + let max = 0; + let total = 0; + for (const slot of slots) { + const opt = optionBySlot.get(slot); + const c = opt && summary ? (summary[opt.id]?.total ?? 0) : 0; + counts.set(slot, c); + if (c > max) max = c; + total += c; + } + + container.classList.add('controller', `controller-${kind}`); + + let buttons = container.querySelectorAll('.controller-button'); + const existingSlots = Array.from(buttons).map((b) => b.dataset.slot); + const slotsMatch = existingSlots.length === slots.length && existingSlots.every((s, i) => s === slots[i]); + if (!slotsMatch) { + container.innerHTML = ''; + for (const slot of slots) { + const el = document.createElement(opts.clickable ? 'button' : 'div'); + el.className = `controller-button slot-${slot}`; + el.dataset.slot = slot; + el.innerHTML = ` + + ${SLOT_STYLES[slot].glyph} + + + `; + container.appendChild(el); + } + buttons = container.querySelectorAll('.controller-button'); + } + + for (const button of buttons) { + const slot = button.dataset.slot as Direction; + const opt = optionBySlot.get(slot); + const count = counts.get(slot) ?? 0; + const labelEl = button.querySelector('.controller-label')!; + const countEl = button.querySelector('.controller-count')!; + + if (opt) { + labelEl.textContent = opt.label; + countEl.textContent = formatCount(count, total, opts.display ?? 'count'); + button.dataset.optionId = opt.id; + button.classList.remove('slot-empty'); + button.style.setProperty('--slot-fill', SLOT_STYLES[slot].color); + + const isSelected = opts.selectedId === opt.id; + const isInverted = !!opts.invertSelected && isSelected; + button.classList.toggle('slot-inverted', isInverted); + + let intensity = 0; + if (opts.showHeatmap) { + // Quadratic so the leader pops harder against trailing options. + const ratio = max > 0 ? count / max : 0; + intensity = ratio * ratio; + } else if (isInverted) { + intensity = 0; // .slot-inverted CSS handles the visual itself. + } else if (isSelected || opts.defaultFilled) { + intensity = 1; + } + button.style.setProperty('--slot-fill-intensity', String(intensity)); + + const isLeader = opts.showLeaderBadge && max > 0 && count === max; + button.classList.toggle('marked-leader', isLeader); + if (opts.clickable && opts.onClick) { + const handler = () => opts.onClick!(opt.id); + const existing = (button as any)._controllerHandler; + if (existing) button.removeEventListener('click', existing); + (button as any)._controllerHandler = handler; + button.addEventListener('click', handler); + (button as HTMLButtonElement).disabled = false; + } + } else { + labelEl.textContent = ''; + countEl.textContent = ''; + delete button.dataset.optionId; + button.classList.add('slot-empty'); + button.classList.remove('marked-leader', 'slot-inverted'); + button.style.setProperty('--slot-fill-intensity', '0'); + if (opts.clickable) (button as HTMLButtonElement).disabled = true; + } + } +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/errors.ts b/examples/pub-sub-live-voting/javascript/src/shared/errors.ts new file mode 100644 index 0000000000..e2b4c6691a --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/errors.ts @@ -0,0 +1,22 @@ +let bannerEl: HTMLElement | null = null; + +function getBanner(): HTMLElement { + if (bannerEl) return bannerEl; + bannerEl = document.createElement('div'); + bannerEl.className = 'error-banner hidden'; + document.body.prepend(bannerEl); + return bannerEl; +} + +export function showError(message: string) { + const banner = getBanner(); + banner.textContent = message; + banner.classList.remove('hidden'); + console.error(message); +} + +export function clearError() { + if (bannerEl) { + bannerEl.classList.add('hidden'); + } +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/format.ts b/examples/pub-sub-live-voting/javascript/src/shared/format.ts new file mode 100644 index 0000000000..6259ae2859 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/format.ts @@ -0,0 +1,13 @@ +export type CountDisplay = 'count' | 'percent' | 'both'; + +export function formatCount(count: number, total: number, mode: CountDisplay): string { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + switch (mode) { + case 'count': + return String(count); + case 'percent': + return `${pct}%`; + case 'both': + return `${count} (${pct}%)`; + } +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/sessionId.ts b/examples/pub-sub-live-voting/javascript/src/shared/sessionId.ts new file mode 100644 index 0000000000..7416a29b33 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/sessionId.ts @@ -0,0 +1,31 @@ +// Session IDs are 6 base36 chars: 5 random + 1 check digit. The check digit +// catches typos when someone is entering the ID by hand (the QR-scan path +// already gets it right). The weighted sum catches single-char substitutions +// and most adjacent transpositions. + +const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; +const SESSION_LENGTH = 6; + +function checkChar(body: string): string { + let sum = 0; + for (let i = 0; i < body.length; i++) { + const idx = ALPHABET.indexOf(body[i]); + if (idx < 0) return ''; + sum += (i + 1) * idx; + } + return ALPHABET[sum % ALPHABET.length]; +} + +export function generateSessionId(): string { + let body = ''; + while (body.length < SESSION_LENGTH - 1) { + body += Math.floor(Math.random() * ALPHABET.length).toString(36); + } + return body + checkChar(body); +} + +export function isValidSessionId(s: string): boolean { + if (s.length !== SESSION_LENGTH) return false; + if (!/^[0-9a-z]+$/.test(s)) return false; + return checkChar(s.slice(0, -1)) === s.slice(-1); +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/types.ts b/examples/pub-sub-live-voting/javascript/src/shared/types.ts new file mode 100644 index 0000000000..3137de6e9b --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/types.ts @@ -0,0 +1,63 @@ +export type PollType = 'list' | 'dpad' | 'suggest'; + +/** Subset of PollType that renders as a directional controller (positional buttons). */ +export type ControllerKind = 'dpad'; + +/** Slot a poll option occupies on the d-pad layout. The DB column and the + * PollOption field are both named `direction` since these are arrow directions. */ +export type Direction = 'up' | 'right' | 'down' | 'left'; + +export const SLOTS_BY_KIND: Record = { + dpad: ['up', 'right', 'down', 'left'], +}; + +export function isControllerKind(t: PollType): t is ControllerKind { + return t === 'dpad'; +} + +export interface PollOption { + id: string; + label: string; + direction?: Direction; +} + +export interface PollDefinition { + id: number; + question: string; + type: PollType; + options: PollOption[]; +} + +export interface Show { + id: number; + name: string; + polls: PollDefinition[]; +} + +export interface PollMessage { + pollId: number; + question: string; + type: PollType; + options: PollOption[]; +} + +export interface ControlMessage { + action: 'close' | 'show-qr' | 'end' | 'reset' | 'clear-suggestions'; + pollId?: number; + voterUrl?: string; +} + +// The annotation type that carries a vote. The `unique` summarisation means +// Ably keeps at most one vote per clientId — a built-in "one person, one vote" +// that you'd otherwise have to enforce yourself. See the README. +export const VOTE_ANNOTATION_TYPE = 'vote:unique.v1'; +export const SUGGESTION_ANNOTATION_TYPE = 'suggestion'; + +/** URL search-param key carrying the session id (e.g. `/?s=ab12cd`). */ +export const SESSION_KEY = 's'; + +export type Role = 'admin' | 'voter' | 'presenter'; + +export type VoterState = 'no-session' | 'invalid-session' | 'enter-name' | 'waiting' | 'voting' | 'voted' | 'results' | 'suggesting' | 'ended'; +export type AdminState = 'setup' | 'manage' | 'ready' | 'poll-open' | 'poll-closed' | 'show-qr'; +export type PresenterState = 'invalid-session' | 'live' | 'results' | 'show-qr' | 'ended' | 'suggesting'; diff --git a/examples/pub-sub-live-voting/javascript/src/styles.css b/examples/pub-sub-live-voting/javascript/src/styles.css new file mode 100644 index 0000000000..85f4bb6812 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/styles.css @@ -0,0 +1,1309 @@ +/* Reset & base */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f7f6f3; + --surface: #ffffff; + --ink: #1a1a1a; + --ink-soft: #6b6b6b; + --border: #e6e3dd; + --border-strong: #d6d2ca; + --primary: #ff5416; + --primary-hover: #e64a12; + --primary-soft: #fff1ea; + --warning: #f5a623; + --warning-hover: #e0961a; + --success: #2ea043; + --danger: #e5484d; + --danger-hover: #cf3b40; + --info: #2563eb; + + --radius: 12px; + --radius-sm: 8px; + --shadow-sm: 0 1px 2px rgba(20, 20, 20, 0.06); + --shadow: 0 2px 10px rgba(20, 20, 20, 0.07); + --shadow-lg: 0 12px 32px rgba(20, 20, 20, 0.10); + + --font-pixel: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; +} + +html, body { + height: 100%; + font-family: var(--font-body); + background: var(--bg); + color: var(--ink); + line-height: 1.5; + font-size: 16px; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3 { + font-family: var(--font-pixel); + font-weight: 700; + letter-spacing: -0.01em; +} + +h1 { font-size: 1.7rem; line-height: 1.3; } +h2 { font-size: 1.3rem; line-height: 1.35; } +h3 { font-size: 1.05rem; line-height: 1.4; } + +p { font-size: 1rem; } + +/* Brand mark (top-left on voter & presenter views) */ +.brand { + position: fixed; + top: 1rem; + left: 1.25rem; + display: flex; + align-items: center; + gap: 0.6rem; + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--ink); + z-index: 10; + pointer-events: none; +} + +body[data-view="presenter"] .brand { + font-size: 1.25rem; + top: 1.25rem; + left: 1.5rem; +} + +@media (max-width: 768px) { + .brand { font-size: 0.85rem; } + body[data-view="presenter"] .brand { font-size: 1rem; } +} + +/* Reserve clear space under the fixed brand mark on branded views */ +body[data-view="voter"] .view { padding-top: 3.5rem; } +body[data-view="presenter"] .view { padding-top: 4.25rem; } +@media (max-width: 768px) { + body[data-view="voter"] .view { padding-top: 3rem; } + body[data-view="presenter"] .view { padding-top: 3rem; } +} + +/* View visibility based on body[data-state] */ +.view { display: none; } + +body[data-state="no-session"] .view-no-session, +body[data-state="invalid-session"] .view-invalid-session, +body[data-state="enter-name"] .view-enter-name, +body[data-state="waiting"] .view-waiting, +body[data-state="voting"] .view-voting, +body[data-state="voted"] .view-voted, +body[data-state="results"] .view-results, +body[data-state="manage"] .view-manage, +body[data-state="setup"] .view-setup, +body[data-state="ready"] .view-ready, +body[data-state="poll-open"] .view-poll-open, +body[data-state="poll-closed"] .view-poll-closed, +body[data-state="show-qr"] .view-show-qr, +body[data-state="live"] .view-live, +body[data-state="suggesting"] .view-suggesting, +body[data-state="ended"] .view-ended { + display: flex; +} + +/* Layout */ +#app { + min-height: 100%; + display: flex; + flex-direction: column; +} + +.view { + flex: 1; + align-items: center; + justify-content: center; + padding: 2rem; +} + +/* Cards */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + max-width: 460px; + width: 100%; + box-shadow: var(--shadow); +} + +.card.wide { + max-width: 680px; +} + +.card h1, .card h2 { + margin-bottom: 1rem; +} + +/* Forms */ +label { + display: block; + font-family: var(--font-pixel); + font-size: 0.72rem; + font-weight: 600; + color: var(--ink-soft); + margin-bottom: 0.4rem; + margin-top: 1rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +input[type="text"], input[type="password"], select { + width: 100%; + padding: 0.65rem 0.8rem; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--ink); + font-family: var(--font-body); + font-size: 1rem; +} + +input[type="text"]:focus, input[type="password"]:focus, select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(255, 84, 22, 0.15); +} + +/* Buttons */ +button { + cursor: pointer; + padding: 0.65rem 1.1rem; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, transform 0.05s; +} + +button:hover { + border-color: var(--ink-soft); +} + +button:active { + transform: translateY(1px); +} + +button:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +form button[type="submit"], .btn-primary { + background: var(--primary); + border-color: var(--primary); + color: #fff; + width: 100%; + margin-top: 1.25rem; +} + +form button[type="submit"]:hover, .btn-primary:hover { + background: var(--primary-hover); + border-color: var(--primary-hover); +} + +.btn-warning { + background: var(--warning); + border-color: var(--warning); + color: #fff; + width: 100%; + margin-top: 0.5rem; +} + +.btn-warning:hover { + background: var(--warning-hover); + border-color: var(--warning-hover); +} + +.btn-secondary { + background: var(--surface); + color: var(--ink); + width: 100%; + margin-top: 0.5rem; +} + +.btn-secondary:hover { + background: var(--bg); +} + +.btn-danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; + width: 100%; + margin-top: 0.5rem; +} + +.btn-danger:hover { + background: var(--danger-hover); + border-color: var(--danger-hover); +} + +a.btn-primary, a.btn-secondary, a.btn-warning, a.btn-danger { + display: block; + text-align: center; + text-decoration: none; + padding: 0.65rem 1.1rem; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +a.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; } +a.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); } +a.btn-secondary:hover { background: var(--bg); border-color: var(--ink-soft); } + +/* Vote buttons */ +.options-grid { + display: grid; + gap: 0.6rem; + margin-bottom: 1.5rem; +} + +.vote-button { + background: var(--surface); + border: 1px solid var(--border-strong); + color: var(--ink); + padding: 1rem; + border-radius: var(--radius-sm); + font-family: var(--font-pixel); + font-size: 1rem; + font-weight: 500; + text-align: left; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; +} + +.vote-button:hover { + border-color: var(--primary); + box-shadow: var(--shadow-sm); +} + +.vote-button.selected { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +body[data-state="results"] .vote-button { + pointer-events: none; + opacity: 0.55; +} + +body[data-state="results"] .vote-button.selected { + opacity: 1; +} + +/* Controller (d-pad — positional button layout) */ +.controller { + margin: 1rem auto; + width: 100%; +} + +.controller-dpad { + display: grid; + /* minmax(0, 1fr) — without the 0 floor, each column's `auto` min-content + would be the full nowrap label width, forcing the controller wider than + its max-width and shoving the card off small screens (e.g. iPhone SE). */ + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(3, minmax(0, 1fr)); + gap: 8px; + max-width: 360px; + aspect-ratio: 1 / 1; +} + +.controller-button { + position: relative; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + color: var(--ink); + font-family: var(--font-pixel); + font-weight: 600; + cursor: pointer; + padding: 0.25rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2rem; + box-shadow: var(--shadow-sm); + overflow: hidden; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s; +} + +.controller-button::before { + content: ''; + position: absolute; + inset: 0; + background: var(--slot-fill, var(--primary)); + opacity: var(--slot-fill-intensity, 0); + transition: opacity 0.3s ease-out; + z-index: 0; + pointer-events: none; +} + +.controller-button > * { + position: relative; + z-index: 1; +} + +/* Slot positions: dpad (3x3) */ +.controller-dpad .slot-up { grid-column: 2; grid-row: 1; } +.controller-dpad .slot-right { grid-column: 3; grid-row: 2; } +.controller-dpad .slot-down { grid-column: 2; grid-row: 3; } +.controller-dpad .slot-left { grid-column: 1; grid-row: 2; } + +/* Controller text sizes are in px, not rem — the buttons themselves are + fixed-size, so the text inside should keep its proportions instead of + stepping at the 768px page font-size breakpoint. */ +.controller-glyph { + font-size: 26px; + line-height: 1; + color: var(--ink-soft); +} + +.controller-button:not(.slot-empty) .controller-glyph { + color: var(--primary); +} + +.controller-label { + font-size: 12px; + font-weight: 600; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.controller-count { + font-size: 14px; +} + +.controller-button.slot-empty { + background: var(--bg); + color: #b8b4ac; + cursor: not-allowed; + box-shadow: none; +} + +button.controller-button:not(:disabled):hover { + border-color: var(--primary); + box-shadow: var(--shadow); +} + +button.controller-button:not(:disabled):active { + transform: translateY(1px); +} + +.controller-badge { + display: none; + position: absolute; + top: 6px; + right: 6px; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--primary); + color: #fff; + font-family: var(--font-pixel); + font-size: 0.95rem; + line-height: 30px; + text-align: center; + z-index: 2; + box-shadow: var(--shadow-sm); + animation: controller-medal 1.2s ease-in-out infinite; +} + +.controller-button.marked-leader .controller-badge { + display: block; +} + +@keyframes controller-medal { + 0%, 100% { transform: scale(1) rotate(-4deg); } + 50% { transform: scale(1.12) rotate(4deg); } +} + +/* Presenter sizing */ +.presenter-controller.controller-dpad { max-width: 540px; } +.presenter-controller .controller-glyph { font-size: 48px; } +.presenter-controller .controller-label { font-size: 18px; } +.presenter-controller .controller-count { font-size: 28px; } + +/* Chart */ +.chart { + margin: 1rem auto; +} + +.chart-row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.chart-label { + width: 160px; + text-align: right; + font-family: var(--font-pixel); + font-size: 0.9rem; + font-weight: 500; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chart-bar-track { + flex: 1; + height: 32px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.chart-bar-fill { + height: 100%; + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + transition: width 0.3s ease-out; + min-width: 0; +} + +.chart-count { + width: 56px; + text-align: right; + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 600; + flex-shrink: 0; +} + +.vote-count { + font-family: var(--font-pixel); + color: var(--ink-soft); + font-size: 0.9rem; + font-weight: 500; + margin-top: 0.75rem; +} + +/* Presenter — body is a two-column grid: main content on the left, + persistent QR sidebar on the right. Show-takeover states (ended, + invalid-session) collapse to a single column. */ +body[data-view="presenter"] { + background: var(--bg); + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 100vh; + min-height: 100vh; +} + +body[data-view="presenter"] #app { + position: relative; + min-width: 0; +} + +.presenter-qr-aside { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem; + border-left: 1px solid var(--border); +} + +.presenter-qr-aside canvas { + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + background: #fff; + width: clamp(260px, 34vw, 640px); + height: auto; +} + +/* End and bad-session states take the whole screen. */ +body[data-view="presenter"][data-state="ended"], +body[data-view="presenter"][data-state="invalid-session"] { + grid-template-columns: 1fr; +} +body[data-view="presenter"][data-state="ended"] .presenter-qr-aside, +body[data-view="presenter"][data-state="invalid-session"] .presenter-qr-aside { + display: none; +} + +/* Phone-width fallback: too narrow for a useful QR alongside the main + pane, so drop the sidebar entirely and let the content take the screen. */ +@media (max-width: 768px) { + body[data-view="presenter"] { + grid-template-columns: 1fr; + } + .presenter-qr-aside { + display: none; + } +} + +/* Hosted docs sandbox: there's no QR onboarding (the voter pane is already + connected), so drop the sidebar and let the results take the pane. */ +body[data-view="presenter"][data-sandbox] { + grid-template-columns: 1fr; +} +body[data-view="presenter"][data-sandbox] .presenter-qr-aside { + display: none; +} + +/* The docs preview panes are short (~320px). Trim the chrome and cap the + d-pad / chart so a poll fits without being clipped. Sandbox-only — the real + app's full-screen views are untouched (they never set data-sandbox). */ +body[data-sandbox] .brand { display: none; } +body[data-sandbox] .view { padding: 0.75rem; } +body[data-sandbox] .card { padding: 1rem; } + +/* Voter d-pad */ +body[data-sandbox] .controller-dpad { max-width: 210px; } + +/* Presenter d-pad + heading */ +body[data-sandbox] .presenter-center h1 { font-size: 1.1rem; margin-bottom: 0.6rem; } +body[data-sandbox] .presenter-controller.controller-dpad { max-width: 230px; } +body[data-sandbox] .presenter-controller .controller-glyph { font-size: 26px; } +body[data-sandbox] .presenter-controller .controller-label { font-size: 11px; } +body[data-sandbox] .presenter-controller .controller-count { font-size: 18px; } + +/* Presenter bar chart (list polls) */ +body[data-sandbox] .presenter-chart .chart-bar-track { height: 30px; } +body[data-sandbox] .presenter-chart .chart-label { font-size: 0.85rem; width: 120px; } +body[data-sandbox] .presenter-chart .chart-count { font-size: 0.95rem; width: 48px; } + +.presenter-center { + text-align: center; + width: 100%; + max-width: 960px; +} + +.presenter-center h1 { + font-size: 2.5rem; + line-height: 1.3; + margin-bottom: 2rem; +} + +.presenter-chart .chart-label { + font-size: 1.1rem; + width: 240px; +} + +.presenter-chart .chart-bar-track { + height: 56px; +} + +.presenter-chart .chart-count { + font-size: 1.5rem; + width: 90px; +} + +.large { + font-size: 1.5rem; +} + +/* Suggest stage (presenter) */ +.suggest-stage { + position: relative; + width: 100%; + height: 100%; + min-height: 80vh; + display: flex; + align-items: center; + justify-content: center; +} + +.suggest-question { + font-size: 2rem; + line-height: 1.35; + text-align: center; + max-width: 60vw; + z-index: 1; +} + +.bubble-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.bubble { + position: absolute; + transform: translate(-50%, -50%); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.5rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.15rem; + max-width: 220px; + animation: bubble-fade var(--lifetime, 8000ms) ease-out forwards; +} + +.bubble-text { + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 600; + line-height: 1.35; + word-break: break-word; +} + +.bubble-attribution { + font-family: var(--font-body); + font-size: 0.8rem; + color: var(--ink-soft); +} + +@keyframes bubble-fade { + 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.85); } + 10% { opacity: 1; transform: translate(-50%, -50%) scale(1); } + 100% { opacity: 0; transform: translate(-50%, calc(-50% - 30px)) scale(1); } +} + +/* Admin tabs */ +.admin-tabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + background: var(--bg); + padding: 0 0.5rem; +} + +.admin-tab { + padding: 0.75rem 1.25rem; + background: transparent; + color: var(--ink-soft); + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + font-family: var(--font-pixel); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.admin-tab:hover { + color: var(--ink); +} + +.admin-tab.active { + color: var(--ink); + border-bottom-color: var(--primary); +} + +/* Admin layout */ +.admin-layout { + display: flex; + gap: 2rem; + width: 100%; + max-width: 1080px; + height: 100%; +} + +.admin-sidebar { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.admin-info { + background: var(--surface); + padding: 1rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); +} + +.admin-info p { + margin-bottom: 0.4rem; + font-family: var(--font-pixel); + font-size: 0.72rem; + font-weight: 600; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-info strong { + font-family: var(--font-body); + font-size: 1.05rem; + font-weight: 600; + color: var(--ink); + text-transform: none; + letter-spacing: 0; +} + +.admin-controls { + display: flex; + flex-direction: column; +} + +.admin-controls hr { + border: none; + border-top: 1px solid var(--border); + margin: 1rem 0; +} + +.admin-main { + flex: 1; + background: var(--surface); + padding: 2rem; + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.admin-question { + margin-bottom: 1.5rem; +} + +.jump-row { + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.jump-row select { + flex: 1; +} + +.jump-row button { + width: auto; + margin-top: 0; +} + +/* Poll management */ +.manage-layout { + display: flex; + gap: 2rem; + width: 100%; + max-width: 1080px; + height: 100%; +} + +.manage-sidebar { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.manage-sidebar h2 { + margin-bottom: 0.25rem; +} + +.manage-sidebar-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.manage-sidebar-actions .btn-primary { + margin-top: 0; +} + +.show-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.show-list li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 0.9rem; + border-radius: var(--radius-sm); + background: var(--surface); + border: 1px solid var(--border); + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; +} + +.show-list .show-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 1rem; +} + +.show-list li::after { + content: '›'; + color: var(--ink-soft); + font-size: 1.2rem; + line-height: 1; + margin-left: 0.25rem; + transition: transform 0.15s; +} + +.show-list li:hover { + border-color: var(--primary); + box-shadow: var(--shadow); +} + +.show-list li:hover::after { + transform: translateX(2px); +} + +.show-list li.selected { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +.show-list li.selected::after { + color: #fff; +} + +.show-list li:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.show-name { + font-weight: 500; +} + +.show-count { + font-family: var(--font-pixel); + font-size: 0.72rem; + font-weight: 600; + color: var(--ink-soft); +} + +.show-list li.selected .show-count { + color: #fff; +} + +.manage-main { + flex: 1; + overflow-y: auto; +} + +.poll-editor { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.poll-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.poll-editor-header h2 { + margin-bottom: 0; + padding: 0.25rem 0.5rem; + outline: none; + min-width: 0; + flex: 1; + border-radius: var(--radius-sm); +} + +.poll-editor-header h2:focus { + background: var(--surface); + box-shadow: 0 0 0 2px var(--primary); +} + +.btn-danger-sm { + background: var(--danger); + border-color: var(--danger); + color: #fff; + padding: 0.45rem 0.85rem; + font-family: var(--font-pixel); + font-size: 0.8rem; + font-weight: 600; + width: auto; + margin: 0; + flex-shrink: 0; +} + +.btn-danger-sm:hover { + background: var(--danger-hover); + border-color: var(--danger-hover); +} + +.poll-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.25rem; + box-shadow: var(--shadow-sm); +} + +.poll-card-header { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.poll-card .poll-type-select { + width: auto; + flex-shrink: 0; + font-family: var(--font-pixel); + font-size: 0.85rem; + padding: 0.4rem 0.5rem; +} + +.poll-type-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.poll-type-row label { + margin: 0; + font-size: 0.72rem; +} + +.poll-type-row .poll-type-select { + width: auto; + font-family: var(--font-pixel); + font-size: 0.85rem; + padding: 0.4rem 0.5rem; + margin: 0; +} + +.slot-edit-grid { + display: grid; + gap: 6px; + width: 100%; + margin: 0.5rem 0; +} + +.slot-edit-grid.kind-dpad { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr 1fr; + max-width: 360px; +} + +.slot-edit-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin: 0; + padding: 0; +} + +.slot-edit-slot .slot-edit-glyph { + font-family: var(--font-pixel); + font-size: 1rem; + line-height: 1; + color: var(--primary); +} + +.slot-edit-slot .slot-input { + width: 100%; + padding: 0.4rem 0.5rem; + font-size: 0.95rem; + text-align: center; +} + +/* dpad slot placement */ +.kind-dpad .slot-edit-up { grid-column: 2; grid-row: 1; } +.kind-dpad .slot-edit-right { grid-column: 3; grid-row: 2; } +.kind-dpad .slot-edit-down { grid-column: 2; grid-row: 3; } +.kind-dpad .slot-edit-left { grid-column: 1; grid-row: 2; } + +.poll-save-row { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; +} + +.poll-save-row .btn-save-poll, +.poll-options-edit .btn-save-poll { + width: auto; + margin: 0; + padding: 0.45rem 1rem; + font-size: 0.85rem; +} + +.poll-question-input { + flex: 1; + font-weight: 500; +} + +.poll-card-actions { + display: flex; + gap: 0.35rem; + flex-shrink: 0; +} + +.btn-icon { + width: 36px; + height: 36px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-pixel); + font-size: 1rem; + background: var(--surface); + color: var(--ink); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); +} + +.btn-icon:hover { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +.poll-options-edit { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + align-items: center; +} + +.poll-options-input { + flex: 1; + font-size: 1rem; +} + +.poll-options-edit .btn-secondary { + width: auto; + margin: 0; + padding: 0.45rem 1rem; + font-size: 0.85rem; +} + +.add-poll-card { + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.add-poll-card .btn-primary { + margin-top: 0.25rem; +} + +/* QR fallback URL — shown beneath the QR for the rare voter without a + working phone camera. Smaller and more subdued than the QR itself. */ +.qr-url-hint { + margin-top: 0.5rem; + font-family: var(--font-body); + font-size: 0.95rem; + color: var(--ink-soft); + word-break: break-all; +} +.qr-url-hint.qr-url { + margin-top: 0.15rem; + color: var(--primary); + font-weight: 600; +} + +/* Suggest poll */ +.suggest-form { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + align-items: stretch; +} + +.suggest-form input[type="text"] { + flex: 1; + margin: 0; +} + +.suggest-form button[type="submit"] { + width: auto; + margin: 0; + flex-shrink: 0; +} + +.my-suggestions-heading { + margin-top: 1.5rem; + margin-bottom: 0.5rem; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-soft); +} + +.my-suggestions { + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.my-suggestions li { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem; + font-size: 1rem; +} + +.admin-suggest-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 60vh; + overflow-y: auto; +} + +.admin-suggest-list li { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.4rem 0.7rem; + font-size: 1rem; +} + +.admin-suggest-text { + flex: 1; + word-break: break-word; +} + +.admin-suggest-from { + font-family: var(--font-pixel); + font-size: 0.78rem; + color: var(--ink-soft); + flex-shrink: 0; +} + +/* Read-only (static demo data) admin mode */ +.readonly-note { + background: var(--primary-soft); + border: 1px solid var(--primary); + color: var(--ink); + border-radius: var(--radius-sm); + padding: 0.6rem 0.9rem; + margin-bottom: 1rem; + font-size: 0.95rem; +} + +body[data-readonly] .manage-sidebar-actions, +body[data-readonly] .add-poll-card, +body[data-readonly] .poll-card-actions, +body[data-readonly] .poll-save-row, +body[data-readonly] #btn-delete-show { display: none; } + +body[data-readonly] #panel-manage input, +body[data-readonly] #panel-manage select, +body[data-readonly] #panel-manage [contenteditable] { + pointer-events: none; + background: var(--bg); + color: var(--ink-soft); +} + +/* Utilities */ +.hidden { display: none !important; } + +/* Error banner */ +.error-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + background: var(--danger); + color: #fff; + padding: 0.75rem 1rem; + text-align: center; + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 600; + z-index: 200; +} + +.subtle { + color: var(--ink-soft); + font-size: 0.95rem; +} + +.error { + color: var(--danger); + text-align: center; + padding: 2rem; + font-family: var(--font-pixel); + font-weight: 600; +} + +.pulse { + animation: pulse 1.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Responsive */ +@media (max-width: 768px) { + .admin-layout, .manage-layout { + flex-direction: column; + } + .admin-sidebar, .manage-sidebar { + width: 100%; + } + .presenter-center h1 { + font-size: 1.6rem; + } + .chart-label { + width: 110px; + font-size: 0.85rem; + } +} + +/* Phones around iPhone SE width (375px) don't have room for the default + 2rem view/card padding plus a 3×3 controller — tighten the surrounding + chrome so the controller stays usable. */ +@media (max-width: 480px) { + .view { padding-left: 1rem; padding-right: 1rem; padding-bottom: 1rem; } + .card { padding: 1.25rem; } + .controller-dpad { gap: 6px; } +} + +/* Below ~360px buttons get small enough that the option label can't share + space with the glyph and count. Drop the dynamic option text and rely on + the static glyph — the chart below still shows label→count. */ +@media (max-width: 360px) { + .controller-label { display: none; } +} diff --git a/examples/pub-sub-live-voting/javascript/src/voter.ts b/examples/pub-sub-live-voting/javascript/src/voter.ts new file mode 100644 index 0000000000..b739272054 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/voter.ts @@ -0,0 +1,442 @@ +import * as Ably from 'ably'; +import { createAblyClient, getChannel } from './shared/ably'; +import { renderChart } from './shared/chart'; +import { renderController } from './shared/controller'; +import { voterClientId } from './shared/clientId'; +import { showError } from './shared/errors'; +import { isValidSessionId } from './shared/sessionId'; +import { IS_SANDBOX, SANDBOX_CHANNEL_SEED } from './config'; +import { + VOTE_ANNOTATION_TYPE, + SUGGESTION_ANNOTATION_TYPE, + SESSION_KEY, + VoterState, + PollMessage, + PollType, + ControlMessage, + PollOption, + isControllerKind, +} from './shared/types'; + +// The phone-side view. A voter only ever *publishes* annotations (a vote, or a +// suggestion) onto the current poll message, and *subscribes* to the poll's +// annotation summary to show live percentages. It never sees other voters' +// individual votes — that high-volume stream goes to the presenter only. + +const VIEW_HTML = ` +
    Ably Live Voting
    +
    + +
    +
    +

    Welcome!

    +

    Scan the QR code, or enter the full URL, to join in.

    +
    +
    + + +
    +
    +

    Hmm, that doesn't look right

    +

    That session ID doesn't look right — please double-check the URL or rescan the QR code.

    +
    +
    + + +
    +
    +

    Join the vote

    +

    Session:

    +
    + + + +
    +
    +
    + + +
    +
    +

    Hi, !

    +

    Waiting for the next poll...

    +
    +
    + + +
    +
    +

    +
    +
    +

    +
    +
    + + +
    +
    +

    +
    + + +
    + +
      +
      +
      + + +
      +
      +

      That's all, folks!

      +

      Thanks for taking part.

      +
      +
      +
      +`; + +let state: VoterState = 'enter-name'; +let channel: Ably.RealtimeChannel; +let clientId: string; +let currentPollOptions: PollOption[] = []; +let currentPollType: PollType = 'list'; +let currentPollSerial: string | null = null; +let currentPollId: number | null = null; +let currentSummary: Ably.SummaryUniqueValues | null = null; +let myVote: string | null = null; + +let sessionId: string; + +const $ = (sel: string) => document.querySelector(sel) as HTMLElement; + +function setState(newState: VoterState) { + state = newState; + document.body.dataset.state = newState; + const statusEl = document.getElementById('status-text'); + if (statusEl) { + statusEl.textContent = + newState === 'voted' ? 'Vote recorded! Tap another option to change your vote.' : + newState === 'results' ? 'Voting closed' : ''; + } +} + +interface SavedSession { + clientId: string; + name: string; +} + +const sessionKey = (sid: string) => `voter-session-${sid}`; +const voteKey = (sid: string, pollId: number) => `voter-vote-${sid}-${pollId}`; +const suggestionsKey = (sid: string, pollId: number) => `voter-suggestions-${sid}-${pollId}`; + +function loadSavedSession(sid: string): SavedSession | null { + try { + const raw = sessionStorage.getItem(sessionKey(sid)); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function loadSavedVote(sid: string, pollId: number): string | null { + try { + return sessionStorage.getItem(voteKey(sid, pollId)); + } catch { + return null; + } +} + +function saveVote(sid: string, pollId: number, optionId: string) { + try { + sessionStorage.setItem(voteKey(sid, pollId), optionId); + } catch {} +} + +function loadSavedSuggestions(sid: string, pollId: number): string[] { + try { + const raw = sessionStorage.getItem(suggestionsKey(sid, pollId)); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function appendSavedSuggestion(sid: string, pollId: number, text: string) { + try { + const existing = loadSavedSuggestions(sid, pollId); + existing.push(text); + sessionStorage.setItem(suggestionsKey(sid, pollId), JSON.stringify(existing)); + } catch {} +} + +function connectAndWait(name: string, existingClientId?: string) { + clientId = existingClientId ?? voterClientId(name); + try { + sessionStorage.setItem(sessionKey(sessionId), JSON.stringify({ clientId, name })); + } catch {} + + $('#display-name').textContent = name; + + const ably = createAblyClient(clientId, sessionId, 'voter'); + channel = getChannel(ably, sessionId, 'voter'); + channel.subscribe(handleMessage); + setState('waiting'); +} + +function handleMessage(msg: Ably.Message) { + if (msg.name === 'poll') { + const data = msg.data as PollMessage; + const summary = msg.annotations?.summary?.[VOTE_ANNOTATION_TYPE] as Ably.SummaryUniqueValues | undefined; + + if (msg.serial !== currentPollSerial) { + // New poll (or rewound message after refresh). + currentPollOptions = data.options; + currentPollType = data.type ?? 'list'; + currentPollSerial = msg.serial!; + currentPollId = data.pollId; + currentSummary = summary ?? null; + + if (currentPollType === 'suggest') { + myVote = null; + renderSuggestPoll(data); + setState('suggesting'); + setSuggestFormDisabled(false); + } else { + myVote = loadSavedVote(sessionId, data.pollId); + renderPoll(data); + if (myVote) { + setState('voted'); + highlightVote(myVote); + } else { + setState('voting'); + } + } + } else if (summary && currentPollType !== 'suggest') { + // Annotation summary update for the current poll. + currentSummary = summary; + renderResults(); + } + } else if (msg.name === 'control') { + const data = msg.data as ControlMessage; + if (data.action === 'end') { + clearVoterStorage(sessionId); + setState('ended'); + return; + } + if (data.action === 'reset') { + currentPollSerial = null; + currentPollId = null; + currentPollOptions = []; + currentSummary = null; + myVote = null; + setState('waiting'); + return; + } + if (data.pollId === currentPollId) { + if (data.action === 'close') { + setState('waiting'); + } + } + } +} + +function clearVoterStorage(sid: string) { + try { + sessionStorage.removeItem(sessionKey(sid)); + const prefixes = [`voter-vote-${sid}-`, `voter-suggestions-${sid}-`]; + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key && prefixes.some((p) => key.startsWith(p))) sessionStorage.removeItem(key); + } + } catch {} +} + +function renderSuggestPoll(data: PollMessage) { + $('#suggest-question').textContent = data.question; + const input = $('#suggest-input') as HTMLInputElement; + input.value = ''; + renderMySuggestions(loadSavedSuggestions(sessionId, data.pollId)); +} + +function renderMySuggestions(items: string[]) { + const list = $('#my-suggestions'); + const heading = $('#my-suggestions-heading'); + list.innerHTML = ''; + for (const text of items) { + const li = document.createElement('li'); + li.textContent = text; + list.appendChild(li); + } + heading.classList.toggle('hidden', items.length === 0); +} + +function setSuggestFormDisabled(disabled: boolean) { + const input = $('#suggest-input') as HTMLInputElement; + const submit = ($('#suggest-form') as HTMLFormElement).querySelector('button') as HTMLButtonElement; + input.disabled = disabled; + submit.disabled = disabled; + if (disabled) input.placeholder = 'Suggestions closed'; + else input.placeholder = 'Type a suggestion…'; +} + +async function castSuggestion(text: string) { + if (!currentPollSerial || currentPollId === null) return; + try { + // A suggestion is just an annotation with a free-text `name` on the poll + // message. Same mechanism as a vote, different annotation type. + await channel.annotations.publish(currentPollSerial, { + type: SUGGESTION_ANNOTATION_TYPE, + name: text, + }); + appendSavedSuggestion(sessionId, currentPollId, text); + renderMySuggestions(loadSavedSuggestions(sessionId, currentPollId)); + } catch (err: any) { + showError(`Failed to submit: ${err.message || err}`); + } +} + +function renderControllerForVoter(selectedId: string | null) { + if (!isControllerKind(currentPollType)) return; + renderController(currentPollType, $('#options'), currentPollOptions, currentSummary, { + clickable: state !== 'results', + onClick: castVote, + selectedId, + showHeatmap: false, + showLeaderBadge: false, + display: 'percent', + }); +} + +function renderPoll(data: PollMessage) { + $('#question').textContent = data.question; + + const optionsEl = $('#options'); + const chartEl = $('#chart'); + + if (isControllerKind(currentPollType)) { + chartEl.innerHTML = ''; + chartEl.classList.add('hidden'); + optionsEl.innerHTML = ''; + optionsEl.classList.remove('options-grid'); + renderControllerForVoter(myVote); + } else { + chartEl.classList.remove('hidden'); + optionsEl.classList.remove('controller', 'controller-dpad'); + optionsEl.classList.add('options-grid'); + optionsEl.innerHTML = ''; + for (const opt of data.options) { + const btn = document.createElement('button'); + btn.className = 'vote-button'; + btn.dataset.optionId = opt.id; + btn.textContent = opt.label; + btn.addEventListener('click', () => castVote(opt.id)); + optionsEl.appendChild(btn); + } + renderChart(chartEl, currentPollOptions, currentSummary, 'percent'); + } +} + +function renderResults() { + if (isControllerKind(currentPollType)) { + renderControllerForVoter(myVote); + } else { + renderChart($('#chart'), currentPollOptions, currentSummary, 'percent'); + } +} + +function highlightVote(optionId: string) { + if (isControllerKind(currentPollType)) { + renderControllerForVoter(optionId); + } else { + document.querySelectorAll('.vote-button').forEach((btn) => { + (btn as HTMLElement).classList.toggle('selected', (btn as HTMLElement).dataset.optionId === optionId); + }); + } +} + +async function castVote(optionId: string) { + if (!currentPollSerial || state === 'results') return; + + try { + // The whole vote: publish a `vote:unique.v1` annotation naming the chosen + // option onto the poll message. Because it's a `unique` annotation, Ably + // replaces any previous vote from this clientId — tapping another option + // moves your vote rather than adding a second one. + await channel.annotations.publish(currentPollSerial, { + type: VOTE_ANNOTATION_TYPE, + name: optionId, + }); + myVote = optionId; + if (currentPollId !== null) saveVote(sessionId, currentPollId, optionId); + highlightVote(optionId); + setState('voted'); + } catch (err: any) { + showError(`Failed to vote: ${err.message || err}`); + } +} + +function wireSuggestForm() { + const suggestForm = $('#suggest-form') as HTMLFormElement; + suggestForm.addEventListener('submit', (e) => { + e.preventDefault(); + const input = $('#suggest-input') as HTMLInputElement; + const text = input.value.trim(); + if (!text) return; + castSuggestion(text); + input.value = ''; + input.focus(); + }); +} + +function init() { + // Sandbox: skip the QR/session/name flow entirely and join the shared demo + // channel as a single voter, so the preview pane is interactive immediately. + if (IS_SANDBOX) { + document.body.dataset.sandbox = 'true'; + sessionId = SANDBOX_CHANNEL_SEED!; + wireSuggestForm(); + connectAndWait('You'); + return; + } + + const params = new URLSearchParams(window.location.search); + sessionId = params.get(SESSION_KEY) ?? ''; + if (!sessionId) { + setState('no-session'); + return; + } + if (!isValidSessionId(sessionId)) { + setState('invalid-session'); + return; + } + + $('#session-name').textContent = sessionId; + + wireSuggestForm(); + + const saved = loadSavedSession(sessionId); + if (saved) { + connectAndWait(saved.name, saved.clientId); + return; + } + + setState('enter-name'); + const nameInput = $('#name-input') as HTMLInputElement; + nameInput.focus(); + + const form = $('#name-form') as HTMLFormElement; + form.addEventListener('submit', (e) => { + e.preventDefault(); + const name = nameInput.value.trim(); + if (!name) return; + connectAndWait(name); + }); +} + +export function mount() { + document.body.dataset.view = 'voter'; + document.body.dataset.state = 'enter-name'; + document.body.innerHTML = VIEW_HTML; + init(); +} diff --git a/examples/pub-sub-live-voting/javascript/tailwind.config.ts b/examples/pub-sub-live-voting/javascript/tailwind.config.ts new file mode 100644 index 0000000000..1c86e1c371 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/tailwind.config.ts @@ -0,0 +1,9 @@ +import baseConfig from '../../tailwind.config'; +import type { Config } from 'tailwindcss'; + +const config: Config = { + ...baseConfig, + content: ['./src/**/*.{js,ts,tsx}', './index.html'], +}; + +export default config; diff --git a/examples/pub-sub-live-voting/javascript/tsconfig.json b/examples/pub-sub-live-voting/javascript/tsconfig.json new file mode 100644 index 0000000000..7b5d055766 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["../../node_modules", "../../dist", "../../lib"] +} diff --git a/examples/pub-sub-live-voting/javascript/vite-env.d.ts b/examples/pub-sub-live-voting/javascript/vite-env.d.ts new file mode 100644 index 0000000000..5c9d57f19d --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/vite-env.d.ts @@ -0,0 +1,9 @@ +interface ImportMetaEnv { + // Hosted docs sandbox only — injected by the docs build. Unset in a clone. + readonly VITE_ABLY_KEY: string; + readonly VITE_NAME: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/examples/pub-sub-live-voting/javascript/vite.config.ts b/examples/pub-sub-live-voting/javascript/vite.config.ts new file mode 100644 index 0000000000..082d066270 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import baseConfig from '../../vite.config'; + +export default defineConfig({ + ...baseConfig, + envDir: '../../', + // Clone-only convenience: when running the client on Vite's dev server, + // proxy the auth + shows API to the local Express server (see ../server). + // The hosted docs sandbox ignores this — it runs without a backend. + server: { + proxy: { + '/auth': 'http://localhost:3000', + '/api': 'http://localhost:3000', + }, + }, +}); diff --git a/examples/yarn.lock b/examples/yarn.lock index 7962b898be..98121e6414 100644 --- a/examples/yarn.lock +++ b/examples/yarn.lock @@ -835,6 +835,13 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== +"@types/qrcode@^1.5.5": + version "1.5.6" + resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.6.tgz#07c33cb9ec0ad88be4636e636e28e54d99b65f42" + integrity sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw== + dependencies: + "@types/node" "*" + "@types/qs@*": version "6.14.0" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.14.0.tgz#d8b60cecf62f2db0fb68e5e006077b9178b85de5" @@ -1152,6 +1159,11 @@ camelcase-css@^2.0.1: resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746: version "1.0.30001748" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001748.tgz#628a5a9293014e58f8ba1216bb4966b04c58bee0" @@ -1185,6 +1197,15 @@ chownr@^3.0.0: resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4" integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + clone-response@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" @@ -1287,6 +1308,11 @@ debug@^4.1.0, debug@^4.3.1, debug@^4.3.2: dependencies: ms "^2.1.3" +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -1329,6 +1355,11 @@ didyoumean@^1.2.2: resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== +dijkstrajs@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23" + integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA== + dlv@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" @@ -1663,6 +1694,14 @@ finalhandler@1.3.1: statuses "2.0.1" unpipe "~1.0.0" +find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -1733,6 +1772,11 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== +get-caller-file@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" @@ -2120,6 +2164,13 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + locate-path@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" @@ -2385,6 +2436,13 @@ p-cancelable@^2.0.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -2392,6 +2450,13 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + p-locate@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" @@ -2399,6 +2464,11 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + package-json-from-dist@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" @@ -2469,6 +2539,11 @@ pirates@^4.0.1: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + postcss-combine-duplicated-selectors@^10.0.3: version "10.0.3" resolved "https://registry.yarnpkg.com/postcss-combine-duplicated-selectors/-/postcss-combine-duplicated-selectors-10.0.3.tgz#71e8b6783e99cd560cf08ba7b896ad0db318c11c" @@ -2561,6 +2636,15 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +qrcode@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88" + integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg== + dependencies: + dijkstrajs "^1.0.1" + pngjs "^5.0.0" + yargs "^15.3.1" + qs@6.13.0: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" @@ -2647,6 +2731,16 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + resolve-alpn@^1.0.0: version "1.2.1" resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" @@ -2777,6 +2871,11 @@ serve-static@1.16.2: parseurl "~1.3.3" send "0.19.0" +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -2863,7 +2962,7 @@ statuses@2.0.1: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@^4.1.0: +string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3130,6 +3229,11 @@ vite@5: optionalDependencies: fsevents "~2.3.3" +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -3151,6 +3255,15 @@ word-wrap@^1.2.5: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -3170,6 +3283,11 @@ ws@^8.17.1: resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" @@ -3180,6 +3298,31 @@ yallist@^5.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533" integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" diff --git a/gatsby-config.ts b/gatsby-config.ts index a6a5597171..0d1665eac7 100644 --- a/gatsby-config.ts +++ b/gatsby-config.ts @@ -150,6 +150,11 @@ export const plugins = [ options: { name: `examples`, path: `${__dirname}/examples`, + // Examples can contain nested projects (e.g. a server) with their own + // installed deps and build output. Never source those — a node_modules + // under an `//` dir would otherwise be picked up as example + // files for that language. + ignore: ['**/node_modules/**', '**/dist/**'], }, }, { diff --git a/src/components/Examples/ExamplesRenderer.tsx b/src/components/Examples/ExamplesRenderer.tsx index 8ccadbc0e3..e2498a33cf 100644 --- a/src/components/Examples/ExamplesRenderer.tsx +++ b/src/components/Examples/ExamplesRenderer.tsx @@ -52,6 +52,7 @@ const getDependencies = (id: string, products: string[], activeLanguage: Languag : {}), ...(products.includes('spaces') ? { '@ably/spaces': '~0.4.0' } : {}), ...(id === 'spaces-component-locking' ? { 'usehooks-ts': '^3.1.0' } : {}), + ...(id === 'pub-sub-live-voting' ? { qrcode: '^1.5.4' } : {}), ...(activeLanguage === 'react' || products.includes('chat') || products.includes('spaces') ? { react: '^18', @@ -200,8 +201,17 @@ const ExamplesRenderer = ({ showRefreshButton {...(id === 'pub-sub-message-encryption' && { startRoute: '?encrypted=true' })} {...(id === 'pub-sub-message-annotations' && { startRoute: '?clientId=user1' })} + {...(id === 'pub-sub-live-voting' && { startRoute: '?role=voter' })} + /> + - {isDoubleLayout && (
      @@ -210,8 +220,9 @@ const ExamplesRenderer = ({ showOpenInCodeSandbox={false} startRoute="/?publisher=false" {...(id === 'pub-sub-message-annotations' && { startRoute: '?publisher=false&clientId=user2' })} + {...(id === 'pub-sub-live-voting' && { startRoute: '?role=presenter&demo=1' })} /> - +
      )} diff --git a/src/data/examples/index.ts b/src/data/examples/index.ts index a09a3d2fa9..f47303e07c 100644 --- a/src/data/examples/index.ts +++ b/src/data/examples/index.ts @@ -249,6 +249,24 @@ export const examples: Example[] = [ metaTitle: `Implement message annotations with the Ably Pub/Sub SDK`, metaDescription: `Use the Ably Pub/Sub JavaScript SDK to implement message annotations. Annotate messages with additional data and view summaries of the annotations.`, }, + { + id: 'pub-sub-live-voting', + name: 'Live voting with annotations', + description: 'Run live polls and tally votes in realtime using message annotations.', + products: ['pubsub'], + languages: ['javascript'], + layout: 'double-horizontal', + visibleFiles: [ + 'src/script.ts', + 'src/shared/ably.ts', + 'src/voter.ts', + 'src/presenter.ts', + 'src/shared/types.ts', + 'src/demo-shows.json', + ], + metaTitle: `Build live voting using annotations with the Ably Pub/Sub SDK`, + metaDescription: `Build a realtime live-voting app with Ably message annotations. Tally votes server-side with unique-vote summaries and stream live results to a presenter view.`, + }, { id: 'spaces-avatar-stack', name: 'Avatar stack', diff --git a/src/images/examples/pub-sub-live-voting.png b/src/images/examples/pub-sub-live-voting.png new file mode 100644 index 0000000000000000000000000000000000000000..ee588d936805299da88ae45045319b968c1cf77a GIT binary patch literal 30935 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 Date: Wed, 3 Jun 2026 16:44:48 +0100 Subject: [PATCH 2/2] feat(examples): point live-voting source links at standalone repo Add an optional `githubUrl` to example metadata so the "View on GitHub" button can target a canonical home other than the docs repo, and set it for pub-sub-live-voting to ably-demos/live-voting-with-annotations. Repoint the README's clone/source links to the same standalone repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pub-sub-live-voting/javascript/README.md | 60 +++++-------------- src/data/examples/index.ts | 1 + src/data/examples/types.ts | 4 ++ src/templates/examples.tsx | 4 +- 4 files changed, 24 insertions(+), 45 deletions(-) diff --git a/examples/pub-sub-live-voting/javascript/README.md b/examples/pub-sub-live-voting/javascript/README.md index a8ebef68c8..941c4ebbed 100644 --- a/examples/pub-sub-live-voting/javascript/README.md +++ b/examples/pub-sub-live-voting/javascript/README.md @@ -41,7 +41,7 @@ modes, each scoped to the view that needs it. cannot publish poll messages, and cannot subscribe to other voters' raw annotations, so it can't tamper with the poll or snoop on individual votes. See the per-role capabilities in -[`server/src/server.ts`](https://github.com/ably/docs/tree/main/examples/pub-sub-live-voting/javascript/server/src/server.ts). +[`server/src/server.ts`](https://github.com/ably-demos/live-voting-with-annotations/blob/main/server/src/server.ts). **One vote per person, enforced by Ably.** Votes use the `unique` aggregation (`vote:unique.v1`). In this aggregation mode, Ably keeps at most one vote per @@ -137,49 +137,21 @@ namespace before running this against your own app. ## Getting started -The live demo above runs entirely in the browser. The full app — the -voter/presenter/admin client plus the backend (`server/`), the poll data -(`data/`) and the database schema (`database/`) — all lives under this -`javascript/` folder, so "Open in CodeSandbox" gives you the whole project. To -run it locally: - -1. Clone the [Ably docs](https://github.com/ably/docs) repository: - - ```sh - git clone git@github.com:ably/docs.git - cd docs/examples/pub-sub-live-voting/javascript - ``` - -2. Run the client. From the docs repo's `examples/` directory: - - ```sh - yarn install - yarn pub-sub-live-voting-javascript # Vite dev server on http://localhost:5173 - ``` - - Vite proxies `/auth` and `/api` to the server on port 3000. - -3. Run the server (in a separate terminal). It's a standalone sub-project: - - ```sh - cd examples/pub-sub-live-voting/javascript/server - cp .env.example .env # set ABLY_API_KEY (keyName:keySecret) and ADMIN_PASSWORD - npm install - npm run dev # auth + polls API on http://localhost:3000 - ``` - - By default the server reads polls from the static `data/demo-shows.json` file - (`SHOWS_FILE`); to use Postgres instead, unset `SHOWS_FILE` and create the - schema with the SQL in `database/`. - -4. Open the client. The default view is the voter; add `?role=admin` to drive - the show and `?role=presenter` for the big screen. The admin generates a - session and a QR code that points voters at the right `?s=` session. - -In this clone the client never sees an API key — it calls the server's `/auth` -endpoint for short-lived, role-scoped tokens. The hosted demo above has no -backend, so it uses a raw key embedded in the page; this is just for demo -purposes and should never be done in the real app. +The live demo above runs entirely in the browser. For a complete app you can +clone and run yourself — admin, voter and presenter, with proper token +authentication and none of the in-page scaffolding — get +[ably-demos/live-voting-with-annotations](https://github.com/ably-demos/live-voting-with-annotations). + +Its README has the full setup, but in short: give the server an Ably API key and +an admin password, pick the static (`SHOWS_FILE`) or Postgres poll store, then +run the server and the client. The default view is the voter; `?role=admin` +drives the show and `?role=presenter` is the big screen, and the admin shows a QR +code that points voters at the right session. + +The client never sees an API key — it calls the server's `/auth` endpoint for +short-lived, role-scoped tokens. The hosted demo above has no backend, so it uses +a raw key embedded in the page; that's just for the demo and should never be done +in a real app. ## Related diff --git a/src/data/examples/index.ts b/src/data/examples/index.ts index f47303e07c..21e57f3987 100644 --- a/src/data/examples/index.ts +++ b/src/data/examples/index.ts @@ -256,6 +256,7 @@ export const examples: Example[] = [ products: ['pubsub'], languages: ['javascript'], layout: 'double-horizontal', + githubUrl: 'https://github.com/ably-demos/live-voting-with-annotations', visibleFiles: [ 'src/script.ts', 'src/shared/ably.ts', diff --git a/src/data/examples/types.ts b/src/data/examples/types.ts index abdfa6f87d..879713bba4 100644 --- a/src/data/examples/types.ts +++ b/src/data/examples/types.ts @@ -10,6 +10,10 @@ export type Example = { visibleFiles?: string[]; metaTitle?: string; metaDescription?: string; + // Override for the "View on GitHub" button. Defaults to this example's folder + // in the ably/docs repo; set it when the canonical source lives elsewhere + // (e.g. a standalone ably-demos repo). + githubUrl?: string; }; export type ExampleWithContent = Example & { diff --git a/src/templates/examples.tsx b/src/templates/examples.tsx index 67cd3222a2..023e3c5774 100644 --- a/src/templates/examples.tsx +++ b/src/templates/examples.tsx @@ -171,7 +171,9 @@ const Examples = ({ pageContext }: { pageContext: { example: ExampleWithContent ) : null}