mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 11:12:16 -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};
|
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-O6d1niaS.js.map
|
//# 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};
|
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-CFFNNLJ4.js.map
|
//# 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)}
|
`;for(const e of t)i+=`${o(e.startTime)} --> ${o(e.endTime)}
|
||||||
`,i+=`${e.text}
|
`,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};
|
`;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>
|
<style>
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -32,4 +32,4 @@ import{R as W}from"./index-DCtVfg7-.js";const $=["abort","canplay","canplaythrou
|
|||||||
</style>
|
</style>
|
||||||
<slot></slot>
|
<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};
|
`);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" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<!-- site-metadata:inject -->
|
<!-- site-metadata:inject -->
|
||||||
<!-- analytics:inject -->
|
<!-- analytics:inject -->
|
||||||
<script type="module" crossorigin src="/assets/index-DCtVfg7-.js"></script>
|
<script type="module" crossorigin src="/assets/index-BRtx5t_a.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CfkQC3hX.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DW6Bt15z.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
// Purpose: Defines the Alert Feed module and the local helpers/components used in this file.
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
import useLayoutMode from '../../hooks/useLayoutMode.js';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import AlertContent from './AlertContent.jsx';
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import useAlertEvents from './useAlertEvents.js';
|
||||||
import { useSocket } from '../../context/SocketContext.jsx';
|
|
||||||
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
|
||||||
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
|
||||||
|
|
||||||
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 RECENT_ALERT_LOOKBACK = 12;
|
||||||
const DEFAULT_COLOR = '#2196f3';
|
|
||||||
|
|
||||||
function buildKey(alert) {
|
function getAlertKey(alert) {
|
||||||
if (alert.id) return alert.id;
|
return alert.id || (alert.timestamp
|
||||||
if (alert.timestamp) return `${alert.timestamp}-${alert.message}`;
|
? `${alert.timestamp}-${alert.message}`
|
||||||
return `${alert.title || 'alert'}-${alert.message}`;
|
: `${alert.title || 'alert'}-${alert.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAlertLifetime(alert) {
|
export default function AlertFeed({ scale = 1, opacity = 0.6 }) {
|
||||||
return Number.isFinite(alert?.lifetimeMs) ? alert.lifetimeMs : LIFETIME_MS;
|
const layout = useLayoutMode();
|
||||||
}
|
const maxVisibleToasts = layout === 'mobile-portrait' || layout === 'mobile-landscape' ? 4 : 10;
|
||||||
|
|
||||||
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 }) {
|
|
||||||
const alerts = useSessionSelector((state) => state.alerts);
|
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 [toasts, setToasts] = useState([]);
|
||||||
const latestTokensRef = useRef(new Set());
|
const seenRef = useRef(new Set());
|
||||||
const timersRef = useRef(new Map());
|
useAlertEvents();
|
||||||
|
|
||||||
const clearToastTimer = useCallback((key) => {
|
useEffect(() => {
|
||||||
const timer = timersRef.current.get(key);
|
// Session history preserves object identity, including distinct arrivals
|
||||||
if (!timer) return;
|
// with the same id and timestamp. Dismissed alerts must not reappear.
|
||||||
clearTimeout(timer);
|
const incoming = alerts.slice(-RECENT_ALERT_LOOKBACK).filter((alert) => !seenRef.current.has(alert));
|
||||||
timersRef.current.delete(key);
|
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;
|
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 (
|
return (
|
||||||
<div className={containerClass} style={containerStyle} aria-live="polite">
|
<div
|
||||||
{toasts.map((toast) => (
|
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"
|
||||||
<AlertToastShell
|
style={{ transform: `translateX(-50%) scale(${scale})`, transformOrigin: 'top center' }}
|
||||||
key={toast.key}
|
aria-live="polite"
|
||||||
toast={toast}
|
>
|
||||||
onDismiss={dismissToast}
|
{toasts.map((alert) => (
|
||||||
onPause={pauseToast}
|
<AlertToast key={getAlertKey(alert)} alert={alert} opacity={opacity} onDismiss={dismiss} />
|
||||||
onResume={resumeToast}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToRgb(hex) {
|
function AlertToast({ alert, opacity, onDismiss }) {
|
||||||
const safe = typeof hex === 'string' ? hex.trim() : '';
|
const [hovered, setHovered] = useState(false);
|
||||||
const match = /^#?([0-9a-fA-F]{6})$/.exec(safe);
|
const [focused, setFocused] = useState(false);
|
||||||
if (!match) return null;
|
const remainingRef = useRef(0);
|
||||||
const value = match[1];
|
const paused = hovered || focused;
|
||||||
return {
|
|
||||||
r: parseInt(value.slice(0, 2), 16),
|
|
||||||
g: parseInt(value.slice(2, 4), 16),
|
|
||||||
b: parseInt(value.slice(4, 6), 16),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSafeWikiUrl(rawUrl) {
|
useEffect(() => {
|
||||||
const text = String(rawUrl || '').trim();
|
remainingRef.current = Number.isFinite(alert.lifetimeMs) ? alert.lifetimeMs : 3000;
|
||||||
if (!text) return '';
|
}, [alert]);
|
||||||
|
|
||||||
try {
|
useEffect(() => {
|
||||||
/*
|
if (paused) return;
|
||||||
Wiki links are operator-authored registry data, but this still normalizes
|
const startedAt = Date.now();
|
||||||
them before rendering an anchor. Allowing only http/https avoids turning a
|
const timer = setTimeout(() => onDismiss(alert), Math.max(0, remainingRef.current));
|
||||||
barcode registry typo into a javascript: link while still supporting both
|
return () => {
|
||||||
absolute URLs and local wiki paths such as /wiki/object-name.
|
clearTimeout(timer);
|
||||||
*/
|
remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startedAt));
|
||||||
const parsed = new URL(text, window.location.origin);
|
};
|
||||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
|
}, [alert, paused, onDismiss]);
|
||||||
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(' ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={shellClass}
|
className="pointer-events-auto w-max max-w-full"
|
||||||
onMouseEnter={() => onPause(toast.key)}
|
style={{ opacity }}
|
||||||
onMouseLeave={() => onResume(toast.key)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onFocusCapture={() => onPause(toast.key)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onBlurCapture={() => onResume(toast.key)}
|
onFocusCapture={() => setFocused(true)}
|
||||||
style={
|
onBlurCapture={(event) => {
|
||||||
generic
|
// Moving focus between controls inside the toast must not resume expiry.
|
||||||
? {
|
if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
|
||||||
/*
|
}}
|
||||||
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
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-stretch">
|
<AlertContent alert={alert} />
|
||||||
<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>
|
|
||||||
</div>
|
</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 />
|
<DisplayNoticeOverlay />
|
||||||
<RewardRunOverlay />
|
<RewardRunOverlay />
|
||||||
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
||||||
<AlertFeed scale={5} />
|
<AlertFeed scale={2} opacity={1}/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function MiniSummaryAppRoot() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MiniSummaryContent />
|
<MiniSummaryContent />
|
||||||
<AlertFeed scale={3} />
|
<AlertFeed scale={3} opacity={1}/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user