mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
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
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
|
||||
+1
-1
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
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user