nixamp 0.27.2 → 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 +39 -16
- package/backtoschool/dist/assets/{index-OE2xWZUH.css → index-Bot4FEXQ.css} +1 -1
- package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
- package/backtoschool/dist/index.html +13 -2
- 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-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/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.d.ts +2 -3
- package/dist/password-reset.js +4 -2
- package/dist/server.d.ts +2 -0
- package/dist/server.js +13 -1
- package/dist/shared-translation.d.ts +1 -0
- package/dist/shared-translation.js +32 -23
- package/package.json +1 -1
- package/src/event-writer.ts +126 -0
- package/src/live-api.ts +26 -2
- package/src/live-interpreter.ts +87 -68
- package/src/live-recovery.ts +10 -0
- package/src/mail-sender.ts +12 -0
- package/src/notify.ts +10 -7
- package/src/password-reset.ts +5 -5
- package/src/server.ts +14 -1
- package/src/shared-translation.ts +27 -19
- package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
- package/web/dist/assets/{hls-3VKVEQE3-CAL4qBEq.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
- package/web/dist/assets/index-BgLxkXKe.js +3 -0
- package/web/dist/assets/{mpegts-H9oC2fAK.js → mpegts-CV0glck7.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Bkd9Sqr6.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +6 -6
- package/backtoschool/dist/assets/index-pQ_uH4bZ.js +0 -93
- package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
- package/web/dist/assets/index-CLeEWlms.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
|
|
@@ -658,7 +675,11 @@ voice player preserves pending speaker turns and fetches the next phrase with
|
|
|
658
675
|
up to three seconds of audio still queued. Speech queues and decoded audio are
|
|
659
676
|
bounded. Old transcript history
|
|
660
677
|
is never spoken. Pause, seek, source changes, and disabling the feature cancel
|
|
661
|
-
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
|
|
662
683
|
interpreter, not a promise of exact lip sync or word-by-word streaming captions.
|
|
663
684
|
|
|
664
685
|
OpenStream currently compresses server-to-server relays, not this browser
|
|
@@ -669,23 +690,25 @@ codecs. The short-window overlap ratio and audio-second spending limits remain
|
|
|
669
690
|
unchanged; smaller windows do not increase the steady-state audio submitted.
|
|
670
691
|
|
|
671
692
|
Translated playback keeps an approximate version of the original background
|
|
672
|
-
sound. FastEnhancer Web's
|
|
673
|
-
browser worker.
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
processing adds no API calls or provider
|
|
678
|
-
recognition starts without waiting for it.
|
|
679
|
-
frames and recover automatically, with
|
|
680
|
-
background delay. A model or device
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
[FastEnhancer Web](https://github.com/ryyr-ry/fastenhancer-web)
|
|
685
|
-
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.
|
|
686
707
|
|
|
687
708
|
**Background level**, in the same collapsed options, balances the separated
|
|
688
|
-
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
|
|
689
712
|
in as a fallback. Increasing it also amplifies any speech the model fails to
|
|
690
713
|
remove. This is an approximate local separator, not lossless dialogue removal.
|
|
691
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}}.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}
|
|
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(()=>$());
|
|
@@ -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">
|
|
@@ -80,6 +80,17 @@
|
|
|
80
80
|
</select>
|
|
81
81
|
</label>
|
|
82
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>
|
|
83
94
|
<div id="schedule-fields" class="form-pair" hidden>
|
|
84
95
|
<label>Date and time<input name="startsAt" type="datetime-local" /></label>
|
|
85
96
|
<label>Expected length
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface EventDraft {
|
|
2
|
+
title: string;
|
|
3
|
+
description: string;
|
|
4
|
+
topic: string;
|
|
5
|
+
}
|
|
6
|
+
export interface WriterInput extends EventDraft {
|
|
7
|
+
hostName: string;
|
|
8
|
+
startsAt: string;
|
|
9
|
+
timezone: string;
|
|
10
|
+
duration: string;
|
|
11
|
+
recurrence: string;
|
|
12
|
+
}
|
|
13
|
+
export interface EventDraftResult {
|
|
14
|
+
draft: EventDraft;
|
|
15
|
+
provider: "openai" | "claude";
|
|
16
|
+
}
|
|
17
|
+
export declare class EventWriterError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
readonly retryAfter?: number | undefined;
|
|
20
|
+
constructor(message: string, status: number, retryAfter?: number | undefined);
|
|
21
|
+
}
|
|
22
|
+
export declare function writerInput(value: unknown): WriterInput;
|
|
23
|
+
export interface EventWriterOptions {
|
|
24
|
+
openaiKey?: string;
|
|
25
|
+
anthropicKey?: string;
|
|
26
|
+
openaiModel?: string;
|
|
27
|
+
claudeModel?: string;
|
|
28
|
+
fetch?: typeof globalThis.fetch;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
/** Server-side only. Drafting has no access to the event store and cannot publish. */
|
|
32
|
+
export declare class EventWriter {
|
|
33
|
+
private readonly options;
|
|
34
|
+
private readonly guard;
|
|
35
|
+
private readonly active;
|
|
36
|
+
constructor(options: EventWriterOptions);
|
|
37
|
+
draft(accountId: string, value: unknown, signal?: AbortSignal): Promise<EventDraftResult>;
|
|
38
|
+
private generate;
|
|
39
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Guard } from "./guard.js";
|
|
2
|
+
export class EventWriterError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
retryAfter;
|
|
5
|
+
constructor(message, status, retryAfter) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.retryAfter = retryAfter;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
class Refused extends EventWriterError {
|
|
12
|
+
constructor() { super("The writer could not draft these notes. Try rephrasing the event details.", 422); }
|
|
13
|
+
}
|
|
14
|
+
const LIMITS = { title: 160, description: 5000, topic: 100, hostName: 100, startsAt: 40, timezone: 100, duration: 20, recurrence: 20 };
|
|
15
|
+
export function writerInput(value) {
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
17
|
+
throw new EventWriterError("Send event details as an object.", 400);
|
|
18
|
+
const source = value;
|
|
19
|
+
const result = {};
|
|
20
|
+
for (const key of Object.keys(LIMITS)) {
|
|
21
|
+
const field = source[key] ?? "";
|
|
22
|
+
if (typeof field !== "string" || field.length > LIMITS[key])
|
|
23
|
+
throw new EventWriterError(`The ${key} field is too long or is not text.`, 422);
|
|
24
|
+
result[key] = field.trim();
|
|
25
|
+
}
|
|
26
|
+
if (!result.title && !result.description && !result.topic)
|
|
27
|
+
throw new EventWriterError("Add a title, description, or topic first, then click Write with AI.", 422);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
const SCHEMA = {
|
|
31
|
+
type: "object", properties: { title: { type: "string" }, description: { type: "string" }, topic: { type: "string" } },
|
|
32
|
+
required: ["title", "description", "topic"], additionalProperties: false,
|
|
33
|
+
};
|
|
34
|
+
const INSTRUCTIONS = `You write useful, welcoming event details for BackToSchool.help, a live learning platform.
|
|
35
|
+
Use the supplied form text as the writing prompt. Rough notes and requests in that text describe what the host wants to teach.
|
|
36
|
+
Return a clear title (at most 160 characters), a description (at most 5000 characters, usually 100–250 words), and a short topic (at most 100 characters).
|
|
37
|
+
Write in the language of the host's notes. Describe what students will learn and what will happen, using only information supported by the notes.
|
|
38
|
+
Keep concrete names and facts. Do not invent dates, prices, qualifications, prerequisites, links, credentials, or promises. Omit unknown logistics. Do not expand into unrelated subjects.
|
|
39
|
+
The schedule and host name are context only; never propose changing them. Return plain text in the required JSON fields, with no HTML. Short paragraphs or simple bullet lists are fine.`;
|
|
40
|
+
function draftFrom(text) {
|
|
41
|
+
const value = JSON.parse(text);
|
|
42
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
43
|
+
throw new Error("invalid draft");
|
|
44
|
+
const row = value;
|
|
45
|
+
const draft = {};
|
|
46
|
+
for (const key of ["title", "description", "topic"]) {
|
|
47
|
+
const field = row[key];
|
|
48
|
+
if (typeof field !== "string" || !field.trim() || field.length > LIMITS[key])
|
|
49
|
+
throw new Error("invalid draft");
|
|
50
|
+
draft[key] = field.trim();
|
|
51
|
+
}
|
|
52
|
+
return draft;
|
|
53
|
+
}
|
|
54
|
+
/** Server-side only. Drafting has no access to the event store and cannot publish. */
|
|
55
|
+
export class EventWriter {
|
|
56
|
+
options;
|
|
57
|
+
guard = new Guard();
|
|
58
|
+
active = new Set();
|
|
59
|
+
constructor(options) {
|
|
60
|
+
this.options = options;
|
|
61
|
+
}
|
|
62
|
+
async draft(accountId, value, signal) {
|
|
63
|
+
if (!accountId)
|
|
64
|
+
throw new EventWriterError("Sign in to use the AI writer.", 401);
|
|
65
|
+
const input = writerInput(value);
|
|
66
|
+
if (!this.options.openaiKey && !this.options.anthropicKey)
|
|
67
|
+
throw new EventWriterError("The AI writer is temporarily unavailable. You can keep writing here.", 503);
|
|
68
|
+
if (this.active.has(accountId))
|
|
69
|
+
throw new EventWriterError("A draft is already being written. Wait for it or cancel it first.", 429, 5);
|
|
70
|
+
if (this.active.size >= 4)
|
|
71
|
+
throw new EventWriterError("The AI writer is busy. Try again in a moment.", 503, 10);
|
|
72
|
+
const limit = this.guard.check(accountId, { allowed: 6, windowMs: 10 * 60_000 });
|
|
73
|
+
if (!limit.ok)
|
|
74
|
+
throw new EventWriterError("You have requested several drafts. Try again in a few minutes.", 429, limit.retryAfter);
|
|
75
|
+
this.active.add(accountId);
|
|
76
|
+
try {
|
|
77
|
+
for (const provider of ["openai", "claude"]) {
|
|
78
|
+
const key = provider === "openai" ? this.options.openaiKey : this.options.anthropicKey;
|
|
79
|
+
if (!key)
|
|
80
|
+
continue;
|
|
81
|
+
if (signal?.aborted)
|
|
82
|
+
throw new EventWriterError("Draft cancelled.", 499);
|
|
83
|
+
try {
|
|
84
|
+
const draft = await this.generate(provider, key, input, signal);
|
|
85
|
+
if (signal?.aborted)
|
|
86
|
+
throw new EventWriterError("Draft cancelled.", 499);
|
|
87
|
+
return { draft, provider };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (signal?.aborted)
|
|
91
|
+
throw new EventWriterError("Draft cancelled.", 499);
|
|
92
|
+
// Refusals are a response to the content, not a provider outage.
|
|
93
|
+
if (error instanceof Refused)
|
|
94
|
+
throw error;
|
|
95
|
+
// Never log prompts, credentials, or a provider's raw response body.
|
|
96
|
+
console.warn(`nixamp: event writer ${provider} request failed`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
throw new EventWriterError("The AI writer could not finish this draft. Your text is unchanged; please try again.", 503);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
this.active.delete(accountId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async generate(provider, key, input, signal) {
|
|
106
|
+
const timeout = AbortSignal.timeout(this.options.timeoutMs ?? 25_000);
|
|
107
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
108
|
+
const prompt = JSON.stringify(input);
|
|
109
|
+
const openai = provider === "openai";
|
|
110
|
+
const response = await (this.options.fetch ?? globalThis.fetch)(openai ? "https://api.openai.com/v1/responses" : "https://api.anthropic.com/v1/messages", {
|
|
111
|
+
method: "POST", signal: requestSignal,
|
|
112
|
+
headers: { "content-type": "application/json", ...(openai ? { authorization: `Bearer ${key}` } : { "x-api-key": key, "anthropic-version": "2023-06-01" }) },
|
|
113
|
+
body: JSON.stringify(openai ? {
|
|
114
|
+
model: this.options.openaiModel ?? "gpt-5-mini", store: false,
|
|
115
|
+
instructions: INSTRUCTIONS, input: [{ role: "user", content: prompt }],
|
|
116
|
+
reasoning: { effort: "minimal" }, max_output_tokens: 2048,
|
|
117
|
+
text: { format: { type: "json_schema", name: "event_draft", strict: true, schema: SCHEMA } },
|
|
118
|
+
} : {
|
|
119
|
+
model: this.options.claudeModel ?? "claude-haiku-4-5", max_tokens: 2048,
|
|
120
|
+
system: INSTRUCTIONS, messages: [{ role: "user", content: prompt }],
|
|
121
|
+
output_config: { format: { type: "json_schema", schema: SCHEMA } },
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
await response.body?.cancel();
|
|
126
|
+
throw new Error("provider unavailable");
|
|
127
|
+
}
|
|
128
|
+
const body = await response.json();
|
|
129
|
+
const blocks = openai
|
|
130
|
+
? (Array.isArray(body["output"]) ? body["output"].flatMap(item => item?.type === "message" && Array.isArray(item.content) ? item.content : []) : [])
|
|
131
|
+
: (Array.isArray(body["content"]) ? body["content"] : []);
|
|
132
|
+
if (body["stop_reason"] === "refusal" || blocks.some(block => block?.type === "refusal"))
|
|
133
|
+
throw new Refused();
|
|
134
|
+
if (openai ? body["status"] !== "completed" : body["stop_reason"] !== "end_turn")
|
|
135
|
+
throw new Error("incomplete draft");
|
|
136
|
+
const text = blocks.filter(block => block?.type === (openai ? "output_text" : "text")).map(block => typeof block.text === "string" ? block.text : "").join("");
|
|
137
|
+
return draftFrom(text);
|
|
138
|
+
}
|
|
139
|
+
}
|
package/dist/live-api.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type EventWriter } from "./event-writer.ts";
|
|
1
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
3
|
import { type Accounts } from "./accounts.ts";
|
|
3
4
|
import { type LiveEvent, type LiveEvents } from "./live-events.ts";
|
|
@@ -6,6 +7,7 @@ import { type Tickets } from "./tickets.ts";
|
|
|
6
7
|
import { type Rooms } from "./rooms.ts";
|
|
7
8
|
export interface LiveApiOptions {
|
|
8
9
|
events: LiveEvents;
|
|
10
|
+
eventWriter?: EventWriter;
|
|
9
11
|
accounts?: Accounts;
|
|
10
12
|
layouts?: Layouts;
|
|
11
13
|
rooms?: Rooms;
|
package/dist/live-api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventWriterError } from "./event-writer.js";
|
|
1
2
|
import { tokenFrom } from "./accounts.js";
|
|
2
3
|
import { INVITATION_STATES, LIVE_EVENT_KINDS, LIVE_EVENT_STATUSES, LiveEventError, isTicketed, } from "./live-events.js";
|
|
3
4
|
import { LAYOUT_SCOPES, LayoutError, PANEL_REGISTRY, layoutNameFor, presetLayout, } from "./layouts.js";
|
|
@@ -19,13 +20,13 @@ function json(response, code, body) {
|
|
|
19
20
|
});
|
|
20
21
|
response.end(value);
|
|
21
22
|
}
|
|
22
|
-
async function body(request) {
|
|
23
|
+
async function body(request, limit = 64 * 1024) {
|
|
23
24
|
const chunks = [];
|
|
24
25
|
let size = 0;
|
|
25
26
|
for await (const chunk of request) {
|
|
26
27
|
const buffer = chunk;
|
|
27
28
|
size += buffer.length;
|
|
28
|
-
if (size >
|
|
29
|
+
if (size > limit)
|
|
29
30
|
throw new LiveEventError("body too large", 413);
|
|
30
31
|
chunks.push(buffer);
|
|
31
32
|
}
|
|
@@ -187,6 +188,32 @@ export async function handleLiveApi(request, response, url, options) {
|
|
|
187
188
|
!path.startsWith("/api/v1/layout-presets"))
|
|
188
189
|
return false;
|
|
189
190
|
try {
|
|
191
|
+
if (path === "/api/v1/events/ai-draft") {
|
|
192
|
+
if (request.method !== "POST") {
|
|
193
|
+
json(response, 405, { error: "POST only" });
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
const account = await requiredAccount(request, response, options);
|
|
197
|
+
if (!account)
|
|
198
|
+
return true;
|
|
199
|
+
if (!options.eventWriter) {
|
|
200
|
+
json(response, 503, { error: "The AI writer is temporarily unavailable. You can keep writing here." });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
const input = await body(request, 12 * 1024);
|
|
204
|
+
const controller = new AbortController();
|
|
205
|
+
const closed = () => controller.abort();
|
|
206
|
+
response.once("close", closed);
|
|
207
|
+
try {
|
|
208
|
+
const result = await options.eventWriter.draft(account.id, input, controller.signal);
|
|
209
|
+
if (!response.destroyed)
|
|
210
|
+
json(response, 200, result);
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
response.off("close", closed);
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
190
217
|
if (path === "/api/v1/events") {
|
|
191
218
|
if (request.method === "GET") {
|
|
192
219
|
const account = await signedIn(request, options.accounts);
|
|
@@ -559,6 +586,14 @@ export async function handleLiveApi(request, response, url, options) {
|
|
|
559
586
|
return true;
|
|
560
587
|
}
|
|
561
588
|
catch (error) {
|
|
589
|
+
if (error instanceof EventWriterError) {
|
|
590
|
+
if (!response.destroyed) {
|
|
591
|
+
if (error.retryAfter)
|
|
592
|
+
response.setHeader("retry-after", String(error.retryAfter));
|
|
593
|
+
json(response, error.status, { error: error.message });
|
|
594
|
+
}
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
562
597
|
if (error instanceof LiveEventError || error instanceof LayoutError || error instanceof RoomError) {
|
|
563
598
|
json(response, error.status, { error: error.message });
|
|
564
599
|
return true;
|