Live communities in the browser. Find a session, register with a name and an email, and take part with polls, quizzes, chat and Q&A on the slide.
SocialTypeScript · PLpgSQLGitHub @rafcastoOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗tertulia.cloud (formerly Cueline). Webinars with the engagement built in. The presenter uploads a deck as a PDF, presents it in the room, and launches polls, quizzes, word clouds and calls to action as overlays for everyone watching. Each activity can carry a timer; when it runs out the presenter reveals the results as an overlay. Participants react at any time and see a waiting room before the session starts.
The full design is in docs/DESIGN.md; a feature and landing-page comparison with Univid is in docs/COMPETITOR-UNIVID.md; tiers, costs and capacity on the self-hosted LiveKit node are in docs/BUSINESS-MODEL.md. This repo is the Phase 1 build: rooms the host creates (each with its own events, email sequence, deck, activities and waiting room), presenters on WebRTC, viewers on HLS, PDF deck, activities, Q&A, reactions, waiting room, recording. No tiers, billing or signup yet.
| Path | What |
|---|---|
supabase/migrations | Schema. Every table carries org_id; session tables carry session_id. RLS, the session:{id} broadcast trigger, timer and quiz-scoring triggers, run_tallies and quiz_scores views, storage buckets, room email sequences and their inheritance by events. |
supabase/tests | Stubs + smoke test that exercise the migration against plain Postgres (pnpm db:test). |
apps/web | Next.js on Vercel: landing page (/), the public directory (/explore, /explore/[slug]), invite link and registration (/join/[token]), the typed door (/join, email + code) and the personal link (/a/[token]), participant room (/r/[slug]), the host's rooms (/host) and the host panel per room (/host/[slug]), and the functions that need the service key (/api/*). |
Landing page. The community front door, on the Meetup pattern and with its restraint (a white page, one big promise, whitespace and type for hierarchy, no panels or product mocks): a search box into Explore, the next public events and the listed rooms (read live from the directory; empty states when there are none or it cannot be reached), topic chips, how it works for someone joining and for someone organising, the plans, and Start a community as the host door. Google Analytics 4 (components/Analytics, measurement ID in NEXT_PUBLIC_GA_MEASUREMENT_ID) loads from the root layout in production builds only; every call to action carries a data-track value that is reported as a cta_click event. Attendees who already registered join with the email and the six-character code from their welcome email, or skip the typing with the personal link in that email.
Rooms. A host creates rooms from /host. A room is a standing space: its own plan, events and invite links, email sequence, deck, activities, waiting room and branding. The plan belongs to the room: an organisation's first room is free, upgrading it makes it a Starter or Pro room, and every further room is bought as its own Starter or Pro subscription from /host/billing. A free room never sits next to a paid one: a second room cannot be added until the first is upgraded, and a paid room whose subscription lapses while the organisation has other rooms is locked (cannot open, go live or take new events) until it is paid again or the others are deleted down to one (roomAccess() in apps/web/src/lib/billing.ts). Platform admins are exempt from all of this: the rooms they create are complimentary, run at Pro, and are never capped or locked. On the way in the host says what the room is about, picking topics the way a Meetup group picks categories (TopicPicker, suggestions in apps/web/src/lib/topics.ts), so it lands under the right filters on Explore; the Settings tab changes the topics, renames the room, changes its /r/ address, sets its branding, or deletes it (which cancels the reminders still scheduled for its registrants).
Branding. Per room (rooms.branding, helpers in apps/web/src/lib/branding.ts): an accent colour, a logo and a standing call to action. The accent is applied as CSS variables on the room and on the invite page, so every button, badge, chosen answer and result bar takes the room's colour; the text on it flips to dark for light colours. The logo (PNG/JPEG/WebP/GIF in the media bucket) replaces the product mark in the room header and sits by the room name on the invite page. The call to action is a label and an https link, shown as a button in the room header (and under the stage on phones) for the whole session. The Settings tab previews all three with the real components before saving; /api/rooms validates the colour and the links.
Invite link and registration. The organiser creates an event (title, start time, blurb, seats) in the host panel, which is also an invite link. Attendees open /join/<token>, see how many seats are left, register with a name and email, and the same page becomes the door on the day: its button follows the session state. /r/<slug> sends anyone without a seat to the room's current link; org members walk in. Registrations bind to the attendee's anonymous identity; the personal link (/a/<token>) and the email + code form re-bind it, so a second device works. The host panel shows seated, waiting and joined. The reasoning and what to measure are in docs/INVITE-FLOW.md.
Seats, the waiting list and opting out. Each event has the seats the host gives it, up to the plan's participants per session (invites.capacity; blank means the plan's maximum, and a downgrade shrinks every event without unseating anyone). register_attendee() seats a registrant while seats remain and puts them on the waiting list otherwise, under a lock on the event so the last seat is never given twice; the join page tells someone their place in line, and the Explore card shows Full · waiting list. Every email carries {{cancel_url}} (/a/<token>/cancel): a registrant who is not coming frees their seat there or from the event page, their pending emails are pulled back, and promote_waitlist() moves the first person in line into the seat and sends them the welcome and reminders. More seats, or a bigger plan, fill from the waiting list the same way. Helpers in apps/web/src/lib/seats.ts.
Event emails. Each room carries an email sequence: a welcome sent on registration with the personal link and code (or a waiting-list note when the seats are gone), then reminders at 48 hours, 24 hours, 2 hours and 15 minutes before the start, each asking anyone who is not coming to free their seat. Every event created in the room inherits the sequence, and the send times are counted back from that event's start. Reminders are a paid feature: the free plan sends the welcome and the waiting-list note only, and the Emails tab shows the reminders locked. The host edits copy and timing once per room in the Emails tab; the change reaches every open event and reschedules it at Resend, and changing an event's start time moves its reminders. Details in docs/EMAILS.md.
Explore. A public directory at /explore, the way Meetup lists groups and events: upcoming public events across every listed room (search, topic chips, people going, register in one tap) and the rooms themselves, each with a public page at /explore/<slug> showing its description, topics, upcoming and past events and a way in when it is live. A host lists a room from its Settings tab (public listing, description, up to six topics) and ticks List on Explore on each event they want shown; an event is only listed while its room is. Visitors read two views (public_rooms, public_events) that expose published rows and counts and nothing else; helpers in apps/web/src/lib/explore.ts.
Plans and billing. Free, Starter and Pro with the caps in docs/BUSINESS-MODEL.md, one subscription per room, paid through Stripe Checkout from /host/billing (rooms.plan, rooms.plan_status; the subscription and its period in room_subscriptions, which attendees cannot read; the Stripe customer on the organisation). The plan is enforced when a room is created or opened (first room free, the rest bought; a locked room does not open), when an event's seats are set (up to the room's participants), when registrations queue their emails (reminders on paid rooms only), when a session goes live (the room's sessions this month, audience cap on the LiveKit room, recording only where included, a hard stop on length) and when attendees ask for a token. Both Stripe modes are configured at once and a platform admin (pnpm admin:create … --super) switches new payments between the sandbox and the live account on the billing page. Wiring in docs/STRIPE.md.
Video room. LiveKit Cloud behind apps/web/src/lib/video.ts. Everyone joins the room over WebRTC: presenters and promoted participants publish, the audience subscribes only (LiveViewer), so they hear and see the presenters with sub-second latency. One room-composite egress per session writes the recording and an HLS playlist; the playlist is the fallback (HlsPlayer) when the room cannot take a participant or a join keeps failing, and sits 6-10 s behind. It takes a few seconds to appear after "Go live", so the player keeps asking for it (and reconnects after a drop) instead of giving up; where the browser refuses autoplay with sound either view starts muted and shows Tap for sound. The bucket behind RECORDING_PUBLIC_BASE_URL must allow cross-origin GETs from the app and must not cache .m3u8 files, or hls.js can neither fetch nor follow the live playlist. Promote-to-stage is stage_roster state (viewer → invited → live → revoked).
Deck. A PDF in the presentations storage bucket. The presenter steps through it (arrow keys or buttons); each navigation inserts a slide_events row and every participant renders that page from the same PDF with pdf.js.
Stage layout. Where the presenter video sits against the deck page, chosen by the host in the live panel: Corner (the deck with the camera bottom-right, the default), Talking head (the camera fills the stage, the deck is hidden), Side by side (deck left, camera right, half each) or Custom (the host drags the camera anywhere on the stage and resizes it from its corner; arrow keys nudge it, plus and minus resize it). The host's stage is the same component attendees see (StageCanvas), around the host's own camera, so what the host sees is what attendees get; dragging the camera in any preset switches to custom from where it was. The layout is one jsonb on the session (sessions.layout, helpers in apps/web/src/lib/stage-layout.ts), so a change rides the session broadcast and attendees follow at once; the room keeps the last choice (rooms.stage_layout) as the starting layout of the next session. Geometry is in percent of a 16:9 stage, where a 16:9 box is as tall as it is wide, so a custom position stores only x, y and w. The camera is a rounded rectangle or, with Camera shape set to Circle, a circle (shape: "circle" on the same jsonb; absent means rectangle, so older rows read unchanged): square in pixels, so w is its diameter and it may be no taller than the stage. The shape shows in the corner and custom placements, where the camera is a thumbnail, and is kept through a talking head or side by side so it comes back with the corner.
Activities. The presenter authors polls, quizzes (with correct answers and points), word clouds and CTAs in the host panel, each with an optional timer. Activating one inserts an activity_run; a trigger sets closes_at from the timer. Participants answer in an overlay that is never taller than the screen: it is capped at the height of the layer it sits in and scrolls inside, and on phones (below lg) that layer is the viewport rather than the short slide strip, so a long quiz is reached by scrolling the card instead of being clipped. The database rejects answers after the timer or after the presenter closes the run, and scores quizzes server-side. "Show results to participants" flips results_shown_at and the overlay switches to tallies, the word cloud, or the quiz leaderboard. "Hide from participants" sets hidden_at and "Show to participants" clears it, at any time and as often as needed (a call to action has only these two); the run keeps its answers either way, and activating from the library starts a fresh run. Participants fetch an activity with its run, so one created or edited while they were already in the room still shows.
Realtime. One broadcast channel per session. Clients never subscribe to postgres_changes; a trigger calls realtime.broadcast_changes on every write and the client applies a reducer (apps/web/src/lib/session-state.ts). The snapshot is taken once the channel is subscribed and again after every reconnect, since broadcasts missed while offline never replay; viewers subscribe only once their anonymous identity exists, because the private channel and most tables need it. Participants delay slide changes and activity openings by the transport's live-edge latency so they land with the video: 0 over WebRTC, the player's estimate on the HLS fallback, 0 while nothing is playing.
Chat. One message stream per session, next to Q&A: attendees chat while the doors are open and during the session, hosts chat from the live panel. In the live room the chat and Q&A panel is as tall as the stage beside it and its list scrolls inside, with the composer fixed at the bottom; on phones it follows the stage. Messages are rows in chat_messages and arrive through the same broadcast; the database fills the org, trims the body, only accepts messages from someone in the room while the session is open, and holds a sender to five messages in ten seconds. The host hides a message (it leaves every screen; its author sees it was removed; the host can restore it) and switches chat off per room in Settings.
Reactions. Like, heart, laugh, sad, wow, clap. Taps batch client-side and land as one row per second per kind through bump_reaction(); everyone sees them float.
Waiting room. Per room: a picture or a looping video as the backdrop, music that loops on top (or the video's own sound), and text boxes positioned on a 16:9 canvas in a small slide-like editor with a sound preview. Shown while the session is waiting and when the room is closed; the music plays only while attendees wait and stops when the host goes live. Browsers hold sound back until the page has had a tap: attendees who come through the invite page's button already have one and hear it straight away, anyone who lands cold gets a Tap for sound button on the canvas, and everyone can mute it. Uploads are checked against the media bucket (100 MB; PNG/JPEG/WebP/GIF, MP4/WebM, MP3/M4A/OGG/WAV) before they start.
Prerequisites: Node 22, pnpm 10, the Supabase CLI, a LiveKit Cloud project, an S3/R2 bucket with a public base URL, a Stripe account, a Resend account with a verified sending domain. Name each of them cueline.
On the hosted Supabase project, turn on Allow anonymous sign-ins (Authentication → Sign In / Providers) and leave captcha off; attendees get an anonymous identity client-side before they register, and supabase/config.toml only configures the local stack. If attendees see a "Could not start a session" message, it names the setting or limit that is in the way.
pnpm install
cp .env.example .env.local # fill in the keys
supabase start && supabase db reset # applies migrations + seed
pnpm dev # http://localhost:3000
pnpm admin:create <email> <password> --super (uses the service-role key from .env.local; it creates the user, adds them to the seeded org as owner, and with --super makes them a platform admin for the Stripe mode switch). Then sign in at /login with email and password./host. Use the seeded Main room or create a new one. In the room, create an event with a start time and copy its invite link; check the Emails tab (the welcome and the four reminders are already there); upload a PDF, add a few activities, edit the waiting room./join with the email and code. Open waiting room puts the session in waiting; registrants see the waiting room under their registered name. Go live starts the stream. Join with camera publishes your video.pnpm video:check asks LiveKit which rooms are open, who is publishing, and the status and error of every egress, then fetches the playlist each one wrote from RECORDING_PUBLIC_BASE_URL and reports the status code, CORS header and content type. Run it after "Go live" when participants see no video.
Checks: pnpm lint, pnpm typecheck, pnpm test, pnpm build, pnpm db:test (needs local Postgres 16 binaries and a non-root user, no Docker; a smoke check that returns f fails the run).
The Stripe webhook endpoint is /api/stripe/webhook (checkout.session.*, customer.subscription.*, invoice.paid, invoice.payment_failed), one endpoint per Stripe mode, both pointing at the same URL. The email dispatcher cron is /api/emails/dispatch (daily via vercel.json, authorised with CRON_SECRET).
hls.js exposes latency, Safari native HLS does not (falls back to 3 s).X-Frame-Options on the first external CTA targets you plan to embed.