nixamp 0.27.1 → 0.27.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -20
- package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-Bot4FEXQ.css} +1 -1
- package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
- package/backtoschool/dist/index.html +36 -9
- package/dist/accounts.d.ts +2 -0
- package/dist/accounts.js +9 -2
- package/dist/classroom.d.ts +15 -0
- package/dist/classroom.js +54 -0
- package/dist/event-writer.d.ts +39 -0
- package/dist/event-writer.js +139 -0
- package/dist/live-api.d.ts +2 -0
- package/dist/live-api.js +37 -2
- package/dist/live-events.d.ts +7 -1
- package/dist/live-events.js +88 -12
- package/dist/live-interpreter.d.ts +3 -1
- package/dist/live-interpreter.js +109 -83
- package/dist/live-recovery.d.ts +6 -0
- package/dist/live-recovery.js +11 -0
- package/dist/live-voice.d.ts +1 -1
- package/dist/live-voice.js +7 -7
- package/dist/mail-sender.d.ts +9 -0
- package/dist/mail-sender.js +11 -0
- package/dist/notify.d.ts +3 -3
- package/dist/notify.js +8 -5
- package/dist/password-reset-page.d.ts +2 -0
- package/dist/password-reset-page.js +106 -0
- package/dist/password-reset.d.ts +34 -0
- package/dist/password-reset.js +121 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +109 -3
- package/dist/shared-translation.d.ts +1 -0
- package/dist/shared-translation.js +57 -40
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.js +2 -0
- package/dist/translation-passes.d.ts +13 -8
- package/dist/translation-passes.js +31 -20
- package/dist/upgrade-allowance.d.ts +23 -0
- package/dist/upgrade-allowance.js +70 -0
- package/package.json +1 -1
- package/src/accounts.ts +8 -2
- package/src/classroom.ts +52 -0
- package/src/event-writer.ts +126 -0
- package/src/live-api.ts +26 -2
- package/src/live-events.ts +81 -13
- package/src/live-interpreter.ts +87 -68
- package/src/live-recovery.ts +10 -0
- package/src/live-voice.ts +7 -7
- package/src/mail-sender.ts +12 -0
- package/src/notify.ts +10 -7
- package/src/password-reset-page.ts +108 -0
- package/src/password-reset.ts +134 -0
- package/src/server.ts +91 -3
- package/src/shared-translation.ts +47 -33
- package/src/tokens.ts +3 -0
- package/src/translation-passes.ts +35 -25
- package/src/types/auth-system.d.ts +10 -0
- package/src/upgrade-allowance.ts +69 -0
- package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
- package/web/dist/assets/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
- package/web/dist/assets/index-BgLxkXKe.js +3 -0
- package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
- package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-CV0glck7.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
- package/web/dist/index.html +6 -5
- package/web/dist/sw.js +7 -7
- package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
- package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
- package/web/dist/assets/index-3WZwGw04.js +0 -3
package/README.md
CHANGED
|
@@ -261,6 +261,23 @@ on first use in the configured PostgreSQL database. `RESEND_API_KEY` and
|
|
|
261
261
|
`NIXAMP_MAIL_FROM` are optional if invitation email should be sent rather than
|
|
262
262
|
only returning a shareable link.
|
|
263
263
|
|
|
264
|
+
For a shared deployment, set `BACKTOSCHOOL_MAIL_FROM` to a sender on the verified
|
|
265
|
+
`backtoschool.help` Resend domain. School password resets and invitations use
|
|
266
|
+
that sender; `BACKTOSCHOOL_RESEND_API_KEY` optionally selects its own key, otherwise
|
|
267
|
+
it uses `RESEND_API_KEY`. Verify the domain's SPF and DKIM records in Resend before
|
|
268
|
+
enabling the sender. Invitation mail identifies the invitation rather than a follow.
|
|
269
|
+
|
|
270
|
+
The event form's optional **Write with AI** button uses its current title,
|
|
271
|
+
description, and topic as the prompt, with host and schedule context. It previews
|
|
272
|
+
a title, description, and topic for explicit application before saving. Configure
|
|
273
|
+
server-only `OPENAI_API_KEY` and `ANTHROPIC_API_KEY`: OpenAI runs first, with Claude
|
|
274
|
+
as fallback for provider failures or invalid output. Defaults are `gpt-5-mini`
|
|
275
|
+
and `claude-haiku-4-5`; override them with `NIXAMP_WRITER_OPENAI_MODEL` and
|
|
276
|
+
`NIXAMP_WRITER_CLAUDE_MODEL`. Signed-in accounts may request six drafts per ten
|
|
277
|
+
minutes, with one active request per account and four across the server. Requests
|
|
278
|
+
time out after 25 seconds per provider and can be cancelled. Drafting never saves
|
|
279
|
+
an event or changes the schedule, visibility, or broadcast links.
|
|
280
|
+
|
|
264
281
|
## Live shows, and tickets
|
|
265
282
|
|
|
266
283
|
A live event carries a **kind**: `talk`, `class`, or `concert`. The kind is
|
|
@@ -557,6 +574,16 @@ uses its direct model; it does not first translate the audio into English.
|
|
|
557
574
|
|
|
558
575
|
### Hear it in your language
|
|
559
576
|
|
|
577
|
+
Every signed-in account gets **five free live-use sessions per UTC day**, shared
|
|
578
|
+
across Nixamp's paid panels and upgrades. There is no timed cutoff: a session
|
|
579
|
+
continues while its listener remains, within the existing API usage limits.
|
|
580
|
+
Reconnects to the same session within 90 seconds reuse it. A stopped session
|
|
581
|
+
expires after that grace period; starting again then uses another allowance.
|
|
582
|
+
The counter resets at midnight UTC without interrupting an ongoing session.
|
|
583
|
+
Balance polling, captions and individual voice chunks never consume new sessions.
|
|
584
|
+
Free usage has zero customer charge and leaves purchased credit untouched.
|
|
585
|
+
The panel shows the remaining allowance alongside any purchased credit.
|
|
586
|
+
|
|
560
587
|
**Buy translated audio** (`$` in the player or Transcript title bar) offers
|
|
561
588
|
prepaid, account-bound passes: **$5 / 24 hours**, **$25 / 7 days**, or **$100 /
|
|
562
589
|
30 days**. Each purchase provides that many dollars of usage credit, not
|
|
@@ -587,15 +614,15 @@ multiplied by five. Credentials and balances never travel in checkout URLs.
|
|
|
587
614
|
New checkout creation is capped at five per account and fifty per account server
|
|
588
615
|
per UTC day, plus IP and request throttles; retries reuse the original invoice.
|
|
589
616
|
|
|
590
|
-
|
|
617
|
+
After the five free sessions, new sessions require purchased credit. Configure `COINPAY_X402_KEY`
|
|
591
618
|
with `payments:create` permission and at least one business wallet; the scoped
|
|
592
|
-
key supplies the merchant identity.
|
|
619
|
+
key supplies the merchant identity. Free sessions and existing credit still work during a
|
|
593
620
|
checkout outage. A self-hosted operator explicitly sponsoring API usage may set
|
|
594
621
|
`NIXAMP_TRANSLATION_BILLING=off`.
|
|
595
622
|
|
|
596
623
|
Live Nixamp channels share **one recognition, translation, and voice pipeline
|
|
597
|
-
per source and target language** on the account server.
|
|
598
|
-
|
|
624
|
+
per source and target language** on the account server. Each listening account
|
|
625
|
+
uses its own free allowance or credit; joining adds no extra recognition or voice generation.
|
|
599
626
|
The pipeline persists while anyone remains and closes its source and pending
|
|
600
627
|
work when the last listener leaves. Disconnecting one viewer does not stop the
|
|
601
628
|
others. Two connections per account, four active source/language pipelines, and
|
|
@@ -648,7 +675,11 @@ voice player preserves pending speaker turns and fetches the next phrase with
|
|
|
648
675
|
up to three seconds of audio still queued. Speech queues and decoded audio are
|
|
649
676
|
bounded. Old transcript history
|
|
650
677
|
is never spoken. Pause, seek, source changes, and disabling the feature cancel
|
|
651
|
-
queued speech
|
|
678
|
+
queued speech. Temporary connection and provider failures recover automatically
|
|
679
|
+
without restoring original speech. Stale phrases are discarded so playback can
|
|
680
|
+
catch up; three consecutive provider failures or an access/budget error end
|
|
681
|
+
the session with a visible message. Network reconnection is limited to five
|
|
682
|
+
attempts with backoff, reusing the active free session where available. This is a delayed live
|
|
652
683
|
interpreter, not a promise of exact lip sync or word-by-word streaming captions.
|
|
653
684
|
|
|
654
685
|
OpenStream currently compresses server-to-server relays, not this browser
|
|
@@ -659,23 +690,25 @@ codecs. The short-window overlap ratio and audio-second spending limits remain
|
|
|
659
690
|
unchanged; smaller windows do not increase the steady-state audio submitted.
|
|
660
691
|
|
|
661
692
|
Translated playback keeps an approximate version of the original background
|
|
662
|
-
sound. FastEnhancer Web's
|
|
663
|
-
browser worker.
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
processing adds no API calls or provider
|
|
668
|
-
recognition starts without waiting for it.
|
|
669
|
-
frames and recover automatically, with
|
|
670
|
-
background delay. A model or device
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
[FastEnhancer Web](https://github.com/ryyr-ry/fastenhancer-web)
|
|
675
|
-
license.
|
|
693
|
+
sound. FastEnhancer Web's Base model estimates speech locally in a dedicated
|
|
694
|
+
browser worker. Every audible channel, including rear and side channels, enters
|
|
695
|
+
the speech estimate. A complementary spectral mask removes estimated voice
|
|
696
|
+
frequencies independently from the left and right source channels, preserving
|
|
697
|
+
their stereo phase. One speech model serves both channels to leave processing
|
|
698
|
+
capacity for video playback. Background processing adds no API calls or provider
|
|
699
|
+
charges. It stops with translation; recognition starts without waiting for it.
|
|
700
|
+
Brief processing stalls drop stale frames and recover automatically, with
|
|
701
|
+
bounded work and at most about 130 ms of background delay. A model or device
|
|
702
|
+
failure silences that branch while translated speech continues. Separation of
|
|
703
|
+
mixed dialogue and background remains approximate. Disable **Keep background
|
|
704
|
+
sound** under **Audio options** when needed. This uses
|
|
705
|
+
[FastEnhancer Web](https://github.com/ryyr-ry/fastenhancer-web) and
|
|
706
|
+
[FFT.js](https://github.com/indutny/fft.js), under the MIT license.
|
|
676
707
|
|
|
677
708
|
**Background level**, in the same collapsed options, balances the separated
|
|
678
|
-
sound from 0–200% (100% by default)
|
|
709
|
+
sound from 0–200% (100% by default), with a fixed +9.5 dB makeup gain after
|
|
710
|
+
separation. There is no automatic gain control, fade, or ducking triggered by
|
|
711
|
+
translated voices. It never mixes the original dialogue back
|
|
679
712
|
in as a fallback. Increasing it also amplifies any speech the model fails to
|
|
680
713
|
remove. This is an approximate local separator, not lossless dialogue removal.
|
|
681
714
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
:root{--ink:#201a32;--purple:#34246d;--purple-2:#5b43a4;--coral:#f56f5d;--yellow:#ffd465;--paper:#f7f2e8;--paper-deep:#eee5d6;--white:#fffdf8;--green:#217a53;--muted:#6f6879;--line:#201a3224;--shadow:0 18px 50px #34246d1f;color:var(--ink);background:var(--paper);font-synthesis:none;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{scroll-behavior:smooth}body{background:var(--paper);min-width:320px;min-height:100vh;margin:0}button,input,select,textarea{font:inherit}button,a{-webkit-tap-highlight-color:transparent}a{color:inherit}[hidden]{display:none!important}body:before{content:"";pointer-events:none;opacity:.34;background-image:radial-gradient(#34246d1f .65px,#0000 .65px);background-size:7px 7px;position:fixed;inset:0;-webkit-mask-image:linear-gradient(#000,#0000 55%);mask-image:linear-gradient(#000,#0000 55%)}#app{flex-direction:column;min-height:100vh;display:flex;position:relative}.site-header,main,.site-footer{width:min(1180px,100% - 40px);margin-inline:auto}.site-header{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:24px;min-height:82px;display:flex}.wordmark{color:var(--purple);letter-spacing:-.035em;align-items:center;gap:10px;font-size:1.12rem;font-weight:850;text-decoration:none;display:inline-flex}.wordmark>span:last-child span{color:var(--coral)}.wordmark-mark{width:34px;height:34px;color:var(--white);background:var(--purple);box-shadow:3px 3px 0 var(--yellow);border-radius:10px 10px 10px 3px;place-items:center;font-family:Georgia,serif;font-size:1.2rem;display:grid;transform:rotate(-3deg)}.site-nav{align-items:center;gap:28px;font-size:.91rem;display:flex}.site-nav a{color:var(--muted);text-decoration:none}.site-nav a:hover{color:var(--purple)}main{outline:none;flex:1}.loading-page,.error-page{text-align:center;place-content:center;min-height:68vh;display:grid}.loading-page h1,.error-page h1{color:var(--purple);margin:8px 0;font:700 clamp(2.4rem,8vw,5rem)/1 Georgia,serif}.loading-page p,.error-page p{color:var(--muted)}.hero{grid-template-columns:minmax(0,1.05fr) minmax(360px,.8fr);align-items:center;gap:clamp(50px,8vw,110px);min-height:610px;padding:92px 0 78px;display:grid}.eyebrow{color:var(--coral);letter-spacing:.13em;text-transform:uppercase;font-size:.75rem;font-weight:850;display:block}.hero h1{max-width:690px;color:var(--purple);letter-spacing:-.065em;margin:14px 0 24px;font:750 clamp(3.6rem,8vw,7.3rem)/.86 Georgia,Times New Roman,serif}.hero h1 em{color:var(--coral);font-weight:inherit}.hero-copy>p{max-width:590px;color:var(--muted);margin:0;font-size:clamp(1.02rem,1.5vw,1.22rem);line-height:1.7}.hero-actions{flex-wrap:wrap;gap:12px;margin-top:34px;display:flex}.hero-copy .hero-note{margin-top:15px;font-size:.82rem}.button{cursor:pointer;border:0;border-radius:999px;justify-content:center;align-items:center;gap:9px;min-height:44px;padding:0 22px;font-weight:780;text-decoration:none;transition:transform .16s,box-shadow .16s,background .16s;display:inline-flex}.button:hover:not(:disabled){transform:translateY(-2px)}.button:focus-visible,.text-button:focus-visible,a:focus-visible{outline-offset:3px;outline:3px solid #f56f5d66}.button:disabled{cursor:wait;opacity:.65}.button-primary{color:#fff;background:var(--purple);box-shadow:0 8px 22px #34246d3d}.button-primary:hover:not(:disabled){background:var(--purple-2);box-shadow:0 12px 28px #34246d47}.button-secondary{color:var(--purple);background:0 0;border:1px solid #34246d47}.button-light{color:var(--purple);background:var(--white)}.button-danger{color:#902d27;background:#ffe5df}.button-quiet{min-height:36px;color:var(--purple);background:#34246d12;padding-inline:17px}.button-quiet.signed-in{color:var(--green);background:#217a531a}.button-small{color:#fff;background:var(--purple);min-height:38px;padding-inline:16px;font-size:.84rem}.live-dot{background:var(--coral);border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #f56f5d2e}.hero-board{color:#fffbe9;background:#285e4d;border:10px solid #bd8654;border-radius:6px;flex-direction:column;justify-content:center;min-height:390px;padding:58px 50px 44px;display:flex;position:relative;transform:rotate(1.2deg);box-shadow:0 26px 60px #201a3233,inset 0 0 50px #0000001f}.hero-board:before{content:"";opacity:.2;background-image:linear-gradient(95deg,#0000 49%,#fff3 50%,#0000 52%),radial-gradient(#fff 0,#0000 55%);background-size:220px 100%,210px 35px;position:absolute;inset:0}.tape{z-index:2;background:#ffdd8dc2;width:86px;height:28px;position:absolute}.tape-one{top:-23px;left:40px;transform:rotate(-7deg)}.tape-two{bottom:-20px;right:28px;transform:rotate(-5deg)}.board-label{opacity:.75;font:italic 1rem Georgia,serif;position:relative}.hero-board strong{letter-spacing:-.035em;max-width:390px;margin:18px 0 15px;font:600 clamp(2rem,4vw,3.2rem)/1.05 Georgia,serif;position:relative}.hero-board p{opacity:.72;margin:0;position:relative}.board-action{color:#fff;cursor:pointer;background:0 0;border:0;border-bottom:1px solid #ffffff80;align-self:flex-start;margin-top:34px;padding-bottom:4px;font-weight:750;text-decoration:none;position:relative}.board-action span{color:var(--yellow)}.board-rule{background:var(--yellow);width:58px;height:3px;position:absolute;top:38px;right:40px;transform:rotate(-4deg)}.board-doodles{opacity:.34;gap:14px;font:2rem Georgia,serif;display:flex;position:absolute;bottom:26px;right:30px;transform:rotate(-8deg)}.event-section{padding:88px 0}.event-section-tint{position:relative}.event-section-tint:before{content:"";z-index:-1;background:var(--paper-deep);position:absolute;inset:24px calc(50% - 50vw)}.section-heading{justify-content:space-between;align-items:end;gap:20px;margin-bottom:30px;display:flex}.section-heading h2,.host-callout h2{color:var(--purple);letter-spacing:-.045em;margin:7px 0 0;font:700 clamp(2.3rem,5vw,4rem)/1 Georgia,serif}.section-count{color:var(--muted);font-size:.84rem}.event-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;display:grid}.event-card{border:1px solid var(--line);background:#fffdf8bd;border-radius:20px;min-height:285px;transition:transform .18s,box-shadow .18s;box-shadow:0 4px 16px #34246d0a}.event-card:hover{box-shadow:var(--shadow);transform:translateY(-5px)rotate(-.3deg)}.event-card.is-live{border-color:#f56f5d73}.event-card>a{flex-direction:column;height:100%;padding:26px;text-decoration:none;display:flex}.event-card-top{justify-content:space-between;align-items:center;gap:12px;display:flex}.status-pill,.topic{width:-moz-fit-content;width:fit-content;min-height:25px;color:var(--purple);letter-spacing:.06em;text-transform:uppercase;background:#34246d14;border-radius:999px;align-items:center;padding:3px 10px;font-size:.69rem;font-weight:850;display:inline-flex}.status-pill.status-live{color:#a42d25;background:#ffe1db}.status-pill.status-live:before{content:"";background:var(--coral);border-radius:50%;width:6px;height:6px;margin-right:6px;animation:1.8s infinite pulse}.topic{color:var(--green);background:#217a5317}.topic-large{margin-top:18px}.event-card h3{color:var(--ink);letter-spacing:-.025em;margin:28px 0 11px;font:700 1.55rem/1.14 Georgia,serif}.event-card p{color:var(--muted);-webkit-line-clamp:3;-webkit-box-orient:vertical;margin:0;line-height:1.55;display:-webkit-box;overflow:hidden}.event-link{color:var(--purple);justify-content:space-between;margin-top:auto;padding-top:24px;font-size:.86rem;font-weight:800;display:flex}.empty-card{text-align:center;border:1px dashed #34246d40;border-radius:20px;grid-column:1/-1;padding:55px 30px}.empty-card>span{font-size:2rem}.empty-card h3{margin:12px 0 6px;font:700 1.5rem Georgia,serif}.empty-card p,.empty-line{color:var(--muted)}.upcoming-list{border-top:1px solid var(--line)}.upcoming-row{border-bottom:1px solid var(--line);grid-template-columns:minmax(130px,.34fr) 1fr 34px;align-items:center;gap:25px;min-height:92px;padding:16px 8px;text-decoration:none;display:grid}.upcoming-row:hover strong,.upcoming-row:hover .row-arrow{color:var(--coral)}.upcoming-row time{color:var(--purple);font-size:.83rem;font-weight:780}.upcoming-row>span:nth-child(2){flex-direction:column;gap:6px;display:flex}.upcoming-row strong{font:650 1.22rem Georgia,serif;transition:color .16s}.upcoming-row small{color:var(--muted)}.row-arrow{color:var(--purple);font-size:1.3rem}.host-callout{color:#fff;background:var(--purple);border-radius:28px;margin:90px 0 100px;padding:clamp(42px,7vw,78px);position:relative;overflow:hidden}.host-callout:after{content:"?";color:#ffffff0f;font:500 25rem Georgia,serif;position:absolute;top:-32%;right:5%;transform:rotate(12deg)}.host-callout .eyebrow{color:var(--yellow)}.host-callout h2{z-index:1;color:#fff;position:relative}.host-callout p{z-index:1;opacity:.75;max-width:520px;line-height:1.6;position:relative}.host-callout .button{z-index:1;margin-top:16px;position:relative}.site-footer{min-height:100px;color:var(--muted);border-top:1px solid var(--line);justify-content:space-between;align-items:center;font-size:.82rem;display:flex}.site-footer a{color:var(--purple);font-weight:750}.dialog{width:min(480px,100% - 28px);color:var(--ink);background:var(--white);border:0;border-radius:24px;padding:38px;box-shadow:0 24px 90px #201a324d}.dialog::backdrop{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background:#201a3294}.dialog h2{color:var(--purple);margin:7px 0 10px;font:700 2.2rem Georgia,serif}.dialog>p{color:var(--muted);margin-top:0;line-height:1.5}.dialog-wide{width:min(650px,100% - 28px)}.dialog-close{width:35px;height:35px;color:var(--muted);cursor:pointer;background:#34246d12;border:0;border-radius:50%;font-size:1.4rem;position:absolute;top:14px;right:18px}.form-stack{gap:17px;margin-top:28px;display:grid}.form-stack.compact{gap:12px;margin-top:0}.form-stack label{color:var(--ink);gap:7px;font-size:.76rem;font-weight:790;display:grid}.form-stack input,.form-stack select,.form-stack textarea,.inline-form input,.share-box input{border:1px solid var(--line);width:100%;min-height:46px;color:var(--ink);background:#fff;border-radius:11px;outline:none;padding:10px 13px}.form-stack textarea{resize:vertical}.form-stack input:focus,.form-stack select:focus,.form-stack textarea:focus,.inline-form input:focus{border-color:var(--purple-2);box-shadow:0 0 0 3px #5b43a41f}.form-stack>.button{width:100%;margin-top:4px}.form-stack.compact>.button{width:-moz-fit-content;width:fit-content}.form-note,.form-error{min-height:1.2em;color:var(--muted);overflow-wrap:anywhere;margin:0;font-size:.76rem}.form-error{color:#a42d25}.form-note:empty,.form-error:empty{display:none}.form-checks{color:var(--muted);flex-wrap:wrap;gap:8px 18px;padding:3px 0;font-size:.8rem;display:flex}.form-stack .form-checks label{cursor:pointer;align-items:center;gap:7px;font-weight:600;display:flex}.form-stack .form-checks input{width:16px;height:16px;min-height:0;accent-color:var(--purple);margin:0;padding:0}.form-stack .form-checks label:has(input:checked){color:var(--purple)}.form-pair{grid-template-columns:1fr 1fr;gap:14px;display:grid}.text-button{width:-moz-fit-content;width:fit-content;color:var(--purple);cursor:pointer;background:0 0;border:0;border-bottom:1px solid #0000;padding:0;font-weight:750}.text-button:hover{border-bottom-color:currentColor}.event-shell{padding:42px 0 100px}.back-link{color:var(--muted);margin-bottom:28px;font-size:.84rem;font-weight:700;text-decoration:none;display:inline-block}.back-link:hover{color:var(--purple)}.event-layout{grid-template-columns:minmax(0,1.55fr) minmax(280px,.75fr);align-items:start;gap:18px;display:grid}.region{gap:18px;min-width:0;display:grid}.region-sidebar,.region-bottom,.region-drawer{grid-column:1/-1;grid-template-columns:repeat(auto-fit,minmax(270px,1fr))}.event-panel{border:1px solid var(--line);background:#fffdf8d1;border-radius:20px;min-width:0;padding:28px;position:relative;box-shadow:0 4px 20px #34246d0a}.panel-title{color:var(--muted);letter-spacing:.1em;text-transform:uppercase;margin-bottom:18px;font-size:.68rem;font-weight:850;display:block}.panel-event-header{color:#fff;background:var(--purple);border:0;align-items:end;min-height:320px;padding:clamp(34px,6vw,62px);display:flex;overflow:hidden}.panel-event-header:after{content:"✦";color:#ffffff12;font-size:15rem;position:absolute;top:-80px;right:-20px;transform:rotate(18deg)}.panel-event-header .panel-title{display:none}.event-heading{z-index:1;position:relative}.event-heading .topic{color:#225b42;background:#cbe9ce;margin-left:8px}.event-heading h1{letter-spacing:-.055em;max-width:780px;margin:24px 0 15px;font:700 clamp(2.7rem,7vw,5.4rem)/.95 Georgia,serif}.event-heading p{color:#ffffffad;max-width:680px;margin:0;font-size:1.03rem;line-height:1.6}.listen-card{grid-template-columns:150px 1fr;align-items:center;gap:30px;min-height:245px;display:grid}.listen-card h2,.stage-card h2{color:var(--purple);margin:7px 0 10px;font:700 2.2rem Georgia,serif}.listen-card p,.stage-card p{color:var(--muted);margin:0 0 20px;line-height:1.55}.listen-card audio{width:100%;margin-top:18px;display:none}.listen-card audio:not([src=""]){display:block}.sound-orbit{background:#ffe3dc;border-radius:50%;place-items:center;width:135px;height:135px;display:grid;position:relative}.sound-orbit b{z-index:2;color:#fff;background:var(--coral);border-radius:50%;place-items:center;width:60px;height:60px;padding-left:4px;font-size:1.25rem;display:grid}.sound-orbit span{border:1px solid #f56f5d59;border-radius:50%;animation:2.6s ease-out infinite sound;position:absolute;inset:18px}.sound-orbit span:nth-child(2){animation-delay:.8s}.sound-orbit span:nth-child(3){animation-delay:1.6s}.waiting .sound-orbit span{animation-play-state:paused}.host-line,.join-line,.people-row,.control-line{align-items:center;gap:14px;display:flex}.host-line small,.people-row small{color:var(--muted);display:block}.host-line strong,.people-row strong{margin-top:2px;display:block}.avatar,.stage-avatar{color:#fff;background:var(--purple);border-radius:14px 14px 14px 4px;flex:none;place-items:center;width:47px;height:47px;font:700 1.2rem Georgia,serif;display:grid}.avatar-small{border-radius:10px 10px 10px 3px;width:32px;height:32px;font-size:.82rem}.reading-copy{color:var(--muted);margin:0;font:1.05rem/1.7 Georgia,serif}.join-line{justify-content:space-between}.join-line strong{color:var(--purple)}.join-line p{color:var(--muted);margin:4px 0 0;font-size:.82rem}.panel-empty,.muted{color:var(--muted);line-height:1.55}.metric{flex-direction:column;gap:5px;display:flex}.metric strong{color:var(--purple);font:700 1.65rem Georgia,serif}.metric span{color:var(--muted);font-size:.78rem}.stage-card{align-items:center;gap:22px;display:flex}.stage-avatar{width:88px;height:88px;color:var(--purple);background:var(--yellow);border-radius:24px 24px 24px 7px;font-size:2.2rem}.control-stack{flex-wrap:wrap;gap:10px;display:flex}.control-line{justify-content:space-between}.status-dot{background:#b9b2bd;border-radius:50%;width:10px;height:10px}.status-dot.on{background:var(--coral);box-shadow:0 0 0 5px #f56f5d26}.share-box,.inline-form{gap:8px;display:flex}.share-box input{min-width:0;color:var(--muted);background:var(--paper)}.inline-form{margin-top:15px}.inline-form input{min-width:0}.chat-messages{gap:13px;min-height:110px;max-height:300px;display:grid;overflow-y:auto}.chat-message{align-items:start;gap:10px;display:flex}.chat-message p{gap:3px;margin:0;display:grid}.chat-message strong{font-size:.76rem}.chat-message span{color:var(--muted);line-height:1.45}.hand-raises{gap:10px;display:grid}.raise-row{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:12px;padding-bottom:10px;display:flex}.raise-row>span{gap:2px;display:grid}.raise-row small{color:var(--muted);text-transform:capitalize}.raise-row>div{gap:10px;display:flex}.panel-failed{border-color:#f56f5d66}@keyframes pulse{50%{opacity:.42;transform:scale(.8)}}@keyframes sound{0%{opacity:.8;transform:scale(.72)}to{opacity:0;transform:scale(1.45)}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media (max-width:900px){.hero{grid-template-columns:1fr;padding-top:70px}.hero-board{width:min(560px,92%);margin-inline:auto}.event-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.event-layout{grid-template-columns:1fr}.region-secondary{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}}@media (max-width:640px){.site-header,main,.site-footer{width:min(100% - 24px,1180px)}.site-header{min-height:70px}.site-nav a{display:none}.wordmark{font-size:1rem}.hero{gap:55px;min-height:auto;padding:60px 0}.hero h1{font-size:clamp(3.5rem,20vw,5.5rem)}.hero-actions{display:grid}.hero-actions .button{width:100%}.hero-board{border-width:8px;width:96%;min-height:340px;padding:48px 29px 38px}.event-section{padding:64px 0}.event-grid{grid-template-columns:1fr}.upcoming-row{grid-template-columns:1fr 26px;gap:9px 14px;padding-block:20px}.upcoming-row time,.upcoming-row>span:nth-child(2){grid-column:1}.upcoming-row .row-arrow{grid-area:1/2/3}.host-callout{margin:64px 0;padding:42px 28px}.site-footer{flex-direction:column;justify-content:center;align-items:start;gap:6px;min-height:90px}.dialog{padding:34px 22px 24px}.form-pair{grid-template-columns:1fr}.event-shell{padding-top:28px}.event-panel{border-radius:16px;padding:22px}.panel-event-header{min-height:390px;padding:32px 23px}.event-heading h1{font-size:clamp(2.65rem,15vw,4.2rem)}.listen-card{grid-template-columns:1fr}.sound-orbit{width:108px;height:108px}.sound-orbit b{width:52px;height:52px}.join-line,.share-box,.inline-form{flex-direction:column;align-items:stretch}.join-line .button,.share-box .button,.inline-form .button{width:100%}.region-secondary,.region-sidebar,.region-bottom,.region-drawer{grid-template-columns:1fr}}
|
|
1
|
+
:root{--ink:#201a32;--purple:#34246d;--purple-2:#5b43a4;--coral:#f56f5d;--yellow:#ffd465;--paper:#f7f2e8;--paper-deep:#eee5d6;--white:#fffdf8;--green:#217a53;--muted:#6f6879;--line:#201a3224;--shadow:0 18px 50px #34246d1f;color:var(--ink);background:var(--paper);font-synthesis:none;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{scroll-behavior:smooth}body{background:var(--paper);min-width:320px;min-height:100vh;margin:0}button,input,select,textarea{font:inherit}button,a{-webkit-tap-highlight-color:transparent}a{color:inherit}[hidden]{display:none!important}body:before{content:"";pointer-events:none;opacity:.34;background-image:radial-gradient(#34246d1f .65px,#0000 .65px);background-size:7px 7px;position:fixed;inset:0;-webkit-mask-image:linear-gradient(#000,#0000 55%);mask-image:linear-gradient(#000,#0000 55%)}#app{flex-direction:column;min-height:100vh;display:flex;position:relative}.site-header,main,.site-footer{width:min(1180px,100% - 40px);margin-inline:auto}.site-header{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:24px;min-height:82px;display:flex}.wordmark{color:var(--purple);letter-spacing:-.035em;align-items:center;gap:10px;font-size:1.12rem;font-weight:850;text-decoration:none;display:inline-flex}.wordmark>span:last-child span{color:var(--coral)}.wordmark-mark{width:34px;height:34px;color:var(--white);background:var(--purple);box-shadow:3px 3px 0 var(--yellow);border-radius:10px 10px 10px 3px;place-items:center;font-family:Georgia,serif;font-size:1.2rem;display:grid;transform:rotate(-3deg)}.site-nav{align-items:center;gap:28px;font-size:.91rem;display:flex}.site-nav a{color:var(--muted);text-decoration:none}.site-nav a:hover{color:var(--purple)}main{outline:none;flex:1}.loading-page,.error-page{text-align:center;place-content:center;min-height:68vh;display:grid}.loading-page h1,.error-page h1{color:var(--purple);margin:8px 0;font:700 clamp(2.4rem,8vw,5rem)/1 Georgia,serif}.loading-page p,.error-page p{color:var(--muted)}.hero{grid-template-columns:minmax(0,1.05fr) minmax(360px,.8fr);align-items:center;gap:clamp(50px,8vw,110px);min-height:610px;padding:92px 0 78px;display:grid}.eyebrow{color:var(--coral);letter-spacing:.13em;text-transform:uppercase;font-size:.75rem;font-weight:850;display:block}.hero h1{max-width:690px;color:var(--purple);letter-spacing:-.065em;margin:14px 0 24px;font:750 clamp(3.6rem,8vw,7.3rem)/.86 Georgia,Times New Roman,serif}.hero h1 em{color:var(--coral);font-weight:inherit}.hero-copy>p{max-width:590px;color:var(--muted);margin:0;font-size:clamp(1.02rem,1.5vw,1.22rem);line-height:1.7}.hero-actions{flex-wrap:wrap;gap:12px;margin-top:34px;display:flex}.hero-copy .hero-note{margin-top:15px;font-size:.82rem}.button{cursor:pointer;border:0;border-radius:999px;justify-content:center;align-items:center;gap:9px;min-height:44px;padding:0 22px;font-weight:780;text-decoration:none;transition:transform .16s,box-shadow .16s,background .16s;display:inline-flex}.button:hover:not(:disabled){transform:translateY(-2px)}.button:focus-visible,.text-button:focus-visible,a:focus-visible{outline-offset:3px;outline:3px solid #f56f5d66}.button:disabled{cursor:wait;opacity:.65}.button-primary{color:#fff;background:var(--purple);box-shadow:0 8px 22px #34246d3d}.button-primary:hover:not(:disabled){background:var(--purple-2);box-shadow:0 12px 28px #34246d47}.button-secondary{color:var(--purple);background:0 0;border:1px solid #34246d47}.button-light{color:var(--purple);background:var(--white)}.button-danger{color:#902d27;background:#ffe5df}.button-quiet{min-height:36px;color:var(--purple);background:#34246d12;padding-inline:17px}.button-quiet.signed-in{color:var(--green);background:#217a531a}.button-small{color:#fff;background:var(--purple);min-height:38px;padding-inline:16px;font-size:.84rem}.live-dot{background:var(--coral);border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #f56f5d2e}.hero-board{color:#fffbe9;background:#285e4d;border:10px solid #bd8654;border-radius:6px;flex-direction:column;justify-content:center;min-height:390px;padding:58px 50px 44px;display:flex;position:relative;transform:rotate(1.2deg);box-shadow:0 26px 60px #201a3233,inset 0 0 50px #0000001f}.hero-board:before{content:"";opacity:.2;background-image:linear-gradient(95deg,#0000 49%,#fff3 50%,#0000 52%),radial-gradient(#fff 0,#0000 55%);background-size:220px 100%,210px 35px;position:absolute;inset:0}.tape{z-index:2;background:#ffdd8dc2;width:86px;height:28px;position:absolute}.tape-one{top:-23px;left:40px;transform:rotate(-7deg)}.tape-two{bottom:-20px;right:28px;transform:rotate(-5deg)}.board-label{opacity:.75;font:italic 1rem Georgia,serif;position:relative}.hero-board strong{letter-spacing:-.035em;max-width:390px;margin:18px 0 15px;font:600 clamp(2rem,4vw,3.2rem)/1.05 Georgia,serif;position:relative}.hero-board p{opacity:.72;margin:0;position:relative}.board-action{color:#fff;cursor:pointer;background:0 0;border:0;border-bottom:1px solid #ffffff80;align-self:flex-start;margin-top:34px;padding-bottom:4px;font-weight:750;text-decoration:none;position:relative}.board-action span{color:var(--yellow)}.board-rule{background:var(--yellow);width:58px;height:3px;position:absolute;top:38px;right:40px;transform:rotate(-4deg)}.board-doodles{opacity:.34;gap:14px;font:2rem Georgia,serif;display:flex;position:absolute;bottom:26px;right:30px;transform:rotate(-8deg)}.event-section{padding:88px 0}.event-section-tint{position:relative}.event-section-tint:before{content:"";z-index:-1;background:var(--paper-deep);position:absolute;inset:24px calc(50% - 50vw)}.section-heading{justify-content:space-between;align-items:end;gap:20px;margin-bottom:30px;display:flex}.section-heading h2,.host-callout h2{color:var(--purple);letter-spacing:-.045em;margin:7px 0 0;font:700 clamp(2.3rem,5vw,4rem)/1 Georgia,serif}.section-count{color:var(--muted);font-size:.84rem}.event-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;display:grid}.event-card{border:1px solid var(--line);background:#fffdf8bd;border-radius:20px;min-height:285px;transition:transform .18s,box-shadow .18s;box-shadow:0 4px 16px #34246d0a}.event-card:hover{box-shadow:var(--shadow);transform:translateY(-5px)rotate(-.3deg)}.event-card.is-live{border-color:#f56f5d73}.event-card>a{flex-direction:column;height:100%;padding:26px;text-decoration:none;display:flex}.event-card-top{justify-content:space-between;align-items:center;gap:12px;display:flex}.status-pill,.topic{width:-moz-fit-content;width:fit-content;min-height:25px;color:var(--purple);letter-spacing:.06em;text-transform:uppercase;background:#34246d14;border-radius:999px;align-items:center;padding:3px 10px;font-size:.69rem;font-weight:850;display:inline-flex}.status-pill.status-live{color:#a42d25;background:#ffe1db}.status-pill.status-live:before{content:"";background:var(--coral);border-radius:50%;width:6px;height:6px;margin-right:6px;animation:1.8s infinite pulse}.topic{color:var(--green);background:#217a5317}.topic-large{margin-top:18px}.event-card h3{color:var(--ink);letter-spacing:-.025em;margin:28px 0 11px;font:700 1.55rem/1.14 Georgia,serif}.event-card p{color:var(--muted);-webkit-line-clamp:3;-webkit-box-orient:vertical;margin:0;line-height:1.55;display:-webkit-box;overflow:hidden}.event-link{color:var(--purple);justify-content:space-between;margin-top:auto;padding-top:24px;font-size:.86rem;font-weight:800;display:flex}.empty-card{text-align:center;border:1px dashed #34246d40;border-radius:20px;grid-column:1/-1;padding:55px 30px}.empty-card>span{font-size:2rem}.empty-card h3{margin:12px 0 6px;font:700 1.5rem Georgia,serif}.empty-card p,.empty-line{color:var(--muted)}.upcoming-list{border-top:1px solid var(--line)}.upcoming-row{border-bottom:1px solid var(--line);grid-template-columns:minmax(130px,.34fr) 1fr 34px;align-items:center;gap:25px;min-height:92px;padding:16px 8px;text-decoration:none;display:grid}.upcoming-row:hover strong,.upcoming-row:hover .row-arrow{color:var(--coral)}.upcoming-row time{color:var(--purple);font-size:.83rem;font-weight:780}.upcoming-row>span:nth-child(2){flex-direction:column;gap:6px;display:flex}.upcoming-row strong{font:650 1.22rem Georgia,serif;transition:color .16s}.upcoming-row small{color:var(--muted)}.row-arrow{color:var(--purple);font-size:1.3rem}.host-callout{color:#fff;background:var(--purple);border-radius:28px;margin:90px 0 100px;padding:clamp(42px,7vw,78px);position:relative;overflow:hidden}.host-callout:after{content:"?";color:#ffffff0f;font:500 25rem Georgia,serif;position:absolute;top:-32%;right:5%;transform:rotate(12deg)}.host-callout .eyebrow{color:var(--yellow)}.host-callout h2{z-index:1;color:#fff;position:relative}.host-callout p{z-index:1;opacity:.75;max-width:520px;line-height:1.6;position:relative}.host-callout .button{z-index:1;margin-top:16px;position:relative}.site-footer{min-height:100px;color:var(--muted);border-top:1px solid var(--line);justify-content:space-between;align-items:center;font-size:.82rem;display:flex}.site-footer a{color:var(--purple);font-weight:750}.dialog{width:min(480px,100% - 28px);color:var(--ink);background:var(--white);border:0;border-radius:24px;padding:38px;box-shadow:0 24px 90px #201a324d}.dialog::backdrop{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);background:#201a3294}.dialog h2{color:var(--purple);margin:7px 0 10px;font:700 2.2rem Georgia,serif}.dialog>p{color:var(--muted);margin-top:0;line-height:1.5}.dialog-wide{width:min(650px,100% - 28px)}.dialog-close{width:35px;height:35px;color:var(--muted);cursor:pointer;background:#34246d12;border:0;border-radius:50%;font-size:1.4rem;position:absolute;top:14px;right:18px}.form-stack{gap:17px;margin-top:28px;display:grid}.form-stack.compact{gap:12px;margin-top:0}.form-stack label{color:var(--ink);gap:7px;font-size:.76rem;font-weight:790;display:grid}.form-stack input,.form-stack select,.form-stack textarea,.inline-form input,.share-box input{border:1px solid var(--line);width:100%;min-height:46px;color:var(--ink);background:#fff;border-radius:11px;outline:none;padding:10px 13px}.form-stack textarea{resize:vertical}.form-stack input:focus,.form-stack select:focus,.form-stack textarea:focus,.inline-form input:focus{border-color:var(--purple-2);box-shadow:0 0 0 3px #5b43a41f}.form-stack>.button{width:100%;margin-top:4px}.form-stack.compact>.button{width:-moz-fit-content;width:fit-content}.form-note,.form-error{min-height:1.2em;color:var(--muted);overflow-wrap:anywhere;margin:0;font-size:.76rem}.form-error{color:#a42d25}.form-note:empty,.form-error:empty{display:none}.form-checks{color:var(--muted);flex-wrap:wrap;gap:8px 18px;padding:3px 0;font-size:.8rem;display:flex}.form-stack .form-checks label{cursor:pointer;align-items:center;gap:7px;font-weight:600;display:flex}.form-stack .form-checks input{width:16px;height:16px;min-height:0;accent-color:var(--purple);margin:0;padding:0}.form-stack .form-checks label:has(input:checked){color:var(--purple)}.form-pair{grid-template-columns:1fr 1fr;gap:14px;display:grid}.text-button{width:-moz-fit-content;width:fit-content;color:var(--purple);cursor:pointer;background:0 0;border:0;border-bottom:1px solid #0000;padding:0;font-weight:750}.text-button:hover{border-bottom-color:currentColor}.event-shell{padding:42px 0 100px}.back-link{color:var(--muted);margin-bottom:28px;font-size:.84rem;font-weight:700;text-decoration:none;display:inline-block}.back-link:hover{color:var(--purple)}.event-layout{grid-template-columns:minmax(0,1.55fr) minmax(280px,.75fr);align-items:start;gap:18px;display:grid}.region{gap:18px;min-width:0;display:grid}.region-sidebar,.region-bottom,.region-drawer{grid-column:1/-1;grid-template-columns:repeat(auto-fit,minmax(270px,1fr))}.event-panel{border:1px solid var(--line);background:#fffdf8d1;border-radius:20px;min-width:0;padding:28px;position:relative;box-shadow:0 4px 20px #34246d0a}.panel-title{color:var(--muted);letter-spacing:.1em;text-transform:uppercase;margin-bottom:18px;font-size:.68rem;font-weight:850;display:block}.panel-event-header{color:#fff;background:var(--purple);border:0;align-items:end;min-height:320px;padding:clamp(34px,6vw,62px);display:flex;overflow:hidden}.panel-event-header:after{content:"✦";color:#ffffff12;font-size:15rem;position:absolute;top:-80px;right:-20px;transform:rotate(18deg)}.panel-event-header .panel-title{display:none}.event-heading{z-index:1;position:relative}.event-heading .topic{color:#225b42;background:#cbe9ce;margin-left:8px}.event-heading h1{letter-spacing:-.055em;max-width:780px;margin:24px 0 15px;font:700 clamp(2.7rem,7vw,5.4rem)/.95 Georgia,serif}.event-heading p{color:#ffffffad;max-width:680px;margin:0;font-size:1.03rem;line-height:1.6}.listen-card{grid-template-columns:150px 1fr;align-items:center;gap:30px;min-height:245px;display:grid}.listen-card h2,.stage-card h2{color:var(--purple);margin:7px 0 10px;font:700 2.2rem Georgia,serif}.listen-card p,.stage-card p{color:var(--muted);margin:0 0 20px;line-height:1.55}.listen-card audio{width:100%;margin-top:18px;display:none}.listen-card audio:not([src=""]){display:block}.sound-orbit{background:#ffe3dc;border-radius:50%;place-items:center;width:135px;height:135px;display:grid;position:relative}.sound-orbit b{z-index:2;color:#fff;background:var(--coral);border-radius:50%;place-items:center;width:60px;height:60px;padding-left:4px;font-size:1.25rem;display:grid}.sound-orbit span{border:1px solid #f56f5d59;border-radius:50%;animation:2.6s ease-out infinite sound;position:absolute;inset:18px}.sound-orbit span:nth-child(2){animation-delay:.8s}.sound-orbit span:nth-child(3){animation-delay:1.6s}.waiting .sound-orbit span{animation-play-state:paused}.host-line,.join-line,.people-row,.control-line{align-items:center;gap:14px;display:flex}.host-line small,.people-row small{color:var(--muted);display:block}.host-line strong,.people-row strong{margin-top:2px;display:block}.avatar,.stage-avatar{color:#fff;background:var(--purple);border-radius:14px 14px 14px 4px;flex:none;place-items:center;width:47px;height:47px;font:700 1.2rem Georgia,serif;display:grid}.avatar-small{border-radius:10px 10px 10px 3px;width:32px;height:32px;font-size:.82rem}.reading-copy{color:var(--muted);margin:0;font:1.05rem/1.7 Georgia,serif}.join-line{justify-content:space-between}.join-line strong{color:var(--purple)}.join-line p{color:var(--muted);margin:4px 0 0;font-size:.82rem}.panel-empty,.muted{color:var(--muted);line-height:1.55}.metric{flex-direction:column;gap:5px;display:flex}.metric strong{color:var(--purple);font:700 1.65rem Georgia,serif}.metric span{color:var(--muted);font-size:.78rem}.stage-card{align-items:center;gap:22px;display:flex}.stage-avatar{width:88px;height:88px;color:var(--purple);background:var(--yellow);border-radius:24px 24px 24px 7px;font-size:2.2rem}.control-stack{flex-wrap:wrap;gap:10px;display:flex}.control-line{justify-content:space-between}.status-dot{background:#b9b2bd;border-radius:50%;width:10px;height:10px}.status-dot.on{background:var(--coral);box-shadow:0 0 0 5px #f56f5d26}.share-box,.inline-form{gap:8px;display:flex}.share-box input{min-width:0;color:var(--muted);background:var(--paper)}.inline-form{margin-top:15px}.inline-form input{min-width:0}.chat-messages{gap:13px;min-height:110px;max-height:300px;display:grid;overflow-y:auto}.chat-message{align-items:start;gap:10px;display:flex}.chat-message p{gap:3px;margin:0;display:grid}.chat-message strong{font-size:.76rem}.chat-message span{color:var(--muted);line-height:1.45}.hand-raises{gap:10px;display:grid}.raise-row{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:12px;padding-bottom:10px;display:flex}.raise-row>span{gap:2px;display:grid}.raise-row small{color:var(--muted);text-transform:capitalize}.raise-row>div{gap:10px;display:flex}.panel-failed{border-color:#f56f5d66}@keyframes pulse{50%{opacity:.42;transform:scale(.8)}}@keyframes sound{0%{opacity:.8;transform:scale(.72)}to{opacity:0;transform:scale(1.45)}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media (max-width:900px){.hero{grid-template-columns:1fr;padding-top:70px}.hero-board{width:min(560px,92%);margin-inline:auto}.event-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.event-layout{grid-template-columns:1fr}.region-secondary{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}}@media (max-width:640px){.site-header,main,.site-footer{width:min(100% - 24px,1180px)}.site-header{min-height:70px}.site-nav a{display:none}.wordmark{font-size:1rem}.hero{gap:55px;min-height:auto;padding:60px 0}.hero h1{font-size:clamp(3.5rem,20vw,5.5rem)}.hero-actions{display:grid}.hero-actions .button{width:100%}.hero-board{border-width:8px;width:96%;min-height:340px;padding:48px 29px 38px}.event-section{padding:64px 0}.event-grid{grid-template-columns:1fr}.upcoming-row{grid-template-columns:1fr 26px;gap:9px 14px;padding-block:20px}.upcoming-row time,.upcoming-row>span:nth-child(2){grid-column:1}.upcoming-row .row-arrow{grid-area:1/2/3}.host-callout{margin:64px 0;padding:42px 28px}.site-footer{flex-direction:column;justify-content:center;align-items:start;gap:6px;min-height:90px}.dialog{padding:34px 22px 24px}.form-pair{grid-template-columns:1fr}.event-shell{padding-top:28px}.event-panel{border-radius:16px;padding:22px}.panel-event-header{min-height:390px;padding:32px 23px}.event-heading h1{font-size:clamp(2.65rem,15vw,4.2rem)}.listen-card{grid-template-columns:1fr}.sound-orbit{width:108px;height:108px}.sound-orbit b{width:52px;height:52px}.join-line,.share-box,.inline-form{flex-direction:column;align-items:stretch}.join-line .button,.share-box .button,.inline-form .button{width:100%}.region-secondary,.region-sidebar,.region-bottom,.region-drawer{grid-template-columns:1fr}}.broadcast-options{border:1px solid var(--line);border-radius:12px;min-width:0;margin:0;padding:16px}.broadcast-options legend{padding-inline:6px;font-weight:700}.broadcast-options p{color:var(--muted);font-size:.9rem;line-height:1.5}.broadcast-options label+label{margin-top:14px}.broadcast-links{flex-wrap:wrap;gap:10px;margin-bottom:16px;display:flex}.broadcast-links .button{padding:12px;font-size:.8rem}.classroom-video{aspect-ratio:16/11;background:#101015;border-radius:12px;overflow:hidden}.classroom-video iframe{border:0;width:100%;height:100%}.broadcast-caption{margin-bottom:0}.host-avatar{object-fit:cover}.host-line a{margin-top:5px;font-size:.85rem;display:block}button[aria-disabled=true]{opacity:.65;cursor:default}.event-panel h2.panel-title{font-size:.7rem}@media (max-width:640px){.classroom-video{aspect-ratio:3/4}}@media (forced-colors:active){.button,.event-panel,.broadcast-options{border:1px solid canvastext}}.event-toolbar{justify-content:space-between;align-items:center;gap:16px;margin-bottom:24px;display:flex}.event-toolbar .back-link{margin:0}.site-nav{min-width:0}.wordmark{flex-shrink:0}#account-button{text-overflow:ellipsis;white-space:nowrap;max-width:100%;overflow:hidden}.event-heading .status-pill{color:var(--purple);background:#eee8ff}.event-heading .status-pill.status-live{color:#a42d25;background:#ffe1db}@media (max-width:640px){.site-header{gap:12px}}.class-update{z-index:20;border:1px solid var(--line);background:var(--cream,#f7f3e9);border-radius:12px;width:min(430px,100% - 24px);padding:14px 18px;position:fixed;bottom:12px;right:12px;box-shadow:0 6px 24px #34246d22}.class-update .form-note{margin:0 0 8px}.class-update button+button{margin-left:18px}.ai-writer{border:1px solid var(--line);border-radius:12px;padding:16px}.ai-writer-actions{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));align-items:center;gap:14px;display:grid}.ai-writer-status{height:5em;margin:10px 0 0;line-height:1.4;overflow:auto}#event-ai-preview{margin-top:12px}#event-ai-preview textarea{resize:vertical}#event-ai-preview input,#event-ai-preview textarea{background:#f7f3e9}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=class extends Error{status;constructor(e,t){super(e),this.status=t}};async function t(t,n={}){let r=await fetch(t,{credentials:`same-origin`,...n,headers:{...n.body?{"content-type":`application/json`}:{},...n.headers}}),i={};try{i=await r.json()}catch{i={}}if(!r.ok)throw new e(String((i&&typeof i==`object`?i:{}).error??`That did not work.`),r.status);return i}function n(e,n,r=`POST`){return t(e,{method:r,body:JSON.stringify(n)})}function r(e,n,r){let i=e.querySelector(`#event-ai-write`),a=e.querySelector(`#event-ai-cancel`),o=e.querySelector(`#event-ai-apply`),s=e.querySelector(`#event-ai-preview`),c=e.querySelector(`#event-ai-status`),l={title:e.querySelector(`#event-ai-title`),description:e.querySelector(`#event-ai-description`),topic:e.querySelector(`#event-ai-topic`)},u=null,d=null,f=null,p=t=>e.elements.namedItem(t).value,m=()=>({title:p(`title`),description:p(`description`),topic:p(`topic`),hostName:p(`hostName`),startsAt:p(`startsAt`),timezone:r(),duration:p(`expectedDurationMinutes`),recurrence:p(`recurrence`)});function h(){u?.abort(),u=null,d=null,f=null,s.hidden=!0,s.removeAttribute(`aria-busy`),a.hidden=!0,i.textContent=`Write with AI`,i.removeAttribute(`aria-disabled`),o.textContent=`Use these details`,o.setAttribute(`aria-disabled`,`true`),c.textContent=``;for(let e of Object.values(l))e.value=``}return i.addEventListener(`click`,async()=>{if(u||e.getAttribute(`aria-busy`)===`true`)return;let r=m();if(![r.title,r.description,r.topic].some(e=>e.trim())){c.textContent=`Add a title, description, or topic first. Your words become the prompt.`;return}let p=new AbortController;u=p,f=r,d=null,i.setAttribute(`aria-disabled`,`true`),i.textContent=`Writing…`,a.hidden=!1,a.textContent=`Cancel`,s.hidden=!1,s.setAttribute(`aria-busy`,`true`),o.textContent=`Use these details`,o.setAttribute(`aria-disabled`,`true`);for(let e of Object.values(l))e.value=``;c.textContent=`Writing a draft from your event details… You can keep editing.`;try{let e=await t(`/api/v1/events/ai-draft`,{method:`POST`,body:JSON.stringify(r),signal:p.signal});if(u!==p||!n.open)return;d=e.draft;for(let e of[`title`,`description`,`topic`])l[e].value=d[e];o.removeAttribute(`aria-disabled`),c.textContent=`Draft ready. Review it below, then use these details if you like them.`}catch(e){if(u!==p||p.signal.aborted)return;c.textContent=e instanceof Error?e.message:`Could not write a draft. Your text is unchanged.`}finally{u===p&&(u=null,s.removeAttribute(`aria-busy`),i.removeAttribute(`aria-disabled`),i.textContent=`Write again`,a.textContent=`Discard draft`)}}),a.addEventListener(`click`,()=>{let e=!!u;h(),c.textContent=e?`Draft cancelled. Your text is unchanged.`:`Draft discarded. Your text is unchanged.`,a.hidden=!1,a.textContent=`Dismiss`}),o.addEventListener(`click`,()=>{if(d&&f&&!u&&o.getAttribute(`aria-disabled`)!==`true`){if(JSON.stringify(m())!==JSON.stringify(f)){c.textContent=`Your inputs changed while this draft was being written. Click Write again to include those edits, or copy text from the preview.`;return}for(let t of[`title`,`description`,`topic`])e.elements.namedItem(t).value=d[t];o.setAttribute(`aria-disabled`,`true`),o.textContent=`Details applied`,c.textContent=`Draft applied to your form. Review your event and save when you’re ready.`}}),n.addEventListener(`close`,h),e.addEventListener(`submit`,()=>{u&&h()}),{reset:h}}function i(e){if(typeof e!=`string`||e.length>2048)return null;try{let t=new URL(e);return t.protocol!==`https:`||t.username||t.password?null:t.href}catch{return null}}function a(e){let t=i(e);if(!t)return null;let n=new URL(t);if(!n.port&&(n.hostname===`pairux.com`||n.hostname===`www.pairux.com`)){let e=/^\/(?:l|join|embed)\/([A-Za-z0-9_-]{1,64})\/?$/.exec(n.pathname)?.[1];return e?{provider:`pairux`,url:`https://pairux.com/l/${e}`,embed:`https://pairux.com/embed/${e}`,join:`https://pairux.com/join/${e}`}:null}if(!n.port&&(n.hostname===`nixamp.com`||n.hostname===`www.nixamp.com`)){if(n.pathname!==`/`)return null;let e=n.searchParams.get(`url`)||``,t=n.searchParams.get(`play`)||(e?`live`:``);if(/\/admin\//i.test(e)||/\/admin\//i.test(t)||n.searchParams.has(`key`)||e&&!i(e)||!/^(?:live|channel:[^\s]+|track:\d+)$/.test(t)&&!i(t))return null;let r=new URL(`https://nixamp.com/`);e&&r.searchParams.set(`url`,e),r.searchParams.set(`play`,t);let a=r.href;return r.searchParams.set(`embed`,`1`),{provider:`nixamp`,url:a,embed:r.href,join:a}}if(n.hostname.endsWith(`.nixamp.com`)&&/^\/view\/[A-Za-z0-9_-]+\/?$/.test(n.pathname)&&!n.search){let e=new URL(`https://nixamp.com/`);return e.searchParams.set(`url`,n.href),e.searchParams.set(`play`,`live`),a(e.href)}return null}var o=document.querySelector(`#main`),s=document.querySelector(`#account-button`),c=document.querySelector(`#account-dialog`),l=document.querySelector(`#account-form`),u=document.querySelector(`#account-title`),d=document.querySelector(`#account-copy`),f=document.querySelector(`#account-error`),p=document.querySelector(`#account-mode`),m=document.querySelector(`#event-dialog`),h=document.querySelector(`#event-form`),g=document.querySelector(`#event-form-title`),_=document.querySelector(`#event-kicker`),v=document.querySelector(`#event-submit`),y=document.querySelector(`#event-error`),b=document.querySelector(`#schedule-fields`),x=null,S=!1,C=!1,w=null,T=!1,E=null,D=null,O=null,k=r(h,m,()=>w?.timezone??Intl.DateTimeFormat().resolvedOptions().timeZone??`UTC`),A=new WeakMap;function j(e,t){A.get(e)===t||e.contains(document.activeElement)||(e.innerHTML=t,A.set(e,t))}function M(e){return String(e??``).replaceAll(`&`,`&`).replaceAll(`<`,`<`).replaceAll(`>`,`>`).replaceAll(`"`,`"`).replaceAll(`'`,`'`)}function N(e){let t=document.querySelector(`#event-update`);t&&t.textContent!==e&&(t.textContent=e),document.querySelector(`.class-update`)?.removeAttribute(`hidden`)}function P(e){return`/live/${encodeURIComponent(e.slug)}`}function F(){let e=new URLSearchParams(location.search).get(`invite`);return e?`?invite=${encodeURIComponent(e)}`:``}function I(e,t={}){return e?new Intl.DateTimeFormat(void 0,{weekday:`short`,month:`short`,day:`numeric`,hour:`numeric`,minute:`2-digit`,...t}).format(new Date(e)):`Time to be announced`}function L(e){return e.status===`live`?`Live now`:e.status===`ended`?e.recordingId?`Replay available`:`Event ended`:e.status===`cancelled`?`Cancelled`:I(e.startsAt)}function R(e){return O?.permissions.includes(e)??!1}function z(){s.textContent=x?x.email.split(`@`)[0]||`Account`:`Sign in`,s.classList.toggle(`signed-in`,!!x)}async function B(){try{x=(await t(`/api/v1/auth/me`)).account}catch{x=null}z()}function V(e){if(x){c.showModal(),u.textContent=`Signed in as ${x.email}`,d.textContent=`Your BackToSchool identity is your NixAmp account.`,l.hidden=!0;return}E=e??null,l.hidden=!1,f.textContent=``,H(),c.showModal()}function H(){u.textContent=S?`Create your account`:`Welcome back`,d.textContent=S?`One NixAmp account works here and everywhere NixAmp goes.`:`Sign in to host, chat, or raise your hand.`,p.textContent=S?`Already have an account? Sign in`:`New here? Create an account`,document.querySelector(`#account-forgot`).hidden=S;let e=l.elements.namedItem(`password`);e.autocomplete=S?`new-password`:`current-password`,l.querySelector(`button[type="submit"]`).textContent=S?`Create account`:`Sign in`}function U(e){let t=new Date(e);return`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,`0`)}-${String(t.getDate()).padStart(2,`0`)}T${String(t.getHours()).padStart(2,`0`)}:${String(t.getMinutes()).padStart(2,`0`)}`}function W(e,t=null){if(!x){V(()=>W(e,t));return}if(T)return;w=t,k.reset(),h.reset(),C=e===`scheduled`,b.hidden=!C,g.textContent=t?`Edit event`:C?`Schedule a class`:`Go live`,_.textContent=t?`Your classroom`:`Teach something live`,v.textContent=t?`Save changes`:C?`Schedule class`:`Create live`,y.textContent=``;let n=h.elements.namedItem(`startsAt`);if(n.required=C&&(!t||!!t.startsAt),t){for(let e of[`title`,`description`,`topic`,`visibility`,`broadcastUrl`,`hostName`,`homepageUrl`,`avatarUrl`,`recurrence`])h.elements.namedItem(e).value=t[e]??(e===`recurrence`?`none`:``);for(let e of[`chatEnabled`,`handRaiseEnabled`])h.elements.namedItem(e).checked=t[e];t.startsAt&&(n.value=U(t.startsAt));let e=h.elements.namedItem(`expectedDurationMinutes`),r=String(t.expectedDurationMinutes??60);[...e.options].some(e=>e.value===r)||e.add(new Option(`${r} minutes`,r)),e.value=r}else if(C){let e=new Date(Date.now()+36e5);e.setMinutes(Math.ceil(e.getMinutes()/15)*15,0,0),n.value=U(e.toISOString())}let r=Intl.DateTimeFormat().resolvedOptions().timeZone||`UTC`;document.querySelector(`#schedule-timezone`).textContent=`Date and time are shown in ${r}.`+(t?.recurrence?` This class repeats in ${t.timezone}.`:``),m.showModal()}function G(e){history.pushState(null,``,e),$()}function K(e){let t=e.status===`live`||e.status===`starting`;return`
|
|
2
|
+
<article class="event-card ${t?`is-live`:``}">
|
|
3
|
+
<a href="${P(e)}" data-link>
|
|
4
|
+
<div class="event-card-top">
|
|
5
|
+
<span class="status-pill ${t?`status-live`:``}">${t?`Live`:M(L(e))}</span>
|
|
6
|
+
${e.topic?`<span class="topic">${M(e.topic)}</span>`:``}
|
|
7
|
+
</div>
|
|
8
|
+
<h3>${M(e.title)}</h3>
|
|
9
|
+
<p>${M(e.description||`Drop in and learn something with us.`)}</p>
|
|
10
|
+
<span class="event-link">${t?`Watch live`:`See event`}<span aria-hidden="true">→</span></span>
|
|
11
|
+
</a>
|
|
12
|
+
</article>`}async function ee(){document.title=`BackToSchool.help — Learn something live`;let{events:e}=await t(`/api/v1/events?limit=60`).catch(()=>({events:[]})),n=e.filter(e=>e.status===`live`||e.status===`starting`),r=e.filter(e=>e.status===`scheduled`||e.status===`draft`),i=e.filter(e=>e.status===`ended`&&e.recordingId);o.innerHTML=`
|
|
13
|
+
<section class="hero">
|
|
14
|
+
<div class="hero-copy">
|
|
15
|
+
<span class="eyebrow">Go back to school whenever you want</span>
|
|
16
|
+
<h1>Learn something<br /><em>live.</em></h1>
|
|
17
|
+
<p>Watch a live lesson, follow a screen share, and ask questions as you learn.</p>
|
|
18
|
+
<div class="hero-actions">
|
|
19
|
+
<button class="button button-primary" type="button" data-event-mode="live"><span class="live-dot"></span> Go live</button>
|
|
20
|
+
<button class="button button-secondary" type="button" data-event-mode="scheduled">Schedule live</button>
|
|
21
|
+
</div>
|
|
22
|
+
<p class="hero-note">Watch classes and screen shares. No account required.</p>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="hero-board" aria-label="What is happening today">
|
|
25
|
+
<span class="tape tape-one" aria-hidden="true"></span>
|
|
26
|
+
<span class="tape tape-two" aria-hidden="true"></span>
|
|
27
|
+
<div class="board-rule"></div>
|
|
28
|
+
<span class="board-label">Today’s lesson</span>
|
|
29
|
+
<strong>${M(n[0]?.title??r[0]?.title??`The best teachers are curious people`)}</strong>
|
|
30
|
+
<p>${n[0]?`Happening right now`:r[0]?L(r[0]):`Host the first live conversation`}</p>
|
|
31
|
+
${n[0]?`<a href="${P(n[0])}" data-link class="board-action">Watch now <span>↗</span></a>`:`<button type="button" data-event-mode="live" class="board-action">Start a room <span>↗</span></button>`}
|
|
32
|
+
<div class="board-doodles" aria-hidden="true"><span>?</span><span>✦</span><span>≈</span></div>
|
|
33
|
+
</div>
|
|
34
|
+
</section>
|
|
35
|
+
|
|
36
|
+
<section class="event-section" id="live">
|
|
37
|
+
<div class="section-heading">
|
|
38
|
+
<div><span class="eyebrow">Walk in anytime</span><h2>Live now</h2></div>
|
|
39
|
+
<span class="section-count">${n.length} ${n.length===1?`room`:`rooms`}</span>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="event-grid">
|
|
42
|
+
${n.length?n.map(K).join(``):`<div class="empty-card"><span>☕</span><h3>The halls are quiet.</h3><p>Start a live and give people something worth dropping into.</p><button class="text-button" data-event-mode="live">Open a room →</button></div>`}
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section class="event-section event-section-tint" id="upcoming">
|
|
47
|
+
<div class="section-heading">
|
|
48
|
+
<div><span class="eyebrow">Save your seat</span><h2>Coming up</h2></div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="upcoming-list">
|
|
51
|
+
${r.length?r.map(e=>`
|
|
52
|
+
<a class="upcoming-row" href="${P(e)}" data-link>
|
|
53
|
+
<time>${M(I(e.startsAt,{weekday:`short`,month:`short`,day:`numeric`}))}</time>
|
|
54
|
+
<span><strong>${M(e.title)}</strong><small>${M(e.topic||`Live conversation`)}</small></span>
|
|
55
|
+
<span class="row-arrow">→</span>
|
|
56
|
+
</a>`).join(``):`<p class="empty-line">Nothing scheduled yet. Your idea could be next.</p>`}
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
${i.length?`<section class="event-section"><div class="section-heading"><div><span class="eyebrow">Listen after class</span><h2>Recent replays</h2></div></div><div class="event-grid">${i.map(K).join(``)}</div></section>`:``}
|
|
61
|
+
|
|
62
|
+
<section class="host-callout">
|
|
63
|
+
<span class="eyebrow">Know a thing or two?</span>
|
|
64
|
+
<h2>Someone wants to hear it.</h2>
|
|
65
|
+
<p>You don’t need a studio or a syllabus. Bring an idea and start talking.</p>
|
|
66
|
+
<button class="button button-light" type="button" data-event-mode="scheduled">Plan a conversation</button>
|
|
67
|
+
</section>`}function te(e){let t=a(e.broadcastUrl);if(t)return`<div class="classroom-video"><iframe src="${M(t.embed)}" title="${M(e.title)} broadcast" allow="autoplay; fullscreen; picture-in-picture; encrypted-media" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe></div>
|
|
68
|
+
<p class="broadcast-caption"><a class="button button-secondary" href="${M(t.join)}" target="_blank" rel="noopener noreferrer">${t.provider===`pairux`?`Join screen share on Pairux`:`Open video in Nixamp`} ↗</a></p>`;let n=F(),r=e.recordingId&&e.status===`ended`?`/api/v1/recordings/${encodeURIComponent(e.recordingId)}`:`/api/channels/${encodeURIComponent(e.roomId)}${n}`,i=e.status===`live`||!!e.recordingId;return`
|
|
69
|
+
<div class="listen-card ${i?``:`waiting`}">
|
|
70
|
+
<div class="sound-orbit" aria-hidden="true"><span></span><span></span><span></span><b>▶</b></div>
|
|
71
|
+
<div>
|
|
72
|
+
<span class="eyebrow">${e.status===`live`?`On air now`:e.recordingId?`Replay`:`The room opens soon`}</span>
|
|
73
|
+
<h2>${i?e.status===`live`?`Listen live`:`Listen again`:`We’ll see you here.`}</h2>
|
|
74
|
+
<p id="player-note">${i?`Press play and you’re in. No sign-up, no setup.`:M(L(e))}</p>
|
|
75
|
+
${i?`<button class="button button-primary listen-button" type="button" data-listen><span class="live-dot"></span> ${e.status===`live`?`Listen live`:`Play replay`}</button><audio id="event-audio" preload="none" src="${M(r)}" controls></audio>`:``}
|
|
76
|
+
</div>
|
|
77
|
+
</div>`}function q(e){return e.chatEnabled?`
|
|
78
|
+
<div id="chat-messages" class="chat-messages"><p class="muted">Loading the conversation…</p></div>
|
|
79
|
+
${x?`<form id="chat-form" class="inline-form"><input name="body" maxlength="1000" placeholder="Add to the conversation" aria-label="Chat message" required /><button class="button button-small" type="submit">Send</button></form>`:`<button class="text-button" type="button" data-sign-in>Sign in to join the chat →</button>`}`:`<div class="panel-empty">Chat is off for this event.</div>`}function J(e,t){switch(e){case`event-header`:return`<div class="event-heading"><span class="status-pill ${t.status===`live`?`status-live`:``}">${M(L(t))}</span>${t.topic?`<span class="topic">${M(t.topic)}</span>`:``}<h1>${M(t.title)}</h1><p>${M(t.description||`A live conversation powered by NixAmp.`)}</p></div>`;case`player`:return te(t);case`stage`:return`<div class="stage-card"><span class="stage-avatar">${M(t.title.slice(0,1).toUpperCase())}</span><div><span class="eyebrow">Host stage</span><h2>${M(t.title)}</h2><p id="broadcast-note">${t.status===`live`?`This event is live.`:`Start when you’re ready.`}</p></div></div>`;case`host`:return`<div class="host-line">${t.avatarUrl?`<img class="avatar host-avatar" src="${M(t.avatarUrl)}" alt="" referrerpolicy="no-referrer" />`:`<span class="avatar">${M((t.hostName||t.title).slice(0,1))}</span>`}<div><small>Your host</small><strong>${M(t.hostName||`Class host`)}</strong>${t.homepageUrl?`<a href="${M(t.homepageUrl)}" target="_blank" rel="noopener noreferrer">Visit homepage ↗</a>`:``}</div></div>`;case`about`:return`<p class="reading-copy">${M(t.description||`Come listen, learn, and ask a question live.`)}</p>${t.topic?`<span class="topic topic-large">${M(t.topic)}</span>`:``}`;case`join`:return x?`<p class="panel-empty">You’re signed in and ready to participate.</p>`:`<div class="join-line"><div><strong>Want to ask something?</strong><p>Join with your NixAmp account.</p></div><button class="button button-secondary" type="button" data-sign-in>Join in</button></div>`;case`chat`:return q(t);case`questions`:return`<div class="panel-empty">Questions shared in chat can be brought onto the stage.</div>`;case`resources`:return`<div class="panel-empty">The host hasn’t added resources yet.</div>`;case`participants`:return`<div class="metric"><strong id="listener-count">—</strong><span>listening now</span></div>`;case`speakers`:return`<div class="people-row"><span class="avatar">H</span><span><strong>Host</strong><small>On stage</small></span></div>`;case`raise-hand`:return t.handRaiseEnabled?`<div class="join-line"><div><strong>Have something to add?</strong><p>Let the host know you’d like to speak.</p></div><button class="button button-secondary" type="button" data-raise-hand>Raise hand</button></div>`:null;case`hand-raises`:return`<div id="hand-raises" class="hand-raises"><p class="muted">No hands raised.</p></div>`;case`invite`:return`<form id="invite-form" class="form-stack compact"><label>Email<input name="email" type="email" placeholder="someone@example.com" required /></label><label>Invite as<select name="role"><option value="listener">Listener</option><option value="speaker">Speaker</option><option value="moderator">Moderator</option></select></label><button class="button button-small" type="submit">Create invite</button><p id="invite-note" class="form-note"></p></form>`;case`share`:return`<div class="share-box"><input id="share-url" value="${M(location.href)}" readonly aria-label="Event URL" /><button class="button button-small" type="button" data-copy>Copy link</button></div>`;case`event-controls`:return`<div class="control-stack">
|
|
80
|
+
<a href="https://pairux.com/dashboard" target="_blank" rel="noopener noreferrer">Screen share with Pairux ↗</a>
|
|
81
|
+
<a href="https://nixamp.com/#files-panel" target="_blank" rel="noopener noreferrer">Upload & broadcast in Nixamp ↗</a>
|
|
82
|
+
${[`draft`,`scheduled`,`starting`].includes(t.status)?`<button class="button button-primary" type="button" data-start-event>Start class</button>`:``}
|
|
83
|
+
<button class="button button-danger" type="button" data-end-event ${t.status===`live`?``:`hidden`}>${t.recurrence?`End session & schedule next`:`End event`}</button>
|
|
84
|
+
<p id="broadcast-note" class="form-note" role="status">${t.broadcastUrl?`Start the broadcast in Pairux or Nixamp, then start the class here.`:`Add your broadcast link with Edit event.`}</p>
|
|
85
|
+
</div>`;case`schedule`:return`<div class="metric"><strong>${M(I(t.startsAt,{timeZone:t.timezone}))}</strong><span>${M(t.timezone)}${t.recurrence?` · Repeats ${t.recurrence}`:``}</span></div>`;case`recording`:return`<div class="control-line"><span>${t.recordingEnabled?`Recording requested`:`Recording is off`}</span><span class="status-dot ${t.recordingEnabled?`on`:``}"></span></div>`;default:return null}}async function Y(e){let n=await t(`/api/v1/events/${encodeURIComponent(e)}${F()}`);D?.(),D=null,O=n;let r=n.event;document.title=`${r.title} — BackToSchool.help`,document.querySelector(`meta[name="description"]`)?.setAttribute(`content`,r.description||`Listen to ${r.title} live on BackToSchool.help.`),document.querySelector(`meta[property="og:title"]`)?.setAttribute(`content`,r.title),document.querySelector(`meta[property="og:description"]`)?.setAttribute(`content`,r.description||`Learn something live.`);let i=(e,t)=>`<section class="event-panel panel-${e}"><h2 class="panel-title">${t}</h2>${J(e,r)??``}</section>`;o.innerHTML=`
|
|
86
|
+
<div class="event-shell">
|
|
87
|
+
<div class="event-toolbar"><a class="back-link" href="/" data-link>← Explore more lives</a>${x?.id===r.ownerId?`<button class="button button-secondary" type="button" data-edit-event>Edit event</button>`:``}</div>
|
|
88
|
+
<div class="class-update" hidden><p id="event-update" role="status" class="form-note"></p><button class="text-button" data-refresh-event>Refresh class details</button><button class="text-button" data-dismiss-update>Dismiss</button></div>
|
|
89
|
+
<div class="event-layout">
|
|
90
|
+
<div class="region region-primary">${i(`event-header`,`Live classroom`)}${i(`player`,`Watch class`)}${i(`chat`,`Class chat`)}</div>
|
|
91
|
+
<aside class="region region-secondary">${i(`host`,`Meet your host`)}${i(`schedule`,`Schedule`)}${x?.id===r.ownerId?i(`event-controls`,`Host controls`):i(`join`,`Join the class`)}${x?.id===r.ownerId&&r.visibility===`private`?`<details class="event-panel"><summary>Invite students</summary>${J(`invite`,r)}</details>`:``}${i(`share`,`Share class`)}${x?.id===r.ownerId&&r.handRaiseEnabled?i(`hand-raises`,`Raised hands`):r.handRaiseEnabled?i(`raise-hand`,`Ask a question`):``}</aside>
|
|
92
|
+
</div>
|
|
93
|
+
</div>`,X(r)}function X(r){document.querySelector(`[data-listen]`)?.addEventListener(`click`,async e=>{let t=e.currentTarget,n=document.querySelector(`#event-audio`);if(n){t.disabled=!0,t.textContent=`Connecting…`;try{await n.play(),t.textContent=`You’re listening`,document.querySelector(`#player-note`).textContent=`Live audio from this NixAmp room.`}catch{t.disabled=!1,t.textContent=`Try listening again`,document.querySelector(`#player-note`).textContent=`The host may still be getting ready. Try again in a moment.`}}});let i=document.querySelector(`#chat-form`);i?.addEventListener(`submit`,e=>{e.preventDefault();let t=i.elements.namedItem(`body`),a=t.value.trim();a&&(t.value=``,n(`/api/v1/events/${encodeURIComponent(r.id)}/chat`,{body:a}).then(()=>Z(r)).catch(e=>{t.placeholder=e instanceof Error?e.message:`Could not send`}))});let a=document.querySelector(`#invite-form`);a?.addEventListener(`submit`,e=>{e.preventDefault();let n=new FormData(a),i=document.querySelector(`#invite-note`);i.textContent=`Creating invite…`,t(`/api/v1/events/${encodeURIComponent(r.id)}/invitations`,{method:`POST`,body:JSON.stringify({email:n.get(`email`),role:n.get(`role`)})}).then(async({inviteUrl:e,sent:t})=>{await navigator.clipboard?.writeText(e).catch(()=>void 0),i.textContent=t?`Invite sent. The link is copied too.`:`Invite link copied: ${e}`,a.reset()}).catch(e=>{i.textContent=e instanceof Error?e.message:`Could not invite`})}),document.querySelector(`[data-raise-hand]`)?.addEventListener(`click`,t=>{let i=t.currentTarget;i.disabled=!0,n(`/api/v1/events/${encodeURIComponent(r.id)}/hand-raises`,{}).then(()=>{i.textContent=`Hand raised ✓`}).catch(t=>{i.disabled=!1,i.textContent=t instanceof e&&t.status===401?`Sign in to raise hand`:`Try again`})}),document.querySelector(`[data-edit-event]`)?.addEventListener(`click`,()=>W(`scheduled`,O.event)),document.querySelector(`[data-start-event]`)?.addEventListener(`click`,()=>void re()),document.querySelector(`[data-dismiss-update]`)?.addEventListener(`click`,()=>document.querySelector(`.class-update`).hidden=!0),document.querySelector(`[data-refresh-event]`)?.addEventListener(`click`,()=>G(P(r)+F())),document.querySelector(`[data-end-event]`)?.addEventListener(`click`,()=>void ie()),document.querySelector(`[data-copy]`)?.addEventListener(`click`,async e=>{await navigator.clipboard.writeText(location.href),e.currentTarget.textContent=`Copied ✓`});let o=0,s=0;r.chatEnabled&&(Z(r),o=window.setInterval(()=>void Z(r),4e3),s=R(`event.moderate`)?window.setInterval(()=>void Q(r),3e3):0,Q(r));let c=r.version,l=window.setInterval(()=>void t(`/api/v1/events/${encodeURIComponent(r.id)}${F()}`).then(e=>{if(e.event.version===O?.event.version||e.event.version===c)return;c=e.event.version;let t=document.querySelector(`.event-heading .status-pill`),n=L(e.event);t&&t.textContent!==n&&(t.textContent=n,t.classList.toggle(`status-live`,e.event.status===`live`)),N(`The host updated this class. Refresh the details when you’re ready.`)}).catch(()=>void 0),5e3);D=()=>{clearInterval(o),clearInterval(s),clearInterval(l)},ne(r)}async function Z(e){let n=document.querySelector(`#chat-messages`);if(n)try{let{messages:r}=await t(`/api/v1/events/${encodeURIComponent(e.id)}/chat${F()}`);j(n,r.length?r.map(e=>`<div class="chat-message"><span class="avatar avatar-small">${M(e.authorName.slice(0,1).toUpperCase())}</span><p><strong>${M(e.authorName)}</strong><span>${M(e.body)}</span></p></div>`).join(``):`<p class="muted">No messages yet. Say hello when you’re ready.</p>`)}catch{n.innerHTML=`<p class="muted">Chat is taking a break. You can still watch the class.</p>`}}async function Q(e){let n=document.querySelector(`#hand-raises`);if(n&&R(`event.moderate`))try{let{handRaises:r}=await t(`/api/v1/events/${encodeURIComponent(e.id)}/hand-raises`);if(n.contains(document.activeElement))return;j(n,r.length?r.map(e=>`<div class="raise-row"><span><strong>${M(e.displayName)}</strong><small>${M(e.state)}</small></span><div><button class="text-button" data-hand="${M(e.accountId)}" data-state="invited">Invite</button><button class="text-button" data-hand="${M(e.accountId)}" data-state="dismissed">Dismiss</button></div></div>`).join(``):`<p class="muted">No hands raised.</p>`)}catch{n.contains(document.activeElement)||(n.innerHTML=`<p class="muted">Could not refresh hand raises.</p>`)}}async function ne(e){let n=document.querySelector(`#listener-count`);if(n)try{let{channels:r}=await t(`/api/channels`);n.textContent=String(r.find(t=>t.id===e.roomId)?.listeners??0)}catch{n.textContent=`0`}}async function re(){if(!O)return;let e=document.querySelector(`#broadcast-note`),n=document.querySelector(`[data-start-event]`);if(!O.event.broadcastUrl){e&&(e.textContent=`Add a Pairux or Nixamp broadcast link with Edit event first.`);return}if(n?.getAttribute(`aria-disabled`)!==`true`){n?.setAttribute(`aria-disabled`,`true`);try{O=await t(`/api/v1/events/${encodeURIComponent(O.event.id)}/start`,{method:`POST`,body:JSON.stringify({version:O.event.version})}),n&&(n.textContent=`Class is live`),document.querySelector(`[data-end-event]`)?.removeAttribute(`hidden`);let r=document.querySelector(`.event-heading .status-pill`);r&&(r.textContent=`Live now`,r.classList.add(`status-live`)),e&&(e.textContent=`Your class is live. Viewers can join using this page.`)}catch(t){n?.removeAttribute(`aria-disabled`),e&&(e.textContent=t instanceof Error?t.message:`Could not start the class.`)}}}async function ie(){let e=document.querySelector(`[data-end-event]`);if(!O||e?.getAttribute(`aria-disabled`)===`true`)return;e?.setAttribute(`aria-disabled`,`true`);let n=O.event;try{let e=await t(`/api/v1/events/${encodeURIComponent(n.id)}/end`,{method:`POST`,body:JSON.stringify({version:n.version})});O=e;let r=document.querySelector(`.event-heading .status-pill`);r&&(r.textContent=L(e.event),r.classList.remove(`status-live`)),await fetch(`/api/channels/${encodeURIComponent(n.roomId)}`,{method:`DELETE`,credentials:`same-origin`});let i=document.querySelector(`#broadcast-note`);i&&(i.textContent=e.event.recurrence?`Session ended. Next class: ${I(e.event.startsAt)}. End the broadcast in Pairux or Nixamp too.`:`Event ended. End the broadcast in Pairux or Nixamp too.`);let a=document.querySelector(`[data-end-event]`);a&&(a.textContent=`Session ended`,a.setAttribute(`aria-disabled`,`true`)),N(`This session has ended. Refresh to see the latest schedule.`)}catch(t){e?.removeAttribute(`aria-disabled`);let n=document.querySelector(`#broadcast-note`);n&&(n.textContent=t instanceof Error?t.message:`Could not end the event.`)}}async function $(){D?.(),D=null,O=null;let t=/^\/live\/([^/]+)\/?$/.exec(location.pathname);try{t?await Y(decodeURIComponent(t[1])):await ee()}catch(t){o.innerHTML=`<section class="error-page"><span class="eyebrow">Class dismissed?</span><h1>${M(t instanceof e&&t.status===404?`That live event isn’t available.`:`We couldn’t open this page.`)}</h1><p>${M(t instanceof Error?t.message:`Try again in a moment.`)}</p><a href="/" data-link class="button button-primary">Back to live events</a></section>`}}s.addEventListener(`click`,()=>{if(!x){V();return}confirm(`Signed in as ${x.email}. Sign out?`)&&t(`/api/v1/auth/logout`,{method:`POST`}).finally(()=>{x=null,z(),$()})}),p.addEventListener(`click`,()=>{S=!S,f.textContent=``,H()}),l.addEventListener(`submit`,e=>{e.preventDefault();let n=new FormData(l),r=l.querySelector(`button[type="submit"]`);r.disabled=!0,f.textContent=``,t(`/api/v1/auth/${S?`signup`:`login`}`,{method:`POST`,body:JSON.stringify({email:n.get(`email`),password:n.get(`password`)})}).then(e=>{x=e.account,z(),l.reset(),c.close();let t=E;E=null,t?t():$()}).catch(e=>{f.textContent=e instanceof Error?e.message:`That did not work.`}).finally(()=>{r.disabled=!1})}),h.querySelector(`[name="recurrence"]`)?.addEventListener(`change`,()=>{h.elements.namedItem(`recurrence`).value!==`none`&&(C=!0,b.hidden=!1,h.elements.namedItem(`startsAt`).required=!0)}),h.addEventListener(`submit`,e=>{if(e.preventDefault(),T)return;let n=new FormData(h),r=w,i=String(n.get(`startsAt`)??``),a=!r?.startsAt||i!==U(r.startsAt),o=String(n.get(`recurrence`));if(o!==`none`&&!i&&!C){C=!0,b.hidden=!1,h.elements.namedItem(`startsAt`).required=!0,y.textContent=`Choose the first class date and time for this recurring schedule.`;return}T=!0,v.setAttribute(`aria-disabled`,`true`),h.setAttribute(`aria-busy`,`true`),y.textContent=``;let s={title:n.get(`title`),description:n.get(`description`),topic:n.get(`topic`),visibility:n.get(`visibility`),broadcastUrl:n.get(`broadcastUrl`),hostName:n.get(`hostName`),homepageUrl:n.get(`homepageUrl`),avatarUrl:n.get(`avatarUrl`),recurrence:o,chatEnabled:n.get(`chatEnabled`)===`on`,handRaiseEnabled:n.get(`handRaiseEnabled`)===`on`,...r?{version:r.version}:{kind:`class`},...C?{startsAt:i?a?new Date(i).toISOString():r.startsAt:null,timezone:a?Intl.DateTimeFormat().resolvedOptions().timeZone||`UTC`:r.timezone,expectedDurationMinutes:Number(n.get(`expectedDurationMinutes`))}:{}};t(r?`/api/v1/events/${encodeURIComponent(r.id)}`:`/api/v1/events`,{method:r?`PATCH`:`POST`,body:JSON.stringify(s)}).then(async e=>{r?(await Y(e.event.slug),m.close(),document.querySelector(`[data-edit-event]`)?.focus({preventScroll:!0})):(m.close(),G(P(e.event))),h.reset(),w=null}).catch(e=>{y.textContent=e instanceof Error?e.message:`Could not save the event.`}).finally(()=>{T=!1,v.removeAttribute(`aria-disabled`),h.removeAttribute(`aria-busy`)})}),document.addEventListener(`click`,e=>{let t=e.target,r=t.closest(`a[data-link]`);if(r&&r.origin===location.origin){e.preventDefault(),G(`${r.pathname}${r.search}`);return}let i=t.closest(`[data-event-mode]`)?.dataset.eventMode;(i===`live`||i===`scheduled`)&&W(i),t.closest(`[data-sign-in]`)&&V(()=>void $());let a=t.closest(`[data-close]`);a&&a.closest(`dialog`)?.close();let o=t.closest(`[data-hand]`);o&&O&&(o.disabled=!0,n(`/api/v1/events/${encodeURIComponent(O.event.id)}/hand-raises/${encodeURIComponent(o.dataset.hand??``)}`,{state:o.dataset.state},`PATCH`).then(()=>Q(O.event)).finally(()=>{o.disabled=!1}))}),window.addEventListener(`popstate`,()=>void $()),B().then(()=>$());
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
6
|
<meta name="theme-color" content="#34246d" />
|
|
7
|
-
<meta name="description" content="Learn something live.
|
|
7
|
+
<meta name="description" content="Learn something live. Watch live classes, screen shares, and talks powered by NixAmp." />
|
|
8
8
|
<meta property="og:site_name" content="BackToSchool.help" />
|
|
9
9
|
<meta property="og:title" content="BackToSchool.help — Learn something live" />
|
|
10
10
|
<meta property="og:description" content="Live conversations with people who know." />
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
13
13
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
|
14
14
|
<title>BackToSchool.help — Learn something live</title>
|
|
15
|
-
<script type="module" crossorigin src="/assets/index-
|
|
16
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
15
|
+
<script type="module" crossorigin src="/assets/index-Cjai7LgT.js"></script>
|
|
16
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Bot4FEXQ.css">
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
19
19
|
<div id="app">
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
</section>
|
|
37
37
|
</main>
|
|
38
38
|
<footer class="site-footer">
|
|
39
|
-
<span>Live
|
|
39
|
+
<span>Live classes. Screen sharing. Learn together.</span>
|
|
40
40
|
<span>Powered by <a href="https://nixamp.com">NixAmp</a></span>
|
|
41
41
|
</footer>
|
|
42
42
|
</div>
|
|
43
43
|
|
|
44
|
-
<dialog id="account-dialog" class="dialog">
|
|
44
|
+
<dialog id="account-dialog" class="dialog" aria-labelledby="account-title">
|
|
45
45
|
<button class="dialog-close" type="button" data-close aria-label="Close">×</button>
|
|
46
46
|
<span class="eyebrow">Your NixAmp account</span>
|
|
47
47
|
<h2 id="account-title">Welcome back</h2>
|
|
@@ -51,20 +51,28 @@
|
|
|
51
51
|
<label>Password<input name="password" type="password" autocomplete="current-password" minlength="8" required /></label>
|
|
52
52
|
<p class="form-error" id="account-error" role="alert"></p>
|
|
53
53
|
<button class="button button-primary" type="submit">Sign in</button>
|
|
54
|
+
<a class="text-button" id="account-forgot" href="/reset-password">Forgot password?</a>
|
|
54
55
|
<button class="text-button" id="account-mode" type="button">New here? Create an account</button>
|
|
55
56
|
</form>
|
|
56
57
|
</dialog>
|
|
57
58
|
|
|
58
|
-
<dialog id="event-dialog" class="dialog dialog-wide">
|
|
59
|
+
<dialog id="event-dialog" class="dialog dialog-wide" aria-labelledby="event-form-title">
|
|
59
60
|
<button class="dialog-close" type="button" data-close aria-label="Close">×</button>
|
|
60
61
|
<span class="eyebrow" id="event-kicker">Start a conversation</span>
|
|
61
62
|
<h2 id="event-form-title">Go live</h2>
|
|
62
63
|
<form id="event-form" class="form-stack">
|
|
64
|
+
<fieldset class="broadcast-options">
|
|
65
|
+
<legend>Broadcast your class</legend>
|
|
66
|
+
<p>Share your screen with Pairux, or upload a file and go live in Nixamp. Paste the share link below.</p>
|
|
67
|
+
<div class="broadcast-links"><a class="button button-secondary" href="https://pairux.com/dashboard" target="_blank" rel="noopener noreferrer">Screen share with Pairux ↗</a><a class="button button-secondary" href="https://nixamp.com/#files-panel" target="_blank" rel="noopener noreferrer">Upload & broadcast in Nixamp ↗</a></div>
|
|
68
|
+
<label>Broadcast link<input name="broadcastUrl" type="url" maxlength="2048" placeholder="Pairux join link or Nixamp Share link" aria-describedby="broadcast-help" /></label>
|
|
69
|
+
<p class="form-note" id="broadcast-help">You can add the link now or edit your event when the broadcast is ready.</p>
|
|
70
|
+
</fieldset>
|
|
63
71
|
<label>Title<input name="title" maxlength="160" placeholder="What will people learn?" required /></label>
|
|
64
|
-
<label>Description<textarea name="description" maxlength="5000" rows="3" placeholder="
|
|
72
|
+
<label>Description<textarea name="description" maxlength="5000" rows="3" placeholder="What will students learn?"></textarea></label>
|
|
65
73
|
<div class="form-pair">
|
|
66
74
|
<label>Topic<input name="topic" maxlength="100" placeholder="Technology, music…" /></label>
|
|
67
|
-
<label>Who can
|
|
75
|
+
<label>Who can join
|
|
68
76
|
<select name="visibility">
|
|
69
77
|
<option value="public">Public</option>
|
|
70
78
|
<option value="unlisted">Anyone with the link</option>
|
|
@@ -72,6 +80,17 @@
|
|
|
72
80
|
</select>
|
|
73
81
|
</label>
|
|
74
82
|
</div>
|
|
83
|
+
<div class="ai-writer">
|
|
84
|
+
<div class="ai-writer-actions"><button class="button button-secondary" type="button" id="event-ai-write">Write with AI</button><button class="text-button" type="button" id="event-ai-cancel" hidden>Cancel</button></div>
|
|
85
|
+
<p class="form-note">Optional: use your current title, description, and topic as the prompt. Review the draft before using it.</p>
|
|
86
|
+
<p id="event-ai-status" class="form-note ai-writer-status" role="status" aria-live="polite"></p>
|
|
87
|
+
<div id="event-ai-preview" class="form-stack" hidden>
|
|
88
|
+
<label for="event-ai-title">Suggested title</label><input id="event-ai-title" readonly />
|
|
89
|
+
<label for="event-ai-description">Suggested description</label><textarea id="event-ai-description" rows="6" readonly></textarea>
|
|
90
|
+
<label for="event-ai-topic">Suggested topic</label><input id="event-ai-topic" readonly />
|
|
91
|
+
<button class="button button-secondary" type="button" id="event-ai-apply" aria-disabled="true">Use these details</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
75
94
|
<div id="schedule-fields" class="form-pair" hidden>
|
|
76
95
|
<label>Date and time<input name="startsAt" type="datetime-local" /></label>
|
|
77
96
|
<label>Expected length
|
|
@@ -83,10 +102,18 @@
|
|
|
83
102
|
</select>
|
|
84
103
|
</label>
|
|
85
104
|
</div>
|
|
105
|
+
<p class="form-note" id="schedule-timezone"></p>
|
|
106
|
+
<label for="event-recurrence">Repeat</label><select id="event-recurrence" name="recurrence"><option value="none">Does not repeat</option><option value="daily">Every day</option><option value="weekly">Every week</option></select>
|
|
107
|
+
<p class="form-note">Repeating classes keep this same link. Ending a session schedules the next one at the same local time.</p>
|
|
108
|
+
<fieldset class="broadcast-options"><legend>Host profile</legend>
|
|
109
|
+
<label>Host name<input name="hostName" maxlength="100" autocomplete="name" /></label>
|
|
110
|
+
<label>Homepage<input name="homepageUrl" type="url" maxlength="2048" placeholder="https://your-site.com" /></label>
|
|
111
|
+
<label>Profile photo URL<input name="avatarUrl" type="url" maxlength="2048" placeholder="https://your-site.com/photo.jpg" /></label>
|
|
112
|
+
</fieldset>
|
|
86
113
|
<div class="form-checks">
|
|
87
114
|
<label><input name="chatEnabled" type="checkbox" checked /> Chat</label>
|
|
88
115
|
<label><input name="handRaiseEnabled" type="checkbox" checked /> Raise hand</label>
|
|
89
|
-
|
|
116
|
+
|
|
90
117
|
</div>
|
|
91
118
|
<p class="form-error" id="event-error" role="alert"></p>
|
|
92
119
|
<button class="button button-primary" type="submit" id="event-submit">Create live</button>
|
package/dist/accounts.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PasswordResets } from "./password-reset.ts";
|
|
1
2
|
import { type Identity, type Users } from "./oauth.ts";
|
|
2
3
|
import type { Queryable } from "./follows.ts";
|
|
3
4
|
import { type IssuedToken, type TokenKind, type TokenRecord, Tokens } from "./tokens.ts";
|
|
@@ -69,6 +70,7 @@ export declare class Accounts {
|
|
|
69
70
|
private readonly system;
|
|
70
71
|
/** Null only where a test injected an auth system and no storage. */
|
|
71
72
|
readonly tokens: Tokens | null;
|
|
73
|
+
readonly passwordResets: PasswordResets | null;
|
|
72
74
|
private readonly identities;
|
|
73
75
|
constructor(options: AccountsOptions);
|
|
74
76
|
signUp(email: unknown, password: unknown): Promise<AuthResult>;
|
package/dist/accounts.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* terminal better than either -- OAuth 2.0 reached through the device grant in
|
|
20
20
|
* device.ts -- and what it ends with is a token from tokens.ts.
|
|
21
21
|
*/
|
|
22
|
-
import { createAuthSystem, PostgresAdapter } from "@profullstack/auth-system";
|
|
22
|
+
import { createAuthSystem, createPasswordUtils, PostgresAdapter } from "@profullstack/auth-system";
|
|
23
|
+
import { PasswordResets } from "./password-reset.js";
|
|
23
24
|
import { Identities } from "./oauth.js";
|
|
24
25
|
import { looksLikeToken, Tokens } from "./tokens.js";
|
|
25
26
|
const NO_ACCOUNT = { ok: false, account: null, token: "", error: "" };
|
|
@@ -85,6 +86,7 @@ export class Accounts {
|
|
|
85
86
|
system;
|
|
86
87
|
/** Null only where a test injected an auth system and no storage. */
|
|
87
88
|
tokens;
|
|
89
|
+
passwordResets;
|
|
88
90
|
identities;
|
|
89
91
|
constructor(options) {
|
|
90
92
|
// The adapter is kept rather than only handed over: tokens and provider
|
|
@@ -105,6 +107,8 @@ export class Accounts {
|
|
|
105
107
|
passwordOptions: { ...PASSWORD_RULES },
|
|
106
108
|
});
|
|
107
109
|
this.tokens = adapter ? new Tokens(adapter) : null;
|
|
110
|
+
this.passwordResets = adapter && this.tokens
|
|
111
|
+
? new PasswordResets(adapter, this.tokens, createPasswordUtils({ ...PASSWORD_RULES })) : null;
|
|
108
112
|
this.identities = adapter ? new Identities(adapter, adapter) : null;
|
|
109
113
|
}
|
|
110
114
|
async signUp(email, password) {
|
|
@@ -173,7 +177,10 @@ export class Accounts {
|
|
|
173
177
|
}
|
|
174
178
|
}
|
|
175
179
|
try {
|
|
176
|
-
|
|
180
|
+
const account = readClaims(await this.system.validateToken(token));
|
|
181
|
+
if (account && this.passwordResets && !(await this.passwordResets.acceptsLegacyToken(account.id, token)))
|
|
182
|
+
return null;
|
|
183
|
+
return account;
|
|
177
184
|
}
|
|
178
185
|
catch {
|
|
179
186
|
return null;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Public classroom metadata, shared by the API and the small school client. */
|
|
2
|
+
export interface ClassroomSettings {
|
|
3
|
+
broadcastUrl?: string;
|
|
4
|
+
hostName?: string;
|
|
5
|
+
homepageUrl?: string;
|
|
6
|
+
avatarUrl?: string;
|
|
7
|
+
recurrence?: "daily" | "weekly";
|
|
8
|
+
}
|
|
9
|
+
export declare function publicWebUrl(value: unknown): string | null;
|
|
10
|
+
export declare function classroomBroadcast(value: unknown): {
|
|
11
|
+
provider: "pairux" | "nixamp";
|
|
12
|
+
url: string;
|
|
13
|
+
embed: string;
|
|
14
|
+
join: string;
|
|
15
|
+
} | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function publicWebUrl(value) {
|
|
2
|
+
if (typeof value !== "string" || value.length > 2048)
|
|
3
|
+
return null;
|
|
4
|
+
try {
|
|
5
|
+
const url = new URL(value);
|
|
6
|
+
if (url.protocol !== "https:" || url.username || url.password)
|
|
7
|
+
return null;
|
|
8
|
+
return url.href;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function classroomBroadcast(value) {
|
|
15
|
+
const safe = publicWebUrl(value);
|
|
16
|
+
if (!safe)
|
|
17
|
+
return null;
|
|
18
|
+
const url = new URL(safe);
|
|
19
|
+
if (!url.port && (url.hostname === "pairux.com" || url.hostname === "www.pairux.com")) {
|
|
20
|
+
const code = /^\/(?:l|join|embed)\/([A-Za-z0-9_-]{1,64})\/?$/.exec(url.pathname)?.[1];
|
|
21
|
+
if (!code)
|
|
22
|
+
return null;
|
|
23
|
+
return { provider: "pairux", url: `https://pairux.com/l/${code}`, embed: `https://pairux.com/embed/${code}`, join: `https://pairux.com/join/${code}` };
|
|
24
|
+
}
|
|
25
|
+
if (!url.port && (url.hostname === "nixamp.com" || url.hostname === "www.nixamp.com")) {
|
|
26
|
+
if (url.pathname !== "/")
|
|
27
|
+
return null;
|
|
28
|
+
// Never publish an admin credential in a classroom share link.
|
|
29
|
+
const server = url.searchParams.get("url") || "";
|
|
30
|
+
// The server's Share panel supplies a viewer URL without a track selection.
|
|
31
|
+
const play = url.searchParams.get("play") || (server ? "live" : "");
|
|
32
|
+
if (/\/admin\//i.test(server) || /\/admin\//i.test(play) || url.searchParams.has("key"))
|
|
33
|
+
return null;
|
|
34
|
+
if (server && !publicWebUrl(server))
|
|
35
|
+
return null;
|
|
36
|
+
if (!/^(?:live|channel:[^\s]+|track:\d+)$/.test(play) && !publicWebUrl(play))
|
|
37
|
+
return null;
|
|
38
|
+
const clean = new URL("https://nixamp.com/");
|
|
39
|
+
if (server)
|
|
40
|
+
clean.searchParams.set("url", server);
|
|
41
|
+
clean.searchParams.set("play", play);
|
|
42
|
+
const original = clean.href;
|
|
43
|
+
clean.searchParams.set("embed", "1");
|
|
44
|
+
return { provider: "nixamp", url: original, embed: clean.href, join: original };
|
|
45
|
+
}
|
|
46
|
+
// A self-hosted Nixamp viewer link can be played through the shared client.
|
|
47
|
+
if (url.hostname.endsWith(".nixamp.com") && /^\/view\/[A-Za-z0-9_-]+\/?$/.test(url.pathname) && !url.search) {
|
|
48
|
+
const player = new URL("https://nixamp.com/");
|
|
49
|
+
player.searchParams.set("url", url.href);
|
|
50
|
+
player.searchParams.set("play", "live");
|
|
51
|
+
return classroomBroadcast(player.href);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|