alert feed betterification
Container image / image (push) Failing after 1m0s

This commit is contained in:
legop3
2026-09-20 16:22:04 -04:00
parent ff8b0c530e
commit e750712a39
38 changed files with 497 additions and 729 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{r as g,R as a}from"./index-DCtVfg7-.js";const d="64px",u={},k=({src:s,light:t,oEmbedUrl:o,onClickPreview:n,playIcon:b,previewTabIndex:_,previewAriaLabel:p})=>{const[f,r]=g.useState(null);g.useEffect(()=>{!s||!t||!o||x({src:s,light:t,oEmbedUrl:o})},[s,t,o]);const x=async({src:e,light:i,oEmbedUrl:E})=>{if(a.isValidElement(i))return;if(typeof i=="string"){r(i);return}if(u[e]){r(u[e]);return}r(null);const m=await(await fetch(E.replace("{url}",e))).json();if(m.thumbnail_url){const h=m.thumbnail_url.replace("height=100","height=480").replace("-d_295x166","-d_640");r(h),u[e]=h}},I=e=>{(e.key==="Enter"||e.key===" ")&&n?.(e)},v=e=>{n?.(e)},l=a.isValidElement(t),y={display:"flex",alignItems:"center",justifyContent:"center"},c={preview:{width:"100%",height:"100%",backgroundImage:f&&!l?`url(${f})`:void 0,backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",...y},shadow:{background:"radial-gradient(rgb(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%)",borderRadius:d,width:d,height:d,position:l?"absolute":void 0,...y},playIcon:{borderStyle:"solid",borderWidth:"16px 0 16px 26px",borderColor:"transparent transparent transparent white",marginLeft:"7px"}},w=a.createElement("div",{style:c.shadow,className:"react-player__shadow"},a.createElement("div",{style:c.playIcon,className:"react-player__play-icon"}));return a.createElement("div",{style:c.preview,className:"react-player__preview",tabIndex:_,onClick:v,onKeyDown:I,...p?{"aria-label":p}:{}},l?t:null,b||w)};var C=k;export{C as default};
//# sourceMappingURL=Preview-O6d1niaS.js.map
import{r as g,R as a}from"./index-BRtx5t_a.js";const d="64px",u={},k=({src:s,light:t,oEmbedUrl:o,onClickPreview:n,playIcon:b,previewTabIndex:_,previewAriaLabel:p})=>{const[f,r]=g.useState(null);g.useEffect(()=>{!s||!t||!o||x({src:s,light:t,oEmbedUrl:o})},[s,t,o]);const x=async({src:e,light:i,oEmbedUrl:E})=>{if(a.isValidElement(i))return;if(typeof i=="string"){r(i);return}if(u[e]){r(u[e]);return}r(null);const m=await(await fetch(E.replace("{url}",e))).json();if(m.thumbnail_url){const h=m.thumbnail_url.replace("height=100","height=480").replace("-d_295x166","-d_640");r(h),u[e]=h}},I=e=>{(e.key==="Enter"||e.key===" ")&&n?.(e)},v=e=>{n?.(e)},l=a.isValidElement(t),y={display:"flex",alignItems:"center",justifyContent:"center"},c={preview:{width:"100%",height:"100%",backgroundImage:f&&!l?`url(${f})`:void 0,backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",...y},shadow:{background:"radial-gradient(rgb(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%)",borderRadius:d,width:d,height:d,position:l?"absolute":void 0,...y},playIcon:{borderStyle:"solid",borderWidth:"16px 0 16px 26px",borderColor:"transparent transparent transparent white",marginLeft:"7px"}},w=a.createElement("div",{style:c.shadow,className:"react-player__shadow"},a.createElement("div",{style:c.playIcon,className:"react-player__play-icon"}));return a.createElement("div",{style:c.preview,className:"react-player__preview",tabIndex:_,onClick:v,onKeyDown:I,...p?{"aria-label":p}:{}},l?t:null,b||w)};var C=k;export{C as default};
//# sourceMappingURL=Preview-CkxopI7c.js.map
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-DCtVfg7-.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-CFFNNLJ4.js.map
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-BRtx5t_a.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-MGvspYuk.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
import{_ as N,R as A}from"./index-DCtVfg7-.js";import{M as D,C as S}from"./mixin-BY5xW4uI.js";class P extends D(S){static shadowRootOptions={...S.shadowRootOptions};static getTemplateHTML=i=>{const{src:n,...o}=i;return S.getTemplateHTML(o)};#t;attributeChangedCallback(i,n,o){i!=="src"&&super.attributeChangedCallback(i,n,o),i==="src"&&n!=o&&this.load()}async _initThumbnails(i){const n=async(d,m)=>{const v=[],g=i.timescale||1,h=i.startNumber||1,E=i.presentationTimeOffset?i.presentationTimeOffset/g:0,_=i.segmentDuration;for(let l=0;l<d;l++){const T=C({thIndex:l,thduration:m,ttiles:d,tduration:_,startNumber:h,pto:E}),w=T+m,M=new Promise((f,y)=>{this.api.provideThumbnail(T,({url:a,width:s,height:c,x:p,y:b})=>{try{const R=new VTTCue(T,w,`${a}#xywh=${p},${b},${s},${c}`);f(R)}catch(R){y(R)}})});v.push(M)}return await Promise.all(v).catch(l=>console.error("Error processing thumbnails",l))},{totalThumbnails:o,thumbnailDuration:e}=$(i),u=await n(o,e);let r=this.nativeEl.querySelector('track[label="thumbnails"]');if(!r){r=O(),this.nativeEl.appendChild(r);const d=x(u);r.src=d,r.dispatchEvent(new Event("change"))}}async load(){if(this.#t){this.api.attachSource(this.src);return}this.#t=!0;const i=await N(()=>import("./dash.all.min-C9gxBrL1.js"),[]);this.api=i.MediaPlayer().create(),this.api.initialize(this.nativeEl,this.src,this.autoplay),this.api.on(i.MediaPlayer.events.STREAM_INITIALIZED,()=>{const n=this.api.getRepresentationsByType("video");let o=this.videoTracks.getTrackById("main");o||(o=this.addVideoTrack("main"),o.id="main",o.selected=!0),n.forEach(e=>{const u=e.bandwidth??e.bitrate??(Number.isFinite(e.bitrateInKbit)?e.bitrateInKbit*1e3:void 0),r=o.addRendition(e.id,e.width,e.height,e.mimeType??e.codec,u);r.id=e.id}),this.videoRenditions.addEventListener("change",()=>{const e=this.videoRenditions[this.videoRenditions.selectedIndex];e?.id?(this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),this.api.setRepresentationForTypeById("video",e.id,!0)):this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}})}),this.api.isDynamic()||this.api.getRepresentationsByType("image").forEach(async(u,r)=>{r>0||this._initThumbnails(u)})})}}function $(t){var i,n;const o=t.essentialProperties[0],[e,u]=o.value.split("x").map(Number),r=e*u,d=((n=(i=t.adaptation)==null?void 0:i.period)==null?void 0:n.duration)||null,m=t.segmentDuration,v=t.timescale||1,h=m/v/r;return{totalThumbnails:d!=null?Math.ceil(d/h):Math.ceil(m/h),thumbnailDuration:h}}function C({thIndex:t,tduration:i,thduration:n,ttiles:o,startNumber:e,pto:u}){const r=Math.floor(t/o)+e,d=t%o+1,m=(r-1)*i-u,v=(d-1)*n;return m+v}function O(){const t=document.createElement("track");return t.kind="metadata",t.label="thumbnails",t.srclang="en",t.mode="hidden",t.default=!0,t}function x(t){let i=`WEBVTT
import{_ as N,R as A}from"./index-BRtx5t_a.js";import{M as D,C as S}from"./mixin-BY5xW4uI.js";class P extends D(S){static shadowRootOptions={...S.shadowRootOptions};static getTemplateHTML=i=>{const{src:n,...o}=i;return S.getTemplateHTML(o)};#t;attributeChangedCallback(i,n,o){i!=="src"&&super.attributeChangedCallback(i,n,o),i==="src"&&n!=o&&this.load()}async _initThumbnails(i){const n=async(d,m)=>{const v=[],g=i.timescale||1,h=i.startNumber||1,E=i.presentationTimeOffset?i.presentationTimeOffset/g:0,_=i.segmentDuration;for(let l=0;l<d;l++){const T=C({thIndex:l,thduration:m,ttiles:d,tduration:_,startNumber:h,pto:E}),w=T+m,M=new Promise((f,y)=>{this.api.provideThumbnail(T,({url:a,width:s,height:c,x:p,y:b})=>{try{const R=new VTTCue(T,w,`${a}#xywh=${p},${b},${s},${c}`);f(R)}catch(R){y(R)}})});v.push(M)}return await Promise.all(v).catch(l=>console.error("Error processing thumbnails",l))},{totalThumbnails:o,thumbnailDuration:e}=$(i),u=await n(o,e);let r=this.nativeEl.querySelector('track[label="thumbnails"]');if(!r){r=O(),this.nativeEl.appendChild(r);const d=x(u);r.src=d,r.dispatchEvent(new Event("change"))}}async load(){if(this.#t){this.api.attachSource(this.src);return}this.#t=!0;const i=await N(()=>import("./dash.all.min-C9gxBrL1.js"),[]);this.api=i.MediaPlayer().create(),this.api.initialize(this.nativeEl,this.src,this.autoplay),this.api.on(i.MediaPlayer.events.STREAM_INITIALIZED,()=>{const n=this.api.getRepresentationsByType("video");let o=this.videoTracks.getTrackById("main");o||(o=this.addVideoTrack("main"),o.id="main",o.selected=!0),n.forEach(e=>{const u=e.bandwidth??e.bitrate??(Number.isFinite(e.bitrateInKbit)?e.bitrateInKbit*1e3:void 0),r=o.addRendition(e.id,e.width,e.height,e.mimeType??e.codec,u);r.id=e.id}),this.videoRenditions.addEventListener("change",()=>{const e=this.videoRenditions[this.videoRenditions.selectedIndex];e?.id?(this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),this.api.setRepresentationForTypeById("video",e.id,!0)):this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}})}),this.api.isDynamic()||this.api.getRepresentationsByType("image").forEach(async(u,r)=>{r>0||this._initThumbnails(u)})})}}function $(t){var i,n;const o=t.essentialProperties[0],[e,u]=o.value.split("x").map(Number),r=e*u,d=((n=(i=t.adaptation)==null?void 0:i.period)==null?void 0:n.duration)||null,m=t.segmentDuration,v=t.timescale||1,h=m/v/r;return{totalThumbnails:d!=null?Math.ceil(d/h):Math.ceil(m/h),thumbnailDuration:h}}function C({thIndex:t,tduration:i,thduration:n,ttiles:o,startNumber:e,pto:u}){const r=Math.floor(t/o)+e,d=t%o+1,m=(r-1)*i-u,v=(d-1)*n;return m+v}function O(){const t=document.createElement("track");return t.kind="metadata",t.label="thumbnails",t.srclang="en",t.mode="hidden",t.default=!0,t}function x(t){let i=`WEBVTT
`;for(const e of t)i+=`${o(e.startTime)} --> ${o(e.endTime)}
`,i+=`${e.text}
`;const n=new Blob([i],{type:"text/vtt"});return URL.createObjectURL(n);function o(e){const u=String(Math.floor(e/3600)).padStart(2,"0"),r=String(Math.floor(e%3600/60)).padStart(2,"0"),d=(e%60).toFixed(3).padStart(6,"0");return`${u}:${r}:${d}`}}globalThis.customElements&&!globalThis.customElements.get("dash-video")&&globalThis.customElements.define("dash-video",P);var B=P,H=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),F={className:"class",htmlFor:"for"};function I(t){return t.toLowerCase()}function k(t){if(typeof t=="boolean")return t?"":void 0;if(typeof t!="function"&&!(typeof t=="object"&&t!==null))return t}function W({react:t,tagName:i,elementClass:n,events:o,displayName:e,defaultProps:u,toAttributeName:r=I,toAttributeValue:d=k}){const m=Number.parseInt(t.version)>=19,v=t.forwardRef((g,h)=>{var E,_;const l=t.useRef(null),T=t.useRef(new Map),w={},M={},f={},y={};for(const[a,s]of Object.entries(g)){if(H.has(a)){f[a]=s;continue}const c=r(F[a]??a);if(n.prototype&&a in n.prototype&&!(a in(((E=globalThis.HTMLElement)==null?void 0:E.prototype)??{}))&&!((_=n.observedAttributes)!=null&&_.some(b=>b===c))){y[a]=s;continue}if(a.startsWith("on")){w[a]=s;continue}const p=d(s);if(c&&p!=null&&(M[c]=String(p),m||(f[c]=p)),c&&m){const b=k(s);p!==b?f[c]=p:f[c]=s}}if(typeof window<"u"){for(const a in w){const s=w[a],c=a.endsWith("Capture"),p=(o?.[a]??a.slice(2).toLowerCase()).slice(0,c?-7:void 0);t.useLayoutEffect(()=>{const b=l?.current;if(!(!b||typeof s!="function"))return b.addEventListener(p,s,c),()=>{b.removeEventListener(p,s,c)}},[l?.current,s])}t.useLayoutEffect(()=>{if(l.current===null)return;const a=new Map;for(const s in y)L(l.current,s,y[s]),T.current.delete(s),a.set(s,y[s]);for(const[s,c]of T.current)L(l.current,s,void 0);T.current=a})}if(typeof window>"u"&&n?.getTemplateHTML&&n?.shadowRootOptions){const{mode:a,delegatesFocus:s}=n.shadowRootOptions,c=t.createElement("template",{shadowrootmode:a,shadowrootdelegatesfocus:s,dangerouslySetInnerHTML:{__html:n.getTemplateHTML(M,g)},key:"ce-la-react-ssr-template-shadow-root"});f.children=[c,f.children]}return t.createElement(i,{...u,...f,ref:t.useCallback(a=>{l.current=a,typeof h=="function"?h(a):h!==null&&(h.current=a)},[h])},f.children)});return v.displayName=e??n.name,v}function L(t,i,n){var o;t[i]=n,n==null&&i in(((o=globalThis.HTMLElement)==null?void 0:o.prototype)??{})&&t.removeAttribute(i)}var V=W({react:A,tagName:"dash-video",elementClass:B,toAttributeName(t){return t==="muted"?"":t==="defaultMuted"?"muted":I(t)}});export{V as default};
//# sourceMappingURL=react-BBapMaLq.js.map
//# sourceMappingURL=react-B4g-wmtx.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{R as W}from"./index-DCtVfg7-.js";const $=["abort","canplay","canplaythrough","durationchange","emptied","encrypted","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting","waitingforkey","resize","enterpictureinpicture","leavepictureinpicture","webkitbeginfullscreen","webkitendfullscreen","webkitpresentationmodechanged"],A=globalThis.document?.createElement("template");A&&(A.innerHTML=`
import{R as W}from"./index-BRtx5t_a.js";const $=["abort","canplay","canplaythrough","durationchange","emptied","encrypted","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting","waitingforkey","resize","enterpictureinpicture","leavepictureinpicture","webkitbeginfullscreen","webkitendfullscreen","webkitpresentationmodechanged"],A=globalThis.document?.createElement("template");A&&(A.innerHTML=`
<style>
:host {
display: inline-block;
@@ -32,4 +32,4 @@ import{R as W}from"./index-DCtVfg7-.js";const $=["abort","canplay","canplaythrou
</style>
<slot></slot>
`);function D(i,t){if(Object.hasOwn(i,t)){const s=i[t];delete i[t],i[t]=s}}class q extends B{static template=P;static skipAttributes=["src"];#t=null;constructor(){super(),D(this,"config")}get config(){return this.#t}set config(t){this.#t=t}get nativeEl(){var t;return((t=this.api)==null?void 0:t.elem())??this.querySelector("video")}async load(){var t;if((t=this.querySelector(".wistia_embed"))==null||t.remove(),!this.src)return;await new Promise(o=>setTimeout(o,50));const s=/(?:wistia\.com|wi\.st)\/(?:medias|embed)\/(.*)$/i,d=this.src.match(s)[1],f={autoPlay:this.autoplay,silentAutoPlay:this.autoplay?"allow":void 0,preload:this.preload??"metadata",playsinline:this.playsInline,endVideoBehavior:this.loop?"loop":void 0,chromeless:!this.controls,playButton:this.controls,muted:this.defaultMuted,...this.#t??{}};this.append(L.content.cloneNode(!0));const c=this.querySelector(".wistia_embed");c.id||(c.id=z(d)),c.classList.add(`wistia_async_${d}`),await F("https://fast.wistia.com/assets/external/E-v1.js","Wistia"),this.api=await new Promise(o=>{globalThis._wq.push({id:c.id,onReady:o,options:f})})}async attributeChangedCallback(t,s,d){if(t==="controls"){switch(await this.loadComplete,t){case"controls":this.api.bigPlayButtonEnabled(this.controls),this.controls?this.api.releaseChromeless():this.api.requestChromeless();break}return}super.attributeChangedCallback(t,s,d)}get duration(){var t;return(t=this.api)==null?void 0:t.duration()}async play(){return await this.loadComplete,this.api.play(),new Promise(t=>this.addEventListener("playing",t))}}const T={};async function F(i,t){if(T[i])return T[i];if(self[t])return self[t];const s=new Promise((d,f)=>{const c=document.createElement("script");c.defer=!0,c.src=i,c.onload=()=>d(self[t]),c.onerror=f,document.head.append(c)});return T[i]=s,s}let U=0;function z(i){const t=++U;return`${i}${t}`}globalThis.customElements&&!globalThis.customElements.get("wistia-video")&&globalThis.customElements.define("wistia-video",q);var V=q,G=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),J={className:"class",htmlFor:"for"};function I(i){return i.toLowerCase()}function j(i){if(typeof i=="boolean")return i?"":void 0;if(typeof i!="function"&&!(typeof i=="object"&&i!==null))return i}function K({react:i,tagName:t,elementClass:s,events:d,displayName:f,defaultProps:c,toAttributeName:e=I,toAttributeValue:o=j}){const u=Number.parseInt(i.version)>=19,r=i.forwardRef((a,p)=>{var M,k;const m=i.useRef(null),E=i.useRef(new Map),C={},_={},v={},w={};for(const[n,l]of Object.entries(a)){if(G.has(n)){v[n]=l;continue}const h=e(J[n]??n);if(s.prototype&&n in s.prototype&&!(n in(((M=globalThis.HTMLElement)==null?void 0:M.prototype)??{}))&&!((k=s.observedAttributes)!=null&&k.some(b=>b===h))){w[n]=l;continue}if(n.startsWith("on")){C[n]=l;continue}const y=o(l);if(h&&y!=null&&(_[h]=String(y),u||(v[h]=y)),h&&u){const b=j(l);y!==b?v[h]=y:v[h]=l}}if(typeof window<"u"){for(const n in C){const l=C[n],h=n.endsWith("Capture"),y=(d?.[n]??n.slice(2).toLowerCase()).slice(0,h?-7:void 0);i.useLayoutEffect(()=>{const b=m?.current;if(!(!b||typeof l!="function"))return b.addEventListener(y,l,h),()=>{b.removeEventListener(y,l,h)}},[m?.current,l])}i.useLayoutEffect(()=>{if(m.current===null)return;const n=new Map;for(const l in w)x(m.current,l,w[l]),E.current.delete(l),n.set(l,w[l]);for(const[l,h]of E.current)x(m.current,l,void 0);E.current=n})}if(typeof window>"u"&&s?.getTemplateHTML&&s?.shadowRootOptions){const{mode:n,delegatesFocus:l}=s.shadowRootOptions,h=i.createElement("template",{shadowrootmode:n,shadowrootdelegatesfocus:l,dangerouslySetInnerHTML:{__html:s.getTemplateHTML(_,a)},key:"ce-la-react-ssr-template-shadow-root"});v.children=[h,v.children]}return i.createElement(t,{...c,...v,ref:i.useCallback(n=>{m.current=n,typeof p=="function"?p(n):p!==null&&(p.current=n)},[p])},v.children)});return r.displayName=f??s.name,r}function x(i,t,s){var d;i[t]=s,s==null&&t in(((d=globalThis.HTMLElement)==null?void 0:d.prototype)??{})&&i.removeAttribute(t)}var X=K({react:W,tagName:"wistia-video",elementClass:V,toAttributeName(i){return i==="muted"?"":i==="defaultMuted"?"muted":I(i)}});export{X as default};
//# sourceMappingURL=react-Bqsex64l.js.map
//# sourceMappingURL=react-BmmYgWje.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-DCtVfg7-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CfkQC3hX.css">
<script type="module" crossorigin src="/assets/index-BRtx5t_a.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DW6Bt15z.css">
</head>
<body>
<div id="root"></div>
@@ -0,0 +1,133 @@
import ChatMessageRow from '../ChatMessageRow/index.jsx';
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
function getSafeWikiUrl(rawUrl) {
const text = String(rawUrl || '').trim();
if (!text) return '';
try {
/*
Wiki links are operator-authored registry data, but this still normalizes
them before rendering an anchor. Allowing only http/https avoids turning a
barcode registry typo into a javascript: link while still supporting both
absolute URLs and local wiki paths such as /wiki/object-name.
*/
const parsed = new URL(text, window.location.origin);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
return parsed.href;
} catch {
return '';
}
}
function BarcodeScanToast({ payload }) {
const wikiUrl = getSafeWikiUrl(payload?.wikiUrl);
const label = payload?.label || payload?.code || 'unknown barcode';
return (
<div className="pointer-events-auto max-w-[80vw] rounded-md bg-neutral-800 px-2 py-1 text-l leading-tight text-slate-100">
<style>
{`
@keyframes barcode-wiki-flat-flash {
0%, 100% {
background: rgb(233, 186, 14);
color: rgb(71, 144, 253);
}
50% {
background: rgb(125, 49, 255);
color: rgb(246, 250, 211);
}
}
`}
</style>
<p className="flex flex-wrap items-center gap-0.5 text-slate-100">
<span className="font-semibold text-slate-300 pr-1">Barcode scanned:</span>
<span className="font-semibold text-white">{label}</span>
{wikiUrl ? (
<a
href={wikiUrl}
target="_blank"
rel="noreferrer"
className="rounded-md ml-0.5 px-2 py-1 text-xl font-semibold leading-tight tracking-normal"
style={{ animation: 'barcode-wiki-flat-flash 0.35s steps(1, end) infinite' }}
>
Wiki link
</a>
) : null}
</p>
</div>
);
}
function UndockedExitWarningToast() {
return (
<div
className="w-[min(32rem,80vw)] rounded-md bg-amber-950 px-4 py-3 text-left text-amber-50"
role="alert"
>
<p className="text-xl font-bold leading-tight text-amber-100">Please dock your rover</p>
<p className="mt-1 text-base font-medium leading-snug text-amber-50/90">
Your rover is still undocked. Please dock it before leaving the page.
</p>
</div>
);
}
export default function AlertContent({ alert }) {
if (alert.kind === 'buttonbox-active' && alert.payload) {
const payload = alert.payload;
return (
<div className={`pointer-events-none w-50 max-w-full rounded-md ${payload.limited ? 'bg-red-950' : 'bg-cyan-900'}`}>
<ButtonBoxTile
buttonId={payload.buttonId}
count={payload.count}
goal={payload.goal}
rewardNumber={payload.rewardNumber}
rewardName={payload.rewardName}
rewardDescription={payload.rewardDescription}
dailyCount={payload.dailyCount}
dailyLimit={payload.dailyLimit}
limited={payload.limited}
className={payload.limited ? 'bg-red-950 ring-1 ring-red-500/70' : 'bg-cyan-900'}
/>
{payload.description ? (
<p className={['mt-1 text-center text-xs font-semibold', payload.limited ? 'text-red-200' : 'text-cyan-100'].join(' ')}>
{payload.description}
</p>
) : null}
</div>
);
}
if (alert.kind === 'chat' && alert.payload) {
return <ChatMessageRow message={alert.payload} />;
}
if (alert.kind === 'chat-typing' && alert.payload) {
return (
// Override only the feed's typing surface so whole-alert opacity has one owner.
<div className="[&>div]:bg-slate-900 [&>div]:opacity-100">
<ChatMessageRow message={alert.payload} variant="typing" />
</div>
);
}
if (alert.kind === 'barcode-scan' && alert.payload) {
return <BarcodeScanToast payload={alert.payload} />;
}
if (alert.kind === 'undocked-exit-warning') {
return <UndockedExitWarningToast />;
}
const color = typeof alert.color === 'string' ? alert.color.trim() : '';
const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3';
return (
<div
className="pointer-events-auto min-w-0 max-w-96 rounded-md px-2 py-1 text-left text-[0.72rem] leading-tight text-white"
style={{ backgroundColor: `color-mix(in srgb, ${backgroundColor} 65%, black)` }}
>
<p className="truncate">
<span className="font-semibold">{alert.title || 'Alert'}</span>
<span className="text-white/70"> · </span>
<span className="text-white">{alert.message}</span>
</p>
</div>
);
}
+68 -534
View File
@@ -1,561 +1,95 @@
// Alert Feed
// Purpose: Defines the Alert Feed module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSocket } from '../../context/SocketContext.jsx';
import ChatMessageRow from '../ChatMessageRow/index.jsx';
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import useLayoutMode from '../../hooks/useLayoutMode.js';
import AlertContent from './AlertContent.jsx';
import useAlertEvents from './useAlertEvents.js';
const LIFETIME_MS = 3000;
const BUTTONBOX_LIFETIME_MS = 5000;
const BARCODE_SCAN_LIFETIME_MS = 10 * 1000;
const MAX_VISIBLE_TOASTS = 6;
const RECENT_ALERT_LOOKBACK = 12;
const DEFAULT_COLOR = '#2196f3';
function buildKey(alert) {
if (alert.id) return alert.id;
if (alert.timestamp) return `${alert.timestamp}-${alert.message}`;
return `${alert.title || 'alert'}-${alert.message}`;
function getAlertKey(alert) {
return alert.id || (alert.timestamp
? `${alert.timestamp}-${alert.message}`
: `${alert.title || 'alert'}-${alert.message}`);
}
function getAlertLifetime(alert) {
return Number.isFinite(alert?.lifetimeMs) ? alert.lifetimeMs : LIFETIME_MS;
}
function buildAlertToken(alert, key) {
/*
A keyed alert such as a barcode scan intentionally reuses the same id so the
visual slot can be refreshed in place. The token must therefore include the
arrival timestamp too; otherwise a new scan with the same id would look like
the same toast and would not get a fresh timer.
*/
const receivedAt = alert?.receivedAt ?? '';
const timestamp = alert?.timestamp ?? '';
const title = alert?.title ?? '';
const message = alert?.message ?? '';
return `${key}:${receivedAt}:${timestamp}:${title}:${message}`;
}
function buildToastRecord(item) {
const lifetimeMs = getAlertLifetime(item.alert);
const expiresAt = Date.now() + lifetimeMs;
return {
key: item.key,
alert: item.alert,
expiresAt,
remainingMs: lifetimeMs,
paused: false,
};
}
function getToastRgb(alert) {
return hexToRgb(alert?.color) || hexToRgb(DEFAULT_COLOR);
}
function getToastAccent(alert) {
const rgb = getToastRgb(alert);
return rgb ? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` : DEFAULT_COLOR;
}
function isGenericAlert(alert) {
/*
The shell needs to know which alerts use the fallback title/message renderer
because those alerts historically carried their own color-tinted surface.
Matching the same payload guards used by AlertToast keeps the shell styling
aligned with the renderer that will actually be selected below.
*/
if (alert?.kind === 'buttonbox-active' && alert.payload) return false;
if (alert?.kind === 'chat' && alert.payload) return false;
if (alert?.kind === 'chat-typing' && alert.payload) return false;
if (alert?.kind === 'barcode-scan' && alert.payload) return false;
if (alert?.kind === 'undocked-exit-warning') return false;
return true;
}
export default function AlertFeed({ scale = 1 }) {
export default function AlertFeed({ scale = 1, opacity = 0.6 }) {
const layout = useLayoutMode();
const maxVisibleToasts = layout === 'mobile-portrait' || layout === 'mobile-landscape' ? 4 : 10;
const alerts = useSessionSelector((state) => state.alerts);
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
const { pushAlert } = useSessionActions();
const socket = useSocket();
const [toasts, setToasts] = useState([]);
const latestTokensRef = useRef(new Set());
const timersRef = useRef(new Map());
const seenRef = useRef(new Set());
useAlertEvents();
const clearToastTimer = useCallback((key) => {
const timer = timersRef.current.get(key);
if (!timer) return;
clearTimeout(timer);
timersRef.current.delete(key);
useEffect(() => {
// Session history preserves object identity, including distinct arrivals
// with the same id and timestamp. Dismissed alerts must not reappear.
const incoming = alerts.slice(-RECENT_ALERT_LOOKBACK).filter((alert) => !seenRef.current.has(alert));
seenRef.current = new Set(alerts);
setToasts((current) => {
if (!incoming.length && current.length <= maxVisibleToasts) return current;
const next = new Map(current.map((alert) => [getAlertKey(alert), alert]));
for (const alert of incoming) {
const key = getAlertKey(alert);
// Refreshed alerts move to the newest slot and receive a fresh lifetime.
next.delete(key);
next.set(key, alert);
}
return [...next.values()].slice(-maxVisibleToasts);
});
}, [alerts, maxVisibleToasts]);
const dismiss = useCallback((alert) => {
setToasts((current) => current.filter((entry) => entry !== alert));
}, []);
const removeToast = useCallback(
(key) => {
clearToastTimer(key);
setToasts((currentToasts) => currentToasts.filter((toast) => toast.key !== key));
},
[clearToastTimer],
);
const scheduleToastRemoval = useCallback(
(key, delayMs) => {
clearToastTimer(key);
/*
Each toast owns exactly one removal timeout. This is cheaper and simpler
than polling all visible alerts for progress/fade state, and it still
lets hover/focus pause by clearing the one timer for the interacted row.
*/
const timer = setTimeout(() => removeToast(key), Math.max(0, delayMs));
timersRef.current.set(key, timer);
},
[clearToastTimer, removeToast],
);
useEffect(
() => () => {
timersRef.current.forEach((timer) => clearTimeout(timer));
timersRef.current.clear();
},
[],
);
useEffect(() => {
function onButtonIncrement(payload = {}) {
const buttonId = Number(payload.buttonId);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
const buttons = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
/*
Event fields win over session fields because the button-box event is the
exact outcome of this press. Session sync follows immediately after, but
using it first can make fast toasts briefly show stale count/limit data.
*/
const goal = Number.isFinite(payload.goal) ? payload.goal : Number.isFinite(button.goal) ? button.goal : 0;
const dailyCount = Number.isFinite(payload.dailyCount)
? payload.dailyCount
: Number.isFinite(button.dailyCount)
? button.dailyCount
: 0;
const dailyLimit = Number.isFinite(payload.dailyLimit)
? payload.dailyLimit
: Number.isFinite(button.dailyLimit)
? button.dailyLimit
: null;
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
const rewardName =
typeof button.rewardName === 'string' && button.rewardName.trim()
? button.rewardName.trim()
: 'Unassigned';
const rewardDescription =
typeof button.rewardDescription === 'string' && button.rewardDescription.trim()
? button.rewardDescription.trim()
: null;
const description =
typeof payload.description === 'string' && payload.description.trim()
? payload.description.trim()
: null;
pushAlert({
id: `buttonbox-active-${buttonId}`,
kind: 'buttonbox-active',
lifetimeMs: BUTTONBOX_LIFETIME_MS,
payload: {
buttonId,
count,
goal,
dailyCount,
dailyLimit,
limited: Boolean(payload.limited),
description,
rewardNumber,
rewardName,
rewardDescription,
},
});
}
socket.on('buttonBox:increment', onButtonIncrement);
return () => {
socket.off('buttonBox:increment', onButtonIncrement);
};
}, [pushAlert, buttonBoxButtons, socket]);
useEffect(() => {
function onBarcodeScanned(payload = {}) {
const label = typeof payload.label === 'string' && payload.label.trim()
? payload.label.trim()
: payload.code || 'unknown barcode';
/*
Barcode scans use a fixed alert id because the scanner can fire quickly
during games. Reusing the id makes the newest scan replace the previous
barcode popup in AlertFeed's keyed visible map instead of building a
stack of old scan cards that would hide the current wiki link.
*/
pushAlert({
id: 'barcode-scan-active',
kind: 'barcode-scan',
lifetimeMs: BARCODE_SCAN_LIFETIME_MS,
payload: {
...payload,
label,
},
});
}
socket.on('barcode:scanned', onBarcodeScanned);
return () => {
socket.off('barcode:scanned', onBarcodeScanned);
};
}, [pushAlert, socket]);
const latest = useMemo(
() =>
alerts.slice(-RECENT_ALERT_LOOKBACK).map((alert) => {
const key = buildKey(alert);
return {
alert,
key,
token: buildAlertToken(alert, key),
};
}),
[alerts],
);
useEffect(() => {
/*
The processed-token set is captured before scheduling the state update
because React may run the functional updater after this effect continues.
Reading latestTokensRef.current inside the updater would race with the ref
assignment below and could make brand-new alerts look already processed,
leaving the local toast list empty.
*/
const previousTokens = latestTokensRef.current;
const newItems = latest.filter((item) => !previousTokens.has(item.token));
if (newItems.length) {
setToasts((currentToasts) => {
const nextByKey = new Map(currentToasts.map((toast) => [toast.key, toast]));
newItems.forEach((item) => {
/*
Setting an existing Map key updates its value but keeps its original
insertion slot. Deleting first makes refreshed keyed alerts behave
like new notifications visually, which is what operators expect when
the barcode lane or a button-box lane receives a fresh event.
*/
nextByKey.delete(item.key);
nextByKey.set(item.key, buildToastRecord(item));
});
/*
The newest records are the only ones that should consume feed slots.
Older active records are dropped immediately so a burst of alerts
cannot cover the rest of the rover interface.
*/
const nextToasts = Array.from(nextByKey.values());
const activeKeys = nextToasts.slice(-MAX_VISIBLE_TOASTS).map((toast) => toast.key);
const activeKeySet = new Set(activeKeys);
return nextToasts.filter((toast) => activeKeySet.has(toast.key));
});
newItems.forEach((item) => {
scheduleToastRemoval(item.key, getAlertLifetime(item.alert));
});
}
latestTokensRef.current = new Set(latest.map((item) => item.token));
}, [latest, scheduleToastRemoval]);
const pauseToast = useCallback((key) => {
const toast = toasts.find((entry) => entry.key === key);
if (!toast || toast.paused) return;
const remainingMs = Math.max(0, toast.expiresAt - Date.now());
clearToastTimer(key);
setToasts((currentToasts) =>
currentToasts.map((toast) => {
if (toast.key !== key || toast.paused) return toast;
/*
Remaining time is captured at the moment interaction begins, not at the
previous render. That makes hover/focus pauses exact enough that a
toast cannot expire while the user is moving toward a link or dismiss
button inside it.
*/
return {
...toast,
paused: true,
remainingMs,
};
}),
);
}, [clearToastTimer, toasts]);
const resumeToast = useCallback((key) => {
const toast = toasts.find((entry) => entry.key === key);
if (!toast || !toast.paused) return;
const startedAt = Date.now();
const remainingMs = Math.max(0, toast.remainingMs);
setToasts((currentToasts) =>
currentToasts.map((toast) => {
if (toast.key !== key || !toast.paused) return toast;
return {
...toast,
paused: false,
expiresAt: startedAt + remainingMs,
remainingMs,
};
}),
);
scheduleToastRemoval(key, remainingMs);
}, [scheduleToastRemoval, toasts]);
const dismissToast = useCallback((key) => {
removeToast(key);
}, [removeToast]);
if (!toasts.length) return null;
const containerStyle =
scale === 1
? undefined
: {
transform: `translateX(-50%) scale(${scale})`,
transformOrigin: 'top center',
};
const containerClass =
scale === 1
? 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5'
: 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex flex-col gap-0.5';
return (
<div className={containerClass} style={containerStyle} aria-live="polite">
{toasts.map((toast) => (
<AlertToastShell
key={toast.key}
toast={toast}
onDismiss={dismissToast}
onPause={pauseToast}
onResume={resumeToast}
/>
<div
className="pointer-events-none fixed top-0.5 left-1/2 z-50 flex w-max max-w-[80vw] flex-col items-center gap-0.5"
style={{ transform: `translateX(-50%) scale(${scale})`, transformOrigin: 'top center' }}
aria-live="polite"
>
{toasts.map((alert) => (
<AlertToast key={getAlertKey(alert)} alert={alert} opacity={opacity} onDismiss={dismiss} />
))}
</div>
);
}
function hexToRgb(hex) {
const safe = typeof hex === 'string' ? hex.trim() : '';
const match = /^#?([0-9a-fA-F]{6})$/.exec(safe);
if (!match) return null;
const value = match[1];
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function AlertToast({ alert, opacity, onDismiss }) {
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const remainingRef = useRef(0);
const paused = hovered || focused;
function getSafeWikiUrl(rawUrl) {
const text = String(rawUrl || '').trim();
if (!text) return '';
useEffect(() => {
remainingRef.current = Number.isFinite(alert.lifetimeMs) ? alert.lifetimeMs : 3000;
}, [alert]);
try {
/*
Wiki links are operator-authored registry data, but this still normalizes
them before rendering an anchor. Allowing only http/https avoids turning a
barcode registry typo into a javascript: link while still supporting both
absolute URLs and local wiki paths such as /wiki/object-name.
*/
const parsed = new URL(text, window.location.origin);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
return parsed.href;
} catch {
return '';
}
}
function AlertToastShell({ toast, onDismiss, onPause, onResume }) {
const accent = getToastAccent(toast.alert);
const generic = isGenericAlert(toast.alert);
const rgb = getToastRgb(toast.alert);
const genericBackgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.5)` : 'rgba(33, 150, 243, 0.18)';
const genericBorderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)` : 'rgba(33, 150, 243, 0.45)';
const shellClass = [
'pointer-events-auto w-fit max-w-[80vw] overflow-hidden rounded-md shadow-xs shadow-black/40',
generic ? '' : 'border border-white/10 bg-black/75',
].join(' ');
useEffect(() => {
if (paused) return;
const startedAt = Date.now();
const timer = setTimeout(() => onDismiss(alert), Math.max(0, remainingRef.current));
return () => {
clearTimeout(timer);
remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startedAt));
};
}, [alert, paused, onDismiss]);
return (
<div
className={shellClass}
onMouseEnter={() => onPause(toast.key)}
onMouseLeave={() => onResume(toast.key)}
onFocusCapture={() => onPause(toast.key)}
onBlurCapture={() => onResume(toast.key)}
style={
generic
? {
/*
Generic alerts keep the same color math they used before the shared
shell was introduced. Putting it on the shell restores the original
brightness while still leaving room for the side dismiss column.
*/
backgroundColor: genericBackgroundColor,
border: `1px solid ${genericBorderColor}`,
}
: undefined
}
className="pointer-events-auto w-max max-w-full"
style={{ opacity }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocusCapture={() => setFocused(true)}
onBlurCapture={(event) => {
// Moving focus between controls inside the toast must not resume expiry.
if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
}}
>
<div className="flex min-w-0 items-stretch">
<div className="min-w-0 flex-1">
<AlertToast alert={toast.alert} />
</div>
<div
className={[
'flex w-5 shrink-0 flex-col items-center justify-start gap-0.5 border-l py-0.5',
generic ? '' : 'border-white/10 bg-black/20',
].join(' ')}
style={
generic
? {
borderLeftColor: genericBorderColor,
backgroundColor: 'rgba(0, 0, 0, 0.08)',
}
: undefined
}
>
<button
type="button"
className="flex h-4 w-4 items-center justify-center rounded-xs text-xs leading-none text-slate-300 hover:bg-white/10 hover:text-white focus:bg-white/10 focus:text-white focus:outline-hidden focus:ring-1"
style={{ '--tw-ring-color': accent }}
onClick={() => onDismiss(toast.key)}
title="Dismiss alert"
aria-label="Dismiss alert"
>
×
</button>
</div>
</div>
</div>
);
}
function BarcodeScanToast({ payload }) {
const wikiUrl = getSafeWikiUrl(payload?.wikiUrl);
const label = payload?.label || payload?.code || 'unknown barcode';
return (
<div className="pointer-events-auto max-w-[80vw] rounded-md bg-neutral-800 px-2 py-1 text-l leading-tight text-slate-100">
<style>
{`
@keyframes barcode-wiki-flat-flash {
0%, 100% {
background: rgb(233, 186, 14);
color: rgb(71, 144, 253);
}
50% {
background: rgb(125, 49, 255);
color: rgb(246, 250, 211);
}
}
`}
</style>
<p className="flex flex-wrap items-center gap-0.5 text-slate-100">
<span className="font-semibold text-slate-300 pr-1">Barcode scanned:</span>
<span className="font-semibold text-white">{label}</span>
{wikiUrl ? (
<a
href={wikiUrl}
target="_blank"
rel="noreferrer"
className="rounded-md ml-0.5 px-2 py-1 text-xl font-semibold leading-tight tracking-normal"
style={{ animation: 'barcode-wiki-flat-flash 0.35s steps(1, end) infinite' }}
>
Wiki link
</a>
) : null}
</p>
</div>
);
}
function UndockedExitWarningToast() {
return (
<div
/*
This warning deliberately keeps AlertFeed's existing dark shell and
compact rounded geometry while giving a safety-critical instruction
enough type size and breathing room to be unmistakable. The responsive
width leaves space for the feed shell's separate dismiss column on
narrow screens instead of allowing the card to overflow the viewport.
*/
className="w-[min(32rem,calc(80vw-1.25rem))] border-l-4 border-amber-400 bg-amber-950/90 px-4 py-3 text-left text-amber-50"
role="alert"
>
<p className="text-xl font-bold leading-tight text-amber-100">Please dock your rover</p>
<p className="mt-1 text-base font-medium leading-snug text-amber-50/90">
Your rover is still undocked. Please dock it before leaving the page.
</p>
</div>
);
}
function AlertToast({ alert }) {
if (alert.kind === 'buttonbox-active' && alert.payload) {
const payload = alert.payload;
return (
<div className="pointer-events-none w-50">
<ButtonBoxTile
buttonId={payload.buttonId}
count={payload.count}
goal={payload.goal}
rewardNumber={payload.rewardNumber}
rewardName={payload.rewardName}
rewardDescription={payload.rewardDescription}
dailyCount={payload.dailyCount}
dailyLimit={payload.dailyLimit}
limited={payload.limited}
className={payload.limited ? 'bg-red-950/70 ring-1 ring-red-500/70' : 'bg-cyan-900/45'}
/>
{payload.description ? (
<p className={['mt-1 text-center text-xs font-semibold', payload.limited ? 'text-red-200' : 'text-cyan-100'].join(' ')}>
{payload.description}
</p>
) : null}
</div>
);
}
if (alert.kind === 'chat' && alert.payload) {
return <ChatMessageRow message={alert.payload} />;
}
if (alert.kind === 'chat-typing' && alert.payload) {
return <ChatMessageRow message={alert.payload} variant="typing" />;
}
if (alert.kind === 'barcode-scan' && alert.payload) {
return <BarcodeScanToast payload={alert.payload} />;
}
if (alert.kind === 'undocked-exit-warning') {
return <UndockedExitWarningToast />;
}
return (
<div
/*
Generic alerts are the payloads that directly provide title/message/color
instead of a richer custom renderer. The surrounding shell owns their
color-tinted background so the dismiss column and content share one
continuous surface while this renderer stays responsible for one line of
compact title/message text.
*/
className="pointer-events-auto min-w-0 max-w-96 px-1.5 py-0.5 text-left text-[0.72rem] leading-tight text-slate-100"
>
<p className="truncate text-slate-100">
<span className="font-semibold text-slate-300">{alert.title || 'Alert'}</span>
<span className="text-slate-500"> · </span>
<span className="text-white">{alert.message}</span>
</p>
<AlertContent alert={alert} />
</div>
);
}
@@ -0,0 +1,101 @@
import { useEffect } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSocket } from '../../context/SocketContext.jsx';
const BUTTONBOX_LIFETIME_MS = 5000;
const BARCODE_SCAN_LIFETIME_MS = 10 * 1000;
export default function useAlertEvents() {
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons);
const { pushAlert } = useSessionActions();
const socket = useSocket();
useEffect(() => {
function onButtonIncrement(payload = {}) {
const buttonId = Number(payload.buttonId);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
const buttons = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
/*
Event fields win over session fields because the button-box event is the
exact outcome of this press. Session sync follows immediately after, but
using it first can make fast toasts briefly show stale count/limit data.
*/
const goal = Number.isFinite(payload.goal) ? payload.goal : Number.isFinite(button.goal) ? button.goal : 0;
const dailyCount = Number.isFinite(payload.dailyCount)
? payload.dailyCount
: Number.isFinite(button.dailyCount)
? button.dailyCount
: 0;
const dailyLimit = Number.isFinite(payload.dailyLimit)
? payload.dailyLimit
: Number.isFinite(button.dailyLimit)
? button.dailyLimit
: null;
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
const rewardName =
typeof button.rewardName === 'string' && button.rewardName.trim()
? button.rewardName.trim()
: 'Unassigned';
const rewardDescription =
typeof button.rewardDescription === 'string' && button.rewardDescription.trim()
? button.rewardDescription.trim()
: null;
const description =
typeof payload.description === 'string' && payload.description.trim()
? payload.description.trim()
: null;
pushAlert({
id: `buttonbox-active-${buttonId}`,
kind: 'buttonbox-active',
lifetimeMs: BUTTONBOX_LIFETIME_MS,
payload: {
buttonId,
count,
goal,
dailyCount,
dailyLimit,
limited: Boolean(payload.limited),
description,
rewardNumber,
rewardName,
rewardDescription,
},
});
}
socket.on('buttonBox:increment', onButtonIncrement);
return () => {
socket.off('buttonBox:increment', onButtonIncrement);
};
}, [pushAlert, buttonBoxButtons, socket]);
useEffect(() => {
function onBarcodeScanned(payload = {}) {
const label = typeof payload.label === 'string' && payload.label.trim()
? payload.label.trim()
: payload.code || 'unknown barcode';
/*
Barcode scans use a fixed alert id because the scanner can fire quickly
during games. Reusing the id makes the newest scan replace the previous
barcode popup in AlertFeed's keyed visible map instead of building a
stack of old scan cards that would hide the current wiki link.
*/
pushAlert({
id: 'barcode-scan-active',
kind: 'barcode-scan',
lifetimeMs: BARCODE_SCAN_LIFETIME_MS,
payload: {
...payload,
label,
},
});
}
socket.on('barcode:scanned', onBarcodeScanned);
return () => {
socket.off('barcode:scanned', onBarcodeScanned);
};
}, [pushAlert, socket]);
}
@@ -50,7 +50,7 @@ export default function ServerDisplayContent() {
<DisplayNoticeOverlay />
<RewardRunOverlay />
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
<AlertFeed scale={5} />
<AlertFeed scale={2} opacity={1}/>
</div>
);
}
@@ -20,7 +20,7 @@ export default function MiniSummaryAppRoot() {
return (
<>
<MiniSummaryContent />
<AlertFeed scale={3} />
<AlertFeed scale={3} opacity={1}/>
</>
);
}