nixamp 0.15.1 → 0.16.0
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 +36 -0
- package/backtoschool/dist/assets/index-6vtCMzi1.css +1 -0
- package/backtoschool/dist/assets/index-D7Oas1m5.js +88 -0
- package/backtoschool/dist/icon.svg +7 -0
- package/backtoschool/dist/index.html +97 -0
- package/backtoschool/dist/manifest.webmanifest +17 -0
- package/dist/channels.d.ts +10 -0
- package/dist/channels.js +8 -0
- package/dist/layouts.d.ts +68 -0
- package/dist/layouts.js +353 -0
- package/dist/live-api.d.ts +20 -0
- package/dist/live-api.js +447 -0
- package/dist/live-events.d.ts +107 -0
- package/dist/live-events.js +520 -0
- package/dist/owner.d.ts +12 -0
- package/dist/owner.js +27 -0
- package/dist/rooms.d.ts +38 -0
- package/dist/rooms.js +150 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +297 -11
- package/package.json +7 -2
- package/src/channels.ts +15 -0
- package/src/layouts.ts +414 -0
- package/src/live-api.ts +492 -0
- package/src/live-events.ts +637 -0
- package/src/owner.ts +23 -0
- package/src/rooms.ts +192 -0
- package/src/server.ts +306 -11
- package/web/dist/assets/{hls-3VKVEQE3-_qQT5aJ-.js → hls-3VKVEQE3-CrILISPJ.js} +1 -1
- package/web/dist/assets/index-CTxPM5KS.js +1 -0
- package/web/dist/assets/{mpegts-DOPWPErb.js → mpegts-CWeN63eG.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-yFFz0FUw.js → mpegts-LO6RVLD6-DaMgRvlO.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-mrvRL9c4.js +0 -1
package/README.md
CHANGED
|
@@ -136,6 +136,42 @@ DATABASE_URL=postgres://user:pass@host/nixamp NIXAMP_JWT_SECRET=… nixamp serve
|
|
|
136
136
|
Accounts live where the directory lives and nowhere else: a nixamp on a laptop
|
|
137
137
|
has nobody to be an account of.
|
|
138
138
|
|
|
139
|
+
## BackToSchool.help
|
|
140
|
+
|
|
141
|
+
BackToSchool.help is a branded, mobile-first client for NixAmp live events. It
|
|
142
|
+
uses the same NixAmp accounts, PostgreSQL data, rooms, invitations, layouts, and
|
|
143
|
+
channel transport as the main app; it has no separate backend or user store.
|
|
144
|
+
|
|
145
|
+
Build the server and both web clients from the repository root:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
bun install --frozen-lockfile
|
|
149
|
+
bun run build
|
|
150
|
+
bun run web:build
|
|
151
|
+
bun run backtoschool:build
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A directory deployment can serve the BackToSchool client instead of the default
|
|
155
|
+
NixAmp PWA by pointing `--web` at its build output:
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
DATABASE_URL=postgres://user:pass@host/nixamp \
|
|
159
|
+
NIXAMP_JWT_SECRET=replace-with-a-long-random-secret \
|
|
160
|
+
NIXAMP_SITE=https://backtoschool.help \
|
|
161
|
+
bun src/main.ts serve /srv/nixamp/media \
|
|
162
|
+
--directory --web "$PWD/backtoschool/dist" --host 127.0.0.1 --port 4321 --no-publish
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Put an HTTPS reverse proxy for `backtoschool.help` in front of that port and
|
|
166
|
+
forward the whole origin, including `/api` and `/live`. Do not buffer responses
|
|
167
|
+
under `/api/channels/`; those responses carry live audio. Keeping the client and
|
|
168
|
+
API on one origin lets the HttpOnly NixAmp session cookie authenticate hosting,
|
|
169
|
+
chat, invitations, and moderation. HTTPS is also required for browser microphone
|
|
170
|
+
access outside localhost. Event, room, layout, and invitation tables are created
|
|
171
|
+
on first use in the configured PostgreSQL database. `RESEND_API_KEY` and
|
|
172
|
+
`NIXAMP_MAIL_FROM` are optional if invitation email should be sent rather than
|
|
173
|
+
only returning a shareable link.
|
|
174
|
+
|
|
139
175
|
## The directory
|
|
140
176
|
|
|
141
177
|
[nixamp.com/directory](https://nixamp.com/directory) lists nixamps that agreed
|
|
@@ -0,0 +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}}
|
|
@@ -0,0 +1,88 @@
|
|
|
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)})}var r=document.querySelector(`#main`),i=document.querySelector(`#account-button`),a=document.querySelector(`#account-dialog`),o=document.querySelector(`#account-form`),s=document.querySelector(`#account-title`),c=document.querySelector(`#account-copy`),l=document.querySelector(`#account-error`),u=document.querySelector(`#account-mode`),d=document.querySelector(`#event-dialog`),f=document.querySelector(`#event-form`),p=document.querySelector(`#event-form-title`),m=document.querySelector(`#event-kicker`),h=document.querySelector(`#event-submit`),g=document.querySelector(`#event-error`),_=document.querySelector(`#schedule-fields`),v=null,y=!1,b=!1,x=null,S=null,C=null,w=null;function T(e){return String(e??``).replaceAll(`&`,`&`).replaceAll(`<`,`<`).replaceAll(`>`,`>`).replaceAll(`"`,`"`).replaceAll(`'`,`'`)}function E(e){return`/live/${encodeURIComponent(e.slug)}`}function D(){let e=new URLSearchParams(location.search).get(`invite`);return e?`?invite=${encodeURIComponent(e)}`:``}function O(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 k(e){return e.status===`live`?`Live now`:e.status===`ended`?e.recordingId?`Replay available`:`Event ended`:e.status===`cancelled`?`Cancelled`:O(e.startsAt)}function A(e){return C?.permissions.includes(e)??!1}function j(){i.textContent=v?v.email.split(`@`)[0]||`Account`:`Sign in`,i.classList.toggle(`signed-in`,!!v)}async function M(){try{v=(await t(`/api/v1/auth/me`)).account}catch{v=null}j()}function N(e){if(v){a.showModal(),s.textContent=`Signed in as ${v.email}`,c.textContent=`Your BackToSchool identity is your NixAmp account.`,o.hidden=!0;return}x=e??null,o.hidden=!1,l.textContent=``,P(),a.showModal()}function P(){s.textContent=y?`Create your account`:`Welcome back`,c.textContent=y?`One NixAmp account works here and everywhere NixAmp goes.`:`Sign in to host, chat, or raise your hand.`,u.textContent=y?`Already have an account? Sign in`:`New here? Create an account`;let e=o.elements.namedItem(`password`);e.autocomplete=y?`new-password`:`current-password`,o.querySelector(`button[type="submit"]`).textContent=y?`Create account`:`Sign in`}function F(e){if(!v){N(()=>F(e));return}b=e===`scheduled`,_.hidden=!b,p.textContent=b?`Schedule a live`:`Go live`,m.textContent=b?`Put it on the calendar`:`Start a conversation`,h.textContent=b?`Schedule live`:`Create live`,g.textContent=``;let t=f.elements.namedItem(`startsAt`);if(t.required=b,b&&!t.value){let e=new Date(Date.now()+36e5);e.setMinutes(Math.ceil(e.getMinutes()/15)*15,0,0),t.value=`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}T${String(e.getHours()).padStart(2,`0`)}:${String(e.getMinutes()).padStart(2,`0`)}`}d.showModal()}function I(e){history.pushState(null,``,e),Z()}function L(e){let t=e.status===`live`||e.status===`starting`;return`
|
|
2
|
+
<article class="event-card ${t?`is-live`:``}">
|
|
3
|
+
<a href="${E(e)}" data-link>
|
|
4
|
+
<div class="event-card-top">
|
|
5
|
+
<span class="status-pill ${t?`status-live`:``}">${t?`Live`:T(k(e))}</span>
|
|
6
|
+
${e.topic?`<span class="topic">${T(e.topic)}</span>`:``}
|
|
7
|
+
</div>
|
|
8
|
+
<h3>${T(e.title)}</h3>
|
|
9
|
+
<p>${T(e.description||`Drop in and learn something with us.`)}</p>
|
|
10
|
+
<span class="event-link">${t?`Listen live`:`See event`}<span aria-hidden="true">→</span></span>
|
|
11
|
+
</a>
|
|
12
|
+
</article>`}async function R(){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`),i=e.filter(e=>e.status===`scheduled`||e.status===`draft`),a=e.filter(e=>e.status===`ended`&&e.recordingId);r.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>Drop into thoughtful conversations, ask real questions, or start a room about something you know.</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">Listening is always one tap away. 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>${T(n[0]?.title??i[0]?.title??`The best teachers are curious people`)}</strong>
|
|
30
|
+
<p>${n[0]?`Happening right now`:i[0]?k(i[0]):`Host the first live conversation`}</p>
|
|
31
|
+
${n[0]?`<a href="${E(n[0])}" data-link class="board-action">Listen 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(L).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
|
+
${i.length?i.map(e=>`
|
|
52
|
+
<a class="upcoming-row" href="${E(e)}" data-link>
|
|
53
|
+
<time>${T(O(e.startsAt,{weekday:`short`,month:`short`,day:`numeric`}))}</time>
|
|
54
|
+
<span><strong>${T(e.title)}</strong><small>${T(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
|
+
${a.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">${a.map(L).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 z(e){let t=D(),n=e.recordingId&&e.status===`ended`?`/api/v1/recordings/${encodeURIComponent(e.recordingId)}`:`/api/channels/${encodeURIComponent(e.roomId)}${t}`,r=e.status===`live`||!!e.recordingId;return`
|
|
68
|
+
<div class="listen-card ${r?``:`waiting`}">
|
|
69
|
+
<div class="sound-orbit" aria-hidden="true"><span></span><span></span><span></span><b>▶</b></div>
|
|
70
|
+
<div>
|
|
71
|
+
<span class="eyebrow">${e.status===`live`?`On air now`:e.recordingId?`Replay`:`The room opens soon`}</span>
|
|
72
|
+
<h2>${r?e.status===`live`?`Listen live`:`Listen again`:`We’ll see you here.`}</h2>
|
|
73
|
+
<p id="player-note">${r?`Press play and you’re in. No sign-up, no setup.`:T(k(e))}</p>
|
|
74
|
+
${r?`<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="${T(n)}" controls></audio>`:``}
|
|
75
|
+
</div>
|
|
76
|
+
</div>`}function B(e){return e.chatEnabled?`
|
|
77
|
+
<div id="chat-messages" class="chat-messages"><p class="muted">Loading the conversation…</p></div>
|
|
78
|
+
${v?`<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 V(e,t){switch(e.type){case`event-header`:return`<div class="event-heading"><span class="status-pill ${t.status===`live`?`status-live`:``}">${T(k(t))}</span>${t.topic?`<span class="topic">${T(t.topic)}</span>`:``}<h1>${T(t.title)}</h1><p>${T(t.description||`A live conversation powered by NixAmp.`)}</p></div>`;case`player`:return z(t);case`stage`:return`<div class="stage-card"><span class="stage-avatar">${T(t.title.slice(0,1).toUpperCase())}</span><div><span class="eyebrow">Host stage</span><h2>${T(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"><span class="avatar">N</span><div><small>Hosted with</small><strong>NixAmp Live</strong></div></div>`;case`about`:return`<p class="reading-copy">${T(t.description||`Come listen, learn, and ask a question live.`)}</p>${t.topic?`<span class="topic topic-large">${T(t.topic)}</span>`:``}`;case`join`:return v?`<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 B(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="${T(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"><button class="button button-primary" type="button" data-broadcast>${t.status===`live`?`Use this microphone`:`Start live audio`}</button>${t.status===`live`?`<button class="button button-danger" type="button" data-end-event>End event</button>`:``}</div>`;case`schedule`:return`<div class="metric"><strong>${T(O(t.startsAt))}</strong><span>${T(t.timezone)}</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}}function H(e,t){try{if(!e.enabled||!e.visible||(e.permissions??[]).some(e=>!A(e)))return``;let n=V(e,t);if(n===null)return``;let r=e.title||e.type.replaceAll(`-`,` `);return`<section class="event-panel panel-${T(e.type)}" data-panel="${T(e.id)}"><span class="panel-title">${T(r)}</span>${n}</section>`}catch{return`<section class="event-panel panel-failed"><span class="panel-title">Panel unavailable</span><p>This part didn’t load. The audio player is unaffected.</p></section>`}}async function U(e){let n=await t(`/api/v1/events/${encodeURIComponent(e)}${D()}`);C=n;let i=n.event;document.title=`${i.title} — BackToSchool.help`,document.querySelector(`meta[name="description"]`)?.setAttribute(`content`,i.description||`Listen to ${i.title} live on BackToSchool.help.`),document.querySelector(`meta[property="og:title"]`)?.setAttribute(`content`,i.title),document.querySelector(`meta[property="og:description"]`)?.setAttribute(`content`,i.description||`Learn something live.`);let a=new Map;for(let e of[...n.layout.panels].sort((e,t)=>e.order-t.order)){let t=H(e,i);if(!t)continue;let n=a.get(e.region)??[];n.push(t),a.set(e.region,n)}r.innerHTML=`
|
|
79
|
+
<div class="event-shell">
|
|
80
|
+
<a class="back-link" href="/" data-link>← Explore more lives</a>
|
|
81
|
+
<div class="event-layout">
|
|
82
|
+
<div class="region region-primary">${(a.get(`primary`)??[]).join(``)}</div>
|
|
83
|
+
<aside class="region region-secondary">${(a.get(`secondary`)??[]).join(``)}</aside>
|
|
84
|
+
${(a.get(`sidebar`)??[]).length?`<aside class="region region-sidebar">${a.get(`sidebar`).join(``)}</aside>`:``}
|
|
85
|
+
${(a.get(`bottom`)??[]).length?`<div class="region region-bottom">${a.get(`bottom`).join(``)}</div>`:``}
|
|
86
|
+
${(a.get(`drawer`)??[]).length?`<div class="region region-drawer">${a.get(`drawer`).join(``)}</div>`:``}
|
|
87
|
+
</div>
|
|
88
|
+
</div>`,W(i)}function W(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(()=>G(r)).catch(e=>{t.placeholder=e instanceof Error?e.message:`Could not send`}))});let a=document.querySelector(`#invite-form`);if(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-broadcast]`)?.addEventListener(`click`,()=>void J()),document.querySelector(`[data-end-event]`)?.addEventListener(`click`,()=>void X()),document.querySelector(`[data-copy]`)?.addEventListener(`click`,async e=>{await navigator.clipboard.writeText(location.href),e.currentTarget.textContent=`Copied ✓`}),r.chatEnabled){G(r);let e=window.setInterval(()=>void G(r),4e3),t=A(`event.moderate`)?window.setInterval(()=>void K(r),3e3):0;K(r),S=()=>{clearInterval(e),t&&clearInterval(t)}}q(r)}async function G(e){let n=document.querySelector(`#chat-messages`);if(n)try{let{messages:r}=await t(`/api/v1/events/${encodeURIComponent(e.id)}/chat${D()}`);n.innerHTML=r.length?r.map(e=>`<div class="chat-message"><span class="avatar avatar-small">${T(e.authorName.slice(0,1).toUpperCase())}</span><p><strong>${T(e.authorName)}</strong><span>${T(e.body)}</span></p></div>`).join(``):`<p class="muted">No messages yet. Say hello when you’re ready.</p>`,n.scrollTop=n.scrollHeight}catch{n.innerHTML=`<p class="muted">Chat is taking a break. The audio still works.</p>`}}async function K(e){let n=document.querySelector(`#hand-raises`);if(n&&A(`event.moderate`))try{let{handRaises:r}=await t(`/api/v1/events/${encodeURIComponent(e.id)}/hand-raises`);n.innerHTML=r.length?r.map(e=>`<div class="raise-row"><span><strong>${T(e.displayName)}</strong><small>${T(e.state)}</small></span><div><button class="text-button" data-hand="${T(e.accountId)}" data-state="invited">Invite</button><button class="text-button" data-hand="${T(e.accountId)}" data-state="dismissed">Dismiss</button></div></div>`).join(``):`<p class="muted">No hands raised.</p>`}catch{n.innerHTML=`<p class="muted">Could not refresh hand raises.</p>`}}async function q(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 J(){if(!C||w)return;let e=document.querySelector(`[data-broadcast]`),n=document.querySelector(`#broadcast-note`);try{e?.setAttribute(`disabled`,`true`),n&&(n.textContent=`Asking for your microphone…`);let r=C.event;if(r.status!==`live`){let e=await t(`/api/v1/events/${encodeURIComponent(r.id)}/start`,{method:`POST`,body:JSON.stringify({version:r.version})});C=e,r=e.event}let i=await navigator.mediaDevices.getUserMedia({audio:!0}),a=[`audio/webm;codecs=opus`,`audio/webm`,`audio/ogg;codecs=opus`].find(e=>MediaRecorder.isTypeSupported(e))??``,o=new MediaRecorder(i,a?{mimeType:a}:void 0),s={recorder:o,stream:i,queue:Promise.resolve()};w=s,o.addEventListener(`dataavailable`,e=>{e.data.size&&(s.queue=s.queue.then(async()=>{if(!(await fetch(`/api/channels/${encodeURIComponent(r.roomId)}/chunk?format=${encodeURIComponent(o.mimeType||`webm`)}&name=${encodeURIComponent(r.title)}`,{method:`POST`,credentials:`same-origin`,headers:{"content-type":o.mimeType||`audio/webm`},body:e.data})).ok)throw Error(`NixAmp could not publish this audio`)}))}),o.start(1e3),e&&(e.disabled=!1,e.textContent=`Stop this microphone`,e.dataset.stopBroadcast=`true`,e.onclick=()=>void Y()),n&&(n.textContent=`Your microphone is live through NixAmp.`)}catch(t){e&&(e.disabled=!1),n&&(n.textContent=t instanceof Error?t.message:`Could not start the microphone.`)}}async function Y(){let e=w;if(!e)return;w=null;let t=new Promise(t=>e.recorder.addEventListener(`stop`,()=>t(),{once:!0}));e.recorder.stop(),e.stream.getTracks().forEach(e=>e.stop()),await t,await e.queue.catch(()=>void 0);let n=document.querySelector(`#broadcast-note`);n&&(n.textContent=`This microphone is off. The event is still open.`);let r=document.querySelector(`[data-broadcast]`);r&&(r.textContent=`Use this microphone`,r.onclick=()=>void J())}async function X(){if(!C)return;await Y();let e=C.event;try{C=await t(`/api/v1/events/${encodeURIComponent(e.id)}/end`,{method:`POST`,body:JSON.stringify({version:e.version})}),await fetch(`/api/channels/${encodeURIComponent(e.roomId)}`,{method:`DELETE`,credentials:`same-origin`}),await U(e.slug)}catch(e){let t=document.querySelector(`#broadcast-note`);t&&(t.textContent=e instanceof Error?e.message:`Could not end the event.`)}}async function Z(){S?.(),S=null,C=null;let t=/^\/live\/([^/]+)\/?$/.exec(location.pathname);try{t?await U(decodeURIComponent(t[1])):await R()}catch(t){r.innerHTML=`<section class="error-page"><span class="eyebrow">Class dismissed?</span><h1>${T(t instanceof e&&t.status===404?`That live event isn’t available.`:`We couldn’t open this page.`)}</h1><p>${T(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>`}r.focus({preventScroll:!0})}i.addEventListener(`click`,()=>{if(!v){N();return}confirm(`Signed in as ${v.email}. Sign out?`)&&t(`/api/v1/auth/logout`,{method:`POST`}).finally(()=>{v=null,j(),Z()})}),u.addEventListener(`click`,()=>{y=!y,l.textContent=``,P()}),o.addEventListener(`submit`,e=>{e.preventDefault();let n=new FormData(o),r=o.querySelector(`button[type="submit"]`);r.disabled=!0,l.textContent=``,t(`/api/v1/auth/${y?`signup`:`login`}`,{method:`POST`,body:JSON.stringify({email:n.get(`email`),password:n.get(`password`)})}).then(e=>{v=e.account,j(),o.reset(),a.close();let t=x;x=null,t?t():Z()}).catch(e=>{l.textContent=e instanceof Error?e.message:`That did not work.`}).finally(()=>{r.disabled=!1})}),f.addEventListener(`submit`,e=>{e.preventDefault();let n=new FormData(f);h.disabled=!0,g.textContent=``;let r=String(n.get(`startsAt`)??``),i={title:n.get(`title`),description:n.get(`description`),topic:n.get(`topic`),visibility:n.get(`visibility`),chatEnabled:n.get(`chatEnabled`)===`on`,handRaiseEnabled:n.get(`handRaiseEnabled`)===`on`,recordingEnabled:n.get(`recordingEnabled`)===`on`,...b?{startsAt:new Date(r).toISOString(),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone||`UTC`,expectedDurationMinutes:Number(n.get(`expectedDurationMinutes`))}:{}};t(`/api/v1/events`,{method:`POST`,body:JSON.stringify(i)}).then(async e=>{let n=e;b||(n=await t(`/api/v1/events/${encodeURIComponent(e.event.id)}/start`,{method:`POST`,body:JSON.stringify({version:e.event.version})})),d.close(),f.reset(),I(E(n.event))}).catch(e=>{g.textContent=e instanceof Error?e.message:`Could not create the event.`}).finally(()=>{h.disabled=!1})}),document.addEventListener(`click`,e=>{let t=e.target,r=t.closest(`a[data-link]`);if(r&&r.origin===location.origin){e.preventDefault(),I(`${r.pathname}${r.search}`);return}let i=t.closest(`[data-event-mode]`)?.dataset.eventMode;(i===`live`||i===`scheduled`)&&F(i),t.closest(`[data-sign-in]`)&&N(()=>void Z());let a=t.closest(`[data-close]`);a&&a.closest(`dialog`)?.close();let o=t.closest(`[data-hand]`);o&&C&&(o.disabled=!0,n(`/api/v1/events/${encodeURIComponent(C.event.id)}/hand-raises/${encodeURIComponent(o.dataset.hand??``)}`,{state:o.dataset.state},`PATCH`).then(()=>K(C.event)).finally(()=>{o.disabled=!1}))}),window.addEventListener(`popstate`,()=>void Z()),window.addEventListener(`beforeunload`,()=>{w?.stream.getTracks().forEach(e=>e.stop())}),M().then(()=>Z());
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
2
|
+
<rect width="512" height="512" rx="112" fill="#34246d"/>
|
|
3
|
+
<path d="M96 190 256 104l160 86-160 86L96 190Z" fill="#fff8ea"/>
|
|
4
|
+
<path d="M148 224v92c0 38 48 70 108 70s108-32 108-70v-92l-108 58-108-58Z" fill="#ff765f"/>
|
|
5
|
+
<path d="M416 194v116" stroke="#ffd465" stroke-width="24" stroke-linecap="round"/>
|
|
6
|
+
<circle cx="416" cy="338" r="20" fill="#ffd465"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<meta name="theme-color" content="#34246d" />
|
|
7
|
+
<meta name="description" content="Learn something live. Listen to public conversations, classes, and talks powered by NixAmp." />
|
|
8
|
+
<meta property="og:site_name" content="BackToSchool.help" />
|
|
9
|
+
<meta property="og:title" content="BackToSchool.help — Learn something live" />
|
|
10
|
+
<meta property="og:description" content="Live conversations with people who know." />
|
|
11
|
+
<meta property="og:type" content="website" />
|
|
12
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
13
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
|
14
|
+
<title>BackToSchool.help — Learn something live</title>
|
|
15
|
+
<script type="module" crossorigin src="/assets/index-D7Oas1m5.js"></script>
|
|
16
|
+
<link rel="stylesheet" crossorigin href="/assets/index-6vtCMzi1.css">
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div id="app">
|
|
20
|
+
<header class="site-header">
|
|
21
|
+
<a class="wordmark" href="/" aria-label="BackToSchool.help home">
|
|
22
|
+
<span class="wordmark-mark" aria-hidden="true">B</span>
|
|
23
|
+
<span>BackToSchool<span>.help</span></span>
|
|
24
|
+
</a>
|
|
25
|
+
<nav class="site-nav" aria-label="Main navigation">
|
|
26
|
+
<a href="/#live">Live now</a>
|
|
27
|
+
<a href="/#upcoming">Upcoming</a>
|
|
28
|
+
<button class="button button-quiet" id="account-button" type="button">Sign in</button>
|
|
29
|
+
</nav>
|
|
30
|
+
</header>
|
|
31
|
+
<main id="main" tabindex="-1">
|
|
32
|
+
<section class="loading-page" aria-live="polite">
|
|
33
|
+
<span class="eyebrow">BackToSchool.help</span>
|
|
34
|
+
<h1>Learn something live.</h1>
|
|
35
|
+
<p>Finding today’s conversations…</p>
|
|
36
|
+
</section>
|
|
37
|
+
</main>
|
|
38
|
+
<footer class="site-footer">
|
|
39
|
+
<span>Live audio, made simple.</span>
|
|
40
|
+
<span>Powered by <a href="https://nixamp.com">NixAmp</a></span>
|
|
41
|
+
</footer>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<dialog id="account-dialog" class="dialog">
|
|
45
|
+
<button class="dialog-close" type="button" data-close aria-label="Close">×</button>
|
|
46
|
+
<span class="eyebrow">Your NixAmp account</span>
|
|
47
|
+
<h2 id="account-title">Welcome back</h2>
|
|
48
|
+
<p id="account-copy">Sign in to host, chat, or raise your hand.</p>
|
|
49
|
+
<form id="account-form" class="form-stack">
|
|
50
|
+
<label>Email<input name="email" type="email" autocomplete="email" required /></label>
|
|
51
|
+
<label>Password<input name="password" type="password" autocomplete="current-password" minlength="8" required /></label>
|
|
52
|
+
<p class="form-error" id="account-error" role="alert"></p>
|
|
53
|
+
<button class="button button-primary" type="submit">Sign in</button>
|
|
54
|
+
<button class="text-button" id="account-mode" type="button">New here? Create an account</button>
|
|
55
|
+
</form>
|
|
56
|
+
</dialog>
|
|
57
|
+
|
|
58
|
+
<dialog id="event-dialog" class="dialog dialog-wide">
|
|
59
|
+
<button class="dialog-close" type="button" data-close aria-label="Close">×</button>
|
|
60
|
+
<span class="eyebrow" id="event-kicker">Start a conversation</span>
|
|
61
|
+
<h2 id="event-form-title">Go live</h2>
|
|
62
|
+
<form id="event-form" class="form-stack">
|
|
63
|
+
<label>Title<input name="title" maxlength="160" placeholder="What will people learn?" required /></label>
|
|
64
|
+
<label>Description<textarea name="description" maxlength="5000" rows="3" placeholder="A short introduction for listeners"></textarea></label>
|
|
65
|
+
<div class="form-pair">
|
|
66
|
+
<label>Topic<input name="topic" maxlength="100" placeholder="Technology, music…" /></label>
|
|
67
|
+
<label>Who can listen
|
|
68
|
+
<select name="visibility">
|
|
69
|
+
<option value="public">Public</option>
|
|
70
|
+
<option value="unlisted">Anyone with the link</option>
|
|
71
|
+
<option value="private">Invited people</option>
|
|
72
|
+
</select>
|
|
73
|
+
</label>
|
|
74
|
+
</div>
|
|
75
|
+
<div id="schedule-fields" class="form-pair" hidden>
|
|
76
|
+
<label>Date and time<input name="startsAt" type="datetime-local" /></label>
|
|
77
|
+
<label>Expected length
|
|
78
|
+
<select name="expectedDurationMinutes">
|
|
79
|
+
<option value="30">30 minutes</option>
|
|
80
|
+
<option value="60" selected>1 hour</option>
|
|
81
|
+
<option value="90">90 minutes</option>
|
|
82
|
+
<option value="120">2 hours</option>
|
|
83
|
+
</select>
|
|
84
|
+
</label>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="form-checks">
|
|
87
|
+
<label><input name="chatEnabled" type="checkbox" checked /> Chat</label>
|
|
88
|
+
<label><input name="handRaiseEnabled" type="checkbox" checked /> Raise hand</label>
|
|
89
|
+
<label><input name="recordingEnabled" type="checkbox" /> Record</label>
|
|
90
|
+
</div>
|
|
91
|
+
<p class="form-error" id="event-error" role="alert"></p>
|
|
92
|
+
<button class="button button-primary" type="submit" id="event-submit">Create live</button>
|
|
93
|
+
</form>
|
|
94
|
+
</dialog>
|
|
95
|
+
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "BackToSchool.help",
|
|
3
|
+
"short_name": "BackToSchool",
|
|
4
|
+
"description": "Learn something live.",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#f7f2e8",
|
|
8
|
+
"theme_color": "#34246d",
|
|
9
|
+
"icons": [
|
|
10
|
+
{
|
|
11
|
+
"src": "/icon.svg",
|
|
12
|
+
"sizes": "any",
|
|
13
|
+
"type": "image/svg+xml",
|
|
14
|
+
"purpose": "any maskable"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
package/dist/channels.d.ts
CHANGED
|
@@ -41,6 +41,12 @@ export interface ChannelInfo {
|
|
|
41
41
|
container: string;
|
|
42
42
|
duration?: number;
|
|
43
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* The nixamp.com account that put it on the air, when a member did rather
|
|
46
|
+
* than the owner. Theirs to take off again, and counted against how many
|
|
47
|
+
* a member may have on at once.
|
|
48
|
+
*/
|
|
49
|
+
startedBy?: string;
|
|
44
50
|
}
|
|
45
51
|
/** Where a pulled source is picked up from, and whether it can be at all. */
|
|
46
52
|
export interface PullResume {
|
|
@@ -222,6 +228,8 @@ export declare class Channels {
|
|
|
222
228
|
get count(): number;
|
|
223
229
|
/** Total listeners across every channel. */
|
|
224
230
|
get listeners(): number;
|
|
231
|
+
/** What is known about one channel, to read or to mark. Undefined when it is not on. */
|
|
232
|
+
info(id: string): ChannelInfo | undefined;
|
|
225
233
|
has(id: string): boolean;
|
|
226
234
|
/**
|
|
227
235
|
* Claim a channel and start decoding into it. Null when that channel is
|
|
@@ -311,6 +319,8 @@ export interface RememberedChannel {
|
|
|
311
319
|
position?: number;
|
|
312
320
|
/** A live source has nowhere to pick up from. */
|
|
313
321
|
live?: boolean;
|
|
322
|
+
/** The member who put it on, so it is still theirs after a restart. */
|
|
323
|
+
startedBy?: string;
|
|
314
324
|
}
|
|
315
325
|
export declare function rememberedChannels(dir: string, port: number): RememberedChannel[];
|
|
316
326
|
/**
|
package/dist/channels.js
CHANGED
|
@@ -576,6 +576,10 @@ export class Channels {
|
|
|
576
576
|
total += channel.listeners.size;
|
|
577
577
|
return total;
|
|
578
578
|
}
|
|
579
|
+
/** What is known about one channel, to read or to mark. Undefined when it is not on. */
|
|
580
|
+
info(id) {
|
|
581
|
+
return this.open.get(id)?.info;
|
|
582
|
+
}
|
|
579
583
|
has(id) {
|
|
580
584
|
return this.open.has(id);
|
|
581
585
|
}
|
|
@@ -741,6 +745,8 @@ export function rememberedChannels(dir, port) {
|
|
|
741
745
|
kept.position = one["position"];
|
|
742
746
|
if (typeof one["live"] === "boolean")
|
|
743
747
|
kept.live = one["live"];
|
|
748
|
+
if (typeof one["startedBy"] === "string" && one["startedBy"] !== "")
|
|
749
|
+
kept.startedBy = one["startedBy"];
|
|
744
750
|
return kept;
|
|
745
751
|
});
|
|
746
752
|
}
|
|
@@ -767,6 +773,8 @@ export function rememberedNow(channels) {
|
|
|
767
773
|
kept.live = one.live;
|
|
768
774
|
if (!one.live && typeof one.position === "number" && one.position > 0)
|
|
769
775
|
kept.position = Math.floor(one.position);
|
|
776
|
+
if (one.startedBy)
|
|
777
|
+
kept.startedBy = one.startedBy;
|
|
770
778
|
return kept;
|
|
771
779
|
});
|
|
772
780
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Queryable } from "./follows.ts";
|
|
2
|
+
export declare const PANEL_REGIONS: readonly ["primary", "secondary", "sidebar", "drawer", "bottom", "overlay"];
|
|
3
|
+
export declare const LAYOUT_SCOPES: readonly ["system", "brand", "role", "user", "event"];
|
|
4
|
+
export type PanelRegion = (typeof PANEL_REGIONS)[number];
|
|
5
|
+
export type LayoutScope = (typeof LAYOUT_SCOPES)[number];
|
|
6
|
+
export interface PanelDefinition {
|
|
7
|
+
type: string;
|
|
8
|
+
title: string;
|
|
9
|
+
permissions?: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface PanelInstance {
|
|
12
|
+
id: string;
|
|
13
|
+
type: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
visible: boolean;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
region: PanelRegion;
|
|
18
|
+
order: number;
|
|
19
|
+
size?: {
|
|
20
|
+
width?: number | string;
|
|
21
|
+
height?: number | string;
|
|
22
|
+
};
|
|
23
|
+
collapsed?: boolean;
|
|
24
|
+
permissions?: string[];
|
|
25
|
+
config: Record<string, unknown>;
|
|
26
|
+
version: number;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
updatedAt: string;
|
|
29
|
+
}
|
|
30
|
+
export interface Layout {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
scope: LayoutScope;
|
|
34
|
+
scopeId?: string;
|
|
35
|
+
ownerId?: string;
|
|
36
|
+
panels: PanelInstance[];
|
|
37
|
+
version: number;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
updatedAt: string;
|
|
40
|
+
}
|
|
41
|
+
export declare class LayoutError extends Error {
|
|
42
|
+
readonly status: number;
|
|
43
|
+
constructor(message: string, status: number);
|
|
44
|
+
}
|
|
45
|
+
export declare const PANEL_REGISTRY: readonly PanelDefinition[];
|
|
46
|
+
export declare const LAYOUT_PRESETS: Readonly<Record<string, Layout>>;
|
|
47
|
+
export declare function panelFrom(value: unknown, position?: number): PanelInstance;
|
|
48
|
+
export declare function presetLayout(name: string): Layout | null;
|
|
49
|
+
export declare function resolveLayout(layers: readonly Layout[], permissions: ReadonlySet<string>): PanelInstance[];
|
|
50
|
+
export declare class Layouts {
|
|
51
|
+
private readonly db;
|
|
52
|
+
private ready;
|
|
53
|
+
constructor(db: Queryable);
|
|
54
|
+
private ensure;
|
|
55
|
+
get(reference: string): Promise<Layout | null>;
|
|
56
|
+
scoped(scope: LayoutScope, scopeId: string): Promise<Layout[]>;
|
|
57
|
+
create(input: {
|
|
58
|
+
name: unknown;
|
|
59
|
+
scope: unknown;
|
|
60
|
+
scopeId?: unknown;
|
|
61
|
+
panels?: unknown;
|
|
62
|
+
}, ownerId: string): Promise<Layout>;
|
|
63
|
+
update(reference: string, panels: unknown, version: unknown, accountId: string): Promise<Layout>;
|
|
64
|
+
addPanel(reference: string, panel: unknown, version: unknown, accountId: string): Promise<Layout>;
|
|
65
|
+
updatePanel(reference: string, panelId: string, patch: unknown, version: unknown, accountId: string): Promise<Layout>;
|
|
66
|
+
removePanel(reference: string, panelId: string, version: unknown, accountId: string): Promise<Layout>;
|
|
67
|
+
reset(reference: string, presetName: string, version: unknown, accountId: string): Promise<Layout>;
|
|
68
|
+
}
|