diff --git a/.firebase/colinmichaels/hosting/index.html b/.firebase/colinmichaels/hosting/index.html index 278bad1..d0ddb53 100644 --- a/.firebase/colinmichaels/hosting/index.html +++ b/.firebase/colinmichaels/hosting/index.html @@ -16,12 +16,12 @@ - + Colin Michaels - + - + diff --git a/.firebase/colinmichaels/hosting/polyfills-FFHMD2TL.js b/.firebase/colinmichaels/hosting/polyfills-FFHMD2TL.js deleted file mode 100644 index b01b791..0000000 --- a/.firebase/colinmichaels/hosting/polyfills-FFHMD2TL.js +++ /dev/null @@ -1,2 +0,0 @@ -var ce=globalThis;function te(e){return(ce.__Zone_symbol_prefix||"__zone_symbol__")+e}function dt(){let e=ce.performance;function n(M){e&&e.mark&&e.mark(M)}function a(M,s){e&&e.measure&&e.measure(M,s)}n("Zone");class t{static{this.__symbol__=te}static assertZonePatched(){if(ce.Promise!==S.ZoneAwarePromise)throw new Error("Zone.js has detected that ZoneAwarePromise `(window|global).Promise` has been overwritten.\nMost likely cause is that a Promise polyfill has been loaded after Zone.js (Polyfilling Promise api is not necessary when zone.js is loaded. If you must load one, do so before loading zone.js.)")}static get root(){let s=t.current;for(;s.parent;)s=s.parent;return s}static get current(){return b.zone}static get currentTask(){return D}static __load_patch(s,i,o=!1){if(S.hasOwnProperty(s)){let g=ce[te("forceDuplicateZoneCheck")]===!0;if(!o&&g)throw Error("Already loaded patch: "+s)}else if(!ce["__Zone_disable_"+s]){let g="Zone:"+s;n(g),S[s]=i(ce,t,w),a(g,g)}}get parent(){return this._parent}get name(){return this._name}constructor(s,i){this._parent=s,this._name=i?i.name||"unnamed":"",this._properties=i&&i.properties||{},this._zoneDelegate=new f(this,this._parent&&this._parent._zoneDelegate,i)}get(s){let i=this.getZoneWith(s);if(i)return i._properties[s]}getZoneWith(s){let i=this;for(;i;){if(i._properties.hasOwnProperty(s))return i;i=i._parent}return null}fork(s){if(!s)throw new Error("ZoneSpec required!");return this._zoneDelegate.fork(this,s)}wrap(s,i){if(typeof s!="function")throw new Error("Expecting function got: "+s);let o=this._zoneDelegate.intercept(this,s,i),g=this;return function(){return g.runGuarded(o,this,arguments,i)}}run(s,i,o,g){b={parent:b,zone:this};try{return this._zoneDelegate.invoke(this,s,i,o,g)}finally{b=b.parent}}runGuarded(s,i=null,o,g){b={parent:b,zone:this};try{try{return this._zoneDelegate.invoke(this,s,i,o,g)}catch(V){if(this._zoneDelegate.handleError(this,V))throw V}}finally{b=b.parent}}runTask(s,i,o){if(s.zone!=this)throw new Error("A task can only be run in the zone of creation! (Creation: "+(s.zone||J).name+"; Execution: "+this.name+")");let g=s,{type:V,data:{isPeriodic:ee=!1,isRefreshable:Z=!1}={}}=s;if(s.state===q&&(V===z||V===y))return;let he=s.state!=A;he&&g._transitionTo(A,d);let _e=D;D=g,b={parent:b,zone:this};try{V==y&&s.data&&!ee&&!Z&&(s.cancelFn=void 0);try{return this._zoneDelegate.invokeTask(this,g,i,o)}catch(Q){if(this._zoneDelegate.handleError(this,Q))throw Q}}finally{let Q=s.state;if(Q!==q&&Q!==X)if(V==z||ee||Z&&Q===k)he&&g._transitionTo(d,A,k);else{let Ee=g._zoneDelegates;this._updateTaskCount(g,-1),he&&g._transitionTo(q,A,q),Z&&(g._zoneDelegates=Ee)}b=b.parent,D=_e}}scheduleTask(s){if(s.zone&&s.zone!==this){let o=this;for(;o;){if(o===s.zone)throw Error(`can not reschedule task to ${this.name} which is descendants of the original zone ${s.zone.name}`);o=o.parent}}s._transitionTo(k,q);let i=[];s._zoneDelegates=i,s._zone=this;try{s=this._zoneDelegate.scheduleTask(this,s)}catch(o){throw s._transitionTo(X,k,q),this._zoneDelegate.handleError(this,o),o}return s._zoneDelegates===i&&this._updateTaskCount(s,1),s.state==k&&s._transitionTo(d,k),s}scheduleMicroTask(s,i,o,g){return this.scheduleTask(new E(G,s,i,o,g,void 0))}scheduleMacroTask(s,i,o,g,V){return this.scheduleTask(new E(y,s,i,o,g,V))}scheduleEventTask(s,i,o,g,V){return this.scheduleTask(new E(z,s,i,o,g,V))}cancelTask(s){if(s.zone!=this)throw new Error("A task can only be cancelled in the zone of creation! (Creation: "+(s.zone||J).name+"; Execution: "+this.name+")");if(!(s.state!==d&&s.state!==A)){s._transitionTo(x,d,A);try{this._zoneDelegate.cancelTask(this,s)}catch(i){throw s._transitionTo(X,x),this._zoneDelegate.handleError(this,i),i}return this._updateTaskCount(s,-1),s._transitionTo(q,x),s.runCount=-1,s}}_updateTaskCount(s,i){let o=s._zoneDelegates;i==-1&&(s._zoneDelegates=null);for(let g=0;gM.hasTask(i,o),onScheduleTask:(M,s,i,o)=>M.scheduleTask(i,o),onInvokeTask:(M,s,i,o,g,V)=>M.invokeTask(i,o,g,V),onCancelTask:(M,s,i,o)=>M.cancelTask(i,o)};class f{get zone(){return this._zone}constructor(s,i,o){this._taskCounts={microTask:0,macroTask:0,eventTask:0},this._zone=s,this._parentDelegate=i,this._forkZS=o&&(o&&o.onFork?o:i._forkZS),this._forkDlgt=o&&(o.onFork?i:i._forkDlgt),this._forkCurrZone=o&&(o.onFork?this._zone:i._forkCurrZone),this._interceptZS=o&&(o.onIntercept?o:i._interceptZS),this._interceptDlgt=o&&(o.onIntercept?i:i._interceptDlgt),this._interceptCurrZone=o&&(o.onIntercept?this._zone:i._interceptCurrZone),this._invokeZS=o&&(o.onInvoke?o:i._invokeZS),this._invokeDlgt=o&&(o.onInvoke?i:i._invokeDlgt),this._invokeCurrZone=o&&(o.onInvoke?this._zone:i._invokeCurrZone),this._handleErrorZS=o&&(o.onHandleError?o:i._handleErrorZS),this._handleErrorDlgt=o&&(o.onHandleError?i:i._handleErrorDlgt),this._handleErrorCurrZone=o&&(o.onHandleError?this._zone:i._handleErrorCurrZone),this._scheduleTaskZS=o&&(o.onScheduleTask?o:i._scheduleTaskZS),this._scheduleTaskDlgt=o&&(o.onScheduleTask?i:i._scheduleTaskDlgt),this._scheduleTaskCurrZone=o&&(o.onScheduleTask?this._zone:i._scheduleTaskCurrZone),this._invokeTaskZS=o&&(o.onInvokeTask?o:i._invokeTaskZS),this._invokeTaskDlgt=o&&(o.onInvokeTask?i:i._invokeTaskDlgt),this._invokeTaskCurrZone=o&&(o.onInvokeTask?this._zone:i._invokeTaskCurrZone),this._cancelTaskZS=o&&(o.onCancelTask?o:i._cancelTaskZS),this._cancelTaskDlgt=o&&(o.onCancelTask?i:i._cancelTaskDlgt),this._cancelTaskCurrZone=o&&(o.onCancelTask?this._zone:i._cancelTaskCurrZone),this._hasTaskZS=null,this._hasTaskDlgt=null,this._hasTaskDlgtOwner=null,this._hasTaskCurrZone=null;let g=o&&o.onHasTask,V=i&&i._hasTaskZS;(g||V)&&(this._hasTaskZS=g?o:c,this._hasTaskDlgt=i,this._hasTaskDlgtOwner=this,this._hasTaskCurrZone=this._zone,o.onScheduleTask||(this._scheduleTaskZS=c,this._scheduleTaskDlgt=i,this._scheduleTaskCurrZone=this._zone),o.onInvokeTask||(this._invokeTaskZS=c,this._invokeTaskDlgt=i,this._invokeTaskCurrZone=this._zone),o.onCancelTask||(this._cancelTaskZS=c,this._cancelTaskDlgt=i,this._cancelTaskCurrZone=this._zone))}fork(s,i){return this._forkZS?this._forkZS.onFork(this._forkDlgt,this.zone,s,i):new t(s,i)}intercept(s,i,o){return this._interceptZS?this._interceptZS.onIntercept(this._interceptDlgt,this._interceptCurrZone,s,i,o):i}invoke(s,i,o,g,V){return this._invokeZS?this._invokeZS.onInvoke(this._invokeDlgt,this._invokeCurrZone,s,i,o,g,V):i.apply(o,g)}handleError(s,i){return this._handleErrorZS?this._handleErrorZS.onHandleError(this._handleErrorDlgt,this._handleErrorCurrZone,s,i):!0}scheduleTask(s,i){let o=i;if(this._scheduleTaskZS)this._hasTaskZS&&o._zoneDelegates.push(this._hasTaskDlgtOwner),o=this._scheduleTaskZS.onScheduleTask(this._scheduleTaskDlgt,this._scheduleTaskCurrZone,s,i),o||(o=i);else if(i.scheduleFn)i.scheduleFn(i);else if(i.type==G)U(i);else throw new Error("Task is missing scheduleFn.");return o}invokeTask(s,i,o,g){return this._invokeTaskZS?this._invokeTaskZS.onInvokeTask(this._invokeTaskDlgt,this._invokeTaskCurrZone,s,i,o,g):i.callback.apply(o,g)}cancelTask(s,i){let o;if(this._cancelTaskZS)o=this._cancelTaskZS.onCancelTask(this._cancelTaskDlgt,this._cancelTaskCurrZone,s,i);else{if(!i.cancelFn)throw Error("Task is not cancelable");o=i.cancelFn(i)}return o}hasTask(s,i){try{this._hasTaskZS&&this._hasTaskZS.onHasTask(this._hasTaskDlgt,this._hasTaskCurrZone,s,i)}catch(o){this.handleError(s,o)}}_updateTaskCount(s,i){let o=this._taskCounts,g=o[s],V=o[s]=g+i;if(V<0)throw new Error("More tasks executed then were scheduled.");if(g==0||V==0){let ee={microTask:o.microTask>0,macroTask:o.macroTask>0,eventTask:o.eventTask>0,change:s};this.hasTask(this._zone,ee)}}}class E{constructor(s,i,o,g,V,ee){if(this._zone=null,this.runCount=0,this._zoneDelegates=null,this._state="notScheduled",this.type=s,this.source=i,this.data=g,this.scheduleFn=V,this.cancelFn=ee,!o)throw new Error("callback is not defined");this.callback=o;let Z=this;s===z&&g&&g.useG?this.invoke=E.invokeTask:this.invoke=function(){return E.invokeTask.call(ce,Z,this,arguments)}}static invokeTask(s,i,o){s||(s=this),K++;try{return s.runCount++,s.zone.runTask(s,i,o)}finally{K==1&&$(),K--}}get zone(){return this._zone}get state(){return this._state}cancelScheduleRequest(){this._transitionTo(q,k)}_transitionTo(s,i,o){if(this._state===i||this._state===o)this._state=s,s==q&&(this._zoneDelegates=null);else throw new Error(`${this.type} '${this.source}': can not transition to '${s}', expecting state '${i}'${o?" or '"+o+"'":""}, was '${this._state}'.`)}toString(){return this.data&&typeof this.data.handleId<"u"?this.data.handleId.toString():Object.prototype.toString.call(this)}toJSON(){return{type:this.type,state:this.state,source:this.source,zone:this.zone.name,runCount:this.runCount}}}let T=te("setTimeout"),p=te("Promise"),C=te("then"),_=[],P=!1,I;function H(M){if(I||ce[p]&&(I=ce[p].resolve(0)),I){let s=I[C];s||(s=I.then),s.call(I,M)}else ce[T](M,0)}function U(M){K===0&&_.length===0&&H($),M&&_.push(M)}function $(){if(!P){for(P=!0;_.length;){let M=_;_=[];for(let s=0;sb,onUnhandledError:W,microtaskDrainDone:W,scheduleMicroTask:U,showUncaughtError:()=>!t[te("ignoreConsoleErrorUncaughtError")],patchEventTarget:()=>[],patchOnProperties:W,patchMethod:()=>W,bindArguments:()=>[],patchThen:()=>W,patchMacroTask:()=>W,patchEventPrototype:()=>W,isIEOrEdge:()=>!1,getGlobalObjects:()=>{},ObjectDefineProperty:()=>W,ObjectGetOwnPropertyDescriptor:()=>{},ObjectCreate:()=>{},ArraySlice:()=>[],patchClass:()=>W,wrapWithCurrentZone:()=>W,filterProperties:()=>[],attachOriginToPatched:()=>W,_redefineProperty:()=>W,patchCallbacks:()=>W,nativeScheduleMicroTask:H},b={parent:null,zone:new t(null,null)},D=null,K=0;function W(){}return a("Zone","Zone"),t}function _t(){let e=globalThis,n=e[te("forceDuplicateZoneCheck")]===!0;if(e.Zone&&(n||typeof e.Zone.__symbol__!="function"))throw new Error("Zone already loaded.");return e.Zone??=dt(),e.Zone}var be=Object.getOwnPropertyDescriptor,Ae=Object.defineProperty,je=Object.getPrototypeOf,Et=Object.create,Tt=Array.prototype.slice,He="addEventListener",xe="removeEventListener",Le=te(He),Ie=te(xe),ae="true",le="false",Pe=te("");function Ve(e,n){return Zone.current.wrap(e,n)}function Ge(e,n,a,t,c){return Zone.current.scheduleMacroTask(e,n,a,t,c)}var j=te,De=typeof window<"u",pe=De?window:void 0,Y=De&&pe||globalThis,gt="removeAttribute";function Fe(e,n){for(let a=e.length-1;a>=0;a--)typeof e[a]=="function"&&(e[a]=Ve(e[a],n+"_"+a));return e}function yt(e,n){let a=e.constructor.name;for(let t=0;t{let p=function(){return T.apply(this,Fe(arguments,a+"."+c))};return fe(p,T),p})(f)}}}function tt(e){return e?e.writable===!1?!1:!(typeof e.get=="function"&&typeof e.set>"u"):!0}var nt=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope,Se=!("nw"in Y)&&typeof Y.process<"u"&&Y.process.toString()==="[object process]",Be=!Se&&!nt&&!!(De&&pe.HTMLElement),rt=typeof Y.process<"u"&&Y.process.toString()==="[object process]"&&!nt&&!!(De&&pe.HTMLElement),Ce={},mt=j("enable_beforeunload"),Ye=function(e){if(e=e||Y.event,!e)return;let n=Ce[e.type];n||(n=Ce[e.type]=j("ON_PROPERTY"+e.type));let a=this||e.target||Y,t=a[n],c;if(Be&&a===pe&&e.type==="error"){let f=e;c=t&&t.call(this,f.message,f.filename,f.lineno,f.colno,f.error),c===!0&&e.preventDefault()}else c=t&&t.apply(this,arguments),e.type==="beforeunload"&&Y[mt]&&typeof c=="string"?e.returnValue=c:c!=null&&!c&&e.preventDefault();return c};function $e(e,n,a){let t=be(e,n);if(!t&&a&&be(a,n)&&(t={enumerable:!0,configurable:!0}),!t||!t.configurable)return;let c=j("on"+n+"patched");if(e.hasOwnProperty(c)&&e[c])return;delete t.writable,delete t.value;let f=t.get,E=t.set,T=n.slice(2),p=Ce[T];p||(p=Ce[T]=j("ON_PROPERTY"+T)),t.set=function(C){let _=this;if(!_&&e===Y&&(_=Y),!_)return;typeof _[p]=="function"&&_.removeEventListener(T,Ye),E&&E.call(_,null),_[p]=C,typeof C=="function"&&_.addEventListener(T,Ye,!1)},t.get=function(){let C=this;if(!C&&e===Y&&(C=Y),!C)return null;let _=C[p];if(_)return _;if(f){let P=f.call(this);if(P)return t.set.call(this,P),typeof C[gt]=="function"&&C.removeAttribute(n),P}return null},Ae(e,n,t),e[c]=!0}function ot(e,n,a){if(n)for(let t=0;tfunction(E,T){let p=a(E,T);return p.cbIdx>=0&&typeof T[p.cbIdx]=="function"?Ge(p.name,T[p.cbIdx],p,c):f.apply(E,T)})}function fe(e,n){e[j("OriginalDelegate")]=n}var Je=!1,Me=!1;function kt(){try{let e=pe.navigator.userAgent;if(e.indexOf("MSIE ")!==-1||e.indexOf("Trident/")!==-1)return!0}catch{}return!1}function vt(){if(Je)return Me;Je=!0;try{let e=pe.navigator.userAgent;(e.indexOf("MSIE ")!==-1||e.indexOf("Trident/")!==-1||e.indexOf("Edge/")!==-1)&&(Me=!0)}catch{}return Me}function Ke(e){return typeof e=="function"}function Qe(e){return typeof e=="number"}var me=!1;if(typeof window<"u")try{let e=Object.defineProperty({},"passive",{get:function(){me=!0}});window.addEventListener("test",e,e),window.removeEventListener("test",e,e)}catch{me=!1}var bt={useG:!0},ne={},st={},it=new RegExp("^"+Pe+"(\\w+)(true|false)$"),ct=j("propagationStopped");function at(e,n){let a=(n?n(e):e)+le,t=(n?n(e):e)+ae,c=Pe+a,f=Pe+t;ne[e]={},ne[e][le]=c,ne[e][ae]=f}function Pt(e,n,a,t){let c=t&&t.add||He,f=t&&t.rm||xe,E=t&&t.listeners||"eventListeners",T=t&&t.rmAll||"removeAllListeners",p=j(c),C="."+c+":",_="prependListener",P="."+_+":",I=function(k,d,A){if(k.isRemoved)return;let x=k.callback;typeof x=="object"&&x.handleEvent&&(k.callback=y=>x.handleEvent(y),k.originalDelegate=x);let X;try{k.invoke(k,d,[A])}catch(y){X=y}let G=k.options;if(G&&typeof G=="object"&&G.once){let y=k.originalDelegate?k.originalDelegate:k.callback;d[f].call(d,A.type,y,G)}return X};function H(k,d,A){if(d=d||e.event,!d)return;let x=k||d.target||e,X=x[ne[d.type][A?ae:le]];if(X){let G=[];if(X.length===1){let y=I(X[0],x,d);y&&G.push(y)}else{let y=X.slice();for(let z=0;z{throw z})}}}let U=function(k){return H(this,k,!1)},$=function(k){return H(this,k,!0)};function J(k,d){if(!k)return!1;let A=!0;d&&d.useG!==void 0&&(A=d.useG);let x=d&&d.vh,X=!0;d&&d.chkDup!==void 0&&(X=d.chkDup);let G=!1;d&&d.rt!==void 0&&(G=d.rt);let y=k;for(;y&&!y.hasOwnProperty(c);)y=je(y);if(!y&&k[c]&&(y=k),!y||y[p])return!1;let z=d&&d.eventNameToString,S={},w=y[p]=y[c],b=y[j(f)]=y[f],D=y[j(E)]=y[E],K=y[j(T)]=y[T],W;d&&d.prepend&&(W=y[j(d.prepend)]=y[d.prepend]);function M(r,u){return!me&&typeof r=="object"&&r?!!r.capture:!me||!u?r:typeof r=="boolean"?{capture:r,passive:!0}:r?typeof r=="object"&&r.passive!==!1?{...r,passive:!0}:r:{passive:!0}}let s=function(r){if(!S.isExisting)return w.call(S.target,S.eventName,S.capture?$:U,S.options)},i=function(r){if(!r.isRemoved){let u=ne[r.eventName],v;u&&(v=u[r.capture?ae:le]);let R=v&&r.target[v];if(R){for(let m=0;mre.zone.cancelTask(re);r.call(Te,"abort",ie,{once:!0}),re.removeAbortListener=()=>Te.removeEventListener("abort",ie)}if(S.target=null,ke&&(ke.taskData=null),Ue&&(S.options.once=!0),!me&&typeof re.options=="boolean"||(re.options=se),re.target=N,re.capture=Oe,re.eventName=L,B&&(re.originalDelegate=F),O?ge.unshift(re):ge.push(re),m)return N}};return y[c]=l(w,C,ee,Z,G),W&&(y[_]=l(W,P,g,Z,G,!0)),y[f]=function(){let r=this||e,u=arguments[0];d&&d.transferEventName&&(u=d.transferEventName(u));let v=arguments[2],R=v?typeof v=="boolean"?!0:v.capture:!1,m=arguments[1];if(!m)return b.apply(this,arguments);if(x&&!x(b,m,r,arguments))return;let O=ne[u],N;O&&(N=O[R?ae:le]);let L=N&&r[N];if(L)for(let F=0;Ffunction(c,f){c[ct]=!0,t&&t.apply(c,f)})}function Rt(e,n){n.patchMethod(e,"queueMicrotask",a=>function(t,c){Zone.current.scheduleMicroTask("queueMicrotask",c[0])})}var Re=j("zoneTask");function ye(e,n,a,t){let c=null,f=null;n+=t,a+=t;let E={};function T(C){let _=C.data;_.args[0]=function(){return C.invoke.apply(this,arguments)};let P=c.apply(e,_.args);return Qe(P)?_.handleId=P:(_.handle=P,_.isRefreshable=Ke(P.refresh)),C}function p(C){let{handle:_,handleId:P}=C.data;return f.call(e,_??P)}c=ue(e,n,C=>function(_,P){if(Ke(P[0])){let I={isRefreshable:!1,isPeriodic:t==="Interval",delay:t==="Timeout"||t==="Interval"?P[1]||0:void 0,args:P},H=P[0];P[0]=function(){try{return H.apply(this,arguments)}finally{let{handle:A,handleId:x,isPeriodic:X,isRefreshable:G}=I;!X&&!G&&(x?delete E[x]:A&&(A[Re]=null))}};let U=Ge(n,P[0],I,T,p);if(!U)return U;let{handleId:$,handle:J,isRefreshable:q,isPeriodic:k}=U.data;if($)E[$]=U;else if(J&&(J[Re]=U,q&&!k)){let d=J.refresh;J.refresh=function(){let{zone:A,state:x}=U;return x==="notScheduled"?(U._state="scheduled",A._updateTaskCount(U,1)):x==="running"&&(U._state="scheduling"),d.call(this)}}return J??$??U}else return C.apply(e,P)}),f=ue(e,a,C=>function(_,P){let I=P[0],H;Qe(I)?(H=E[I],delete E[I]):(H=I?.[Re],H?I[Re]=null:H=I),H?.type?H.cancelFn&&H.zone.cancelTask(H):C.apply(e,P)})}function Ct(e,n){let{isBrowser:a,isMix:t}=n.getGlobalObjects();if(!a&&!t||!e.customElements||!("customElements"in e))return;let c=["connectedCallback","disconnectedCallback","adoptedCallback","attributeChangedCallback","formAssociatedCallback","formDisabledCallback","formResetCallback","formStateRestoreCallback"];n.patchCallbacks(n,e.customElements,"customElements","define",c)}function Dt(e,n){if(Zone[n.symbol("patchEventTarget")])return;let{eventNames:a,zoneSymbolEventNames:t,TRUE_STR:c,FALSE_STR:f,ZONE_SYMBOL_PREFIX:E}=n.getGlobalObjects();for(let p=0;pf.target===e);if(!t||t.length===0)return n;let c=t[0].ignoreProperties;return n.filter(f=>c.indexOf(f)===-1)}function et(e,n,a,t){if(!e)return;let c=ut(e,n,a);ot(e,c,t)}function Ze(e){return Object.getOwnPropertyNames(e).filter(n=>n.startsWith("on")&&n.length>2).map(n=>n.substring(2))}function Ot(e,n){if(Se&&!rt||Zone[e.symbol("patchEvents")])return;let a=n.__Zone_ignore_on_properties,t=[];if(Be){let c=window;t=t.concat(["Document","SVGElement","Element","HTMLElement","HTMLBodyElement","HTMLMediaElement","HTMLFrameSetElement","HTMLFrameElement","HTMLIFrameElement","HTMLMarqueeElement","Worker"]);let f=kt()?[{target:c,ignoreProperties:["error"]}]:[];et(c,Ze(c),a&&a.concat(f),je(c))}t=t.concat(["XMLHttpRequest","XMLHttpRequestEventTarget","IDBIndex","IDBRequest","IDBOpenDBRequest","IDBDatabase","IDBTransaction","IDBCursor","WebSocket"]);for(let c=0;c{let a=n[e.__symbol__("legacyPatch")];a&&a()}),e.__load_patch("timers",n=>{let a="set",t="clear";ye(n,a,t,"Timeout"),ye(n,a,t,"Interval"),ye(n,a,t,"Immediate")}),e.__load_patch("requestAnimationFrame",n=>{ye(n,"request","cancel","AnimationFrame"),ye(n,"mozRequest","mozCancel","AnimationFrame"),ye(n,"webkitRequest","webkitCancel","AnimationFrame")}),e.__load_patch("blocking",(n,a)=>{let t=["alert","prompt","confirm"];for(let c=0;cfunction(C,_){return a.current.run(E,n,_,p)})}}),e.__load_patch("EventTarget",(n,a,t)=>{St(n,t),Dt(n,t);let c=n.XMLHttpRequestEventTarget;c&&c.prototype&&t.patchEventTarget(n,t,[c.prototype])}),e.__load_patch("MutationObserver",(n,a,t)=>{ve("MutationObserver"),ve("WebKitMutationObserver")}),e.__load_patch("IntersectionObserver",(n,a,t)=>{ve("IntersectionObserver")}),e.__load_patch("FileReader",(n,a,t)=>{ve("FileReader")}),e.__load_patch("on_property",(n,a,t)=>{Ot(t,n)}),e.__load_patch("customElements",(n,a,t)=>{Ct(n,t)}),e.__load_patch("XHR",(n,a)=>{C(n);let t=j("xhrTask"),c=j("xhrSync"),f=j("xhrListener"),E=j("xhrScheduled"),T=j("xhrURL"),p=j("xhrErrorBeforeScheduled");function C(_){let P=_.XMLHttpRequest;if(!P)return;let I=P.prototype;function H(w){return w[t]}let U=I[Le],$=I[Ie];if(!U){let w=_.XMLHttpRequestEventTarget;if(w){let b=w.prototype;U=b[Le],$=b[Ie]}}let J="readystatechange",q="scheduled";function k(w){let b=w.data,D=b.target;D[E]=!1,D[p]=!1;let K=D[f];U||(U=D[Le],$=D[Ie]),K&&$.call(D,J,K);let W=D[f]=()=>{if(D.readyState===D.DONE)if(!b.aborted&&D[E]&&w.state===q){let s=D[a.__symbol__("loadfalse")];if(D.status!==0&&s&&s.length>0){let i=w.invoke;w.invoke=function(){let o=D[a.__symbol__("loadfalse")];for(let g=0;gfunction(w,b){return w[c]=b[2]==!1,w[T]=b[1],x.apply(w,b)}),X="XMLHttpRequest.send",G=j("fetchTaskAborting"),y=j("fetchTaskScheduling"),z=ue(I,"send",()=>function(w,b){if(a.current[y]===!0||w[c])return z.apply(w,b);{let D={target:w,url:w[T],isPeriodic:!1,args:b,aborted:!1},K=Ge(X,d,D,k,A);w&&w[p]===!0&&!D.aborted&&K.state===q&&K.invoke()}}),S=ue(I,"abort",()=>function(w,b){let D=H(w);if(D&&typeof D.type=="string"){if(D.cancelFn==null||D.data&&D.data.aborted)return;D.zone.cancelTask(D)}else if(a.current[G]===!0)return S.apply(w,b)})}}),e.__load_patch("geolocation",n=>{n.navigator&&n.navigator.geolocation&&yt(n.navigator.geolocation,["getCurrentPosition","watchPosition"])}),e.__load_patch("PromiseRejectionEvent",(n,a)=>{function t(c){return function(f){lt(n,c).forEach(T=>{let p=n.PromiseRejectionEvent;if(p){let C=new p(c,{promise:f.promise,reason:f.rejection});T.invoke(C)}})}}n.PromiseRejectionEvent&&(a[j("unhandledPromiseRejectionHandler")]=t("unhandledrejection"),a[j("rejectionHandledHandler")]=t("rejectionhandled"))}),e.__load_patch("queueMicrotask",(n,a,t)=>{Rt(n,t)})}function Lt(e){e.__load_patch("ZoneAwarePromise",(n,a,t)=>{let c=Object.getOwnPropertyDescriptor,f=Object.defineProperty;function E(h){if(h&&h.toString===Object.prototype.toString){let l=h.constructor&&h.constructor.name;return(l||"")+": "+JSON.stringify(h)}return h?h.toString():Object.prototype.toString.call(h)}let T=t.symbol,p=[],C=n[T("DISABLE_WRAPPING_UNCAUGHT_PROMISE_REJECTION")]!==!1,_=T("Promise"),P=T("then"),I="__creationTrace__";t.onUnhandledError=h=>{if(t.showUncaughtError()){let l=h&&h.rejection;l?console.error("Unhandled Promise rejection:",l instanceof Error?l.message:l,"; Zone:",h.zone.name,"; Task:",h.task&&h.task.source,"; Value:",l,l instanceof Error?l.stack:void 0):console.error(h)}},t.microtaskDrainDone=()=>{for(;p.length;){let h=p.shift();try{h.zone.runGuarded(()=>{throw h.throwOriginal?h.rejection:h})}catch(l){U(l)}}};let H=T("unhandledPromiseRejectionHandler");function U(h){t.onUnhandledError(h);try{let l=a[H];typeof l=="function"&&l.call(this,h)}catch{}}function $(h){return h&&h.then}function J(h){return h}function q(h){return Z.reject(h)}let k=T("state"),d=T("value"),A=T("finally"),x=T("parentPromiseValue"),X=T("parentPromiseState"),G="Promise.then",y=null,z=!0,S=!1,w=0;function b(h,l){return r=>{try{M(h,l,r)}catch(u){M(h,!1,u)}}}let D=function(){let h=!1;return function(r){return function(){h||(h=!0,r.apply(null,arguments))}}},K="Promise resolved with itself",W=T("currentTaskTrace");function M(h,l,r){let u=D();if(h===r)throw new TypeError(K);if(h[k]===y){let v=null;try{(typeof r=="object"||typeof r=="function")&&(v=r&&r.then)}catch(R){return u(()=>{M(h,!1,R)})(),h}if(l!==S&&r instanceof Z&&r.hasOwnProperty(k)&&r.hasOwnProperty(d)&&r[k]!==y)i(r),M(h,r[k],r[d]);else if(l!==S&&typeof v=="function")try{v.call(r,u(b(h,l)),u(b(h,!1)))}catch(R){u(()=>{M(h,!1,R)})()}else{h[k]=l;let R=h[d];if(h[d]=r,h[A]===A&&l===z&&(h[k]=h[X],h[d]=h[x]),l===S&&r instanceof Error){let m=a.currentTask&&a.currentTask.data&&a.currentTask.data[I];m&&f(r,W,{configurable:!0,enumerable:!1,writable:!0,value:m})}for(let m=0;m{try{let O=h[d],N=!!r&&A===r[A];N&&(r[x]=O,r[X]=R);let L=l.run(m,void 0,N&&m!==q&&m!==J?[]:[O]);M(r,!0,L)}catch(O){M(r,!1,O)}},r)}let g="function ZoneAwarePromise() { [native code] }",V=function(){},ee=n.AggregateError;class Z{static toString(){return g}static resolve(l){return l instanceof Z?l:M(new this(null),z,l)}static reject(l){return M(new this(null),S,l)}static withResolvers(){let l={};return l.promise=new Z((r,u)=>{l.resolve=r,l.reject=u}),l}static any(l){if(!l||typeof l[Symbol.iterator]!="function")return Promise.reject(new ee([],"All promises were rejected"));let r=[],u=0;try{for(let m of l)u++,r.push(Z.resolve(m))}catch{return Promise.reject(new ee([],"All promises were rejected"))}if(u===0)return Promise.reject(new ee([],"All promises were rejected"));let v=!1,R=[];return new Z((m,O)=>{for(let N=0;N{v||(v=!0,m(L))},L=>{R.push(L),u--,u===0&&(v=!0,O(new ee(R,"All promises were rejected")))})})}static race(l){let r,u,v=new this((O,N)=>{r=O,u=N});function R(O){r(O)}function m(O){u(O)}for(let O of l)$(O)||(O=this.resolve(O)),O.then(R,m);return v}static all(l){return Z.allWithCallback(l)}static allSettled(l){return(this&&this.prototype instanceof Z?this:Z).allWithCallback(l,{thenCallback:u=>({status:"fulfilled",value:u}),errorCallback:u=>({status:"rejected",reason:u})})}static allWithCallback(l,r){let u,v,R=new this((L,F)=>{u=L,v=F}),m=2,O=0,N=[];for(let L of l){$(L)||(L=this.resolve(L));let F=O;try{L.then(B=>{N[F]=r?r.thenCallback(B):B,m--,m===0&&u(N)},B=>{r?(N[F]=r.errorCallback(B),m--,m===0&&u(N)):v(B)})}catch(B){v(B)}m++,O++}return m-=2,m===0&&u(N),R}constructor(l){let r=this;if(!(r instanceof Z))throw new Error("Must be an instanceof Promise.");r[k]=y,r[d]=[];try{let u=D();l&&l(u(b(r,z)),u(b(r,S)))}catch(u){M(r,!1,u)}}get[Symbol.toStringTag](){return"Promise"}get[Symbol.species](){return Z}then(l,r){let u=this.constructor?.[Symbol.species];(!u||typeof u!="function")&&(u=this.constructor||Z);let v=new u(V),R=a.current;return this[k]==y?this[d].push(R,v,l,r):o(this,R,v,l,r),v}catch(l){return this.then(null,l)}finally(l){let r=this.constructor?.[Symbol.species];(!r||typeof r!="function")&&(r=Z);let u=new r(V);u[A]=A;let v=a.current;return this[k]==y?this[d].push(v,u,l,l):o(this,v,u,l,l),u}}Z.resolve=Z.resolve,Z.reject=Z.reject,Z.race=Z.race,Z.all=Z.all;let he=n[_]=n.Promise;n.Promise=Z;let _e=T("thenPatched");function Q(h){let l=h.prototype,r=c(l,"then");if(r&&(r.writable===!1||!r.configurable))return;let u=l.then;l[P]=u,h.prototype.then=function(v,R){return new Z((O,N)=>{u.call(this,O,N)}).then(v,R)},h[_e]=!0}t.patchThen=Q;function Ee(h){return function(l,r){let u=h.apply(l,r);if(u instanceof Z)return u;let v=u.constructor;return v[_e]||Q(v),u}}return he&&(Q(he),ue(n,"fetch",h=>Ee(h))),Promise[a.__symbol__("uncaughtPromiseErrors")]=p,Z})}function It(e){e.__load_patch("toString",n=>{let a=Function.prototype.toString,t=j("OriginalDelegate"),c=j("Promise"),f=j("Error"),E=function(){if(typeof this=="function"){let _=this[t];if(_)return typeof _=="function"?a.call(_):Object.prototype.toString.call(_);if(this===Promise){let P=n[c];if(P)return a.call(P)}if(this===Error){let P=n[f];if(P)return a.call(P)}}return a.call(this)};E[t]=a,Function.prototype.toString=E;let T=Object.prototype.toString,p="[object Promise]";Object.prototype.toString=function(){return typeof Promise=="function"&&this instanceof Promise?p:T.call(this)}})}function Mt(e,n,a,t,c){let f=Zone.__symbol__(t);if(n[f])return;let E=n[f]=n[t];n[t]=function(T,p,C){return p&&p.prototype&&c.forEach(function(_){let P=`${a}.${t}::`+_,I=p.prototype;try{if(I.hasOwnProperty(_)){let H=e.ObjectGetOwnPropertyDescriptor(I,_);H&&H.value?(H.value=e.wrapWithCurrentZone(H.value,P),e._redefineProperty(p.prototype,_,H)):I[_]&&(I[_]=e.wrapWithCurrentZone(I[_],P))}else I[_]&&(I[_]=e.wrapWithCurrentZone(I[_],P))}catch{}}),E.call(n,T,p,C)},e.attachOriginToPatched(n[t],E)}function Zt(e){e.__load_patch("util",(n,a,t)=>{let c=Ze(n);t.patchOnProperties=ot,t.patchMethod=ue,t.bindArguments=Fe,t.patchMacroTask=pt;let f=a.__symbol__("BLACK_LISTED_EVENTS"),E=a.__symbol__("UNPATCHED_EVENTS");n[E]&&(n[f]=n[E]),n[f]&&(a[f]=a[E]=n[f]),t.patchEventPrototype=wt,t.patchEventTarget=Pt,t.isIEOrEdge=vt,t.ObjectDefineProperty=Ae,t.ObjectGetOwnPropertyDescriptor=be,t.ObjectCreate=Et,t.ArraySlice=Tt,t.patchClass=ve,t.wrapWithCurrentZone=Ve,t.filterProperties=ut,t.attachOriginToPatched=fe,t._redefineProperty=Object.defineProperty,t.patchCallbacks=Mt,t.getGlobalObjects=()=>({globalSources:st,zoneSymbolEventNames:ne,eventNames:c,isBrowser:Be,isMix:rt,isNode:Se,TRUE_STR:ae,FALSE_STR:le,ZONE_SYMBOL_PREFIX:Pe,ADD_EVENT_LISTENER_STR:He,REMOVE_EVENT_LISTENER_STR:xe})})}function At(e){Lt(e),It(e),Zt(e)}var ft=_t();At(ft);Nt(ft); diff --git a/.firebase/hosting.LmZpcmViYXNlL2NvbGlubWljaGFlbHMvaG9zdGluZw.cache b/.firebase/hosting.LmZpcmViYXNlL2NvbGlubWljaGFlbHMvaG9zdGluZw.cache index 9bcc427..3dc0bf1 100644 --- a/.firebase/hosting.LmZpcmViYXNlL2NvbGlubWljaGFlbHMvaG9zdGluZw.cache +++ b/.firebase/hosting.LmZpcmViYXNlL2NvbGlubWljaGFlbHMvaG9zdGluZw.cache @@ -1,121 +1,127 @@ -chunk-ZGUKRA6E.js,1748572479730,0efabd2c10497ecc7eac94808e95ef33c6090b864769b2712860dcb59ad69ddb -index.html,1748572479729,17ed4364c2ea4c2f7e80a1ca8b03ba945336fe52c954faaea2a1b2e6ea15e0a7 -scripts-V4VO4MR6.js,1748572479727,38591f32e5f3ae992f35e023a4328f6e9aa886f35c5728b06ab3efbc447e09fa -polyfills-FFHMD2TL.js,1748572479728,02522c25699ab93e99a93914c3fc1158c874090dc123472be90c6746e6cf2d13 -chunk-ZWATAFO6.js,1748572479729,8b3e8a77c3db1a7f0d4749fa8a3f9ca1d9d58efe5104a8d3650b0169f4c272e9 -chunk-YDJWLCK6.js,1748572479731,a56be79910746e6f017db1b829d6587f4eb9fbbaf0592d28b937e2f2ce647650 -chunk-WWDJYTTK.js,1748572479733,ce13b8b4314236a5a4c29fc2e0605c3f24134b07bdb4d523268c23de5dbba3bd -chunk-UBVQATGU.js,1748572479733,5db064dacca072c9104bd338d5ae966b614bc9e5b3b9fb9704a93f4e024e7e37 -favicon.ico,1748572479729,8f1fc0f94ae643bb8757c3cb49f69bf6cb2228f632cb77c7e47d8ee3995d88a7 -chunk-RQSW5DVD.js,1748572479733,ccdc57525d3b99216fc05e83e624f87a06e5315c2ace14e50fca0402051a1c36 -chunk-ODE4NBXK.js,1748572479735,ee70bbb44719f8b0469f06129abaed0e3885ad541beb18a820996e3cb1333d38 -chunk-LNTGOE3M.js,1748572479735,9b5d93b9b1de3e560675ea6eb49508f6bdc4ba31a6d24b0ee74c906d6668ecb5 -chunk-RE5V77OY.js,1748572479734,a9a9f264718aab4aaf9f1f258bb860df131984c399404b763575fa58dc6d7b79 -chunk-IJ5MGM3Y.js,1748572479736,d3cf56c854e19155cf49094f8bb22ec4d4f648489b80c2e8e4c4aaf5f3d2a3e4 -chunk-J2B6NU2D.js,1748572479736,f6e60a67e02d30ccd9c1b8ee47e10499fdb92c8da34d5cb2ebc0ed615ca383ef -chunk-FKXIWFMO.js,1748572479736,1ec03de231ba51d8c18443c7467a8de2752de6e0fa2bd4a511091b292cefb664 -assets/files.json,1748572479753,f1c5c147a48e1507ad4e45d5da26441543c6969d92afef0a3fb2eeb42b2e3c9b -assets/site.webmanifest,1748572479737,1070ac375a6fc0a08c7f761891ce7f24862dd5693be284dec2fde12e3df1368a -assets/browserconfig.xml,1748572479754,6d3864fc7eef2280f3c1149bf074e00ea8ade65feeae32d65ddaa1b5b80e8412 -chunk-GITJRJ46.js,1748572479736,126e509960224ca7acd35dfc857651b7c31611fd20a7703bdbf9ae754fd59f9c -chunk-6GTWYHP4.js,1748572479736,5f1ea1633b5963af866f32c28484c17cb6bdf41e1802c51b850b5a0a0265bc20 -assets/icons/safari-pinned-tab.svg,1748572479740,65729c2333f30b56eae5e1d0bc0a8fa2e749d9348d7d640ad58ef196d366416b -assets/icons/mstile-70x70.png,1748572479741,f32880d2a737263aeb5d3ed9054f34939544ba8e05644cb85794b2096ff30075 -assets/icons/mstile-310x310.png,1748572479741,8a93720e944a5e58190b31552553edcdc7df5fdc50c47d2c14190ef64cef159a -assets/icons/mstile-310x150.png,1748572479741,44c484a4768aef97bb462d163649b71b0cc25fe62f39070c9c338ef587caa02e -assets/icons/mstile-150x150.png,1748572479741,44fc3c79497dd7fff2cce4f337a3e4b05ae3a794c3b34614e76076eafe3ce997 -assets/icons/mstile-144x144.png,1748572479741,2a3d79be470997f72cae96f9fd14e7215f82b0e6c8e0ffa4411cddf1f966da59 -assets/icons/favicon-16x16.png,1748572479742,58a3341209281a404cde68dd52f41f7056a76441b150e0b867b6023c00cc1581 -assets/icons/favicon-32x32.png,1748572479742,78cba0e271812a5a050fb832a76d166733da17dca4f76e0e3a7966aed40ad57a -assets/icons/apple-touch-icon.png,1748572479750,69f1be179ce863d8f1cd53c544d0bbfa2e75b8fb91bc6c46c2c79f24e553cc6a -assets/icons/cm-rect-320X132.png,1748572479750,4acaf18c9fa8d964e0a8e373b9f536acb4829d6010f010d99515915e94ba98b6 -assets/icons/android-chrome-192x192.png,1748572479750,7a3c2852d7d4a2ba28a46ed9f11eff9a0cbd8561dc42a5dff057a1a5e0423e75 -assets/icons/favicon.ico,1748572479741,178c96afd2edf1e231b8d3a09e6a4af3325d17dc8a9e47bc432977610f278f28 -assets/icons/android-chrome-512x512.png,1748572479750,4a926301c700ddfe7d4687e4510d20e85504090efc5caeba27ff9b1b8a3f44f8 -assets/icons/cm-rect-320_132.jpg,1748572479749,81d5239eff004740e648ca3c8b942ebccfb90ec570fb3653a44c2883534feac1 -chunk-UORSHSI3.js,1748572479733,c18b760eddcf83227b0088114fb630c274545d055161a2bcf0fb2f9d4eed8f68 -main-UABJFXNS.js,1748572479728,e8ffa4e152c558006b975819a4ec1f3ceda00629ea8bd99b9cc7fc52ef46c719 -chunk-ROD7CKOQ.js,1748572479734,abae34e8c4bbad1910e82abc16f574c6a5dc8779ac3508bbc0e6830594d7d604 -assets/images/backgrounds/night.webp,1748572479739,8f34835ea8f6edc7a9bd75e311c9c1151db34ce298113f34790ad02110f7ecec -assets/images/overlays/cracked_corner.webp,1748572479738,6744ea79435170154610db67b14f4dc96452022917d5c8238324dd2f12a8df3e -assets/icons/custom/system/weather.svg,1748572479742,4e06cec7930f015e8ec91c5810e6575abcd86878af36b1e6360115a03c236456 -assets/icons/custom/system/safari.svg,1748572479743,b6399638755d8df2673ec628ac4ec2630bb8e7e2f8065e2fb657d7ecf9eba39c -assets/icons/custom/system/photos.svg,1748572479743,62d0ac0f8ae434bbe612c341c5ae893fb8f5c8640d553646b7285c72b4983908 -assets/icons/custom/system/phone.svg,1748572479743,8b82224a2bc8cbdaa2b4fd2bb1c89345b4974498a4e8b2ab54190a71145aa3a3 -assets/icons/custom/system/mail.svg,1748572479744,d61f13a08c0df8f47649cfbb385d0598f6758036fc186728ddf3e341d13cf609 -assets/icons/custom/system/notes.svg,1748572479743,5ed1516f03c3c530892207832dd4e5721a1bbafb832a9485cdcb537e82d28482 -assets/icons/custom/system/itunes.svg,1748572479744,c95c71399e16d3e1c1175e5b56f4082560d2eaa54f2a27e2c67facaee43a6f0a -assets/icons/custom/system/gif.svg,1748572479744,99bc4100505b7ec5207484c6a6ab58ec68e30dc41256aa5041b7e77cb5e825b6 -assets/icons/custom/system/imessage.svg,1748572479744,462c33b9374830efb9501285cf3aa3466c756681c0e1dca7961976ba72ab111a -assets/icons/custom/system/clock.svg,1748572479745,263230d2e0681eff0fab2ad9ed1d10c3b85d27c29a262f82fa13aa6c52821fce -assets/icons/custom/system/camera.svg,1748572479745,5f0069f2f83de435958b13da81f621ffd800a02f44aaa17eec466ac6ac9648ef -assets/icons/custom/system/calendar.svg,1748572479745,7650e279882307a4a52fb1028ee90015dd81eec9ed0d9b4da4cd7edf9060ee65 -assets/icons/custom/system/calculator.svg,1748572479745,f858554fdcc3a0e54301d59bf56e18014bd294ef285cdc72f9ab2af2d4c1c138 -assets/icons/custom/filetypes/zip.svg,1748572479746,57679a088049582c9e995ea9d23186144d0f7242b61ccc362920ebd0a830f9f3 -assets/icons/custom/filetypes/txt.svg,1748572479746,cf9fd38dff3bc1558108521feb99888d6ff41f3591910460d28e3ff2c11bf183 -assets/icons/custom/filetypes/xls.svg,1748572479746,cdb36e073ca4ac8d98611cd4e81a2ca9c6565ed03bc616af19920ce3d3dc3c9f -assets/icons/custom/filetypes/svg.svg,1748572479747,43743525967e95b45d77f1b0a4ccaadfac1de43f082cc7ebf125bedbbc1091a5 -assets/icons/custom/filetypes/ppt.svg,1748572479747,bb6e10a985dcac1a9e0f0f130ee35b1286d94ea42f1392c77699504bc73be169 -assets/icons/custom/filetypes/png.svg,1748572479747,916c7997f077d27f89e758262740fe6bea2fe93ec9214bfdefc4211ec940c0bc -assets/icons/custom/filetypes/rar.svg,1748572479747,1310c8f408b4a3fcaaf393a55ef6f5292eabb0ce9bbaac0e890a6dc5901fca0c -assets/icons/custom/filetypes/pdf.svg,1748572479748,683cde37b87dda6acb8c871992111d5194cd29aa6b20cb3aeb2baf8d2e111ed9 -assets/icons/custom/filetypes/mp3.svg,1748572479748,8cf7540423a91b659a28d62036227879dd4b9422e722d8f65c05db107aebbb55 -assets/icons/custom/filetypes/mov.svg,1748572479748,6404455d3c88dd0ce68ec7f42bf29aa9e6e628f226940bd20cfe0b2a7de695e1 -chunk-YP32UGNH.js,1748572479730,b09a94bbc67c3b5ceb91b0ba42757c755a597b996b4d984ef693dce7351309b8 -assets/icons/custom/filetypes/jpg.svg,1748572479748,fbf5ca14b64eb9448a06e5615b9a4dc266df969262f306eccbb48dd8710f41ff -assets/icons/custom/filetypes/htm.svg,1748572479748,5ff44e456dc5efa6e375a796cd88159f4a7be3c379bfd6e0ed16f1802f817f97 -assets/icons/custom/filetypes/gif.svg,1748572479749,a214f5bdda315c3970e0e0bee1baab13fa0aa5a740d210dc2ebde99a19cb6198 -assets/icons/custom/filetypes/doc.svg,1748572479749,5e2e01abf6aff5084726023a9020984aa174e804a41a75b773282def3240cc7a -assets/icons/custom/filetypes/css.svg,1748572479749,fbeaa48d0bd23c234793ba8f646763621fceec341d42b758f22a355b31a391d9 -assets/icons/custom/filetypes/avi.svg,1748572479749,311552c79de10828cd02d9243360795a7da07084b927c353651cdded7ac8c6d1 -assets/game/levels/level-4.json,1748572479751,8407fbd53874f356c2372c1368cfca333a071867bd249110fa91f78719a55b85 -assets/game/levels/level-3.json,1748572479751,8407fbd53874f356c2372c1368cfca333a071867bd249110fa91f78719a55b85 -assets/game/levels/level-2.json,1748572479751,b0b51f4b1b3b4c05f74c149f3b72f30e7042b4e1396201fdb96f771423f286b0 -assets/game/levels/level-1.json,1748572479751,a04ee7432f0db2a24775907b51bfbf28364b2da5210da194b5ecf1bb4fcedbd0 -assets/game/commands/level-4.commands.ts,1748572479752,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 -assets/game/commands/level-3.commands.ts,1748572479752,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 -assets/game/commands/level-2.commands.ts,1748572479752,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 -assets/images/backgrounds/night.jpg,1748572479739,0318dcbc1b3de1d390d30c70909d36a15e6befba0a9b6a2c4ca118ba482ff53e -assets/game/commands/level-1.commands.ts,1748572479752,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 -assets/docs/tooltip.doc.md,1748572479753,58bd9d691eb866e54db725c1d42751fae00df9f163e0f02ebb50202716fe2fb3 -assets/docs/gameplay.doc.md,1748572479753,206eebe2a53128796afdc3abae60503abf548653461500d5e7d134da301223b2 -assets/docs/colinos-demo.doc.md,1748572479753,42b83da2243d95a17996f8a4cdca7a46d5f7f1274a66861b53c12b521c23866a -assets/docs/cipher.md,1748572479753,9837101837279dd8b9c5b794236e1572c086cd6de70346a8792b135ad07ca7dd -assets/config/game-levels.json,1748572479754,b780536a8230944ac695522d8943a148faa5145dd4612c401794c0129b9166b4 -assets/audio/efx/tada.mp3,1748572479771,7315f1ff418743154af5c6e178c6394d0a14c2b3dcf07b292300947f68bd1601 -assets/audio/efx/glitch-2.mp3,1748572479773,9ae0a5e5e2abceb20245864c4f4a9f18442c88031a8e31d283d8289d7609aeba -assets/audio/efx/glitch-3.mp3,1748572479772,b6985b6593afca9f2cfa7efd1b5d1bcf26f773c2d5c3efeb14d285ba55ea13de -assets/audio/efx/glitch-4.mp3,1748572479772,14f7a834fcbf7d36014f1dd3ef37e679267d49cd759561dd1196e170967923fa -assets/audio/efx/glitch-1.mp3,1748572479773,dd4af4ef693dd863a4dcb3cf84b6bb94aa1a43e225407c944c4e2739a391dae5 -assets/audio/efx/drum-5.mp3,1748572479773,1e98c4879c40c4a2b7e65ab6ba003a9d44896c067ac151db5d4434fefd24ebd8 -assets/audio/efx/digital-beep-2.mp3,1748572479775,2e22c97a131ea52eb92a1093fd3b4e6f8bf75a959f894b5f2a7eeb206bbedc7e -assets/audio/efx/digital-beep-1.mp3,1748572479775,3bbe90bc19f11e5e0bdd1b2f0cdd228556f8b764a8a0331494bffc79a3a781fe -assets/audio/efx/click-2.mp3,1748572479776,5bfca51e3998410c2f589b900257be323896d9f14baa4de677ad35f40b5dd55d -assets/audio/efx/click-1.mp3,1748572479776,d38579cf84b3ee4bf978099b9e4ea97f58df6bc3dc0313edd6bd92aeb44af81a -chunk-KGNW53ZE.js,1748572479735,3be20408682bad947b2dde88205dbfa6df1485a0759f7761e8329f88ab2e6deb -assets/audio/efx/startup.mp3,1748572479771,ffb2fe1ec722ed91745826198ed886bd1fcbe681f53c70c8a6d1b3e3995df5eb -assets/audio/efx/drum-4.mp3,1748572479774,ac109de6eebae5a0c9e1254d2d6572efde47332571698a922c99608656d2bb6a -assets/audio/efx/drum-3.mp3,1748572479774,67f1a984344de0e4e058404e2e492061144cabda5b45b5e409bd2b8036b0a31b -assets/audio/efx/drum-1.mp3,1748572479774,493c442755378fb423af30f0ed426c69c0b7b6e9e77ead49d1748bc6f273da18 -assets/audio/efx/drum-2.mp3,1748572479774,94b4a2c246a88d9dd77c47057a2918b46d2fc97fd8a4f356c1a7fdb2b2391ff9 -assets/audio/efx/wawa.mp3,1748572479771,be58f038fcef57ef69a5faf7149efd44292a846ef48624309d709506bf45bc40 -assets/audio/efx/response_good.mp3,1748572479772,86c003eca9769ff5517711833b7e7a7af7c4855d1f5e07877e0ac5da02001285 -assets/audio/efx/response_bad.mp3,1748572479772,51337646c0a4a84f8aadc727bec438ac99ee78c7da274e87084efe47a51ca880 -assets/audio/efx/count-beep.mp3,1748572479775,29a8856c1fb0a841a034ddfd39af24317a9ac1764b9ce428c1e767c10a97a453 -assets/audio/efx/feedback-glitch.mp3,1748572479773,260a8fecf314dd9a3307c2c091293de9b3e9d0aa6c6433395391ca4d4cee8015 -assets/audio/efx/click-3.mp3,1748572479775,45a55543d3324c253dc5911f04e44ef8b054f9186ee189b23b3e4a4b9acab5cc -assets/audio/efx/beep-warning.mp3,1748572479776,a472f7670641b352130b455b2aec042fd7f964965dd27ee44faeb2e48b685cf9 -chunk-Y4BUDMDQ.js,1748572479731,2db91255e385ef476204b807fedd18f7397178f45f05dfee9e373545c5d4994e -assets/audio/efx/bootup.mp3,1748572479776,4fac68d098db1957664d548202185786883148c0d2da3d033e479e855f164e1e -assets/images/backgrounds/day.webp,1748572479739,c51f3e78acb9db88bf1909818bd6b9690eba7de786ef6d7e0ffcb6b187248a18 -assets/audio/efx/ambient.mp3,1748572479777,23914af46bf99953fe8621cf57b1896c14add6afe688b68dfc49853e7e5a8e85 -assets/images/backgrounds/day.jpg,1748572479740,26a62ca72544e77d7e3762c10d84291d4335bde0660f81df0beffd396a0ee5e2 -chunk-PHZMN5UB.js,1748572479734,ac15286cfa9a30b2f6268e12daae2cfef5764d834bdf0701f19ca39eba0ce482 -assets/images/overlays/cracked_full.webp,1748572479738,10326386ca2a86528a771a6c6b015b1813b92b09bfb8fef1f50dfefd3967c3ac -assets/audio/music/game_loop_1.mp3,1748572479755,bb75cc68119b2d9bcd166c8634a8a6ca8e56077bd392d2d1647050db60a8be48 -styles-WHOFTCPY.css,1748572479711,d66886ad2159f8737767f74959562d8c28aed094fd048977361a10018173c0a8 -assets/audio/music/ambient_3.mp3,1748572479760,a3c3186f18c3cffd986fb1b9b7021442f9a8612b74e6fdeae8b649882ceea702 -assets/audio/music/ambient_4.mp3,1748572479758,485184f819943e4f817c22edcff2bdaa55bb8e376d3dab9199d2069522181b82 -assets/audio/music/ambient_1.mp3,1748572479770,ec4d8c9263bfbb162210576b1f123464f8b872adfb58e95a4b8fd86962f55848 -chunk-X5NZQJRD.js,1748572479732,9ab3e8d05fdf88774777022fd9a1edad2c3e6df1adf0d14fe71a7f37ba5775c4 -assets/audio/music/ambient_2.mp3,1748572479766,e023f2af13b83b61fa29d6facaf04deff94c7f03d4aed2c442f708732838cff8 +scripts-V4VO4MR6.js,1757187150841,38591f32e5f3ae992f35e023a4328f6e9aa886f35c5728b06ab3efbc447e09fa +polyfills-B6TNHZQ6.js,1757187150841,0d470d11ef45790298a0211b78859f53935f24a7d05c357a0bce142e2e12a925 +chunk-ZOCWA4ED.js,1757187150843,61afa67f588cfe3911964bf283317eee1965f705e413e1569c2749117c893e2a +chunk-XT5AIZD2.js,1757187150844,5ba05955a04ee2af46077458fa6bf7c512780f58bc006ef950858eceeada1e09 +chunk-YIQKWPT5.js,1757187150844,83b9c62878419c2e6e5173ea738110fc8307f9aa5278e8ac28eae52c0656e916 +chunk-RHXNB347.js,1757187150844,a393aa92bdaec7c0f77d3331cdbb4ca94667b77c301d18d98ebf8239837c9b57 +index.html,1757187150842,1ae941799f4c905034b306431178418a916ac0f1546ceff1701c0745a2ea1d5a +chunk-P3F2VVNI.js,1757187150846,2e7d39737627a4b105f82986eb7fa9361866bc8bd417ece8a4e2f58326d4f1fb +chunk-UZ5S3YEG.js,1757187150844,7671e73f1b8a8626cbe476791e2baecdda0fe33992c94577d5e11ffeb9835ae9 +chunk-LQKPBLYP.js,1757187150848,4c675ef993afe4208a239e64ef0d0a86f81aef569185ca0b1d03c44ede23d7e7 +favicon.ico,1757187150843,8f1fc0f94ae643bb8757c3cb49f69bf6cb2228f632cb77c7e47d8ee3995d88a7 +chunk-N7ZX7E3B.js,1757187150846,016b3107ed1e67ec40f326928d70f53bb9349a3ee7ce4609db1a892f378f3a06 +chunk-KIJZZIYW.js,1757187150848,787a0c53776139217e00a8e55b43975f5a6fed5414ab875c503ad69a5d284dbd +chunk-IK4IJ7P6.js,1757187150848,213b0350d9e2430ac36aeddd43e55040b8dc3943f2b25bbf8860895d8b61abe8 +chunk-GXSJH6RE.js,1757187150848,081ab3e765c00d542730c5e08155389a90141eef14fc88b534f9a816e596d46e +chunk-BNKGFPS2.js,1757187150850,a6b5de469c2a17567264ea79064a11b9fd047b44fad04ed7e7d9b743425fdb8e +chunk-6OJJVJXD.js,1757187150854,bbd47713d2eb4cedf60f18fa3f24a68756803f4a450d8de91b7f3b81aeac7c80 +chunk-6GSGN5NW.js,1757187150854,b90f40fb9ce0b0c69cd551dc909321ed0433fbab04ba1877ebf6dac60123b957 +chunk-54LBGYYD.js,1757187150854,224d045361c165356b02eb5fc0e5d59bb85ee8cb0dcba535d95b21e888e01ef5 +chunk-4VSPGYCZ.js,1757187150854,bb338a2753ea25b80e8533e441ae69656b859d478e13047ebfbdf03d3b951f57 +assets/files.json,1757187150871,f1c5c147a48e1507ad4e45d5da26441543c6969d92afef0a3fb2eeb42b2e3c9b +chunk-452WUMRF.js,1757187150854,c2bef2d3505dfe733b31cc742886e2682aed102b97c6888de8c0581a1fb92b52 +assets/browserconfig.xml,1757187150873,6d3864fc7eef2280f3c1149bf074e00ea8ade65feeae32d65ddaa1b5b80e8412 +assets/site.webmanifest,1757187150855,1070ac375a6fc0a08c7f761891ce7f24862dd5693be284dec2fde12e3df1368a +chunk-72CIPDHL.js,1757187150853,896179b7800f56a5aa47fafd4c329f05a0188c358dbb07499af8a93171d6774d +assets/icons/safari-pinned-tab.svg,1757187150858,65729c2333f30b56eae5e1d0bc0a8fa2e749d9348d7d640ad58ef196d366416b +assets/icons/mstile-70x70.png,1757187150859,f32880d2a737263aeb5d3ed9054f34939544ba8e05644cb85794b2096ff30075 +assets/icons/mstile-310x310.png,1757187150859,8a93720e944a5e58190b31552553edcdc7df5fdc50c47d2c14190ef64cef159a +assets/icons/mstile-310x150.png,1757187150859,44c484a4768aef97bb462d163649b71b0cc25fe62f39070c9c338ef587caa02e +assets/icons/mstile-150x150.png,1757187150859,44fc3c79497dd7fff2cce4f337a3e4b05ae3a794c3b34614e76076eafe3ce997 +assets/icons/favicon-32x32.png,1757187150860,78cba0e271812a5a050fb832a76d166733da17dca4f76e0e3a7966aed40ad57a +assets/icons/mstile-144x144.png,1757187150859,2a3d79be470997f72cae96f9fd14e7215f82b0e6c8e0ffa4411cddf1f966da59 +assets/icons/favicon.ico,1757187150859,178c96afd2edf1e231b8d3a09e6a4af3325d17dc8a9e47bc432977610f278f28 +main-555JKOLT.js,1757187150842,cb8a5923a68283baac087cd0d7f8568c63487426da1f4ba10ecd6204555ed520 +chunk-NXOWNF6Z.js,1757187150846,af58a7cc4f23515983aca27517a138bc2013cbce79e71daec40ff1915dc4c1e3 +assets/images/backgrounds/night.webp,1757187150857,8f34835ea8f6edc7a9bd75e311c9c1151db34ce298113f34790ad02110f7ecec +assets/images/overlays/cracked_corner.webp,1757187150856,6744ea79435170154610db67b14f4dc96452022917d5c8238324dd2f12a8df3e +assets/icons/favicon-16x16.png,1757187150860,58a3341209281a404cde68dd52f41f7056a76441b150e0b867b6023c00cc1581 +assets/icons/cm-rect-320X132.png,1757187150868,4acaf18c9fa8d964e0a8e373b9f536acb4829d6010f010d99515915e94ba98b6 +assets/icons/apple-touch-icon.png,1757187150868,69f1be179ce863d8f1cd53c544d0bbfa2e75b8fb91bc6c46c2c79f24e553cc6a +assets/icons/android-chrome-512x512.png,1757187150869,4a926301c700ddfe7d4687e4510d20e85504090efc5caeba27ff9b1b8a3f44f8 +assets/icons/android-chrome-192x192.png,1757187150869,7a3c2852d7d4a2ba28a46ed9f11eff9a0cbd8561dc42a5dff057a1a5e0423e75 +assets/icons/custom/system/weather.svg,1757187150861,4e06cec7930f015e8ec91c5810e6575abcd86878af36b1e6360115a03c236456 +assets/icons/custom/system/safari.svg,1757187150861,b6399638755d8df2673ec628ac4ec2630bb8e7e2f8065e2fb657d7ecf9eba39c +assets/icons/cm-rect-320_132.jpg,1757187150868,81d5239eff004740e648ca3c8b942ebccfb90ec570fb3653a44c2883534feac1 +assets/icons/custom/system/photos.svg,1757187150861,62d0ac0f8ae434bbe612c341c5ae893fb8f5c8640d553646b7285c72b4983908 +assets/icons/custom/system/phone.svg,1757187150861,8b82224a2bc8cbdaa2b4fd2bb1c89345b4974498a4e8b2ab54190a71145aa3a3 +assets/icons/custom/system/notes.svg,1757187150861,5ed1516f03c3c530892207832dd4e5721a1bbafb832a9485cdcb537e82d28482 +assets/icons/custom/system/mail.svg,1757187150862,d61f13a08c0df8f47649cfbb385d0598f6758036fc186728ddf3e341d13cf609 +assets/icons/custom/system/itunes.svg,1757187150862,c95c71399e16d3e1c1175e5b56f4082560d2eaa54f2a27e2c67facaee43a6f0a +assets/icons/custom/system/imessage.svg,1757187150862,462c33b9374830efb9501285cf3aa3466c756681c0e1dca7961976ba72ab111a +assets/icons/custom/system/gif.svg,1757187150862,99bc4100505b7ec5207484c6a6ab58ec68e30dc41256aa5041b7e77cb5e825b6 +assets/icons/custom/system/clock.svg,1757187150862,263230d2e0681eff0fab2ad9ed1d10c3b85d27c29a262f82fa13aa6c52821fce +assets/icons/custom/system/camera.svg,1757187150863,5f0069f2f83de435958b13da81f621ffd800a02f44aaa17eec466ac6ac9648ef +assets/icons/custom/system/calendar.svg,1757187150863,7650e279882307a4a52fb1028ee90015dd81eec9ed0d9b4da4cd7edf9060ee65 +assets/icons/custom/system/calculator.svg,1757187150863,f858554fdcc3a0e54301d59bf56e18014bd294ef285cdc72f9ab2af2d4c1c138 +assets/icons/custom/filetypes/zip.svg,1757187150863,57679a088049582c9e995ea9d23186144d0f7242b61ccc362920ebd0a830f9f3 +assets/icons/custom/filetypes/xls.svg,1757187150864,cdb36e073ca4ac8d98611cd4e81a2ca9c6565ed03bc616af19920ce3d3dc3c9f +assets/icons/custom/filetypes/txt.svg,1757187150864,cf9fd38dff3bc1558108521feb99888d6ff41f3591910460d28e3ff2c11bf183 +assets/icons/custom/filetypes/svg.svg,1757187150864,43743525967e95b45d77f1b0a4ccaadfac1de43f082cc7ebf125bedbbc1091a5 +assets/icons/custom/filetypes/rar.svg,1757187150864,1310c8f408b4a3fcaaf393a55ef6f5292eabb0ce9bbaac0e890a6dc5901fca0c +chunk-CMKSZQDC.js,1757187150849,5670a74340acbc0257cc5f446be25e15f9cc50770040caf6fc75e4b4d98e60af +assets/icons/custom/filetypes/ppt.svg,1757187150865,bb6e10a985dcac1a9e0f0f130ee35b1286d94ea42f1392c77699504bc73be169 +assets/icons/custom/filetypes/png.svg,1757187150865,916c7997f077d27f89e758262740fe6bea2fe93ec9214bfdefc4211ec940c0bc +chunk-C265U2LU.js,1757187150849,045d04745ddb0c128e249cb956bd27ce859edfa08a61031b8ec9514d28315f1f +assets/icons/custom/filetypes/pdf.svg,1757187150865,683cde37b87dda6acb8c871992111d5194cd29aa6b20cb3aeb2baf8d2e111ed9 +assets/icons/custom/filetypes/mp3.svg,1757187150866,8cf7540423a91b659a28d62036227879dd4b9422e722d8f65c05db107aebbb55 +assets/icons/custom/filetypes/mov.svg,1757187150866,6404455d3c88dd0ce68ec7f42bf29aa9e6e628f226940bd20cfe0b2a7de695e1 +assets/icons/custom/filetypes/jpg.svg,1757187150866,fbf5ca14b64eb9448a06e5615b9a4dc266df969262f306eccbb48dd8710f41ff +assets/icons/custom/filetypes/htm.svg,1757187150866,5ff44e456dc5efa6e375a796cd88159f4a7be3c379bfd6e0ed16f1802f817f97 +assets/icons/custom/filetypes/gif.svg,1757187150867,a214f5bdda315c3970e0e0bee1baab13fa0aa5a740d210dc2ebde99a19cb6198 +assets/icons/custom/filetypes/doc.svg,1757187150867,5e2e01abf6aff5084726023a9020984aa174e804a41a75b773282def3240cc7a +assets/icons/custom/filetypes/css.svg,1757187150867,fbeaa48d0bd23c234793ba8f646763621fceec341d42b758f22a355b31a391d9 +assets/images/backgrounds/night.jpg,1757187150857,0318dcbc1b3de1d390d30c70909d36a15e6befba0a9b6a2c4ca118ba482ff53e +assets/icons/custom/filetypes/avi.svg,1757187150867,311552c79de10828cd02d9243360795a7da07084b927c353651cdded7ac8c6d1 +assets/game/levels/level-4.json,1757187150869,8407fbd53874f356c2372c1368cfca333a071867bd249110fa91f78719a55b85 +chunk-BCXQU5SJ.js,1757187150850,53df1490c4cb5eb0208ec4ba210f64621dc9b539b1aa9d27c8d3eb7a78d193cb +assets/game/levels/level-3.json,1757187150870,8407fbd53874f356c2372c1368cfca333a071867bd249110fa91f78719a55b85 +assets/game/levels/level-2.json,1757187150870,b0b51f4b1b3b4c05f74c149f3b72f30e7042b4e1396201fdb96f771423f286b0 +assets/game/levels/level-1.json,1757187150870,a04ee7432f0db2a24775907b51bfbf28364b2da5210da194b5ecf1bb4fcedbd0 +assets/game/commands/level-4.commands.ts,1757187150871,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 +assets/game/commands/level-2.commands.ts,1757187150871,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 +assets/game/commands/level-3.commands.ts,1757187150871,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 +assets/game/commands/level-1.commands.ts,1757187150871,f66e88d478cf577e0de090f419de63ce031c9b101d61fa5b1e3c9a8848b89272 +assets/docs/tooltip.doc.md,1757187150872,58bd9d691eb866e54db725c1d42751fae00df9f163e0f02ebb50202716fe2fb3 +assets/docs/gameplay.doc.md,1757187150872,206eebe2a53128796afdc3abae60503abf548653461500d5e7d134da301223b2 +assets/docs/colinos-demo.doc.md,1757187150872,42b83da2243d95a17996f8a4cdca7a46d5f7f1274a66861b53c12b521c23866a +assets/config/game-levels.json,1757187150873,b780536a8230944ac695522d8943a148faa5145dd4612c401794c0129b9166b4 +chunk-Q42YD6Y3.js,1757187150845,9c69db1bc91fbef59182c89e6ee9afb048be5857d6613aa41d19d25858d22c91 +assets/docs/cipher.md,1757187150873,9837101837279dd8b9c5b794236e1572c086cd6de70346a8792b135ad07ca7dd +assets/audio/efx/tada.mp3,1757187150891,7315f1ff418743154af5c6e178c6394d0a14c2b3dcf07b292300947f68bd1601 +assets/audio/efx/glitch-3.mp3,1757187150892,b6985b6593afca9f2cfa7efd1b5d1bcf26f773c2d5c3efeb14d285ba55ea13de +assets/audio/efx/glitch-4.mp3,1757187150892,14f7a834fcbf7d36014f1dd3ef37e679267d49cd759561dd1196e170967923fa +assets/audio/efx/glitch-2.mp3,1757187150893,9ae0a5e5e2abceb20245864c4f4a9f18442c88031a8e31d283d8289d7609aeba +assets/audio/efx/glitch-1.mp3,1757187150893,dd4af4ef693dd863a4dcb3cf84b6bb94aa1a43e225407c944c4e2739a391dae5 +assets/audio/efx/drum-5.mp3,1757187150893,1e98c4879c40c4a2b7e65ab6ba003a9d44896c067ac151db5d4434fefd24ebd8 +assets/audio/efx/digital-beep-2.mp3,1757187150895,2e22c97a131ea52eb92a1093fd3b4e6f8bf75a959f894b5f2a7eeb206bbedc7e +assets/audio/efx/digital-beep-1.mp3,1757187150895,3bbe90bc19f11e5e0bdd1b2f0cdd228556f8b764a8a0331494bffc79a3a781fe +assets/audio/efx/startup.mp3,1757187150891,ffb2fe1ec722ed91745826198ed886bd1fcbe681f53c70c8a6d1b3e3995df5eb +assets/audio/efx/drum-4.mp3,1757187150894,ac109de6eebae5a0c9e1254d2d6572efde47332571698a922c99608656d2bb6a +assets/audio/efx/drum-3.mp3,1757187150894,67f1a984344de0e4e058404e2e492061144cabda5b45b5e409bd2b8036b0a31b +assets/audio/efx/drum-2.mp3,1757187150894,94b4a2c246a88d9dd77c47057a2918b46d2fc97fd8a4f356c1a7fdb2b2391ff9 +assets/audio/efx/drum-1.mp3,1757187150894,493c442755378fb423af30f0ed426c69c0b7b6e9e77ead49d1748bc6f273da18 +assets/audio/efx/click-1.mp3,1757187150896,d38579cf84b3ee4bf978099b9e4ea97f58df6bc3dc0313edd6bd92aeb44af81a +assets/audio/efx/click-2.mp3,1757187150896,5bfca51e3998410c2f589b900257be323896d9f14baa4de677ad35f40b5dd55d +assets/audio/efx/wawa.mp3,1757187150891,be58f038fcef57ef69a5faf7149efd44292a846ef48624309d709506bf45bc40 +assets/audio/efx/response_good.mp3,1757187150891,86c003eca9769ff5517711833b7e7a7af7c4855d1f5e07877e0ac5da02001285 +assets/audio/efx/response_bad.mp3,1757187150892,51337646c0a4a84f8aadc727bec438ac99ee78c7da274e87084efe47a51ca880 +assets/audio/efx/click-3.mp3,1757187150896,45a55543d3324c253dc5911f04e44ef8b054f9186ee189b23b3e4a4b9acab5cc +assets/audio/efx/feedback-glitch.mp3,1757187150893,260a8fecf314dd9a3307c2c091293de9b3e9d0aa6c6433395391ca4d4cee8015 +assets/audio/efx/count-beep.mp3,1757187150895,29a8856c1fb0a841a034ddfd39af24317a9ac1764b9ce428c1e767c10a97a453 +chunk-A74NXCP5.js,1757187150851,b242254c3ed9fbc7dc89570588ea97458f18e51428aa2a1b4b5b872e422f5c23 +assets/audio/efx/beep-warning.mp3,1757187150896,a472f7670641b352130b455b2aec042fd7f964965dd27ee44faeb2e48b685cf9 +chunk-PJL6QLWO.js,1757187150845,46ea222855f2204976749c991d6b257a9f4e845d38003f41068ffe1714434778 +assets/audio/efx/bootup.mp3,1757187150896,4fac68d098db1957664d548202185786883148c0d2da3d033e479e855f164e1e +assets/images/backgrounds/day.webp,1757187150857,c51f3e78acb9db88bf1909818bd6b9690eba7de786ef6d7e0ffcb6b187248a18 +assets/audio/efx/ambient.mp3,1757187150897,23914af46bf99953fe8621cf57b1896c14add6afe688b68dfc49853e7e5a8e85 +chunk-BAC7DBFE.js,1757187150851,e29c687c759822e48382cb44b157f23338d9535dd73737941d82023a7dd2569c +assets/images/backgrounds/day.jpg,1757187150858,26a62ca72544e77d7e3762c10d84291d4335bde0660f81df0beffd396a0ee5e2 +assets/images/overlays/cracked_full.webp,1757187150856,10326386ca2a86528a771a6c6b015b1813b92b09bfb8fef1f50dfefd3967c3ac +assets/audio/music/game_loop_1.mp3,1757187150874,bb75cc68119b2d9bcd166c8634a8a6ca8e56077bd392d2d1647050db60a8be48 +styles-YSYK66JW.css,1757187150828,95118d84f18919528657d1aad9e893a543ada6a958c769e18495865fef7238c9 +assets/audio/music/ambient_3.mp3,1757187150879,a3c3186f18c3cffd986fb1b9b7021442f9a8612b74e6fdeae8b649882ceea702 +assets/audio/music/ambient_4.mp3,1757187150877,485184f819943e4f817c22edcff2bdaa55bb8e376d3dab9199d2069522181b82 +assets/audio/music/ambient_1.mp3,1757187150890,ec4d8c9263bfbb162210576b1f123464f8b872adfb58e95a4b8fd86962f55848 +assets/audio/music/ambient_2.mp3,1757187150886,e023f2af13b83b61fa29d6facaf04deff94c7f03d4aed2c442f708732838cff8 +chunk-M5UIXMUN.js,1757187150847,c88252f3f4f3be0413c9d3c6fcc428ddf711b13048ac6d24da66f0a98c1b73f6 diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index b6aedde..88513dd 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -2,21 +2,88 @@ # https://github.com/firebase/firebase-tools name: Deploy to Firebase Hosting on merge + on: push: branches: - master + +permissions: + contents: read + jobs: build_and_deploy: runs-on: ubuntu-latest + environment: production + env: + APP_TITLE: ${{ vars.APP_TITLE || secrets.APP_TITLE }} + APP_API_URL: ${{ vars.APP_API_URL || vars.API_URL || secrets.APP_API_URL || secrets.API_URL }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || vars.OPENAI_API_KEY }} + OPEN_WEATHER_MAP_API_KEY: ${{ secrets.OPEN_WEATHER_MAP_API_KEY || vars.OPEN_WEATHER_MAP_API_KEY }} + FIREBASE_API_KEY: ${{ vars.FIREBASE_API_KEY || secrets.FIREBASE_API_KEY }} + FIREBASE_AUTH_DOMAIN: ${{ vars.FIREBASE_AUTH_DOMAIN || secrets.FIREBASE_AUTH_DOMAIN }} + FIREBASE_DATABASE_URL: ${{ vars.FIREBASE_DATABASE_URL || secrets.FIREBASE_DATABASE_URL }} + FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID }} + FIREBASE_STORAGE_BUCKET: ${{ vars.FIREBASE_STORAGE_BUCKET || secrets.FIREBASE_STORAGE_BUCKET }} + FIREBASE_MESSAGING_SENDER_ID: ${{ vars.FIREBASE_MESSAGING_SENDER_ID || secrets.FIREBASE_MESSAGING_SENDER_ID }} + FIREBASE_APP_ID: ${{ vars.FIREBASE_APP_ID || secrets.FIREBASE_APP_ID }} + FIREBASE_MEASUREMENT_ID: ${{ vars.FIREBASE_MEASUREMENT_ID || secrets.FIREBASE_MEASUREMENT_ID }} steps: - - uses: actions/checkout@v4 - - run: npm ci - - uses: FirebaseExtended/action-hosting-deploy@v0 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Validate required environment variables + run: | + missing=() + required=( + APP_TITLE + APP_API_URL + OPENAI_API_KEY + OPEN_WEATHER_MAP_API_KEY + FIREBASE_API_KEY + FIREBASE_AUTH_DOMAIN + FIREBASE_DATABASE_URL + FIREBASE_PROJECT_ID + FIREBASE_STORAGE_BUCKET + FIREBASE_MESSAGING_SENDER_ID + FIREBASE_APP_ID + FIREBASE_MEASUREMENT_ID + ) + + for name in "${required[@]}"; do + if [ -z "${!name}" ]; then + missing+=("$name") + fi + done + + if [ "${#missing[@]}" -gt 0 ]; then + echo "Missing required workflow environment variables:" + printf -- '- %s\n' "${missing[@]}" + echo "Check GitHub repository/environment Variables and Secrets configuration." + exit 1 + fi + + - name: Generate Angular environment files + run: npm run generate:env + + - name: Build Angular app + run: npm run build + + - name: Deploy to Firebase Hosting + uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: ${{ secrets.GITHUB_TOKEN }} firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_COLINMICHAELS }} channelId: live - projectId: colinmichaels + projectId: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID || 'colinmichaels' }} env: FIREBASE_CLI_EXPERIMENTS: webframeworks diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index ecb06ea..c6ad6a8 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -11,13 +11,63 @@ jobs: build_and_preview: if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest + environment: production + env: + APP_TITLE: ${{ vars.APP_TITLE || secrets.APP_TITLE }} + APP_API_URL: ${{ vars.APP_API_URL || vars.API_URL || secrets.APP_API_URL || secrets.API_URL }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || vars.OPENAI_API_KEY }} + OPEN_WEATHER_MAP_API_KEY: ${{ secrets.OPEN_WEATHER_MAP_API_KEY || vars.OPEN_WEATHER_MAP_API_KEY }} + FIREBASE_API_KEY: ${{ vars.FIREBASE_API_KEY || secrets.FIREBASE_API_KEY }} + FIREBASE_AUTH_DOMAIN: ${{ vars.FIREBASE_AUTH_DOMAIN || secrets.FIREBASE_AUTH_DOMAIN }} + FIREBASE_DATABASE_URL: ${{ vars.FIREBASE_DATABASE_URL || secrets.FIREBASE_DATABASE_URL }} + FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID }} + FIREBASE_STORAGE_BUCKET: ${{ vars.FIREBASE_STORAGE_BUCKET || secrets.FIREBASE_STORAGE_BUCKET }} + FIREBASE_MESSAGING_SENDER_ID: ${{ vars.FIREBASE_MESSAGING_SENDER_ID || secrets.FIREBASE_MESSAGING_SENDER_ID }} + FIREBASE_APP_ID: ${{ vars.FIREBASE_APP_ID || secrets.FIREBASE_APP_ID }} + FIREBASE_MEASUREMENT_ID: ${{ vars.FIREBASE_MEASUREMENT_ID || secrets.FIREBASE_MEASUREMENT_ID }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm - run: npm ci + - name: Validate required environment variables + run: | + missing=() + required=( + APP_TITLE + APP_API_URL + OPENAI_API_KEY + OPEN_WEATHER_MAP_API_KEY + FIREBASE_API_KEY + FIREBASE_AUTH_DOMAIN + FIREBASE_DATABASE_URL + FIREBASE_PROJECT_ID + FIREBASE_STORAGE_BUCKET + FIREBASE_MESSAGING_SENDER_ID + FIREBASE_APP_ID + FIREBASE_MEASUREMENT_ID + ) + + for name in "${required[@]}"; do + if [ -z "${!name}" ]; then + missing+=("$name") + fi + done + + if [ "${#missing[@]}" -gt 0 ]; then + echo "Missing required workflow environment variables:" + printf -- '- %s\n' "${missing[@]}" + echo "Check GitHub repository Variables/Secrets or PR secret availability." + exit 1 + fi + - run: npm run generate:env + - run: npm run build - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: ${{ secrets.GITHUB_TOKEN }} firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_COLINMICHAELS }} - projectId: colinmichaels + projectId: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID || 'colinmichaels' }} env: FIREBASE_CLI_EXPERIMENTS: webframeworks diff --git a/.github/workflows/firebase_deployment_workflow.yml b/.github/workflows/firebase_deployment_workflow.yml new file mode 100644 index 0000000..fbdcb31 --- /dev/null +++ b/.github/workflows/firebase_deployment_workflow.yml @@ -0,0 +1,84 @@ +name: Manual Firebase Deploy + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + env: + APP_TITLE: ${{ vars.APP_TITLE || secrets.APP_TITLE }} + APP_API_URL: ${{ vars.APP_API_URL || vars.API_URL || secrets.APP_API_URL || secrets.API_URL }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || vars.OPENAI_API_KEY }} + OPEN_WEATHER_MAP_API_KEY: ${{ secrets.OPEN_WEATHER_MAP_API_KEY || vars.OPEN_WEATHER_MAP_API_KEY }} + FIREBASE_API_KEY: ${{ vars.FIREBASE_API_KEY || secrets.FIREBASE_API_KEY }} + FIREBASE_AUTH_DOMAIN: ${{ vars.FIREBASE_AUTH_DOMAIN || secrets.FIREBASE_AUTH_DOMAIN }} + FIREBASE_DATABASE_URL: ${{ vars.FIREBASE_DATABASE_URL || secrets.FIREBASE_DATABASE_URL }} + FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID }} + FIREBASE_STORAGE_BUCKET: ${{ vars.FIREBASE_STORAGE_BUCKET || secrets.FIREBASE_STORAGE_BUCKET }} + FIREBASE_MESSAGING_SENDER_ID: ${{ vars.FIREBASE_MESSAGING_SENDER_ID || secrets.FIREBASE_MESSAGING_SENDER_ID }} + FIREBASE_APP_ID: ${{ vars.FIREBASE_APP_ID || secrets.FIREBASE_APP_ID }} + FIREBASE_MEASUREMENT_ID: ${{ vars.FIREBASE_MEASUREMENT_ID || secrets.FIREBASE_MEASUREMENT_ID }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Validate required environment variables + run: | + missing=() + required=( + APP_TITLE + APP_API_URL + OPENAI_API_KEY + OPEN_WEATHER_MAP_API_KEY + FIREBASE_API_KEY + FIREBASE_AUTH_DOMAIN + FIREBASE_DATABASE_URL + FIREBASE_PROJECT_ID + FIREBASE_STORAGE_BUCKET + FIREBASE_MESSAGING_SENDER_ID + FIREBASE_APP_ID + FIREBASE_MEASUREMENT_ID + ) + + for name in "${required[@]}"; do + if [ -z "${!name}" ]; then + missing+=("$name") + fi + done + + if [ "${#missing[@]}" -gt 0 ]; then + echo "Missing required workflow environment variables:" + printf -- '- %s\n' "${missing[@]}" + echo "Check GitHub repository/environment Variables and Secrets configuration." + exit 1 + fi + + - name: Generate Angular environment files + run: npm run generate:env + + - name: Build + run: npm run build + + - name: Deploy to Firebase + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_COLINMICHAELS || secrets.FIREBASE_SERVICE_ACCOUNT }} + channelId: live + projectId: ${{ vars.FIREBASE_PROJECT_ID || secrets.FIREBASE_PROJECT_ID || 'colinmichaels' }} + env: + FIREBASE_CLI_EXPERIMENTS: webframeworks diff --git a/.gitignore b/.gitignore index cf44da0..47821c1 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ Thumbs.db # Firebase local config .firebase/* -# env configs -environments/* +# Environment files +.env +src/environments/environment.local.ts +src/environments/.env.* +!src/environments/.env.example diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/angular.json b/angular.json index 7555a61..f1d76ae 100644 --- a/angular.json +++ b/angular.json @@ -143,8 +143,20 @@ ], "scripts": [] } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "src/**/*.ts", + "src/**/*.html" + ] + } } } } + }, + "cli": { + "analytics": false } } diff --git a/database.rules.json b/database.rules.json new file mode 100644 index 0000000..541d78f --- /dev/null +++ b/database.rules.json @@ -0,0 +1,20 @@ +{ + "rules": { + ".read": "auth != null", + ".write": "auth != null", + "users": { + "$uid": { + ".read": "$uid === auth.uid", + ".write": "$uid === auth.uid" + } + }, + "logs": { + ".read": "auth != null", + ".write": "auth != null" + }, + "public": { + ".read": true, + ".write": "auth != null" + } + } +} diff --git a/docs/AGENT_PROMPT.md b/docs/AGENT_PROMPT.md new file mode 100644 index 0000000..5977e80 --- /dev/null +++ b/docs/AGENT_PROMPT.md @@ -0,0 +1,140 @@ +You are a senior Staff-level Angular + TypeScript engineer and security-minded reviewer. + +Repository context: + +- This is an Angular project recreating a Mac OS desktop experience in a web browser using Tailwind CSS. +- Priorities: maintainability, clarity, performance, and safe-by-default patterns. +- Keep behavior/UI/UX stable unless you are fixing a clear bug. + +Primary goal: +Do a comprehensive review of the codebase and clean it up: organize, optimize, remove tech debt, align with industry standards, and improve safety/security. + +Non-goals / Constraints: + +- Do NOT do major framework upgrades (Angular major version, Tailwind major version) unless absolutely necessary; if you believe it’s necessary, document the rationale and stop before applying it. +- Avoid adding new dependencies unless there’s a strong justification (lint/format/test tooling is allowed if the repo is missing basics, but prefer using existing tooling). +- Keep refactors incremental and reviewable (small cohesive commits if possible). +- Do not delete functionality; deprecate or isolate if needed. +- If you are uncertain whether a change alters runtime behavior, do NOT guess—document the uncertainty and propose a safer alternative. + +Workflow (follow in order): + +1) Baseline discovery (no code changes yet) + +- Read and summarize: package.json scripts, angular.json, tsconfig*, tailwind config, and overall folder layout. +- Identify how the app is structured (apps/windows/overlays/app manager/etc). +- Identify quality gates that exist already (lint, format, tests, build). +- If tool execution is available, run the project’s existing commands (in this order): install (npm ci or npm install), lint, unit tests, build. Record outputs. + +2) Produce an “Audit & Plan” before refactoring + Create a short plan (bulleted) and categorize findings: + +- Quick wins (mechanical / low risk) +- Medium refactors (worth it but need care) +- Larger changes (risky; propose but do not implement unless clearly safe) + For each item: describe the problem, proposed fix, risk level, and how you’ll validate it. + +Pay special attention to these services (review them early and document issues/patterns): + +- sound.service.ts +- user.service.ts +- overlay.service.ts +- cli.service.ts +- typewriter.service.ts +- settings.service.ts +- application-manager.service.ts +- media.service.ts +- storage.service.ts +- file-system.service.ts +- game-config.service.ts + +3) Implement improvements (in small, safe steps) + Apply the plan with an emphasis on: + Code quality & architecture: + +- Consistent naming, folder structure, and separation of concerns. +- Reduce circular dependencies and “god services”. +- Prefer explicit interfaces/types over `any`. +- Improve error handling; remove noisy logs; standardize logging if present. + Angular & RxJS best practices: +- Ensure subscriptions don’t leak (use takeUntilDestroyed if available; otherwise a consistent teardown pattern). +- Avoid nested subscriptions; prefer pipeable operators; handle errors. +- Avoid direct DOM manipulation; use Angular patterns (Renderer2, sanitization where appropriate). + Performance: +- Avoid heavy synchronous work on the UI thread; debounce/throttle where appropriate. +- Ensure Tailwind usage is efficient (remove unused classes/duplicates if reasonable). + Security / safety review: +- Check for XSS vectors (innerHTML, bypassSecurityTrust*, DOM insertion). +- Validate/sanitize any user-controlled inputs (e.g., CLI commands, filenames, “paths”, storage keys). +- Ensure localStorage/sessionStorage usage is safe and scoped; avoid storing secrets. +- Run npm audit if available; do NOT blindly “audit fix” if it risks breaking builds—document what you would change. + +Validation: + +- After each meaningful change, rerun the relevant checks (lint/tests/build). +- If tests are missing for critical logic, add a minimal set for the riskiest modules/services (prioritize CLI parsing, file-system/storage behaviors, and application/window manager state logic). + +4) Create documentation under /docs (required output) + Create /docs at the project root (if it doesn’t exist), and organize it into these subfolders: + +- /docs/ARCHITECTURE +- /docs/README +- /docs/TODOS +- /docs/FUTURE_FEATURES + (If the repository already uses the misspellings “ARCHETECTURE” or “FURTURE_FFEATURES”, match the existing convention, but prefer corrected names for new repos.) + +All docs must be Markdown. Create at minimum: + +/docs/README/INDEX.md + +- A hub that links to every doc below. + +/docs/README/PROJECT_OVERVIEW.md + +- What this project is, the user experience it recreates, key features. +- Tech stack summary (Angular + Tailwind + notable libs). +- Folder/module map and where to start reading the code. + +/docs/README/DEVELOPMENT.md + +- Setup instructions, common scripts, how to run locally, how to build, how to test, troubleshooting tips. + +/docs/ARCHITECTURE/OVERVIEW.md + +- System overview and major subsystems (desktop, windows/apps, overlays, settings, storage, media/sound, CLI). +- Include a Mermaid diagram for high-level architecture (components/services and relationships). + +/docs/ARCHITECTURE/SERVICES.md + +- A section for each key service listed above: + - Responsibility + - Key methods/events/observables + - Dependencies (what it calls / what calls it) + - Risks/footguns + - Recommended improvements (and what you implemented) + +/docs/ARCHITECTURE/STATE_EVENTS.md + +- Explain state management approach, event flows, window/app lifecycle, persistence strategy, and how overlays interact with apps/windows. + +/docs/ARCHITECTURE/SECURITY.md + +- Threat model relevant to this app (XSS, injection via CLI, unsafe storage, supply chain). +- Findings + mitigations you applied; remaining risks. + +/docs/TODOS/TECH_DEBT.md + +- A prioritized checklist of remaining tech debt with impact + effort estimates (S/M/L) and suggested order of operations. + +/docs/FUTURE_FEATURES/ROADMAP.md + +- A realistic roadmap: short-term enhancements, medium-term refactors, long-term features. +- Call out dependencies/risks for each. + +5) Final output (what you tell me) + When finished, respond with: + +- A concise summary of what you changed and why (grouped by category). +- Commands you ran + results (lint/tests/build/audit). +- A “review checklist” for me to validate changes quickly. +- Any follow-up items you intentionally did not change (and why). diff --git a/docs/ARCHITECTURE/OVERVIEW.md b/docs/ARCHITECTURE/OVERVIEW.md new file mode 100644 index 0000000..648e644 --- /dev/null +++ b/docs/ARCHITECTURE/OVERVIEW.md @@ -0,0 +1,65 @@ +# Architecture Overview + +## Runtime Shape + +The app uses Angular standalone components with route-driven screens and service-centric state. + +- Router controls entry screens (home, login, desktop, boot, sleep). +- Desktop screen coordinates window lifecycle and system UI. +- Services hold long-lived state (apps, user, settings, storage, CLI, sound, files, notifications). +- Dynamic component loading is used for in-window apps. + +## Major Subsystems + +- Desktop shell: + desktop surface, tray, dock, route params, context menu. +- Window/app manager: + app registry, app launch, focus, close, persisted open apps. +- CLI gameplay: + command execution, typewriter output, user/level progression. +- Persistence: + settings/user/tasks/patches through storage strategy. +- Media/audio: + icon/media helpers, sound playback, music and effects. +- Overlay and notifications: + global overlays and in-app notification stream. + +## High-Level Diagram + +```mermaid +graph TD + A[AppComponent] --> B[Router] + B --> C[DesktopComponent] + B --> D[Login and Boot Screens] + + C --> E[ApplicationManagerService] + E --> F[AppWindowComponent Dynamic Apps] + E --> G[Dock and SystemTray] + + C --> H[LevelLoaderComponent] + H --> I[GameConfigService] + + F --> J[CliGameComponent] + J --> K[CLIService] + K --> I + K --> L[UserService] + + J --> M[TypewriterService] + M --> N[SoundService] + + L --> O[SettingsService] + O --> P[StorageService] + + C --> Q[OverlayService] + C --> R[NotificationService] + R --> S[NotificationServerComponent] + + G --> T[FileSystemService] +``` + +## Design Notes + +- This codebase favors behavior in services over local component state. +- The primary maintainability pressure points are large services with mixed responsibilities and untyped dynamic data flows. +- Behavior stability depends heavily on preserving service public APIs while tightening internals. + diff --git a/docs/ARCHITECTURE/SECURITY.md b/docs/ARCHITECTURE/SECURITY.md new file mode 100644 index 0000000..9205c2a --- /dev/null +++ b/docs/ARCHITECTURE/SECURITY.md @@ -0,0 +1,59 @@ +# Security Notes + +## Threat Model (Relevant to This App) + +- XSS via dynamic HTML rendering (`innerHTML`, dynamic tooltip/notification content, terminal output). +- Open redirect or unsafe external navigation from route-driven URL values. +- Client-side secret exposure (API keys in browser bundle/environment files). +- Unsafe parsing and persistence of local storage data. +- Supply-chain risk from dependency drift and failing quality gates. + +## Audit Findings + +## 1) XSS Surface + +Current sinks include: + +- CLI output rendering with `[innerHTML]` +- notification message rendering with `[innerHTML]` +- tooltip text rendering with `[innerHTML]` +- raw `innerHTML` writes in settings subpanel fallback +- SVG trust bypass via `bypassSecurityTrustHtml` + +Risk: +user-controlled or remotely controlled strings could execute markup/script payloads if not constrained. + +## 2) External URL Handling + +- Redirect guard opens decoded route param in a new tab with no allowlist. + +Risk: +malicious URLs or script schemes can be triggered by crafted routes. + +## 3) Secrets in Client + +- OpenAI and weather API keys are configured for client-side use. + +Risk: +keys are recoverable from browser context and can be abused. + +## 4) Storage Trust + +- App/session state read from local storage without robust schema validation. + +Risk: +tampered storage payloads can produce runtime errors or unintended behavior. + +## Recommended Mitigations (Planned) + +1. Replace unsafe HTML sinks with safe rendering primitives and explicit formatting tokens. +2. Validate external URLs with strict scheme/domain checks before `window.open`. +3. Move third-party API calls requiring secrets behind a backend proxy/function. +4. Add schema guards for storage rehydration and fail-safe defaults. +5. Reduce `bypassSecurityTrust*` usage to controlled, immutable asset paths only. + +## Operational Notes + +- Firebase database rules are present; keep auth checks strict and avoid widening `.read` scopes. +- Use supported Node LTS for reproducible builds and security patch coverage. + diff --git a/docs/ARCHITECTURE/SERVICES.md b/docs/ARCHITECTURE/SERVICES.md new file mode 100644 index 0000000..48d2711 --- /dev/null +++ b/docs/ARCHITECTURE/SERVICES.md @@ -0,0 +1,147 @@ +# Core Services + +This section focuses on the key game/runtime services prioritized in the cleanup audit. + +## `sound.service.ts` + +- Responsibility: + effect audio preload/playback/mute and variant pools. +- Dependencies: + `SettingsService`, `PatchService`, `LogService`, sound config token. +- Called by: + desktop flow, CLI/typewriter, login, intro overlays. +- Current risks: + service `OnInit` lifecycle not invoked by Angular DI, weak filename sanitization, cache key inconsistency. +- Planned cleanup: + move init to explicit constructor/init method, harden filename allowlist, normalize cache keys. + +## `user.service.ts` + +- Responsibility: + user profile state and persistence bridge. +- Dependencies: + `SettingsService`, `LogService`. +- Called by: + login, desktop, CLI, settings, game config, tray. +- Current risks: + `previousUserSubject` not updated, unnecessary async wrapper around sync settings call. +- Planned cleanup: + simplify update path, ensure previous/current snapshots are coherent. + +## `overlay.service.ts` + +- Responsibility: + global overlay image visibility/state. +- Dependencies: + none. +- Called by: + desktop and overlay component. +- Current risks: + temporary overlay timeout races and no timeout tracking. +- Planned cleanup: + track/cancel previous timers and provide deterministic hide behavior. + +## `cli.service.ts` + +- Responsibility: + command registry and command execution. +- Dependencies: + `GameConfigService`, `UserService`. +- Called by: + CLI game component. +- Current risks: + auth bug in `su` command (`isAuthorized` not invoked), direct `localStorage` reads, weak input validation. +- Planned cleanup: + fix auth branch, route identity through `UserService`, validate command parameters. + +## `typewriter.service.ts` + +- Responsibility: + queued typed output with mode-dependent sound behavior. +- Dependencies: + `SoundService`, `UserService`. +- Called by: + CLI, desktop intro, home terminal. +- Current risks: + loose typings (`any`), timer lifecycle concerns, `onBegin` called per char instead of per line. +- Planned cleanup: + strict event payload types, line-level hook semantics, safer timer teardown. + +## `settings.service.ts` + +- Responsibility: + register/get/update single settings and setting sets, form sync. +- Dependencies: + `StorageService`, `NotificationService`. +- Called by: + user, weather, sound player, appearance panel, patch/music features. +- Current risks: + broad `any` typing, untracked subscriptions, nested persistence flows. +- Planned cleanup: + type-safe setting models, explicit subscription lifecycle, flatten async logic. + +## `application-manager.service.ts` + +- Responsibility: + app registry, launch/close/focus, memory checks, persistence of open apps. +- Dependencies: + `ApplicationFactory`, `NotificationService`, `LogService`. +- Called by: + desktop, dock, tray, app window template, activity monitor, CLI. +- Current risks: + very large mixed-responsibility service, unsafe `localStorage` JSON parse, fragile instance counting. +- Planned cleanup: + extract persistence/registry helpers, guard JSON parse, fix instance limit accounting. + +## `media.service.ts` + +- Responsibility: + media item helpers and basic preload behavior. +- Dependencies: + none. +- Called by: + notification/media rendering pathways. +- Current risks: + weak typing around icon/svg data and inconsistent factory outputs. +- Planned cleanup: + normalize `MediaItem` factory return types and tighten icon interfaces. + +## `storage.service.ts` + +- Responsibility: + persistence abstraction (`IndexedDB` first, localStorage fallback). +- Dependencies: + browser storage APIs. +- Called by: + settings, tasks, patch editor. +- Current risks: + `getAllKeys()` bypasses strategy and always reads localStorage. +- Planned cleanup: + add strategy-level key enumeration and align behavior across storage backends. + +## `file-system.service.ts` + +- Responsibility: + virtual file tree, path navigation, finder data/view modes. +- Dependencies: + `HttpClient`, faker. +- Called by: + finder UI and tray view mode controls. +- Current risks: + startup faker generation cost, nondeterministic tree shape, duplicate favorites. +- Planned cleanup: + deterministic seed or static mock loading in prod, dedupe favorites, lazy/mock gate. + +## `game-config.service.ts` + +- Responsibility: + level loading, current level state, unlocked commands, log file content lookup. +- Dependencies: + `HttpClient`, `UserService`. +- Called by: + CLI service, level loader, CLI component. +- Current risks: + awkward `Promise>` API, unused parameters, fragile level load expectations. +- Planned cleanup: + return clean observables/promises (one async model), remove unused args, tighten level indexing. + diff --git a/docs/ARCHITECTURE/STATE_EVENTS.md b/docs/ARCHITECTURE/STATE_EVENTS.md new file mode 100644 index 0000000..bbdb10e --- /dev/null +++ b/docs/ARCHITECTURE/STATE_EVENTS.md @@ -0,0 +1,49 @@ +# State and Event Flow + +## State Management Model + +This codebase uses service-local reactive state (mostly `BehaviorSubject`) instead of a central state library. + +- Long-lived state: + app registry/open apps/focus, user profile, settings, file system, notifications. +- Component-local state: + UI toggles, view pagination, current selected items. +- Persistence: + settings/user/tasks/patches via `StorageService`. + +## Core Event Flows + +## App and Window Lifecycle + +1. Desktop requests open app via `ApplicationManagerService.openApplication(id)`. +2. Manager validates registry, memory, and instance limits. +3. `ApplicationFactory` creates window instance metadata. +4. `AppWindowComponent` dynamically creates embedded component. +5. Focus updates reorder open apps list and tray state. +6. Open app list is persisted to local storage key `applications`. + +## CLI Command Flow + +1. User enters command in CLI app. +2. `CliGameComponent` normalizes input and invokes `CLIService.executeInput`. +3. Command output is routed to `TypewriterService`. +4. Follow-up actions can mutate user/level state through `UserService` and `GameConfigService`. + +## User and Settings Persistence Flow + +1. `UserService` registers/reads `user` setting set. +2. Updates flow through `SettingsService.updateSettingSet`. +3. `SettingsService` persists through `StorageService` strategy. + +## Overlay and Notification Flow + +1. Any producer calls `OverlayService` or `NotificationService`. +2. Overlay and notification renderer components subscribe and update UI. +3. Notifications auto-dismiss on timeout unless duration is zero. + +## Hotspots for Cleanup + +- Unbounded subscriptions in services/components that do not use `takeUntilDestroyed`. +- Dynamic window/component lifecycle depends on mutable shared objects. +- App persistence uses raw object snapshots and should be narrowed to safe fields. + diff --git a/docs/FUTURE_FEATURES/ROADMAP.md b/docs/FUTURE_FEATURES/ROADMAP.md new file mode 100644 index 0000000..08d3ff5 --- /dev/null +++ b/docs/FUTURE_FEATURES/ROADMAP.md @@ -0,0 +1,51 @@ +# Roadmap + +## Short-Term (1-2 Sprints) + +- Re-enable trustworthy quality gates (lint, real test execution, stable build environment). +- Fix high-risk correctness and security bugs identified in audit. +- Add targeted tests around CLI parsing/auth, app manager lifecycle, and storage rehydration. + +Dependencies: + +- Supported Node runtime for reproducible builds. +- Stable lint config path. + +Risks: + +- Existing script/config drift may hide real code issues until gates are repaired. + +## Medium-Term (2-4 Sprints) + +- Service decomposition: + split `ApplicationManagerService` and simplify `SettingsService`. +- Strong typing pass: + remove high-impact `any` usage in service contracts and dynamic payloads. +- Performance cleanup: + reduce startup random generation and avoid avoidable subscription churn. + +Dependencies: + +- Baseline tests in key services/components. + +Risks: + +- Refactor can affect runtime sequencing in desktop/window lifecycle if done too broadly. + +## Long-Term (4+ Sprints) + +- Security hardening: + migrate secret-bearing API calls to backend proxy. +- Rendering safety: + remove risky `innerHTML` patterns in terminal/tooltip/notification systems. +- Product evolution: + richer app ecosystem, saved desktop sessions, advanced window tiling/layout presets. + +Dependencies: + +- Backend support for proxy endpoints and auth/rate-limiting. + +Risks: + +- UI behavior drift during renderer hardening unless covered by tests and visual checks. + diff --git a/docs/README/DEVELOPMENT.md b/docs/README/DEVELOPMENT.md new file mode 100644 index 0000000..0ba783e --- /dev/null +++ b/docs/README/DEVELOPMENT.md @@ -0,0 +1,65 @@ +# Development + +## Prerequisites + +- Node.js LTS (recommended: Node 20 or Node 22) +- npm 10+ +- Chrome/Chromium for Karma tests + +Current environment note: Node `23.11.1` is unsupported by Angular 19 and currently causes unstable build behavior in this repository. + +## Install + +```bash +npm ci +``` + +## Environment Setup (Local) + +1. Copy `src/environments/.env.example` to `src/environments/.env.local`. +2. Fill in your local values. +3. Keep `src/environments/.env.local` and `src/environments/environment.local.ts` uncommitted. + +## Run Locally + +```bash +npm start +``` + +## Build + +```bash +npm run build +``` + +## Test + +```bash +npm run test -- --watch=false --browsers=ChromeHeadless +``` + +## Lint + +```bash +npm run lint +``` + +## Current Quality Gate Status (Audit Baseline) + +- `npm ci`: not re-run in this pass (existing `node_modules` reused) +- `npm run lint`: runs and reports real issues (`372` current errors) +- `npm run test -- --watch=false --browsers=ChromeHeadless`: passing (`88/88`) +- `npm run build`: still aborts early under unsupported Node `23.11.1` + +## Recommended Local Tooling Alignment + +1. Use Node `22` (or any version matching `package.json#engines`). +2. Run `nvm use` (project now includes `.nvmrc`). +3. Continue reducing lint backlog from current baseline. + +## Troubleshooting + +- If test fails with port binding in sandboxed environments, run with elevated permissions or locally in non-sandbox shell. +- If Angular CLI prompts for analytics in CI/local automation, set: + - `NG_CLI_ANALYTICS=false` +- If build crashes with memory allocator errors, switch to supported LTS Node first before code-level debugging. diff --git a/docs/README/ENVIRONMENT_SECRETS.md b/docs/README/ENVIRONMENT_SECRETS.md new file mode 100644 index 0000000..df55a6f --- /dev/null +++ b/docs/README/ENVIRONMENT_SECRETS.md @@ -0,0 +1,61 @@ +# Environment and Secrets Setup (GitHub Actions) + +This project builds Angular environment files during CI from GitHub Actions settings, not from committed local secrets. + +## Required GitHub Variables + +Add these under: `Settings -> Secrets and variables -> Actions -> Variables` + +| Name | Description | Example Value | +| --- | --- | --- | +| `APP_TITLE` | App title shown in the UI. | `Colin Michaels - Production` | +| `APP_API_URL` | Backend API base URL used by the frontend. | `https://api.example.com` | +| `FIREBASE_API_KEY` | Firebase Web API key from Firebase project settings. | `example_firebase_web_api_key` | +| `FIREBASE_AUTH_DOMAIN` | Firebase Auth domain for the project. | `your-project.firebaseapp.com` | +| `FIREBASE_DATABASE_URL` | Firebase Realtime Database URL. | `https://your-project-default-rtdb.firebaseio.com/` | +| `FIREBASE_PROJECT_ID` | Firebase project id used by SDK and deploy. | `your-project` | +| `FIREBASE_STORAGE_BUCKET` | Firebase storage bucket host. | `your-project.firebasestorage.app` | +| `FIREBASE_MESSAGING_SENDER_ID` | Firebase messaging sender id. | `123456789012` | +| `FIREBASE_APP_ID` | Firebase web app id. | `1:123456789012:web:abcdef1234567890` | +| `FIREBASE_MEASUREMENT_ID` | GA4 measurement id for Firebase Analytics. | `G-ABCDEFGH12` | + +## Required GitHub Secrets + +Add these under: `Settings -> Secrets and variables -> Actions -> Secrets` + +| Name | Description | Example Value | +| --- | --- | --- | +| `OPENAI_API_KEY` | API key used by the AI chat service. | `example_openai_api_key_value` | +| `OPEN_WEATHER_MAP_API_KEY` | API key used by weather data calls. | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | +| `FIREBASE_SERVICE_ACCOUNT_COLINMICHAELS` | Firebase service account JSON used by GitHub Action deploy. | `{"type":"service_account","project_id":"your-project",...}` | + +## Optional Compatibility Keys + +Workflows support fallbacks for legacy names: + +- `API_URL` (legacy fallback for `APP_API_URL`) +- `APP_TITLE`/`APP_API_URL` can be provided as either Variables or Secrets +- `FIREBASE_SERVICE_ACCOUNT` can be used as fallback for manual deploy workflow + +## Local Development Files + +- Keep local-only values in ignored files: + - `src/environments/environment.local.ts` + - `src/environments/.env.local` +- Do not commit those files. +- Use `src/environments/.env.example` as the safe template. + +## CI Environment Generation + +CI runs: + +```bash +npm run generate:env +``` + +This script writes: + +- `src/environments/environment.ts` +- `src/environments/environment.prod.ts` + +Both are generated from CI environment values before `npm run build`. diff --git a/docs/README/INDEX.md b/docs/README/INDEX.md new file mode 100644 index 0000000..39bf00d --- /dev/null +++ b/docs/README/INDEX.md @@ -0,0 +1,21 @@ +# Documentation Index + +This folder is the entry point for project documentation. + +## Read First + +- [Project Overview](./PROJECT_OVERVIEW.md) +- [Development Setup](./DEVELOPMENT.md) +- [Environment and Secrets Setup](./ENVIRONMENT_SECRETS.md) + +## Architecture + +- [Architecture Overview](../ARCHITECTURE/OVERVIEW.md) +- [Core Services](../ARCHITECTURE/SERVICES.md) +- [State and Event Flow](../ARCHITECTURE/STATE_EVENTS.md) +- [Security Notes](../ARCHITECTURE/SECURITY.md) + +## Planning + +- [Tech Debt TODOs](../TODOS/TECH_DEBT.md) +- [Future Roadmap](../FUTURE_FEATURES/ROADMAP.md) diff --git a/docs/README/PROJECT_OVERVIEW.md b/docs/README/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..8701646 --- /dev/null +++ b/docs/README/PROJECT_OVERVIEW.md @@ -0,0 +1,45 @@ +# Project Overview + +## What This Project Is + +This project recreates a macOS-inspired desktop experience in the browser using Angular and Tailwind CSS. It mixes a portfolio-style landing experience with an interactive in-browser "OS" that includes movable windows, app launching, CLI-like gameplay, notifications, overlays, and media tools. + +## Core Experience + +- Desktop shell with app windows and focus management. +- App registry and launcher behavior (dock/system tray/menu). +- CLI-driven game flow with typed output and level progression. +- Settings, storage, and user profile persistence. +- Audio, media rendering, and visual overlay systems. + +## Tech Stack + +- Angular 19 standalone components +- TypeScript (strict mode enabled) +- Tailwind CSS 3 +- RxJS for reactive state/event streams +- AngularFire/Firebase for backend integration points +- FontAwesome and CDK helpers in UI components + +## High-Level Module Map + +- `src/app/components/main`: + public/portfolio-facing experience. +- `src/app/components/game`: + desktop simulation, apps, system UI, and most game services. +- `src/app/components/game/services`: + core runtime logic (app manager, CLI, settings, storage, media, sound, user). +- `src/app/services`: + shared Firebase/auth services. +- `src/app/guards`, `src/app/pipes`, `src/app/modules`: + route guards, pipe helpers, feature modules. + +## Suggested Reading Order + +1. `src/app/app.routes.ts` +2. `src/app/app.config.ts` +3. `src/app/components/game/desktop/desktop.component.ts` +4. `src/app/components/game/services/application-manager.service.ts` +5. `src/app/components/game/apps/cli-game/cli-game.component.ts` +6. `src/app/components/game/services/*` for subsystem behavior + diff --git a/docs/TODOS/TECH_DEBT.md b/docs/TODOS/TECH_DEBT.md new file mode 100644 index 0000000..afd3238 --- /dev/null +++ b/docs/TODOS/TECH_DEBT.md @@ -0,0 +1,100 @@ +# Tech Debt TODOs + +Status legend: + +- `[ ]` not started +- `[~]` in progress +- `[x]` complete + +## Quick Wins (Do First) + +- [x] Fix lint gate wiring (`npm run lint` currently non-functional). + - Impact: High + - Effort: S + - Validation: lint command runs and reports real issues. + +- [x] Fix test script include pattern so specs are discovered (currently 0 tests executed). + - Impact: High + - Effort: S + - Validation: test command executes existing specs. + +- [x] Fix `CLIService` auth bug in `su` command path. + - Impact: High + - Effort: S + - Validation: CLI command behavior tests/manual checks. + +- [x] Fix notification dismiss/click behavior to use notification `id` consistently. + - Impact: Medium + - Effort: S + - Validation: notification component spec/manual UX check. + +- [x] Add URL validation allowlist in redirect guard. + - Impact: High + - Effort: S + - Validation: guard unit tests for allowed and blocked URLs. + +- [x] Stabilize baseline unit tests to full pass (`88/88` in CI-like headless run). + - Impact: High + - Effort: M + - Validation: `npm run test -- --watch=false --browsers=ChromeHeadless`. + +## Medium Refactors + +- [~] Refactor `SettingsService` for typed models and safe subscription lifecycle. + - Impact: High + - Effort: M + - Validation: lint/test/build + settings UI regression check. + +- [x] Optimize `ScrollClassToggleDirective` scroll handling by batching with `requestAnimationFrame` and caching class lists. + - Impact: Medium + - Effort: S + - Validation: manual scroll regression across main page header transitions. + +- [x] Move `PatchEditorComponent` inline template styles into component stylesheet. + - Impact: Low + - Effort: S + - Validation: visual regression check for patch envelope controls. + +- [x] Align `StorageService` strategy behavior, including `getAllKeys`. + - Impact: Medium + - Effort: M + - Validation: storage-focused unit tests across strategy paths. + +- [~] Break `ApplicationManagerService` into smaller responsibilities (registry, persistence, lifecycle). + - Impact: High + - Effort: M + - Validation: app launch/focus/close regression tests. + +- [~] Stabilize `TypewriterService` timer and callback semantics. + - Impact: Medium + - Effort: M + - Validation: CLI typing flow checks and queue behavior tests. + +- [~] Reduce startup randomness/cost in `FileSystemService`. + - Impact: Medium + - Effort: M + - Validation: finder behavior and startup responsiveness. + +## Larger Changes (Riskier, Stage Later) + +- [ ] Move OpenAI and weather calls behind backend proxy/functions. + - Impact: High (security) + - Effort: L + - Validation: integration tests and production key removal. + +- [ ] Replace `innerHTML` rendering paths with safe renderers. + - Impact: High (security) + - Effort: L + - Validation: XSS regression tests + UI snapshot/manual checks. + +- [~] Enforce supported Node LTS through `.nvmrc`/`engines` and CI checks. + - Impact: Medium + - Effort: S + - Validation: consistent local/CI build success. + +## Suggested Execution Order + +1. Restore quality gates (lint/test/build reliability). +2. Patch clear correctness/security bugs. +3. Refactor high-impact services incrementally. +4. Address secret-handling and HTML-rendering hardening. diff --git a/eslint.config.mjs b/eslint.config.mjs index 19c2576..3218c2d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,30 +1,31 @@ -import js from "@eslint/js"; +import eslint from "@eslint/js"; import globals from "globals"; import tseslint from "typescript-eslint"; -import json from "@eslint/json"; -import { defineConfig } from "eslint/config"; -import angular from "@analogjs/vite-plugin-angular"; -import eslint from "@eslint/css"; +import angular from "angular-eslint"; - -export default defineConfig([ - { files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] }, - { files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.browser } }, - tseslint.configs.recommended, - { files: ["**/*.json"], plugins: { json }, language: "json/json", extends: ["json/recommended"] }, - { files: ["**/*.jsonc"], plugins: { json }, language: "json/jsonc", extends: ["json/recommended"] }, - {files: ["**/*.css"], plugins: {eslint}, language: "css/css", extends: ["css/recommended"]}, -]); - -module.exports = tseslint.config( +export default tseslint.config( + { + ignores: [ + "dist/**", + "coverage/**", + "node_modules/**", + ".angular/**", + "out-tsc/**" + ] + }, { files: ["**/*.ts"], extends: [ eslint.configs.recommended, ...tseslint.configs.recommended, - ...tseslint.configs.stylistic, - ...angular.configs.tsRecommended, + ...angular.configs.tsRecommended ], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + } + }, processor: angular.processInlineTemplates, rules: { "@angular-eslint/directive-selector": [ @@ -32,25 +33,24 @@ module.exports = tseslint.config( { type: "attribute", prefix: "app", - style: "camelCase", - }, + style: "camelCase" + } ], "@angular-eslint/component-selector": [ "error", { type: "element", prefix: "app", - style: "kebab-case", - }, - ], - }, + style: "kebab-case" + } + ] + } }, { files: ["**/*.html"], extends: [ ...angular.configs.templateRecommended, - ...angular.configs.templateAccessibility, - ], - rules: {}, + ...angular.configs.templateAccessibility + ] } ); diff --git a/package-lock.json b/package-lock.json index e64e043..7f2a13e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.1", "chart.js": "^4.4.9", "dayjs": "^1.11.13", + "firebase": "^11.8.1", "marked": "^15.0.11", "ng2-charts": "^8.0.0", "ngx-markdown": "^19.1.1", diff --git a/package.json b/package.json index 41955a1..45bc192 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,17 @@ "scripts": { "ng": "ng", "start": "ng serve", + "generate:env": "node scripts/generate-environment.mjs", "build": "ng build", + "build:prod": "ng build --configuration production", "build:css": "npx tailwindcss -o ./dist/output.css --minify", "watch": "ng build --watch --configuration development", "test": "ng test", "lint": "ng lint" }, + "engines": { + "node": ">=20.11 <23" + }, "private": true, "dependencies": { "@angular/animations": "^19.2.14", @@ -28,6 +33,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.1", "chart.js": "^4.4.9", "dayjs": "^1.11.13", + "firebase": "^11.8.1", "marked": "^15.0.11", "ng2-charts": "^8.0.0", "ngx-markdown": "^19.1.1", diff --git a/scripts/generate-environment.mjs b/scripts/generate-environment.mjs new file mode 100644 index 0000000..c34a49f --- /dev/null +++ b/scripts/generate-environment.mjs @@ -0,0 +1,68 @@ +import {mkdirSync, writeFileSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; + +const readEnv = (name) => (process.env[name] ?? '').trim(); + +const appTitle = readEnv('APP_TITLE'); +const apiUrl = readEnv('APP_API_URL') || readEnv('API_URL'); +const openAiApiKey = readEnv('OPENAI_API_KEY'); +const openWeatherMapApiKey = readEnv('OPEN_WEATHER_MAP_API_KEY'); +const firebaseApiKey = readEnv('FIREBASE_API_KEY'); +const firebaseAuthDomain = readEnv('FIREBASE_AUTH_DOMAIN'); +const firebaseDatabaseUrl = readEnv('FIREBASE_DATABASE_URL'); +const firebaseProjectId = readEnv('FIREBASE_PROJECT_ID'); +const firebaseStorageBucket = readEnv('FIREBASE_STORAGE_BUCKET'); +const firebaseMessagingSenderId = readEnv('FIREBASE_MESSAGING_SENDER_ID'); +const firebaseAppId = readEnv('FIREBASE_APP_ID'); +const firebaseMeasurementId = readEnv('FIREBASE_MEASUREMENT_ID'); + +const missing = []; +if (!appTitle) missing.push('APP_TITLE'); +if (!apiUrl) missing.push('APP_API_URL (or API_URL)'); +if (!openAiApiKey) missing.push('OPENAI_API_KEY'); +if (!openWeatherMapApiKey) missing.push('OPEN_WEATHER_MAP_API_KEY'); +if (!firebaseApiKey) missing.push('FIREBASE_API_KEY'); +if (!firebaseAuthDomain) missing.push('FIREBASE_AUTH_DOMAIN'); +if (!firebaseDatabaseUrl) missing.push('FIREBASE_DATABASE_URL'); +if (!firebaseProjectId) missing.push('FIREBASE_PROJECT_ID'); +if (!firebaseStorageBucket) missing.push('FIREBASE_STORAGE_BUCKET'); +if (!firebaseMessagingSenderId) missing.push('FIREBASE_MESSAGING_SENDER_ID'); +if (!firebaseAppId) missing.push('FIREBASE_APP_ID'); +if (!firebaseMeasurementId) missing.push('FIREBASE_MEASUREMENT_ID'); + +if (missing.length > 0) { + console.error('Missing required environment variables:'); + missing.forEach((name) => console.error(`- ${name}`)); + process.exit(1); +} + +const environmentConfig = { + production: true, + title: appTitle, + apiUrl, + openAiApiKey, + openWeatherMapApiKey, + firebaseConfig: { + apiKey: firebaseApiKey, + authDomain: firebaseAuthDomain, + databaseURL: firebaseDatabaseUrl, + projectId: firebaseProjectId, + storageBucket: firebaseStorageBucket, + messagingSenderId: firebaseMessagingSenderId, + appId: firebaseAppId, + measurementId: firebaseMeasurementId + } +}; + +const fileContent = `export const environment = ${JSON.stringify(environmentConfig, null, 2)};\n`; +const outputFiles = [ + resolve('src/environments/environment.ts'), + resolve('src/environments/environment.prod.ts') +]; + +for (const filePath of outputFiles) { + mkdirSync(dirname(filePath), {recursive: true}); + writeFileSync(filePath, fileContent, 'utf8'); +} + +console.log(`Generated ${outputFiles.length} environment file(s).`); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index dbcd16b..628339d 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,28 +1,92 @@ import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core'; import {provideRouter, withHashLocation} from '@angular/router'; -import { routes } from './app.routes'; +import {routes} from './app.routes'; import {provideHttpClient} from '@angular/common/http'; import {provideMarkdown} from 'ngx-markdown'; import {provideAnimations} from '@angular/platform-browser/animations'; -import {defaultSoundConfig, SOUND_SERVICE_CONFIG} from './components/game/services/sound.service'; import {initializeApp, provideFirebaseApp} from '@angular/fire/app'; import {environment} from '../environments/environment'; import {getAuth, provideAuth} from '@angular/fire/auth'; +import {getFirestore, provideFirestore} from '@angular/fire/firestore'; +import {getStorage, provideStorage} from '@angular/fire/storage'; +import { + SOUND_SERVICE_CONFIG, + SoundServiceConfig +} from './providers/sound/sound.module'; +import {getDatabase, provideDatabase} from '@angular/fire/database'; +export const defaultSoundConfig: SoundServiceConfig = { + debounceInterval: 60, + maxCacheSize: 20, + defaultVolume: 1.0, + basePath: 'assets/audio/efx/' +}; export const appConfig: ApplicationConfig = { providers: [ + provideHttpClient(), - provideZoneChangeDetection({ eventCoalescing: true }), + provideZoneChangeDetection({eventCoalescing: true}), provideRouter(routes, withHashLocation()), - provideFirebaseApp(() => initializeApp(environment.firebaseConfig)), - provideAuth(() => getAuth()), provideMarkdown(), provideAnimations(), { provide: SOUND_SERVICE_CONFIG, useValue: defaultSoundConfig - } + }, + provideFirebaseApp(() => { + try { + const app = initializeApp(environment.firebaseConfig); + console.log('Firebase app initialized successfully'); + return app; + } catch (error) { + console.error('Error initializing Firebase app:', error); + throw error; + } + }), + provideAuth(() => { + try { + const auth = getAuth(); + console.log('Auth initialized successfully'); + return auth; + } catch (error) { + console.error('Error initializing Auth:', error); + throw error; + } + }), + provideDatabase(() => { + try { + const app = initializeApp(environment.firebaseConfig); + const db = getDatabase(app); // Pass the app instance explicitly + console.log('Database initialized successfully'); + return db; + } catch (error) { + console.error('Error initializing Database:', error); + throw error; + } + + }), + provideFirestore(() => { + try { + const firestore = getFirestore(); + console.log('Firestore initialized successfully'); + return firestore; + } catch (error) { + console.error('Error initializing Firestore:', error); + throw error; + } + }), + provideStorage(() => { + try { + const storage = getStorage(); + console.log('Storage initialized successfully'); + return storage; + } catch (error) { + console.error('Error initializing Storage:', error); + throw error; + } + }) + ] }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 0e8082e..3da126a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -11,12 +11,17 @@ export const PATH_NAMES = { OS_LOGIN: 'login', OS_BOOT: 'boot', OS_EXTERNAL: 'external', - OS_SLEEP: 'sleep' + OS_SLEEP: 'sleep', + FS_BACKGROUND: 'background', } export const routes: Routes = [ { path: '', loadComponent: () => import('./components/main/main.component').then(m => m.MainComponent) }, + { + path: 'background', + loadComponent: () => import('./components/game/system/full-screen-background/background-example.component').then(m => m.BackgroundExampleComponent) + }, { path: PATH_NAMES.OS_MAIN, pathMatch: 'full', diff --git a/src/app/components/game/apps/cli-game/cli-game.component.spec.ts b/src/app/components/game/apps/cli-game/cli-game.component.spec.ts index 4acced1..aea9f34 100644 --- a/src/app/components/game/apps/cli-game/cli-game.component.spec.ts +++ b/src/app/components/game/apps/cli-game/cli-game.component.spec.ts @@ -1,14 +1,68 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {BehaviorSubject, of} from 'rxjs'; +import {TypewriterService} from '../../services/typewriter.service'; +import {SoundService} from '../../services/sound.service'; +import {CLIService} from '../../services/cli.service'; +import {GameConfigService} from '../../services/game-config.service'; +import {ApplicationManagerService} from '../../services/application-manager.service'; +import {AiChatService} from '../../services/ai-chat.service'; +import {UserService} from '../../services/user.service'; +import {NotificationService} from '../../services/notification.service'; +import {LogService} from '../../services/log.service'; import { CliGameComponent } from './cli-game.component'; describe('CliGameComponent', () => { let component: CliGameComponent; let fixture: ComponentFixture; + const typewriterServiceMock = { + typedText$: new BehaviorSubject(''), + activeMode$: new BehaviorSubject<'default' | 'system' | 'dramatic'>('default'), + lineCompleted$: new BehaviorSubject(undefined), + enqueueLine: jasmine.createSpy('enqueueLine'), + clear: jasmine.createSpy('clear') + }; + const soundServiceMock = jasmine.createSpyObj('SoundService', ['bootAudio', 'stopAll', 'play']); + soundServiceMock.bootAudio.and.returnValue(Promise.resolve()); + const cliServiceMock = { + executeInput: jasmine.createSpy('executeInput').and.returnValue({status: 200, output: 'ok'}) + }; + const gameConfigServiceMock = { + getAvailableCommands: jasmine.createSpy('getAvailableCommands').and.returnValue([]), + loadLevels: jasmine.createSpy('loadLevels').and.returnValue(Promise.resolve()) + }; + const appManagerServiceMock = { + closeApplication: jasmine.createSpy('closeApplication') + }; + const aiChatServiceMock = { + generateAiAnswer: jasmine.createSpy('generateAiAnswer').and.returnValue(of({choices: []})) + }; + const userServiceMock = { + user: {level: 1}, + previousLevel: 1 + }; + const notificationServiceMock = { + show: jasmine.createSpy('show') + }; + const loggerMock = { + logs$: new BehaviorSubject([]), + getLogsPage: jasmine.createSpy('getLogsPage').and.returnValue([]) + }; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [CliGameComponent] + imports: [CliGameComponent], + providers: [ + {provide: TypewriterService, useValue: typewriterServiceMock}, + {provide: SoundService, useValue: soundServiceMock}, + {provide: CLIService, useValue: cliServiceMock}, + {provide: GameConfigService, useValue: gameConfigServiceMock}, + {provide: ApplicationManagerService, useValue: appManagerServiceMock}, + {provide: AiChatService, useValue: aiChatServiceMock}, + {provide: UserService, useValue: userServiceMock}, + {provide: NotificationService, useValue: notificationServiceMock}, + {provide: LogService, useValue: loggerMock} + ] }) .compileComponents(); diff --git a/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts b/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts index 2bcff67..3a801c0 100644 --- a/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts +++ b/src/app/components/game/apps/markdown-reader/markdown-reader.component.ts @@ -10,11 +10,11 @@ import {ApplicationManagerService} from '../../services/application-manager.serv encapsulation: ViewEncapsulation.None, styles: [ `.markdown-body { - @apply prose text-xs leading-5 mx-auto; + @apply prose text-xs leading-5 mx-auto ; }`, ], template: ` -
+
@@ -26,6 +26,8 @@ export class MarkdownReaderComponent { private _filename: string = 'gameplay.doc.md'; + @Input() params: any; + @Input() set filename(value: string) { this._filename = value || 'gameplay.doc.md'; @@ -38,10 +40,12 @@ export class MarkdownReaderComponent { constructor(private readonly appManager: ApplicationManagerService) { - this.document = this.docsPath + this._filename; + const currentApp = this.appManager.getCurrentApp(); this.filename = currentApp?.params?.file; + console.warn('FILE', this.filename); + this.document = this.docsPath + this.filename; } diff --git a/src/app/components/game/apps/messages/messages.component.html b/src/app/components/game/apps/messages/messages.component.html new file mode 100644 index 0000000..89b9fa6 --- /dev/null +++ b/src/app/components/game/apps/messages/messages.component.html @@ -0,0 +1 @@ +

messages works!

diff --git a/src/app/components/game/apps/messages/messages.component.spec.ts b/src/app/components/game/apps/messages/messages.component.spec.ts new file mode 100644 index 0000000..8b7c2d9 --- /dev/null +++ b/src/app/components/game/apps/messages/messages.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {MessagesComponent} from './messages.component'; + +describe('MessagesComponent', () => { + let component: MessagesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MessagesComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(MessagesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/game/apps/messages/messages.component.ts b/src/app/components/game/apps/messages/messages.component.ts new file mode 100644 index 0000000..4e0439b --- /dev/null +++ b/src/app/components/game/apps/messages/messages.component.ts @@ -0,0 +1,453 @@ +import {Component, ElementRef, ViewChild} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; + +// +// Simple interfaces for Users, Messages, and Chats +// +interface User { + id: number; + name: string; + avatarUrl: string; +} + +interface Message { + id: number; + sender: User; + content: string; // textual content (including emojis) + timestamp: Date; + imageUrl?: string; // if message is an image attachment +} + +interface Chat { + id: number; + name: string; // chat name (for groups) or counterpart name (for 1:1) + participants: User[]; // list of users in this chat + messages: Message[]; // the history + isGroup: boolean; + avatarUrl?: string; // if you want a single avatar for the chat +} + +@Component({ + selector: 'app-messages', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+ + + + + +
+ + +
+ chat avatar + +
+ avatar +
+
+ +
+
+ {{ selectedChat.isGroup ? selectedChat.name : selectedChat.participants[0].name }} +
+ +
+ + + +
+ + +
+
+ + + avatar +
+
+ + attached + +
{{ msg.content }}
+
+
+ {{ msg.timestamp | date: 'shortTime' }} +
+
+
+ + + +
+
+
+ + attached + +
{{ msg.content }}
+
+
+ {{ msg.timestamp | date: 'shortTime' }} +
+
+
+
+
+ + +
+ + + + + + + + + + + + +
+
+ + + +
+ Select a chat to start messaging +
+
+
+
+ + +
+ {{ emo }} +
+ `, + styles: [], +}) +export class MessagesComponent { + @ViewChild('messageContainer') private messageContainer!: ElementRef; + @ViewChild('fileInput') private fileInput!: ElementRef; + + // Simulate a “logged‐in” user + currentUser: User = { + id: 1, + name: 'You', + avatarUrl: 'https://i.pravatar.cc/150?img=3', // placeholder + }; + + // Sample other users + userAlice: User = {id: 2, name: 'Alice', avatarUrl: 'https://i.pravatar.cc/150?img=4'}; + userBob: User = {id: 3, name: 'Bob', avatarUrl: 'https://i.pravatar.cc/150?img=5'}; + userCarol: User = {id: 4, name: 'Carol', avatarUrl: 'https://i.pravatar.cc/150?img=6'}; + + // Sample chats + chatList: Chat[] = [ + { + id: 101, + name: '', + participants: [this.userAlice], + messages: [ + { + id: 1, + sender: this.userAlice, + content: 'Hey, how are you?', + timestamp: new Date(new Date().getTime() - 1000 * 60 * 60), + }, + { + id: 2, + sender: this.currentUser, + content: 'I’m good—just testing out this new chat UI!', + timestamp: new Date(new Date().getTime() - 1000 * 60 * 30), + }, + ], + isGroup: false, + avatarUrl: '', // if blank, it will show participant’s avatar + }, + { + id: 102, + name: 'Friends Group', + participants: [this.currentUser, this.userBob, this.userCarol], + messages: [ + { + id: 1, + sender: this.userBob, + content: 'Anyone up for lunch today? 😊', + timestamp: new Date(new Date().getTime() - 1000 * 60 * 120), + }, + { + id: 2, + sender: this.userCarol, + content: "Count me in! 🍕", + timestamp: new Date(new Date().getTime() - 1000 * 60 * 90), + }, + ], + isGroup: true, + avatarUrl: '', // group avatar can be custom, otherwise show combined avatars + }, + ]; + + selectedChat: Chat | null = null; + draftMessage: string = ''; + showEmojiPicker = false; + + // A small set of emojis for the basic picker + emojiList = ['😀', '😂', '😍', '🤔', '🙌', '👍', '🐱', '🎉', '❤️', '🔥', '😎', '🤷‍♂️']; + + constructor() { + // Select the first chat by default + this.selectedChat = this.chatList[0]; + // Scroll to bottom on load + setTimeout(() => this.scrollToBottom(), 0); + } + + /**** Helper Methods ****/ + + selectChat(chat: Chat) { + this.selectedChat = chat; + this.scrollToBottom(); + } + + getLastMessagePreview(chat: Chat): string { + if (!chat.messages.length) return 'No messages yet'; + const last = chat.messages[chat.messages.length - 1]; + return (last.sender.id === this.currentUser.id ? 'You: ' : `${last.sender.name}: `) + + (last.content.length > 20 ? last.content.slice(0, 20) + '…' : last.content); + } + + getUnreadCount(chat: Chat): number { + // For demo purposes, just zero + return 0; + } + + /** Sends the message (with optional image). */ + sendMessage() { + if (!this.selectedChat) return; + if (!this.draftMessage.trim() && !this.pendingImageBase64) { + return; + } + + const newMsg: Message = { + id: Date.now(), + sender: this.currentUser, + content: this.draftMessage, + timestamp: new Date(), + }; + + if (this.pendingImageBase64) { + newMsg.imageUrl = this.pendingImageBase64; + this.pendingImageBase64 = null; + } + + this.selectedChat.messages.push(newMsg); + this.draftMessage = ''; + this.scrollToBottom(); + } + + /** When the user picks an image file, convert it to base64 and store. */ + pendingImageBase64: string | null = null; + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (!input.files?.length) return; + + const file = input.files[0]; + const reader = new FileReader(); + reader.onload = () => { + this.pendingImageBase64 = reader.result as string; + // Immediately send as an image message (optional: you could wait for “Send” click) + this.sendMessage(); + // Clear the file input + this.fileInput.nativeElement.value = ''; + }; + reader.readAsDataURL(file); + } + + /** Emoji picker toggling */ + toggleEmojiPicker() { + this.showEmojiPicker = !this.showEmojiPicker; + } + + addEmoji(emo: string) { + this.draftMessage += emo; + this.showEmojiPicker = false; + } + + /** Always scroll message list to bottom on new message */ + scrollToBottom() { + try { + const el = this.messageContainer.nativeElement as HTMLElement; + setTimeout(() => { + el.scrollTop = el.scrollHeight; + }, 50); + } catch { + } + } + + /** Creates a brand new one-to-one chat with Alice (demo only) */ + createNewChat() { + const newId = Math.floor(Math.random() * 10000) + 200; + const newChat: Chat = { + id: newId, + name: '', + participants: [this.userAlice], + messages: [], + isGroup: false, + avatarUrl: '', + }; + this.chatList.push(newChat); + this.selectChat(newChat); + } +} diff --git a/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.html b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.html index 802ffca..a0dfdfa 100644 --- a/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.html +++ b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.html @@ -39,21 +39,6 @@

🎹 Load Saved Patch

-
diff --git a/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.scss b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.scss new file mode 100644 index 0000000..cb4bf56 --- /dev/null +++ b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.scss @@ -0,0 +1,11 @@ +.env-container { + @apply grid grid-cols-4 gap-2; +} + +.env-container label { + @apply text-sm font-light; +} + +.env-container input { + @apply w-full text-xs bg-black text-white/90 py-1 px-2 rounded-lg; +} diff --git a/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.ts b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.ts index 7a80be1..d29809b 100644 --- a/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.ts +++ b/src/app/components/game/apps/music-apps/patch-editor/patch-editor.component.ts @@ -19,7 +19,8 @@ import {TooltipDirective} from '../../../directives/tooltip.directive'; NgSwitch, NgSwitchCase ], - templateUrl: './patch-editor.component.html' + templateUrl: './patch-editor.component.html', + styleUrls: ['./patch-editor.component.scss'] }) export class PatchEditorComponent implements OnInit { defaultPatch: SynthPatch = { diff --git a/src/app/components/game/apps/space-x/space-x.component.spec.ts b/src/app/components/game/apps/space-x/space-x.component.spec.ts index 99ae23c..131abe1 100644 --- a/src/app/components/game/apps/space-x/space-x.component.spec.ts +++ b/src/app/components/game/apps/space-x/space-x.component.spec.ts @@ -1,4 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {SpaceXComponent} from './space-x.component'; @@ -8,7 +10,8 @@ describe('SpaceXComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SpaceXComponent] + imports: [SpaceXComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/apps/space-x/spacex-crew/spacex-crew.component.spec.ts b/src/app/components/game/apps/space-x/spacex-crew/spacex-crew.component.spec.ts index afed228..dd5ca9a 100644 --- a/src/app/components/game/apps/space-x/spacex-crew/spacex-crew.component.spec.ts +++ b/src/app/components/game/apps/space-x/spacex-crew/spacex-crew.component.spec.ts @@ -1,4 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {SpacexCrewComponent} from './spacex-crew.component'; @@ -8,7 +10,8 @@ describe('SpacexCrewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SpacexCrewComponent] + imports: [SpacexCrewComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/apps/space-x/spacex-launchpad/spacex-launchpad.component.spec.ts b/src/app/components/game/apps/space-x/spacex-launchpad/spacex-launchpad.component.spec.ts index 0dd3963..c29ccb3 100644 --- a/src/app/components/game/apps/space-x/spacex-launchpad/spacex-launchpad.component.spec.ts +++ b/src/app/components/game/apps/space-x/spacex-launchpad/spacex-launchpad.component.spec.ts @@ -1,4 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {SpacexLaunchpadComponent} from './spacex-launchpad.component'; @@ -8,7 +10,8 @@ describe('SpacexLaunchpadComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SpacexLaunchpadComponent] + imports: [SpacexLaunchpadComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/apps/space-x/spacex-rocket/spacex-rocket.component.spec.ts b/src/app/components/game/apps/space-x/spacex-rocket/spacex-rocket.component.spec.ts index bdf21e2..684cb36 100644 --- a/src/app/components/game/apps/space-x/spacex-rocket/spacex-rocket.component.spec.ts +++ b/src/app/components/game/apps/space-x/spacex-rocket/spacex-rocket.component.spec.ts @@ -1,4 +1,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; +import {SpaceXRocket} from '../models/spacex-models'; import {SpacexRocketComponent} from './spacex-rocket.component'; @@ -8,12 +11,40 @@ describe('SpacexRocketComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SpacexRocketComponent] + imports: [SpacexRocketComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); fixture = TestBed.createComponent(SpacexRocketComponent); component = fixture.componentInstance; + component.rocket = { + name: 'Falcon 9', + active: true, + flickr_images: [], + company: 'SpaceX', + country: 'USA', + first_flight: '2010-06-04', + cost_per_launch: 62000000, + success_rate_pct: 98, + description: 'Test', + height: {meters: 70, feet: 229.6}, + diameter: {meters: 3.7, feet: 12}, + mass: {kg: 549054, lb: 1207920}, + engines: { + number: 9, + type: 'merlin', + version: '1D+', + thrust_vacuum: {kN: 8227, lbf: 1849500}, + propellant_1: 'RP-1', + propellant_2: 'LOX' + }, + stages: 2, + boosters: 0, + payload_weights: [], + id: 'falcon9', + wikipedia: '' + } as unknown as SpaceXRocket; fixture.detectChanges(); }); diff --git a/src/app/components/game/apps/space-x/spacex-sub-panel/spacex-sub-panel.component.spec.ts b/src/app/components/game/apps/space-x/spacex-sub-panel/spacex-sub-panel.component.spec.ts index 1a71c79..9a9634e 100644 --- a/src/app/components/game/apps/space-x/spacex-sub-panel/spacex-sub-panel.component.spec.ts +++ b/src/app/components/game/apps/space-x/spacex-sub-panel/spacex-sub-panel.component.spec.ts @@ -1,4 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {SpacexSubPanelComponent} from './spacex-sub-panel.component'; @@ -8,7 +10,8 @@ describe('SpacexSubPanelComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SpacexSubPanelComponent] + imports: [SpacexSubPanelComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/apps/task-app/task-app.component.ts b/src/app/components/game/apps/task-app/task-app.component.ts index 2259ac4..899d7d1 100644 --- a/src/app/components/game/apps/task-app/task-app.component.ts +++ b/src/app/components/game/apps/task-app/task-app.component.ts @@ -5,7 +5,7 @@ import {Task, TaskService} from '../../services/task.service'; import {catchError, map, startWith} from 'rxjs/operators'; import {BehaviorSubject, combineLatest, debounceTime, Observable, of} from 'rxjs'; import {TooltipDirective} from '../../directives/tooltip.directive'; -import {TimeAgoPipe} from '../../../../pipes/time-ago,pipe'; +import {TimeAgoPipe} from '../../../../pipes/time-ago.pipe'; import {FaIconComponent} from '@fortawesome/angular-fontawesome'; import {faArchive, faCheck, faPlus, faRedo, faTrash} from '@fortawesome/free-solid-svg-icons'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; diff --git a/src/app/components/game/apps/weather/weather.component.spec.ts b/src/app/components/game/apps/weather/weather.component.spec.ts index 99d9513..0a81019 100644 --- a/src/app/components/game/apps/weather/weather.component.spec.ts +++ b/src/app/components/game/apps/weather/weather.component.spec.ts @@ -1,4 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import {WeatherComponent} from './weather.component'; @@ -8,7 +10,8 @@ describe('WeatherComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [WeatherComponent] + imports: [WeatherComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/desktop/desktop.component.spec.ts b/src/app/components/game/desktop/desktop.component.spec.ts index f547519..b03eb8f 100644 --- a/src/app/components/game/desktop/desktop.component.spec.ts +++ b/src/app/components/game/desktop/desktop.component.spec.ts @@ -9,6 +9,7 @@ import { TypewriterService } from '../services/typewriter.service'; import { SoundService } from '../services/sound.service'; import { ActivatedRoute } from '@angular/router'; import { of } from 'rxjs'; +import {LogService} from '../services/log.service'; describe('DesktopComponent', () => { let component: DesktopComponent; @@ -56,9 +57,23 @@ describe('DesktopComponent', () => { }), }; + const logServiceMock = { + debug: jasmine.createSpy('debug'), + info: jasmine.createSpy('info'), + warn: jasmine.createSpy('warn'), + error: jasmine.createSpy('error'), + }; + beforeEach(async () => { + TestBed.overrideComponent(DesktopComponent, { + set: { + template: '', + imports: [], + } + }); + await TestBed.configureTestingModule({ - declarations: [DesktopComponent], + imports: [DesktopComponent], providers: [ { provide: ApplicationManagerService, useValue: appManagerServiceMock }, { provide: ContextMenuService, useValue: contextMenuServiceMock }, @@ -68,6 +83,7 @@ describe('DesktopComponent', () => { { provide: TypewriterService, useValue: typewriterServiceMock }, { provide: SoundService, useValue: soundServiceMock }, { provide: ActivatedRoute, useValue: activatedRouteMock }, + {provide: LogService, useValue: logServiceMock}, ], }).compileComponents(); @@ -80,18 +96,17 @@ describe('DesktopComponent', () => { expect(component).toBeTruthy(); }); - it('should call onBeginInvestigation if no user exists in localStorage', () => { + it('should call onBeginInvestigation when view initializes', () => { spyOn(component, 'onBeginInvestigation'); - spyOn(localStorage, 'getItem').and.returnValue(null); - - component.ngOnInit(); + component.ngAfterViewInit(); expect(component.onBeginInvestigation).toHaveBeenCalled(); }); it('should call openApp on route param change', () => { + appManagerServiceMock.openApplication.calls.reset(); component.ngOnInit(); - expect(appManagerServiceMock.openApplication).toHaveBeenCalledWith('testApp'); + expect(appManagerServiceMock.openApplication).toHaveBeenCalledWith('testApp', undefined); }); it('should handle onDoubleClicked and close all apps', () => { @@ -142,7 +157,7 @@ describe('DesktopComponent', () => { component.onBeginInvestigation(); expect(soundServiceMock.play).toHaveBeenCalledWith('glitch-1.mp3', { - volume: 0.1, + volume: 0.3, forceRestart: true, }); expect(typewriterServiceMock.enqueueLine).toHaveBeenCalledWith({ diff --git a/src/app/components/game/desktop/desktop.component.ts b/src/app/components/game/desktop/desktop.component.ts index 085e236..70771b3 100644 --- a/src/app/components/game/desktop/desktop.component.ts +++ b/src/app/components/game/desktop/desktop.component.ts @@ -1,4 +1,4 @@ -import {Component, DestroyRef, OnInit} from '@angular/core'; +import {AfterViewInit, Component, DestroyRef, OnInit} from '@angular/core'; import {NgForOf} from "@angular/common"; import {LevelLoaderComponent} from '../utils/level-loader/level-loader.component'; import {AppWindowComponent} from '../templates/app-window/app-window.component'; @@ -43,7 +43,7 @@ import {LogService} from '../services/log.service'; templateUrl: './desktop.component.html', styles: `` }) -export class DesktopComponent implements OnInit { +export class DesktopComponent implements OnInit, AfterViewInit { showIntro = false; overlayImagePath = 'assets/images/overlays/cracked_corner.webp'; backgroundImage = 'assets/images/backgrounds/night.webp'; @@ -71,6 +71,10 @@ export class DesktopComponent implements OnInit { }) } + ngAfterViewInit() { + this.onBeginInvestigation(); + } + openApp(id: string, params?: any) { this.appManager.openApplication(id, params); } @@ -120,9 +124,10 @@ export class DesktopComponent implements OnInit { onBeginInvestigation() { this.showIntro = false; + this.appManager.openApplication('cli'); this.showNotificationUpdates(); if (!this.userService.user.name) { - this.soundService.play('glitch-1.mp3', {volume: 0.1, forceRestart: true}); + this.soundService.play('glitch-1.mp3', {volume: 0.3, forceRestart: true}); this.typewriter.enqueueLine({ text: '> who_are_you?', agent: 'system', diff --git a/src/app/components/game/services/application-manager.service.ts b/src/app/components/game/services/application-manager.service.ts index 37f3cb1..c6758ea 100644 --- a/src/app/components/game/services/application-manager.service.ts +++ b/src/app/components/game/services/application-manager.service.ts @@ -5,7 +5,7 @@ import { faChartSimple, faCircleInfo, faCloudSunRain, faCogs, faComputer, - faExclamationTriangle, faHexagonNodesBolt, faIcons, faKeyboard, faMusic, faNoteSticky, + faExclamationTriangle, faHexagonNodesBolt, faIcons, faKeyboard, faMessage, faMusic, faNoteSticky, faPerson, faRocket } from '@fortawesome/free-solid-svg-icons'; import {faFaceGrin} from '@fortawesome/free-regular-svg-icons'; @@ -31,6 +31,8 @@ import {LogService} from './log.service'; import {PianoComponent} from '../apps/music-apps/piano/piano.component'; import {PatchEditorComponent} from '../apps/music-apps/patch-editor/patch-editor.component'; import {WeatherComponent} from '../apps/weather/weather.component'; +import {MessagesComponent} from '../apps/messages/messages.component'; +import {ChatBotComponent} from '../../../modules/chat/chat.component'; export interface ApplicationInstance extends AppEntry { id: string; @@ -77,6 +79,7 @@ export interface AppEntry { license?: string; website?: string; } + status?: 'development' | 'stable' | 'deprecated' | 'obsolete' autofit?: boolean; windowSize?: { width?: number; @@ -104,6 +107,7 @@ export const DEFAULT_WINDOW_OFFSET_X = 40; const INSTANCE_LIMIT_ERROR_MESSAGE = "Cannot open application. Maximum number of instances reached."; const INSTANCE_LIMIT_ERROR_TITLE = "System Error"; +const OPEN_APPS_STORAGE_KEY = 'applications'; export enum APP_ID { @@ -123,6 +127,8 @@ export enum APP_ID { space_x_app = 'space-x-app', icon_playground = 'icon-playground', weather_app = 'weather-app', + messages_app = 'messages-app', + chat_bot = 'chat-bot', } @Injectable({providedIn: 'root'}) @@ -216,7 +222,14 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, - instanceIndex: 0 + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } }); this.registerApp({ @@ -233,7 +246,14 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, - instanceIndex: 0 + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } }); this.registerApp({ @@ -250,7 +270,14 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, - instanceIndex: 0 + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } }); this.registerApp({ @@ -267,7 +294,14 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, - instanceIndex: 0 + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } }); @@ -276,7 +310,7 @@ export class ApplicationManagerService { title: 'Space X Launches', component: SpaceXComponent, installed: true, - windowSize: {height: 400, width: 200}, + windowSize: {height: 800, width: 600}, autofit: false, icon: { class: 'text-white p-1 rounded-lg border-2 border-zinc-700', @@ -285,6 +319,47 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } + }); + + this.registerApp({ + id: APP_ID.messages_app, + title: 'Messages', + component: MessagesComponent, + installed: true, + windowSize: {height: 800, width: 600}, + autofit: false, + icon: { + class: 'text-white p-1 rounded-lg border-2 border-zinc-700', + svgPath: faMessage + }, + memory: 512, + maxInstances: 1, + type: AppType.app, + instanceIndex: 0 + }); + + this.registerApp({ + id: APP_ID.chat_bot, + title: 'Chat', + component: ChatBotComponent, + installed: true, + windowSize: {height: 800, width: 600}, + autofit: false, + icon: { + class: 'text-white p-1 rounded-lg border-2 border-zinc-700', + svgPath: faMessage + }, + memory: 512, + maxInstances: 1, + type: AppType.app, instanceIndex: 0 }); @@ -298,9 +373,9 @@ export class ApplicationManagerService { svgPath: faCogs }, memory: 512, - maxInstances: 1, + maxInstances: 10, type: AppType.system, - params: {file: 'cipher.md'}, + params: {file: 'colinos-demo.doc.md'}, instanceIndex: 0 }); @@ -316,7 +391,14 @@ export class ApplicationManagerService { memory: 512, maxInstances: 1, type: AppType.app, - instanceIndex: 0 + instanceIndex: 0, + status: 'development', + metadata: { + version: '0.0.1', + author: '', + license: 'MIT', + website: 'https://github.com/colinmichaels' + } }); this.registerApp({ @@ -416,9 +498,39 @@ export class ApplicationManagerService { } private loadSavedApplications() { - const savedApps = localStorage.getItem('applications'); - if (savedApps) { - JSON.parse(savedApps).map((a: { id: any; }) => a.id).map((id: string) => this.openApplication(id)); + const appIds = this.getSavedApplicationIds(); + for (const appId of appIds) { + this.openApplication(appId); + } + } + + private getSavedApplicationIds(): string[] { + const savedApps = localStorage.getItem(OPEN_APPS_STORAGE_KEY); + if (!savedApps) { + return []; + } + + try { + const parsed: unknown = JSON.parse(savedApps); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map((entry) => { + if (typeof entry === 'string') { + return entry; + } + if (entry && typeof entry === 'object' && 'id' in entry) { + const maybeId = (entry as { id?: unknown }).id; + return typeof maybeId === 'string' ? maybeId : null; + } + return null; + }) + .filter((id): id is string => Boolean(id)); + } catch (error) { + this.logger.warn('Failed to parse saved applications.', {error}); + return []; } } @@ -467,9 +579,16 @@ export class ApplicationManagerService { const focusId = this.focusedAppId.getValue(); - if (focusId === id || app?.running) { - return !this.setApplicationFocus(id); + if (focusId === id) return true; + + if (app?.running) { + const existing = this.getMostRecentApplicationInstance(id); + if (existing) { + this.setApplicationFocus(existing.id, existing.offsetX, existing.offsetY); + return true; + } } + if (!app) return false; if (this.usedMemory + app.memory > this.maxMemory) { @@ -491,14 +610,9 @@ export class ApplicationManagerService { return false; } - const isNewInstanceNeeded = !!this.applications.value.find(t => t.id === id); - - const curAppIndex = this.applications.value.length + 1; - - const newAppInstanceId = isNewInstanceNeeded ? `${id}-${curAppIndex}` : id; - - - app.instanceIndex = isNewInstanceNeeded ? app.instanceIndex + 1 : app.instanceIndex; + const openInstanceCount = this.getOpenInstanceCount(app.id); + const newAppInstanceId = openInstanceCount > 0 ? `${id}-${openInstanceCount + 1}` : id; + app.instanceIndex = openInstanceCount + 1; app.running = true; this.applications.next([...this.applications.value, this.appFactory @@ -523,8 +637,8 @@ export class ApplicationManagerService { } private isInstanceLimitReached(app: AppEntry): boolean { - if (app.instanceIndex < app.maxInstances) { - app.instanceIndex += 1; // Increment instanceIndex when under limit + const openInstanceCount = this.getOpenInstanceCount(app.id); + if (openInstanceCount < app.maxInstances) { return false; } @@ -548,29 +662,39 @@ export class ApplicationManagerService { saveOpenApplications() { - localStorage.setItem('applications', JSON.stringify(this.applications.value)); + const openAppIds = this.applications.value.map((app) => app.id); + localStorage.setItem(OPEN_APPS_STORAGE_KEY, JSON.stringify(openAppIds)); } - closeApplication(id: string, args?: any): void { + closeApplication(id: string): void { const application = this.getAppByID(id); if (!application) return; // Mark the application as no longer running application.running = false; // Decrement the instanceIndex for the parent AppEntry + // Remove the application from the active applications list + const remainingApplications = this.applications.getValue().filter(app => app.id !== id); + this.applications.next(remainingApplications); + if (application.parent) { - application.parent.instanceIndex = Math.max(0, application.parent.instanceIndex - 1); - application.parent.running = application.parent.instanceIndex > 0; + const remainingInstances = remainingApplications.filter((openApp) => openApp.parent?.id === application.parent?.id); + application.parent.instanceIndex = remainingInstances.length; + application.parent.running = remainingInstances.length > 0; } - application.instanceIndex = 0; - - // Remove the application from the active applications list - this.applications.next(this.applications.getValue().filter(app => app.id !== id)); - // Save the state of opened applications this.saveOpenApplications(); } + private getOpenInstanceCount(appId: string): number { + return this.applications.value.filter((openApp) => openApp.parent?.id === appId).length; + } + + private getMostRecentApplicationInstance(appId: string): ApplicationInstance | undefined { + const appInstances = this.applications.value.filter((openApp) => openApp.parent?.id === appId); + return appInstances[appInstances.length - 1]; + } + setApplicationFocus(id: string, offsetX?: number, offsetY?: number): boolean { const application = this.applications.value.find(t => t.id === id); if (id === 'desktop') { diff --git a/src/app/components/game/services/cli.service.ts b/src/app/components/game/services/cli.service.ts index c0ebd1c..4459bf4 100644 --- a/src/app/components/game/services/cli.service.ts +++ b/src/app/components/game/services/cli.service.ts @@ -24,12 +24,9 @@ export class CLIService { private commands = new Map(); constructor(private config: GameConfigService, private userService: UserService) { - this.config.loadLevelsForProgress().then((levels) => { - levels.subscribe((level) => { - console.warn('level', level); - }); - this.registerBuiltins(); - }); + this.config.loadLevelsForProgress() + .then(() => this.registerBuiltins()) + .catch(() => this.registerBuiltins()); } private registerBuiltins() { @@ -37,7 +34,6 @@ export class CLIService { name: 'help', description: 'List available commands', execute: () => { - console.warn('commands', this.commands.keys()); const commands = '\n ' + Array.from(this.commands.keys()).join('\n '); return { status: commands ? 200 : 404, @@ -48,11 +44,13 @@ export class CLIService { this.registerCommand({ name: 'whoami', description: 'Returns user identity', - execute: () => ({ - status: localStorage.getItem('user') ? 200 : 404, - output: localStorage.getItem('user') || 'Unknown' - } - ) + execute: () => { + const username = this.userService.user.name?.trim(); + return { + status: username ? 200 : 404, + output: username || 'Unknown' + }; + } }); this.registerCommand({ name: 'exit', @@ -80,7 +78,6 @@ export class CLIService { name: 'leet', description: 'Convert text to leet speak', execute: (args: string[]) => { - console.warn('params', args); if (!args.length) { return { status: 400, @@ -140,7 +137,7 @@ export class CLIService { const isAuthorized = (password: string) => { return password === '1234'; } // implement secure validation - if (!isAuthorized) { + if (!isAuthorized(password)) { return { status: 401, output: 'Unauthorized' @@ -152,13 +149,13 @@ export class CLIService { output: `Already logged in as admin.` }; } - this.userService.updateUser({name: username, level: 2, score: this.userService.user.score + 1}); + void this.userService.updateUser({name: username, level: 2, score: this.userService.user.score + 1}); return { status: 201, output: `Switched to user: ${username}` }; } - this.userService.updateUser({name: username, level: 1, score: 0}); + void this.userService.updateUser({name: username, level: 1, score: 0}); return { status: 201, output: `Switched to user: ${username}` @@ -182,8 +179,8 @@ export class CLIService { output: 'Unauthorized!' }; } else { - this.userService.updateUser({[param]: value}); - this.userService.updateUser({name: 'unknown'}); + void this.userService.updateUser({[param]: value}); + void this.userService.updateUser({name: 'unknown'}); return { status: 200, output: `Updated user: ${param} to ${value}` diff --git a/src/app/components/game/services/file-system.service.ts b/src/app/components/game/services/file-system.service.ts index 32a0f2a..ca944a4 100644 --- a/src/app/components/game/services/file-system.service.ts +++ b/src/app/components/game/services/file-system.service.ts @@ -108,8 +108,6 @@ export class FileSystemService { {name: 'Documents', path: '/Documents', icon: this.getIconForType('folder')}, {name: 'Photos', path: '/Photos', icon: this.getIconForType('folder')}, {name: 'Videos', path: '/Videos', icon: this.getIconForType('folder')}, - {name: 'Downloads', path: '/Downloads', icon: this.getIconForType('folder')}, - {name: 'Music', path: '/Music', icon: this.getIconForType('folder')}, {name: 'Recents', path: '/Recents', icon: this.getIconForType('folder')} ]; @@ -278,7 +276,7 @@ export class FileSystemService { } return { - name: faker.person.jobType(), + name, path: `${path}`.replace(/\/+/g, '/'), // Normalize the path created: new Date().toISOString(), modified: new Date().toISOString(), @@ -301,7 +299,7 @@ export class FileSystemService { // Normalize the parent folder path and create the folder const folderPath = `${path.replace(/\/+$/, '')}/${name}`; - const folder: FileEntry = this.createFolder(name, path, false); + const folder: FileEntry = this.createFolder(name, folderPath, false); // Create a set to track existing children paths and ensure no duplicates const childPaths = new Set(); diff --git a/src/app/components/game/services/log.service.ts b/src/app/components/game/services/log.service.ts index 2329dbb..d447539 100644 --- a/src/app/components/game/services/log.service.ts +++ b/src/app/components/game/services/log.service.ts @@ -1,5 +1,6 @@ -import {Injectable} from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {BehaviorSubject, Observable} from 'rxjs'; +import {FirestoreService} from '../../../services/firebase/firestore.service'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -15,6 +16,13 @@ export class LogService { private logSubject = new BehaviorSubject([]); private mutedLevels: Set = new Set(); private globalMute = false; + private firestore: FirestoreService | null = (() => { + try { + return inject(FirestoreService); + } catch { + return null; + } + })(); get logs(): LogEntry[] { return [...this.logBuffer]; @@ -87,6 +95,24 @@ export class LogService { if (this.globalMute || this.mutedLevels.has(level)) return; const entry: LogEntry = {level, message, timestamp: new Date()}; + + if (this.firestore) { + this.firestore.saveLogEntry( + { + level: entry.level, + message: typeof entry.message === 'string' ? entry.message : '', + userId: 'unknown', + metadata: 'log entry' + }, + ).subscribe({ + next: () => { + // Successfully saved log to Firestore + }, + error: (err) => { + console.error('Failed to save log to Firestore:', err); + } + }); + } this.logBuffer.push(entry); this.logSubject.next([...this.logBuffer]); diff --git a/src/app/components/game/services/settings.service.ts b/src/app/components/game/services/settings.service.ts index 6471c93..bfb2e83 100644 --- a/src/app/components/game/services/settings.service.ts +++ b/src/app/components/game/services/settings.service.ts @@ -3,6 +3,7 @@ import {BehaviorSubject} from 'rxjs'; import {StorageService} from './storage.service'; import {NotificationService} from './notification.service'; import {FormControl, FormGroup} from '@angular/forms'; +import {Subscription} from 'rxjs'; export interface Setting { @@ -48,6 +49,7 @@ export interface SettingsSet { export class SettingsService { private settings = new Map>(); private settingSets = new Map>(); + private settingValueSubjects = new Map>(); constructor(private storageService: StorageService, private notify: NotificationService) { this.loadPersistedSettings(); @@ -225,25 +227,34 @@ export class SettingsService { } getSettingValue$(setId: string, settingId?: string): BehaviorSubject { + const cacheKey = settingId ? `${setId}:${settingId}` : setId; + const existingSubject = this.settingValueSubjects.get(cacheKey) as BehaviorSubject | undefined; + if (existingSubject) { + return existingSubject; + } + if (!settingId) { // Return observable for a single standalone setting const subject = this.getSetting(setId); - return subject ? (subject as BehaviorSubject) : new BehaviorSubject(null); + const fallback = subject ? (subject as BehaviorSubject) : new BehaviorSubject(null); + this.settingValueSubjects.set(cacheKey, fallback as BehaviorSubject); + return fallback; } // Create an on-the-fly observable to watch settingSet changes const settingSet$ = this.getSettingSet(setId); + const subject = new BehaviorSubject(null); if (settingSet$) { - const subject = new BehaviorSubject(null); + const initialValue = this.findSettingValueInSet(setId, settingId); + subject.next(initialValue); settingSet$.subscribe((set) => { const found = set.find((setting) => setting.id === settingId); subject.next(found ? (found.value as T) : null); }); - return subject; } - - return new BehaviorSubject(null); + this.settingValueSubjects.set(cacheKey, subject as BehaviorSubject); + return subject; } createFormGroupForSettings(setId: string): FormGroup | null { @@ -261,9 +272,10 @@ export class SettingsService { ); } - syncFormGroupWithSettingSet(formGroup: FormGroup, setId: string): void { - formGroup.valueChanges.subscribe((newValues) => { + syncFormGroupWithSettingSet(formGroup: FormGroup, setId: string): Subscription { + return formGroup.valueChanges.subscribe((newValues) => { const settingSet = this.getSettingSet(setId); + console.warn('settingSet', settingSet?.value); if (!settingSet) { console.warn(`No settings set found with ID: "${setId}".`); return; diff --git a/src/app/components/game/services/sound.service.ts b/src/app/components/game/services/sound.service.ts index a69bc87..56ddb20 100644 --- a/src/app/components/game/services/sound.service.ts +++ b/src/app/components/game/services/sound.service.ts @@ -1,8 +1,9 @@ -import {Inject, Injectable, InjectionToken, OnDestroy, OnInit} from '@angular/core'; +import {Inject, Injectable, OnDestroy, OnInit} from '@angular/core'; import {BehaviorSubject} from 'rxjs'; import {SettingsService} from './settings.service'; import {LogService} from './log.service'; import {PatchService} from './patch.service'; +import {SOUND_SERVICE_CONFIG, SoundServiceConfig} from '../../../providers/sound/sound.module'; interface SoundOptions { loop?: boolean; @@ -11,22 +12,6 @@ interface SoundOptions { onEnded?: () => void; } -export interface SoundServiceConfig { - debounceInterval: number; - maxCacheSize: number; - defaultVolume: number; - basePath: string; -} - -export const SOUND_SERVICE_CONFIG = new InjectionToken('SOUND_SERVICE_CONFIG'); - -export const defaultSoundConfig: SoundServiceConfig = { - debounceInterval: 60, - maxCacheSize: 20, - defaultVolume: 1.0, - basePath: 'assets/audio/efx/' -}; - @Injectable({ providedIn: 'root' }) export class SoundService implements OnDestroy, OnInit { @@ -51,10 +36,10 @@ export class SoundService implements OnDestroy, OnInit { private debounceIntervalMs = 60; // Adjust as needed constructor( - @Inject(SOUND_SERVICE_CONFIG) private config: SoundServiceConfig, private settingsService: SettingsService, private readonly patchService: PatchService, - private readonly logger: LogService + private readonly logger: LogService, + @Inject(SOUND_SERVICE_CONFIG) private soundConfig: SoundServiceConfig ) { } @@ -146,7 +131,7 @@ export class SoundService implements OnDestroy, OnInit { const sanitizedName = this.sanitizeFileName(fileName); - const path = `${this.config.basePath}${sanitizedName}`; + const path = `${this.soundConfig.basePath}${sanitizedName}`; let audio = this.audioCache.get(path); @@ -185,7 +170,7 @@ export class SoundService implements OnDestroy, OnInit { stop(fileName: string) { - const path = this.config.basePath + fileName; + const path = this.soundConfig.basePath + fileName; const audio = this.audioCache.get(path); if (audio) { audio.pause(); @@ -195,7 +180,7 @@ export class SoundService implements OnDestroy, OnInit { } pause(fileName: string) { - const path = this.config.basePath + fileName; + const path = this.soundConfig.basePath + fileName; const audio = this.audioCache.get(path); if (audio) { audio.pause(); @@ -227,7 +212,7 @@ export class SoundService implements OnDestroy, OnInit { setVolume(fileName: string, volume: number) { - const path = this.config.basePath + fileName; + const path = this.soundConfig.basePath + fileName; const audio = this.audioCache.get(path); if (audio) { // Convert volume from 0-100 range to 0-1 range diff --git a/src/app/components/game/services/storage.service.ts b/src/app/components/game/services/storage.service.ts index 4be632e..54b9b53 100644 --- a/src/app/components/game/services/storage.service.ts +++ b/src/app/components/game/services/storage.service.ts @@ -7,6 +7,8 @@ interface StorageStrategy { getItem(key: string): Promise; + getAllKeys(): Promise; + removeItem(key: string): Promise; clear(): Promise; @@ -95,8 +97,12 @@ export class StorageService { } getAllKeys(): Observable { - const keys = Object.keys(localStorage); - return of(keys); + return from(this.strategy.getAllKeys()).pipe( + catchError(error => { + console.error('Storage operation failed:', error); + return of([]); + }) + ); } } @@ -163,6 +169,18 @@ class IndexedDBStrategy implements StorageStrategy { }); } + async getAllKeys(): Promise { + const db = await this.db; + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, 'readonly'); + const store = transaction.objectStore(this.storeName); + const request = store.getAllKeys(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result.map((key) => String(key))); + }); + } + async clear(): Promise { const db = await this.db; return new Promise((resolve, reject) => { @@ -203,6 +221,15 @@ class LocalStorageStrategy implements StorageStrategy { } } + async getAllKeys(): Promise { + try { + return Object.keys(localStorage); + } catch (error) { + console.error('LocalStorage operation failed:', error); + return []; + } + } + async clear(): Promise { try { localStorage.clear(); diff --git a/src/app/components/game/services/typewriter.service.ts b/src/app/components/game/services/typewriter.service.ts index 678e951..f255004 100644 --- a/src/app/components/game/services/typewriter.service.ts +++ b/src/app/components/game/services/typewriter.service.ts @@ -17,13 +17,18 @@ interface TypewriterLine { onComplete?: () => void; } +interface CompletedLineEvent { + text: string; + agent: 'user' | 'system'; +} + @Injectable({ providedIn: 'root' }) export class TypewriterService { public typedText$ = new BehaviorSubject(''); - public lineCompleted$ = new Subject(); + public lineCompleted$ = new Subject(); private queue: TypewriterLine[] = []; private currentIndex = 0; - private typingInterval: any; + private typingInterval: ReturnType | null = null; private lineBuffer = ''; public activeMode$ = new BehaviorSubject('default'); @@ -72,6 +77,8 @@ export class TypewriterService { ' ' ) + line.onBegin?.(); + const config = this.getTypingConfig(mode); this.typingInterval = setInterval(() => this.typeNextChar(line), config.speed); } @@ -79,7 +86,6 @@ export class TypewriterService { private typeNextChar(line: TypewriterLine) { const mode = line.mode ?? 'default'; const config = this.getTypingConfig(mode); - line.onBegin?.(); if (this.currentIndex < line.text.length) { const char = line.text[this.currentIndex]; @@ -98,7 +104,9 @@ export class TypewriterService { line.onCharTyped?.(char, this.currentIndex, mode); } else { - clearInterval(this.typingInterval); + if (this.typingInterval !== null) { + clearInterval(this.typingInterval); + } this.typingInterval = null; line.onComplete?.(); @@ -118,7 +126,9 @@ export class TypewriterService { } clear() { - clearInterval(this.typingInterval); + if (this.typingInterval !== null) { + clearInterval(this.typingInterval); + } this.typingInterval = null; this.queue = []; this.currentIndex = 0; diff --git a/src/app/components/game/system/dock/dock.component.spec.ts b/src/app/components/game/system/dock/dock.component.spec.ts index 8665c70..b8d1225 100644 --- a/src/app/components/game/system/dock/dock.component.spec.ts +++ b/src/app/components/game/system/dock/dock.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import { DockComponent } from './dock.component'; @@ -8,7 +10,8 @@ describe('DockComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [DockComponent] + imports: [DockComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/system/dock/dock.component.ts b/src/app/components/game/system/dock/dock.component.ts index fc6e95c..36f89e5 100644 --- a/src/app/components/game/system/dock/dock.component.ts +++ b/src/app/components/game/system/dock/dock.component.ts @@ -120,8 +120,8 @@ export class DockComponent { this.appManager.openApplication(id, args); } - closeApp(id: string, args?: any) { - this.appManager.closeApplication(id, args); + closeApp(id: string) { + this.appManager.closeApplication(id); } trash(key: string) { diff --git a/src/app/components/game/system/finder-app/finder-app.component.spec.ts b/src/app/components/game/system/finder-app/finder-app.component.spec.ts index 8503f42..67433e2 100644 --- a/src/app/components/game/system/finder-app/finder-app.component.spec.ts +++ b/src/app/components/game/system/finder-app/finder-app.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import { FinderAppComponent } from './finder-app.component'; @@ -8,7 +10,8 @@ describe('FinderAppComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [FinderAppComponent] + imports: [FinderAppComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/system/full-screen-background/background-example.component.ts b/src/app/components/game/system/full-screen-background/background-example.component.ts new file mode 100644 index 0000000..9150935 --- /dev/null +++ b/src/app/components/game/system/full-screen-background/background-example.component.ts @@ -0,0 +1,277 @@ +import {ChangeDetectorRef, Component} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {BackgroundConfig, FullScreenBackgroundComponent, ParallaxElement} from './full-screen-background.component'; +import {MainHeaderComponent} from '../../../main/main-header.component'; +import {SocialsComponent} from '../../../main/socials/socials.component'; +import {RouterLink} from '@angular/router'; + +@Component({ + selector: 'app-background-example', + standalone: true, + imports: [CommonModule, FullScreenBackgroundComponent, MainHeaderComponent, SocialsComponent, RouterLink], + template: ` +
+ +
+ +
+ + + +
+

+ Welcome to the Game. +

+ +
+

This should do something shouldn't it?

+
+ +
+
+
+
+
+
+
+ + +
+ +
+ +
+

More Content Below

+
+ +
+

More Content + Below

+

Just keep scrolling you know you want to

+
+
+
+
+
+
+
+
+
+ +
+

SOMETHING SHOULD BE SAID HERE

+
+ + @defer (on hover; prefetch on immediate) { +
+ + +
+ } @placeholder () { +
+

Hover to see effect!

+
+ } + +
+
+

YOU HAVE BEEN RICK ROLLED

+

Thanks for playing

+ +
+ + `, + styles: [` + .content-container { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 10; + } + + .hero-title { + font-size: 4rem; + font-weight: bold; + color: white; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + margin-bottom: 2rem; + text-align: center; + } + + .floating-card { + backdrop-filter: blur(10px); + border-radius: 1rem; + padding: 2rem; + margin: 2rem; + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .background-shapes { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + } + + .shape { + position: absolute; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + } + + .shape-1 { + width: 100px; + height: 100px; + top: 20%; + left: 10%; + } + + .shape-2 { + width: 150px; + height: 150px; + top: 60%; + right: 15%; + } + + .shape-3 { + width: 80px; + height: 80px; + top: 40%; + left: 70%; + } + + .additional-content { + min-height: 100vh; + padding: 4rem 2rem; + @apply flex flex-col items-center justify-center bg-zinc-600 text-gray-200; + } + + .video-section { + @apply flex flex-col items-center justify-center w-full min-h-screen bg-black text-gray-300 font-mono; + } + + .additional-content h2 { + font-size: 2rem; + margin-bottom: 1rem; + } + `] +}) +export class BackgroundExampleComponent { + // Video background configuration + isDark = false; + imageSrc = 'assets/images/backgrounds/night.webp'; + + backgroundConfig: BackgroundConfig = { + type: 'image', + source: this.imageSrc, + fallbackImage: this.imageSrc, + opacity: 0.8, + blur: 6, + overlay: { + color: '#000000', + opacity: 0.3 + } + }; + + // Alternative configurations you can switch between + imageBackgroundConfig: BackgroundConfig = { + type: 'image', + source: this.imageSrc, + opacity: 1, + blur: 2, + overlay: { + color: '#4f46e5', + opacity: 0.4 + } + }; + + gradientBackgroundConfig: BackgroundConfig = { + type: 'gradient', + gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + opacity: 1 + }; + + // Parallax elements configuration + parallaxElements: ParallaxElement[] = [ + { + id: 'main-title', + speed: 0.3, + direction: 'vertical', + initialOffset: {x: 0, y: 0} + }, + { + id: 'floating-element', + speed: 0.5, + direction: 'vertical', + initialOffset: {x: 0, y: 0} + }, + { + id: 'background-shapes', + speed: 0.2, + direction: 'both', + initialOffset: {x: 0, y: 0} + } + ]; + + constructor(private cd: ChangeDetectorRef) { + + } + + // Method to switch background types + switchToVideo(): void { + this.backgroundConfig = { + type: 'video', + source: 'assets/videos/background-video.mp4', + fallbackImage: 'assets/images/fallback-bg.jpg', + opacity: 0.8, + overlay: { + color: '#000000', + opacity: 0.3 + } + }; + } + + toggleBackground(): void { + + console.warn('TOGGLE BACKGROUND'); + this.isDark = !this.isDark; + if (this.isDark) { + this.imageSrc = 'assets/images/backgrounds/night.webp'; + } else { + this.imageSrc = 'assets/images/backgrounds/day.webp'; + } + this.cd.detectChanges(); + } + + switchToImage(): void { + this.backgroundConfig = this.imageBackgroundConfig; + } + + switchToGradient(): void { + this.backgroundConfig = this.gradientBackgroundConfig; + } +} diff --git a/src/app/components/game/system/full-screen-background/background.service.ts b/src/app/components/game/system/full-screen-background/background.service.ts new file mode 100644 index 0000000..ee0db53 --- /dev/null +++ b/src/app/components/game/system/full-screen-background/background.service.ts @@ -0,0 +1,211 @@ +import {Injectable, signal} from '@angular/core'; +import {BackgroundConfig, ParallaxElement, VideoProvider} from './full-screen-background.component'; + +export interface BackgroundPreset { + id: string; + name: string; + config: BackgroundConfig; + parallaxElements?: ParallaxElement[]; +} + +@Injectable({ + providedIn: 'root' +}) +export class BackgroundService { + private currentConfig = signal({type: 'solid', color: '#000000'}); + private currentParallaxElements = signal([]); + + private presets: BackgroundPreset[] = [ + { + id: 'youtube-hero', + name: 'YouTube Hero Video', + config: { + type: 'video', + videoProvider: { + type: 'youtube', + videoId: 'dQw4w9WgXcQ', // Example video ID + autoplay: true, + muted: true, + loop: true, + controls: false, + quality: 'hd720' + }, + fallbackImage: 'assets/images/youtube-fallback.jpg', + opacity: 0.8, + overlay: {color: '#000000', opacity: 0.3} + } + }, + { + id: 'vimeo-cinematic', + name: 'Vimeo Cinematic', + config: { + type: 'video', + videoProvider: { + type: 'vimeo', + videoId: '148751763', // Example video ID + autoplay: true, + muted: true, + loop: true, + controls: false + }, + fallbackImage: 'assets/images/vimeo-fallback.jpg', + opacity: 1, + overlay: {color: '#1a1a1a', opacity: 0.4} + } + } + ]; + + getCurrentConfig() { + return this.currentConfig.asReadonly(); + } + + getCurrentParallaxElements() { + return this.currentParallaxElements.asReadonly(); + } + + getPresets(): BackgroundPreset[] { + return [...this.presets]; + } + + // YouTube URL parsing utilities + extractYouTubeVideoId(url: string): string | null { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, + /youtube\.com\/v\/([^&\n?#]+)/ + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return match[1]; + } + } + return null; + } + + // Vimeo URL parsing utilities + extractVimeoVideoId(url: string): string | null { + const patterns = [ + /vimeo\.com\/(\d+)/, + /player\.vimeo\.com\/video\/(\d+)/ + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return match[1]; + } + } + return null; + } + + createYouTubeConfigFromUrl( + url: string, + options?: { + videoProvider?: Partial>; + background?: Partial>; + } + ): BackgroundConfig | null { + return this.createVideoConfigFromProviderUrl(url, 'youtube', options); + } + + createVimeoConfigFromUrl( + url: string, + options?: { + videoProvider?: Partial>; + background?: Partial>; + } + ): BackgroundConfig | null { + return this.createVideoConfigFromProviderUrl(url, 'vimeo', options); + } + + + createVideoConfigFromProviderUrl( + url: string, + providerType: 'youtube' | 'vimeo', + options?: { + videoProvider?: Partial>; + background?: Partial>; + } + ): BackgroundConfig | null { + // Extract video ID based on provider type + const videoId = providerType === 'youtube' + ? this.extractYouTubeVideoId(url) + : this.extractVimeoVideoId(url); + + if (!videoId) return null; + + return { + type: 'video', + videoProvider: { + type: providerType, + videoId, + autoplay: true, + muted: true, + loop: true, + controls: false, + ...options?.videoProvider + }, + opacity: 1, + ...options?.background + }; + } + + + private isDirectVideoUrl(url: string): boolean { + const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi']; + const lowercaseUrl = url.toLowerCase(); + return videoExtensions.some(ext => lowercaseUrl.includes(ext)); + } + + // Existing methods... + setConfig(config: BackgroundConfig): void { + this.currentConfig.set(config); + } + + setParallaxElements(elements: ParallaxElement[]): void { + this.currentParallaxElements.set(elements); + } + + applyPreset(presetId: string): boolean { + const preset = this.getPreset(presetId); + if (preset) { + this.currentConfig.set(preset.config); + if (preset.parallaxElements) { + this.currentParallaxElements.set(preset.parallaxElements); + } + return true; + } + return false; + } + + getPreset(id: string): BackgroundPreset | undefined { + return this.presets.find(preset => preset.id === id); + } + + addPreset(preset: BackgroundPreset): void { + const existingIndex = this.presets.findIndex(p => p.id === preset.id); + if (existingIndex > -1) { + this.presets[existingIndex] = preset; + } else { + this.presets.push(preset); + } + } + + // YouTube-specific utilities + getYouTubeThumbnail(videoId: string, quality: 'default' | 'medium' | 'high' | 'standard' | 'maxres' = 'high'): string { + return `https://img.youtube.com/vi/${videoId}/${quality}default.jpg`; + } + + // Vimeo-specific utilities + async getVimeoThumbnail(videoId: string): Promise { + try { + const response = await fetch(`https://vimeo.com/api/v2/video/${videoId}.json`); + const data = await response.json(); + return data[0]?.thumbnail_large || null; + } catch (error) { + console.error('Failed to fetch Vimeo thumbnail:', error); + return null; + } + } +} diff --git a/src/app/components/game/system/full-screen-background/full-screen-background.component.html b/src/app/components/game/system/full-screen-background/full-screen-background.component.html new file mode 100644 index 0000000..cf7e705 --- /dev/null +++ b/src/app/components/game/system/full-screen-background/full-screen-background.component.html @@ -0,0 +1 @@ +

full-screen-background works!

diff --git a/src/app/components/game/system/full-screen-background/full-screen-background.component.spec.ts b/src/app/components/game/system/full-screen-background/full-screen-background.component.spec.ts new file mode 100644 index 0000000..43e64de --- /dev/null +++ b/src/app/components/game/system/full-screen-background/full-screen-background.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {FullScreenBackgroundComponent} from './full-screen-background.component'; + +describe('FullScreenBackgroundComponent', () => { + let component: FullScreenBackgroundComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FullScreenBackgroundComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(FullScreenBackgroundComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/game/system/full-screen-background/full-screen-background.component.ts b/src/app/components/game/system/full-screen-background/full-screen-background.component.ts new file mode 100644 index 0000000..877c27e --- /dev/null +++ b/src/app/components/game/system/full-screen-background/full-screen-background.component.ts @@ -0,0 +1,741 @@ +import { + Component, + Input, + OnInit, + OnDestroy, + ElementRef, + ViewChild, + HostListener, + AfterViewInit, + ChangeDetectionStrategy, + signal, + computed, + PLATFORM_ID, + Inject +} from '@angular/core'; +import {CommonModule, isPlatformBrowser} from '@angular/common'; +import {DomSanitizer} from '@angular/platform-browser'; + +export interface ParallaxElement { + id: string; + element?: HTMLElement; + speed: number; // 0-1, where 1 is normal scroll speed + direction?: 'vertical' | 'horizontal' | 'both'; + initialOffset?: { x: number; y: number }; +} + +export interface VideoProvider { + type: 'youtube' | 'vimeo' | 'direct'; + videoId?: string; // For YouTube/Vimeo + url?: string; // For direct video files + autoplay?: boolean; + muted?: boolean; + loop?: boolean; + controls?: boolean; + startTime?: number; // In seconds + endTime?: number; // In seconds + quality?: 'auto' | 'small' | 'medium' | 'large' | 'hd720' | 'hd1080'; +} + +export interface BackgroundConfig { + type: 'video' | 'image' | 'gradient' | 'solid'; + source?: string; // video URL or image URL + videoProvider?: VideoProvider; // For YouTube/Vimeo integration + fallbackImage?: string; // fallback for video + gradient?: string; // CSS gradient + color?: string; // solid color + opacity?: number; + blur?: number; + overlay?: { + color: string; + opacity: number; + }; +} + +declare global { + interface Window { + YT: any; + onYouTubeIframeAPIReady: () => void; + Vimeo: any; + } +} + +@Component({ + selector: 'app-full-screen-background', + standalone: true, + imports: [CommonModule], + template: ` +
+ + + + + +
+
+ Video fallback +
+ + +
+ + Video fallback +
+ + + Background + + +
+
+ + +
+
+ + +
+ +
+
+ `, + styles: [` + .fullscreen-background { + position: relative; + width: 100vw; + overflow: hidden; + z-index: 0; + } + + .background-media { + position: absolute; + top: 50%; + left: 50%; + min-width: 100%; + min-height: 100%; + width: auto; + height: auto; + transform: translate(-50%, -50%); + object-fit: cover; + z-index: -2; + } + + .video-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -2; + } + + .youtube-player { + position: absolute; + top: 50%; + left: 50%; + width: 100vw; + height: 56.25vw; /* 16:9 aspect ratio */ + min-height: 100vh; + min-width: 177.77vh; /* 16:9 aspect ratio */ + transform: translate(-50%, -50%); + } + + .vimeo-player { + position: absolute; + top: 50%; + left: 50%; + width: 100vw; + height: 56.25vw; /* 16:9 aspect ratio */ + min-height: 100vh; + min-width: 177.77vh; /* 16:9 aspect ratio */ + transform: translate(-50%, -50%); + border: none; + } + + .background-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + } + + .parallax-container { + position: relative; + width: 100%; + height: 100%; + z-index: 1; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +/** + * Fullscreen background component. + * + * Supports: + * - Solid background + * - Video background + * - Image background + * - Gradient background + * - Overlay + * - Parallax elements + * - YouTube video background + * - Vimeo video background + * - Blur filter + * + * Example Usage: + * + *
+ *
+ * + *
+ * + * + > + * + * + */ +export class FullScreenBackgroundComponent implements OnInit, AfterViewInit, OnDestroy { + @Input() config: BackgroundConfig = {type: 'solid', color: '#000000'}; + @Input() height: number | string = '100vh'; + @Input() enableParallax = true; + @Input() parallaxElements: ParallaxElement[] = []; + @Input() parallaxIntensity = 1; // Global multiplier for parallax effects + + @ViewChild('videoElement') videoElement?: ElementRef; + @ViewChild('youtubePlayer') youtubePlayerElement?: ElementRef; + @ViewChild('vimeoPlayer') vimeoPlayerElement?: ElementRef; + @ViewChild('parallaxContainer') parallaxContainer?: ElementRef; + + private scrollY = signal(0); + private windowHeight = signal(window.innerHeight); + protected youtubeReady = signal(false); + protected vimeoReady = signal(false); + private resizeObserver?: ResizeObserver; + private animationFrame?: number; + private youtubePlayer?: any; + private vimeoPlayer?: any; + + constructor( + @Inject(PLATFORM_ID) private platformId: Object, + private sanitizer: DomSanitizer + ) { + } + + containerHeight = computed(() => { + if (typeof this.height === 'number') { + return this.height; + } + if (this.height === '100vh') { + return this.windowHeight(); + } + return parseInt(this.height) || this.windowHeight(); + }); + + solidBackground = computed(() => { + if (this.config.type === 'solid' && this.config.color) { + return this.config.color; + } + return 'transparent'; + }); + + filterStyles = computed(() => { + const filters: string[] = []; + if (this.config.blur) { + filters.push(`blur(${this.config.blur}px)`); + } + return filters.join(' '); + }); + + vimeoEmbedUrl = computed(() => { + if (this.config.videoProvider?.type === 'vimeo' && this.config.videoProvider.videoId) { + const provider = this.config.videoProvider; + const params = new URLSearchParams(); + + params.set('autoplay', provider.autoplay !== false ? '1' : '0'); + params.set('muted', provider.muted !== false ? '1' : '0'); + params.set('loop', provider.loop !== false ? '1' : '0'); + params.set('controls', provider.controls ? '1' : '0'); + params.set('background', '1'); // Vimeo background mode + + if (provider.startTime) { + params.set('t', `${provider.startTime}s`); + } + + const url = `https://player.vimeo.com/video/${provider.videoId}?${params.toString()}`; + return this.sanitizer.bypassSecurityTrustResourceUrl(url); + } + return null; + }); + + ngOnInit(): void { + this.initializeParallaxElements(); + } + + ngAfterViewInit(): void { + if (this.enableParallax) { + this.setupParallaxListeners(); + } + this.setupResizeObserver(); + + if (isPlatformBrowser(this.platformId)) { + this.initializeVideoProviders(); + } + } + + ngOnDestroy(): void { + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + } + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + if (this.youtubePlayer) { + this.youtubePlayer.destroy(); + } + window.removeEventListener('scroll', this.onScroll); + window.removeEventListener('resize', this.onResize); + } + + private async initializeVideoProviders(): Promise { + if (this.config.type === 'video' && this.config.videoProvider) { + switch (this.config.videoProvider.type) { + case 'youtube': + await this.initializeYouTube(); + break; + case 'vimeo': + await this.initializeVimeo(); + break; + default: + // Direct video, no additional setup needed + break; + } + } + } + + private async initializeYouTube(): Promise { + if (!this.config.videoProvider?.videoId || !this.youtubePlayerElement) return; + + try { + await this.loadYouTubeAPI(); + this.createYouTubePlayer(); + } catch (error) { + console.error('Failed to initialize YouTube player:', error); + this.onVideoError(); + } + } + + private async initializeVimeo(): Promise { + if (!this.config.videoProvider?.videoId || !this.vimeoPlayerElement) return; + + try { + await this.loadVimeoAPI(); + this.createVimeoPlayer(); + } catch (error) { + console.error('Failed to initialize Vimeo player:', error); + this.onVideoError(); + } + } + + private loadYouTubeAPI(): Promise { + return new Promise((resolve, reject) => { + if (window.YT && window.YT.Player) { + resolve(); + return; + } + + window.onYouTubeIframeAPIReady = () => { + resolve(); + }; + + if (!document.querySelector('script[src*="youtube.com/iframe_api"]')) { + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/iframe_api'; + script.onerror = reject; + document.body.appendChild(script); + } + + // Timeout after 10 seconds + setTimeout(() => reject(new Error('YouTube API failed to load')), 10000); + }); + } + + private loadVimeoAPI(): Promise { + return new Promise((resolve, reject) => { + if (window.Vimeo && window.Vimeo.Player) { + resolve(); + return; + } + + if (!document.querySelector('script[src*="player.vimeo.com"]')) { + const script = document.createElement('script'); + script.src = 'https://player.vimeo.com/api/player.js'; + script.onload = () => resolve(); + script.onerror = reject; + document.body.appendChild(script); + } else { + resolve(); + } + + // Timeout after 10 seconds + setTimeout(() => reject(new Error('Vimeo API failed to load')), 10000); + }); + } + + private createYouTubePlayer(): void { + if (!this.youtubePlayerElement || !this.config.videoProvider?.videoId) return; + + const provider = this.config.videoProvider; + + this.youtubePlayer = new window.YT.Player(this.youtubePlayerElement.nativeElement, { + videoId: provider.videoId, + width: '100%', + height: '100%', + playerVars: { + autoplay: provider.autoplay !== false ? 1 : 0, + mute: provider.muted !== false ? 1 : 0, + loop: provider.loop !== false ? 1 : 0, + controls: provider.controls ? 1 : 0, + showinfo: 0, + rel: 0, + iv_load_policy: 3, + modestbranding: 1, + playsinline: 1, + start: provider.startTime || 0, + end: provider.endTime || 0, + hd: provider.quality === 'hd720' || provider.quality === 'hd1080' ? 1 : 0 + }, + events: { + onReady: (event: any) => { + this.youtubeReady.set(true); + if (provider.autoplay !== false) { + event.target.playVideo(); + } + this.onVideoLoaded(); + }, + onStateChange: (event: any) => { + // Loop the video if needed + if (event.data === window.YT.PlayerState.ENDED && provider.loop !== false) { + event.target.playVideo(); + } + }, + onError: () => { + this.onVideoError(); + } + } + }); + } + + private createVimeoPlayer(): void { + if (!this.vimeoPlayerElement || !this.config.videoProvider?.videoId) return; + + const provider = this.config.videoProvider; + + this.vimeoPlayer = new window.Vimeo.Player(this.vimeoPlayerElement.nativeElement, { + id: provider.videoId, + width: '100%', + height: '100%', + autoplay: provider.autoplay !== false, + muted: provider.muted !== false, + loop: provider.loop !== false, + controls: provider.controls || false, + background: true + }); + + this.vimeoPlayer.ready().then(() => { + this.vimeoReady.set(true); + this.onVideoLoaded(); + }).catch(() => { + this.onVideoError(); + }); + + if (provider.startTime) { + this.vimeoPlayer.setCurrentTime(provider.startTime); + } + } + + @HostListener('window:scroll', ['$event']) + onScroll(): void { + if (this.enableParallax) { + this.scrollY.set(window.pageYOffset); + this.updateParallax(); + } + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + this.windowHeight.set(window.innerHeight); + } + + private initializeParallaxElements(): void { + this.parallaxElements.forEach(element => { + if (!element.direction) { + element.direction = 'vertical'; + } + if (!element.initialOffset) { + element.initialOffset = {x: 0, y: 0}; + } + }); + } + + private setupParallaxListeners(): void { + window.addEventListener('scroll', this.onScroll.bind(this), {passive: true}); + window.addEventListener('resize', this.onResize.bind(this), {passive: true}); + } + + private setupResizeObserver(): void { + if (this.parallaxContainer) { + this.resizeObserver = new ResizeObserver(() => { + this.updateParallax(); + }); + this.resizeObserver.observe(this.parallaxContainer.nativeElement); + } + } + + private updateParallax(): void { + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + } + + this.animationFrame = requestAnimationFrame(() => { + this.parallaxElements.forEach(parallaxElement => { + if (parallaxElement.element) { + this.applyParallaxTransform(parallaxElement); + } else { + const element = document.getElementById(parallaxElement.id); + if (element) { + parallaxElement.element = element; + this.applyParallaxTransform(parallaxElement); + } + } + }); + }); + } + + private applyParallaxTransform(parallaxElement: ParallaxElement): void { + if (!parallaxElement.element) return; + + const scrollY = this.scrollY(); + const speed = parallaxElement.speed * this.parallaxIntensity; + const {x: initialX, y: initialY} = parallaxElement.initialOffset!; + + let transformX = initialX; + let transformY = initialY; + + switch (parallaxElement.direction) { + case 'vertical': + transformY = initialY + (scrollY * speed); + break; + case 'horizontal': + transformX = initialX + (scrollY * speed); + break; + case 'both': + transformX = initialX + (scrollY * speed * 0.5); + transformY = initialY + (scrollY * speed); + break; + } + + parallaxElement.element.style.transform = + `translate3d(${transformX}px, ${transformY}px, 0)`; + } + + // Video-specific methods + onVideoLoaded(): void { + console.log('Video background loaded successfully'); + } + + onVideoError(): void { + console.warn('Video background failed to load, falling back to image'); + } + + // Public methods for external control + playVideo(): void { + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.play().catch(console.error); + } else if (this.youtubePlayer) { + this.youtubePlayer.playVideo(); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.play(); + } + } + + pauseVideo(): void { + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.pause(); + } else if (this.youtubePlayer) { + this.youtubePlayer.pauseVideo(); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.pause(); + } + } + + setVideoVolume(volume: number): void { + const normalizedVolume = Math.max(0, Math.min(1, volume)); + + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.volume = normalizedVolume; + } else if (this.youtubePlayer) { + this.youtubePlayer.setVolume(normalizedVolume * 100); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.setVolume(normalizedVolume); + } + } + + muteVideo(): void { + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.muted = true; + } else if (this.youtubePlayer) { + this.youtubePlayer.mute(); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.setVolume(0); + } + } + + unmuteVideo(): void { + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.muted = false; + } else if (this.youtubePlayer) { + this.youtubePlayer.unMute(); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.setVolume(1); + } + } + + seekTo(timeInSeconds: number): void { + if (this.videoElement?.nativeElement) { + this.videoElement.nativeElement.currentTime = timeInSeconds; + } else if (this.youtubePlayer) { + this.youtubePlayer.seekTo(timeInSeconds); + } else if (this.vimeoPlayer) { + this.vimeoPlayer.setCurrentTime(timeInSeconds); + } + } + + // Parallax methods + addParallaxElement(element: ParallaxElement): void { + this.parallaxElements.push(element); + this.initializeParallaxElements(); + } + + removeParallaxElement(id: string): void { + const index = this.parallaxElements.findIndex(el => el.id === id); + if (index > -1) { + this.parallaxElements.splice(index, 1); + } + } + + updateParallaxIntensity(intensity: number): void { + this.parallaxIntensity = intensity; + this.updateParallax(); + } + + // Utility methods for creating video configs + static createYouTubeConfig(videoId: string, options?: Partial): VideoProvider { + return { + type: 'youtube', + videoId, + autoplay: true, + muted: true, + loop: true, + controls: false, + ...options + }; + } + + static createVimeoConfig(videoId: string, options?: Partial): VideoProvider { + return { + type: 'vimeo', + videoId, + autoplay: true, + muted: true, + loop: true, + controls: false, + ...options + }; + } + + static createDirectVideoConfig(url: string, options?: Partial): VideoProvider { + return { + type: 'direct', + url, + autoplay: true, + muted: true, + loop: true, + controls: false, + ...options + }; + } +} diff --git a/src/app/components/game/system/login-screen/login-screen.component.spec.ts b/src/app/components/game/system/login-screen/login-screen.component.spec.ts index 8b1ddec..9ac8278 100644 --- a/src/app/components/game/system/login-screen/login-screen.component.spec.ts +++ b/src/app/components/game/system/login-screen/login-screen.component.spec.ts @@ -1,14 +1,43 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {ActivatedRoute} from '@angular/router'; +import {of} from 'rxjs'; +import {RouterTestingModule} from '@angular/router/testing'; +import {AuthService} from '../../../../services/auth.service'; +import {UserService} from '../../services/user.service'; +import {SoundService} from '../../services/sound.service'; +import {MusicService} from '../../services/music.service'; +import {LogService} from '../../services/log.service'; import { LoginScreenComponent } from './login-screen.component'; describe('LoginScreenComponent', () => { let component: LoginScreenComponent; let fixture: ComponentFixture; + const authServiceMock = { + user$: of(null), + handleRedirectResult: jasmine.createSpy('handleRedirectResult').and.returnValue(of(null)), + signInWithEmail: jasmine.createSpy('signInWithEmail').and.returnValue(of(null)), + registerWithEmail: jasmine.createSpy('registerWithEmail').and.returnValue(of(null)), + loginWithGoogle: jasmine.createSpy('loginWithGoogle').and.returnValue(of(null)) + }; + const userServiceMock = { + updateUser: jasmine.createSpy('updateUser').and.returnValue(Promise.resolve()) + }; + const soundServiceMock = jasmine.createSpyObj('SoundService', ['stopAll']); + const musicServiceMock = jasmine.createSpyObj('MusicService', ['stopAll']); + const loggerMock = jasmine.createSpyObj('LogService', ['info', 'error', 'warn', 'debug']); beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [LoginScreenComponent] + imports: [LoginScreenComponent, RouterTestingModule], + providers: [ + {provide: AuthService, useValue: authServiceMock}, + {provide: UserService, useValue: userServiceMock}, + {provide: SoundService, useValue: soundServiceMock}, + {provide: MusicService, useValue: musicServiceMock}, + {provide: LogService, useValue: loggerMock}, + {provide: ActivatedRoute, useValue: {queryParams: of({})}} + ] }) .compileComponents(); diff --git a/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts b/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts index d809e95..4a717b2 100644 --- a/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts +++ b/src/app/components/game/system/settings-panel/panels/appearance-settings/appearance-settings.component.ts @@ -1,7 +1,8 @@ -import { Component, OnInit, inject } from '@angular/core'; +import {Component, OnDestroy, OnInit, inject} from '@angular/core'; import { CommonModule } from '@angular/common'; import {Setting, SettingsService} from '../../../../services/settings.service'; import {FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {Subscription} from 'rxjs'; export type ThemeOption = 'light' | 'dark' | 'system'; @Component({ @@ -20,16 +21,12 @@ export type ThemeOption = 'light' | 'dark' | 'system'; -
@@ -48,9 +45,10 @@ export type ThemeOption = 'light' | 'dark' | 'system'; `, styles: `` }) -export class AppearanceSettingsComponent implements OnInit { +export class AppearanceSettingsComponent implements OnInit, OnDestroy { private settingsService = inject(SettingsService); private readonly settingsSetId = 'appearance'; + private formSyncSub?: Subscription; formGroup!: FormGroup; accentColor: string = '#4f46e5'; theme: ThemeOption = 'light'; @@ -78,17 +76,12 @@ export class AppearanceSettingsComponent implements OnInit { if (formGroup) { this.formGroup = formGroup; this.settingKeys = Object.keys(this.formGroup.controls); - this.settingsService.syncFormGroupWithSettingSet(this.formGroup, this.settingsSetId); + console.warn('Form group created:', this.formGroup.value, this.settingKeys, this.settingsSetId); + this.formSyncSub = this.settingsService.syncFormGroupWithSettingSet(this.formGroup, this.settingsSetId); } } - getThemeClass(theme: string){ - if(!this.formGroup) return ''; - const themeControl = this.formGroup.get('theme'); - return (themeControl && themeControl.value === theme) ? 'border-2 border-blue-500 opacity-100 grayscale-0' : 'opacity-30 grayscale' - } - setTheme(theme: ThemeOption): void { this.settingsService.updateSettingSetWithSingleValue(this.settingsSetId,'theme', theme); } @@ -97,4 +90,8 @@ export class AppearanceSettingsComponent implements OnInit { console.log('Settings saved:', this.formGroup.value); } + ngOnDestroy(): void { + this.formSyncSub?.unsubscribe(); + } + } diff --git a/src/app/components/game/system/system-tray/system-tray.component.spec.ts b/src/app/components/game/system/system-tray/system-tray.component.spec.ts index cf4583e..665e884 100644 --- a/src/app/components/game/system/system-tray/system-tray.component.spec.ts +++ b/src/app/components/game/system/system-tray/system-tray.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import { SystemTrayComponent } from './system-tray.component'; @@ -8,7 +10,8 @@ describe('SystemTrayComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SystemTrayComponent] + imports: [SystemTrayComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/system/system-tray/system-tray.component.ts b/src/app/components/game/system/system-tray/system-tray.component.ts index b1f8158..319f7d6 100644 --- a/src/app/components/game/system/system-tray/system-tray.component.ts +++ b/src/app/components/game/system/system-tray/system-tray.component.ts @@ -47,7 +47,7 @@ import {SoundPlayerComponent} from '../sound-player/sound-player.component'; export class SystemTrayComponent { isVisible = signal(true); cursorY = signal(1000); - hoverThreshold = 20; + hoverThreshold = 40; autoHide = signal(false); isHoveringMenu = signal(false); menuOpen = signal(''); diff --git a/src/app/components/game/templates/app-window/app-window.component.spec.ts b/src/app/components/game/templates/app-window/app-window.component.spec.ts index 8773183..e376abe 100644 --- a/src/app/components/game/templates/app-window/app-window.component.spec.ts +++ b/src/app/components/game/templates/app-window/app-window.component.spec.ts @@ -14,7 +14,6 @@ describe('TerminalWindowComponent', () => { fixture = TestBed.createComponent(AppWindowComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/components/game/templates/app-window/app-window.component.ts b/src/app/components/game/templates/app-window/app-window.component.ts index 1b6167c..ee52475 100644 --- a/src/app/components/game/templates/app-window/app-window.component.ts +++ b/src/app/components/game/templates/app-window/app-window.component.ts @@ -6,7 +6,7 @@ import { Input, AfterViewInit, ViewContainerRef, - Type, computed, OnChanges, OnDestroy + Type, computed, OnChanges, OnDestroy, ComponentRef } from '@angular/core'; import {CommonModule} from '@angular/common'; import {CliGameComponent} from '../../apps/cli-game/cli-game.component'; @@ -72,6 +72,8 @@ export class AppWindowComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() focused: boolean = false; @Input() params: any; + private componentRef?: ComponentRef; + /** Font Awesome Icons */ faTimes = faTimes; faMinus = faMinus; @@ -108,7 +110,6 @@ export class AppWindowComponent implements AfterViewInit, OnChanges, OnDestroy { } ngOnChanges(changes: any) { - console.warn('AppWindowComponent: ngOnChanges', changes); if(changes.id){ this.focused = this.embeddedApp()?.id === changes.id.currentValue; } @@ -161,7 +162,8 @@ export class AppWindowComponent implements AfterViewInit, OnChanges, OnDestroy { private loadEmbeddedComponent(): void { if (this.embeddedComponent) { this.containerRef.clear(); - this.containerRef.createComponent(this.embeddedComponent); + this.componentRef = this.containerRef.createComponent(this.embeddedComponent); + this.componentRef.instance.params = this.params; } } diff --git a/src/app/components/game/templates/context-menu/context-menu.component.spec.ts b/src/app/components/game/templates/context-menu/context-menu.component.spec.ts index c61e2a1..149ef87 100644 --- a/src/app/components/game/templates/context-menu/context-menu.component.spec.ts +++ b/src/app/components/game/templates/context-menu/context-menu.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {CONTEXT_MENU_DATA} from '../../services/context-menu.service'; import { ContextMenuComponent } from './context-menu.component'; @@ -8,7 +9,11 @@ describe('ContextMenuComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ContextMenuComponent] + imports: [ContextMenuComponent], + providers: [{ + provide: CONTEXT_MENU_DATA, + useValue: {menuId: 'test-menu', items: []} + }] }) .compileComponents(); diff --git a/src/app/components/game/templates/intro-overlay/intro-overlay.component.spec.ts b/src/app/components/game/templates/intro-overlay/intro-overlay.component.spec.ts index 376ad73..cd3c658 100644 --- a/src/app/components/game/templates/intro-overlay/intro-overlay.component.spec.ts +++ b/src/app/components/game/templates/intro-overlay/intro-overlay.component.spec.ts @@ -1,14 +1,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {SoundService} from '../../services/sound.service'; +import {RouterTestingModule} from '@angular/router/testing'; import { IntroOverlayComponent } from './intro-overlay.component'; describe('IntroOverlayComponent', () => { let component: IntroOverlayComponent; let fixture: ComponentFixture; + const soundServiceMock = jasmine.createSpyObj('SoundService', ['bootAudio', 'playVariant']); + soundServiceMock.bootAudio.and.returnValue(Promise.resolve()); beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [IntroOverlayComponent] + imports: [IntroOverlayComponent, RouterTestingModule], + providers: [{provide: SoundService, useValue: soundServiceMock}] }) .compileComponents(); diff --git a/src/app/components/game/templates/tooltip-overlay/tooltip-overlay.component.spec.ts b/src/app/components/game/templates/tooltip-overlay/tooltip-overlay.component.spec.ts index 39a360d..66100f0 100644 --- a/src/app/components/game/templates/tooltip-overlay/tooltip-overlay.component.spec.ts +++ b/src/app/components/game/templates/tooltip-overlay/tooltip-overlay.component.spec.ts @@ -14,6 +14,18 @@ describe('TooltipOverlayComponent', () => { fixture = TestBed.createComponent(TooltipOverlayComponent); component = fixture.componentInstance; + component.hostElement = document.createElement('div'); + spyOn(component.hostElement, 'getBoundingClientRect').and.returnValue({ + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}) + }); fixture.detectChanges(); }); diff --git a/src/app/components/game/utils/level-loader/level-loader.component.spec.ts b/src/app/components/game/utils/level-loader/level-loader.component.spec.ts index 356dc19..dbd3d62 100644 --- a/src/app/components/game/utils/level-loader/level-loader.component.spec.ts +++ b/src/app/components/game/utils/level-loader/level-loader.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; import { LevelLoaderComponent } from './level-loader.component'; @@ -8,7 +10,8 @@ describe('LevelLoaderComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [LevelLoaderComponent] + imports: [LevelLoaderComponent], + providers: [provideHttpClient(), provideHttpClientTesting()] }) .compileComponents(); diff --git a/src/app/components/game/utils/notifications-server/notifications-server.component.html b/src/app/components/game/utils/notifications-server/notifications-server.component.html index 4a22924..ce50d76 100644 --- a/src/app/components/game/utils/notifications-server/notifications-server.component.html +++ b/src/app/components/game/utils/notifications-server/notifications-server.component.html @@ -1,7 +1,7 @@
setTimeout(res, 30)); } diff --git a/src/app/components/main/home-terminal-window/home-terminal-window.component.spec.ts b/src/app/components/main/home-terminal-window/home-terminal-window.component.spec.ts index 49db0cf..5177f55 100644 --- a/src/app/components/main/home-terminal-window/home-terminal-window.component.spec.ts +++ b/src/app/components/main/home-terminal-window/home-terminal-window.component.spec.ts @@ -1,14 +1,27 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {HomeTerminalWindowComponent} from './home-terminal-window.component'; +import {TypewriterService} from '../../game/services/typewriter.service'; +import {BehaviorSubject} from 'rxjs'; describe('HomeTerminalWindowComponent', () => { let component: HomeTerminalWindowComponent; let fixture: ComponentFixture; + const typedText$ = new BehaviorSubject(''); + const typewriterServiceMock = { + enableSound: jasmine.createSpy('enableSound'), + setVolume: jasmine.createSpy('setVolume'), + typedText$, + clear: jasmine.createSpy('clear'), + enqueueLine: jasmine.createSpy('enqueueLine'), + }; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [HomeTerminalWindowComponent] + imports: [HomeTerminalWindowComponent], + providers: [ + {provide: TypewriterService, useValue: typewriterServiceMock} + ] }) .compileComponents(); diff --git a/src/app/components/main/joke-tray/joke-tray.component.spec.ts b/src/app/components/main/joke-tray/joke-tray.component.spec.ts index 4c684dd..06fd46c 100644 --- a/src/app/components/main/joke-tray/joke-tray.component.spec.ts +++ b/src/app/components/main/joke-tray/joke-tray.component.spec.ts @@ -1,4 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {provideHttpClient} from '@angular/common/http'; +import {provideHttpClientTesting} from '@angular/common/http/testing'; +import {RouterTestingModule} from '@angular/router/testing'; +import {defaultSoundConfig, SOUND_SERVICE_CONFIG} from '../../../providers/sound/sound.module'; import { JokeTrayComponent } from './joke-tray.component'; @@ -8,7 +12,12 @@ describe('JokeTrayComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [JokeTrayComponent] + imports: [JokeTrayComponent, RouterTestingModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + {provide: SOUND_SERVICE_CONFIG, useValue: defaultSoundConfig} + ] }) .compileComponents(); diff --git a/src/app/components/main/joke-tray/joke-tray.component.ts b/src/app/components/main/joke-tray/joke-tray.component.ts index e3a3d4b..4703c8f 100644 --- a/src/app/components/main/joke-tray/joke-tray.component.ts +++ b/src/app/components/main/joke-tray/joke-tray.component.ts @@ -6,7 +6,7 @@ import {INotification, NotificationService} from '../../game/services/notificati import {JokesService} from '../../game/services/jokes.service'; import {SoundService} from '../../game/services/sound.service'; import {RouterLink} from '@angular/router'; -import {HOME_NOTIFY_CLASSES} from '../main.component'; +import {HOME_NOTIFY_CLASSES} from '../main.constants'; import {faSmile} from '@fortawesome/free-regular-svg-icons'; import {FaIconComponent} from '@fortawesome/angular-fontawesome'; import {TooltipDirective} from '../../game/directives/tooltip.directive'; diff --git a/src/app/components/main/main.component.html b/src/app/components/main/main.component.html index 6fa02e2..caa1c5d 100644 --- a/src/app/components/main/main.component.html +++ b/src/app/components/main/main.component.html @@ -42,7 +42,7 @@ } ]" description="Building a powerful operating system emulator with modern web technologies." - githubUrl="https://github.com/colinmichaels/" + githubUrl="https://github.com/ColinMichaels/ColinMichaels-Angular" title="Current Project(s)"> diff --git a/src/app/components/main/main.component.ts b/src/app/components/main/main.component.ts index 6849014..9c50af2 100644 --- a/src/app/components/main/main.component.ts +++ b/src/app/components/main/main.component.ts @@ -18,9 +18,7 @@ import {HomeTerminalWindowComponent} from './home-terminal-window/home-terminal- import {ProjectsOverviewComponent} from './projects-overview/projects-overview.component'; import {ProjectItemComponent} from './project-item/project-item.component'; import {DisclaimerComponent} from './disclaimer/disclaimer.component'; - - -export const HOME_NOTIFY_CLASSES = 'bg-black/80 text-green-500 border-2 border-green-500'; +import {HOME_NOTIFY_CLASSES} from './main.constants'; @Component({ selector: 'app-main', diff --git a/src/app/components/main/main.constants.ts b/src/app/components/main/main.constants.ts new file mode 100644 index 0000000..911cebc --- /dev/null +++ b/src/app/components/main/main.constants.ts @@ -0,0 +1,2 @@ +export const HOME_NOTIFY_CLASSES = 'bg-black/80 text-green-500 border-2 border-green-500'; + diff --git a/src/app/components/main/projects-overview/projects-overview.component.html b/src/app/components/main/projects-overview/projects-overview.component.html index 053af0d..f811a09 100644 --- a/src/app/components/main/projects-overview/projects-overview.component.html +++ b/src/app/components/main/projects-overview/projects-overview.component.html @@ -12,7 +12,7 @@

{{ title }}

+
+
+ + +
+
+
+
+
+ {{ chat.name.charAt(0).toUpperCase() }} + +
+
+
+
+ +
+
+

{{ chat.name }}

+ + {{ formatTime(chat.lastMessage?.timestamp) }} + +
+

+ {{ chat.lastMessage?.text || 'No messages yet' }} +

+
+ +
+ {{ chat.unreadCount }} +
+
+
+
+
+ + +
+ +
+
+
+
+
+ {{ selectedChat().name }} + +
+
+
+
+
+

{{ selectedChat().name }}

+

+ {{ selectedChat() ? selectedChat().participants.length : 0 }} participants +

+

+ Online +

+
+
+ +
+ + + + +
+
+
+ + +
+
+
+
+ + +
+ {{ message.senderName.charAt(0).toUpperCase() }} +
+ +
+ +
+ {{ message.senderName }} +
+ + +
+ + +
+ +
+ + +
{{ message.text }}
+ + +
+ + {{ reaction.emoji }} {{ reaction.count }} + +
+
+ + +
+ {{ formatMessageTime(message.timestamp) }} +
+
+
+ + +
+ +
+
+
+
+ + +
+
+ + + + + +
+ + + + + + +
+
+ +
+
+
+ + + +
+
+
+ + + +
+
+
💬
+

Select a conversation

+

Choose from your existing conversations or start a new one

+
+
+
+
+ + +
+
+

New Chat

+ +
+
+ + +
+ +
+ +
+ +
+ + +
+
+
+
+ + +
+
+ +
+
+ + +
+
+

Group Info

+ +
+
+
+ {{ selectedChat() ? selectedChat().name.charAt(0).toUpperCase() : '' }} +
+

{{ selectedChat().name }}

+

{{ selectedChat().participants.length }} participants

+
+ +
+
Participants
+
+
+
+ {{ participant.charAt(0).toUpperCase() }} +
+ {{ participant }} +
+
+
+ + +
+
+
+ `, + styles: [` + :host { + display: block; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + font-size: 12px; + } + `] +}) +export class ChatBotComponent { + @ViewChild('messagesContainer') messagesContainer!: ElementRef; + @ViewChild('fileInput') fileInput!: ElementRef; + + // Icons + faImage = faImage; + faPaperPlane = faPaperPlane; + faSmile = faSmile; + faPlus = faPlus; + faPhone = faPhone; + faVideo = faVideo; + faInfo = faInfo; + faUsers = faUsers; + + // Signals + chats = signal([]); + selectedChat = signal({} as Chat); + messages = signal([]); + currentMessages = computed(() => { + const selected = this.selectedChat(); + if (!selected) return []; + if (selected.participants) { + return this.messages().filter(m => + selected.participants.includes(m.senderId) ?? m.senderId === 'current-user' + ); + } + + return this.messages().filter(m => m.senderId === selected.id); + + }); + + // Component state + newMessage = ''; + showEmojiPicker = false; + showNewChatModal = false; + showGroupInfo = false; + selectedImage: string | null = null; + newChatName = ''; + newChatIsGroup = false; + + // Emojis + emojis = ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳', '😏', '😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓']; + quickReactions = ['👍', '❤️', '😂', '😮', '😢', '😡']; + + constructor(private realTimeDb: RealtimeDbService) { + this.realTimeDb.create('users', { + id: 'current-user', + name: 'You', + avatar: 'https://avatars.dicebear.com/api/bottts/john-doe.svg', + isOnline: true + }); + this.initializeData(); + } + + initializeData() { + // Sample chats + const sampleChats: Chat[] = [ + { + id: '1', + name: 'John Doe', + participants: ['john-doe'], + unreadCount: 2, + isGroup: false, + isOnline: true, + lastMessage: { + id: '1', + text: 'Hey! How are you doing?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 300000), + isOwn: false + } + }, + { + id: '2', + name: 'Work Team', + participants: ['alice-smith', 'bob-wilson', 'carol-brown'], + unreadCount: 0, + isGroup: true, + lastMessage: { + id: '2', + text: 'Great job on the presentation!', + senderId: 'alice-smith', + senderName: 'Alice Smith', + timestamp: new Date(Date.now() - 3600000), + isOwn: false + } + }, + { + id: '3', + name: 'Sarah Johnson', + participants: ['sarah-johnson'], + unreadCount: 0, + isGroup: false, + isOnline: false, + lastMessage: { + id: '3', + text: 'Thanks for the help! 👍', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 7200000), + isOwn: true + } + } + ]; + + sampleChats.forEach(chat => { + this.realTimeDb.create('chats', chat); + }) + + // Sample messages + const sampleMessages: Message[] = [ + { + id: '1', + text: 'Hey! How are you doing?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 300000), + isOwn: false + }, + { + id: '2', + text: 'I\'m doing great! Just working on some new projects.', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 240000), + isOwn: true + }, + { + id: '3', + text: 'That sounds exciting! What kind of projects?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 180000), + isOwn: false + }, + { + id: '4', + text: 'Mainly web development stuff. Building some Angular components.', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 120000), + isOwn: true + }, + { + id: '5', + text: 'Cool! I love Angular. Let me know if you need any help!', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 60000), + isOwn: false, + reactions: [ + {emoji: '👍', count: 1, users: ['current-user']} + ] + } + ]; + + this.chats.set(sampleChats); + this.messages.set(sampleMessages); + } + + selectChat(chat: Chat) { + this.selectedChat.set(chat); + // Mark as read + chat.unreadCount = 0; + this.chats.update(chats => [...chats]); + + setTimeout(() => this.scrollToBottom(), 100); + } + + sendMessage() { + if (!this.newMessage.trim() || !this.selectedChat()) return; + + const message: Message = { + id: Date.now().toString(), + text: this.newMessage, + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(), + isOwn: true + }; + + this.messages.update(messages => [...messages, message]); + + // Update last message in chat + const selected = this.selectedChat()!; + selected.lastMessage = message; + this.chats.update(chats => [...chats]); + + this.newMessage = ''; + this.showEmojiPicker = false; + + setTimeout(() => this.scrollToBottom(), 100); + + // Simulate a response + this.simulateResponse(); + } + + simulateResponse() { + setTimeout(() => { + const responses = [ + "That's interesting!", + "I see what you mean.", + "Great point!", + "Thanks for sharing that.", + "I agree!", + "That makes sense.", + "Good to know!", + "Absolutely!", + "I understand.", + "Thanks for the update!" + ]; + + const response: Message = { + id: Date.now().toString(), + text: responses[Math.floor(Math.random() * responses.length)], + senderId: this.selectedChat()!.participants[0], + senderName: this.selectedChat()!.name, + timestamp: new Date(), + isOwn: false + }; + + this.messages.update(messages => [...messages, response]); + + // Update last message in chat + const selected = this.selectedChat()!; + selected.lastMessage = response; + this.chats.update(chats => [...chats]); + + setTimeout(() => this.scrollToBottom(), 100); + }, 1000 + Math.random() * 2000); + } + + onKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.sendMessage(); + } + } + + addEmoji(emoji: string) { + this.newMessage += emoji; + this.showEmojiPicker = false; + } + + onImageSelect(event: any) { + const file = event.target.files[0]; + if (!file || !this.selectedChat()) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const imageUrl = e.target?.result as string; + + const message: Message = { + id: Date.now().toString(), + text: '', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(), + isOwn: true, + image: imageUrl + }; + + this.messages.update(messages => [...messages, message]); + + // Update last message in chat + const selected = this.selectedChat()!; + selected.lastMessage = {...message, text: '📷 Photo'}; + this.chats.update(chats => [...chats]); + + setTimeout(() => this.scrollToBottom(), 100); + this.simulateResponse(); + }; + + reader.readAsDataURL(file); + + // Reset file input + this.fileInput.nativeElement.value = ''; + } + + openImageModal(imageUrl: string) { + this.selectedImage = imageUrl; + } + + closeImageModal() { + this.selectedImage = null; + } + + addReaction(message: Message, emoji: string) { + if (!message.reactions) { + message.reactions = []; + } + + const existingReaction = message.reactions.find(r => r.emoji === emoji); + if (existingReaction) { + const userIndex = existingReaction.users.indexOf('current-user'); + if (userIndex > -1) { + existingReaction.users.splice(userIndex, 1); + existingReaction.count--; + if (existingReaction.count === 0) { + message.reactions = message.reactions.filter(r => r.emoji !== emoji); + } + } else { + existingReaction.users.push('current-user'); + existingReaction.count++; + } + } else { + message.reactions.push({ + emoji, + count: 1, + users: ['current-user'] + }); + } + + this.messages.update(messages => [...messages]); + } + + toggleReaction(message: Message, emoji: string) { + this.addReaction(message, emoji); + } + + createNewChat() { + if (!this.newChatName.trim()) return; + + const newChat: Chat = { + id: Date.now().toString(), + name: this.newChatName, + participants: [this.newChatName.toLowerCase().replace(/\s+/g, '-')], + unreadCount: 0, + isGroup: this.newChatIsGroup, + isOnline: !this.newChatIsGroup && Math.random() > 0.5 + }; + + this.chats.update(chats => [newChat, ...chats]); + this.selectChat(newChat); + this.cancelNewChat(); + } + + cancelNewChat() { + this.showNewChatModal = false; + this.newChatName = ''; + this.newChatIsGroup = false; + } + + formatTime(date?: Date): string { + if (!date) return ''; + + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'now'; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 7) return `${days}d`; + + return date.toLocaleDateString(); + } + + formatMessageTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } + + private scrollToBottom() { + if (this.messagesContainer) { + const element = this.messagesContainer.nativeElement; + element.scrollTop = element.scrollHeight; + } + } +} diff --git a/src/app/modules/chat/chat.module.ts b/src/app/modules/chat/chat.module.ts new file mode 100644 index 0000000..860f508 --- /dev/null +++ b/src/app/modules/chat/chat.module.ts @@ -0,0 +1,20 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {FontAwesomeModule} from '@fortawesome/angular-fontawesome'; +import {ChatBotComponent} from './chat.component'; + +@NgModule({ + declarations: [], + imports: [ + CommonModule, + FormsModule, + FontAwesomeModule, + ChatBotComponent + ], + exports: [ + ChatBotComponent + ] +}) +export class ChatModule { +} diff --git a/src/app/modules/chat/chat.service.ts b/src/app/modules/chat/chat.service.ts new file mode 100644 index 0000000..2a775c4 --- /dev/null +++ b/src/app/modules/chat/chat.service.ts @@ -0,0 +1,239 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; + +export interface Message { + id: string; + text: string; + senderId: string; + senderName: string; + timestamp: Date; + isOwn: boolean; + image?: string; + reactions?: { emoji: string; count: number; users: string[] }[]; +} + +export interface Chat { + id: string; + name: string; + participants: string[]; + lastMessage?: Message; + unreadCount: number; + isGroup: boolean; + avatar?: string; + isOnline?: boolean; +} + +export interface User { + id: string; + name: string; + avatar?: string; + isOnline: boolean; +} + +@Injectable({ + providedIn: 'root' +}) +export class ChatBotService { + private chatsSubject = new BehaviorSubject([]); + private messagesSubject = new BehaviorSubject([]); + private currentUserSubject = new BehaviorSubject({ + id: 'current-user', + name: 'You', + isOnline: true + }); + + chats$ = this.chatsSubject.asObservable(); + messages$ = this.messagesSubject.asObservable(); + currentUser$ = this.currentUserSubject.asObservable(); + + constructor() { + this.initializeData(); + } + + private initializeData() { + // Initialize with sample data + const sampleChats: Chat[] = [ + { + id: '1', + name: 'John Doe', + participants: ['john-doe'], + unreadCount: 2, + isGroup: false, + isOnline: true, + lastMessage: { + id: '1', + text: 'Hey! How are you doing?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 300000), + isOwn: false + } + }, + { + id: '2', + name: 'Work Team', + participants: ['alice-smith', 'bob-wilson', 'carol-brown'], + unreadCount: 0, + isGroup: true, + lastMessage: { + id: '2', + text: 'Great job on the presentation!', + senderId: 'alice-smith', + senderName: 'Alice Smith', + timestamp: new Date(Date.now() - 3600000), + isOwn: false + } + }, + { + id: '3', + name: 'Sarah Johnson', + participants: ['sarah-johnson'], + unreadCount: 0, + isGroup: false, + isOnline: false, + lastMessage: { + id: '3', + text: 'Thanks for the help! 👍', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 7200000), + isOwn: true + } + } + ]; + + const sampleMessages: Message[] = [ + { + id: '1', + text: 'Hey! How are you doing?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 300000), + isOwn: false + }, + { + id: '2', + text: 'I\'m doing great! Just working on some new projects.', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 240000), + isOwn: true + }, + { + id: '3', + text: 'That sounds exciting! What kind of projects?', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 180000), + isOwn: false + }, + { + id: '4', + text: 'Mainly web development stuff. Building some Angular components.', + senderId: 'current-user', + senderName: 'You', + timestamp: new Date(Date.now() - 120000), + isOwn: true + }, + { + id: '5', + text: 'Cool! I love Angular. Let me know if you need any help!', + senderId: 'john-doe', + senderName: 'John Doe', + timestamp: new Date(Date.now() - 60000), + isOwn: false, + reactions: [ + {emoji: '👍', count: 1, users: ['current-user']} + ] + } + ]; + + this.chatsSubject.next(sampleChats); + this.messagesSubject.next(sampleMessages); + } + + getChats(): Chat[] { + return this.chatsSubject.value; + } + + getMessages(): Message[] { + return this.messagesSubject.value; + } + + getMessagesForChat(chatId: string): Message[] { + const chat = this.chatsSubject.value.find(c => c.id === chatId); + if (!chat) return []; + + return this.messagesSubject.value.filter(m => + chat.participants.includes(m.senderId) || m.senderId === 'current-user' + ); + } + + addMessage(message: Message) { + const messages = this.messagesSubject.value; + this.messagesSubject.next([...messages, message]); + } + + addChat(chat: Chat) { + const chats = this.chatsSubject.value; + this.chatsSubject.next([chat, ...chats]); + } + + updateChat(chatId: string, updates: Partial) { + const chats = this.chatsSubject.value.map(chat => + chat.id === chatId ? {...chat, ...updates} : chat + ); + this.chatsSubject.next(chats); + } + + addReactionToMessage(messageId: string, emoji: string, userId: string) { + const messages = this.messagesSubject.value.map(message => { + if (message.id === messageId) { + if (!message.reactions) { + message.reactions = []; + } + + const existingReaction = message.reactions.find(r => r.emoji === emoji); + if (existingReaction) { + const userIndex = existingReaction.users.indexOf(userId); + if (userIndex > -1) { + existingReaction.users.splice(userIndex, 1); + existingReaction.count--; + if (existingReaction.count === 0) { + message.reactions = message.reactions.filter(r => r.emoji !== emoji); + } + } else { + existingReaction.users.push(userId); + existingReaction.count++; + } + } else { + message.reactions.push({ + emoji, + count: 1, + users: [userId] + }); + } + } + return message; + }); + + this.messagesSubject.next(messages); + } + + markChatAsRead(chatId: string) { + this.updateChat(chatId, {unreadCount: 0}); + } + + simulateTyping(): Observable { + return new BehaviorSubject(false); + } + + // WebSocket or real-time connection methods would go here + connectToRealTime() { + // Implementation for real-time messaging + } + + disconnectFromRealTime() { + // Implementation for disconnecting + } +} diff --git a/src/app/modules/scroll/directives/scroll-class-toggle.directive.ts b/src/app/modules/scroll/directives/scroll-class-toggle.directive.ts index 1f0d2e2..31f2720 100644 --- a/src/app/modules/scroll/directives/scroll-class-toggle.directive.ts +++ b/src/app/modules/scroll/directives/scroll-class-toggle.directive.ts @@ -7,6 +7,8 @@ import { AfterViewInit, } from '@angular/core'; +type ScrollDirection = 'left' | 'right' | 'top' | 'bottom'; + /** * A directive that applies or removes specified CSS classes to an element based on the user's scroll position * relative to a defined scroll threshold. Supports additional animation effects for entry and exit behavior. @@ -51,84 +53,154 @@ import { selector: '[appScrollClassToggle]', standalone: false }) -/** - * TODO: need to test more and fix - */ export class ScrollClassToggleDirective implements AfterViewInit { @Input() scrollThreshold = 100; - @Input() enterClasses = ''; // class names to add when the threshold passed - @Input() exitClasses = ''; // class names to add when below the threshold @Input() applyTransition = true; @Input() duration = 'duration-500'; - @Input() flyIn: 'left' | 'right' | 'top' | 'bottom' | null = null; - @Input() leaveTo: 'left' | 'right' | 'top' | 'bottom' | null = null; + + private _enterClasses = ''; + @Input() + set enterClasses(value: string) { + this._enterClasses = value ?? ''; + this.enterClassList = this.toClassList(this._enterClasses); + } + + get enterClasses(): string { + return this._enterClasses; + } + + private _exitClasses = ''; + @Input() + set exitClasses(value: string) { + this._exitClasses = value ?? ''; + this.exitClassList = this.toClassList(this._exitClasses); + } + + get exitClasses(): string { + return this._exitClasses; + } + + private _flyIn: ScrollDirection | null = null; + @Input() + set flyIn(value: ScrollDirection | null) { + this._flyIn = value; + this.flyInClassList = value ? this.toClassList(this.getFlyInPreset(value)) : []; + } + + get flyIn(): ScrollDirection | null { + return this._flyIn; + } + + private _leaveTo: ScrollDirection | null = null; + @Input() + set leaveTo(value: ScrollDirection | null) { + this._leaveTo = value; + this.leaveToClassList = value ? this.toClassList(this.getLeaveToPreset(value)) : []; + } + + get leaveTo(): ScrollDirection | null { + return this._leaveTo; + } private hasEntered = false; + private scrollTicking = false; + private enterClassList: string[] = []; + private exitClassList: string[] = []; + private flyInClassList: string[] = []; + private leaveToClassList: string[] = []; constructor( - private readonly el: ElementRef, + private readonly el: ElementRef, private readonly renderer: Renderer2) { } ngAfterViewInit(): void { - // Apply initial state - this.applyClasses(this.exitClasses); - if (this.flyIn) { - this.applyClasses(this.getFlyInPreset(this.flyIn)); - } // Ensure transition classes are present if (this.applyTransition) { this.addIfMissing(this.duration); this.addIfMissing('transition-all'); this.addIfMissing('ease-in-out'); } + + this.initializeState(); } @HostListener('window:scroll', []) - onWindowScroll() { - const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const passedThreshold = scrollTop > this.scrollThreshold; - - const enterClassList = this.enterClasses.split(' ').filter(c => c); - const exitClassList = this.exitClasses.split(' ').filter(c => c); - - if (passedThreshold && !this.hasEntered) { - // Add enter classes, remove exit - enterClassList.forEach(cls => this.renderer.addClass(this.el.nativeElement, cls)); - exitClassList.forEach(cls => this.renderer.removeClass(this.el.nativeElement, cls)); - if (this.flyIn) { - this.getFlyInPreset(this.flyIn).split(' ').forEach(cls => - this.renderer.removeClass(this.el.nativeElement, cls) - ); - } - this.hasEntered = true; - } else if (!passedThreshold && this.hasEntered) { - // Revert to exit classes - exitClassList.forEach(cls => this.renderer.addClass(this.el.nativeElement, cls)); - enterClassList.forEach(cls => this.renderer.removeClass(this.el.nativeElement, cls)); - if (this.leaveTo) { - this.getLeaveToPreset(this.leaveTo).split(' ').forEach(cls => - this.renderer.addClass(this.el.nativeElement, cls) - ); - } - this.hasEntered = false; + onWindowScroll(): void { + if (this.scrollTicking) { + return; } + + this.scrollTicking = true; + requestAnimationFrame(() => { + this.scrollTicking = false; + this.updateStateFromScroll(); + }); } - private addIfMissing(className: string) { + private addIfMissing(className: string): void { const el = this.el.nativeElement; if (!el.classList.contains(className)) { this.renderer.addClass(el, className); } } - private applyClasses(classString: string) { - classString.split(' ').filter(c => c).forEach(cls => - this.renderer.addClass(this.el.nativeElement, cls) - ); + private initializeState(): void { + if (this.isThresholdPassed()) { + this.applyEnteredState(); + this.hasEntered = true; + return; + } + + this.addClasses(this.exitClassList); + this.addClasses(this.flyInClassList); + } + + private updateStateFromScroll(): void { + const passedThreshold = this.isThresholdPassed(); + if (passedThreshold === this.hasEntered) { + return; + } + + if (passedThreshold) { + this.applyEnteredState(); + } else { + this.applyExitedState(); + } + this.hasEntered = passedThreshold; + } + + private applyEnteredState(): void { + this.addClasses(this.enterClassList); + this.removeClasses(this.exitClassList); + this.removeClasses(this.flyInClassList); + this.removeClasses(this.leaveToClassList); + } + + private applyExitedState(): void { + this.addClasses(this.exitClassList); + this.removeClasses(this.enterClassList); + this.addClasses(this.leaveToClassList); + } + + private addClasses(classes: string[]): void { + classes.forEach((cssClass) => this.renderer.addClass(this.el.nativeElement, cssClass)); + } + + private removeClasses(classes: string[]): void { + classes.forEach((cssClass) => this.renderer.removeClass(this.el.nativeElement, cssClass)); + } + + private toClassList(classString: string): string[] { + return classString.split(/\s+/).filter(Boolean); + } + + private isThresholdPassed(): boolean { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; + return scrollTop > this.scrollThreshold; } - private getFlyInPreset(direction: string): string { + private getFlyInPreset(direction: ScrollDirection): string { switch (direction) { case 'left': return '-translate-x-full opacity-0'; @@ -143,7 +215,7 @@ export class ScrollClassToggleDirective implements AfterViewInit { } } - private getLeaveToPreset(direction: string): string { + private getLeaveToPreset(direction: ScrollDirection): string { return this.getFlyInPreset(direction); } } diff --git a/src/app/pipes/obfuscate.pipe.spec.ts b/src/app/pipes/obfuscate.pipe.spec.ts index 6537d4c..d615a77 100644 --- a/src/app/pipes/obfuscate.pipe.spec.ts +++ b/src/app/pipes/obfuscate.pipe.spec.ts @@ -19,6 +19,6 @@ describe('ObfuscatePipe', () => { it('should handle unsupported characters', () => { const cipher = { 'h': 'x', 'e': 'y' }; - expect(pipe.transform('hi there!', cipher, 'encode')).toEqual('xi tyery!'); + expect(pipe.transform('hi there!', cipher, 'encode')).toEqual('xi txyry!'); }); }); diff --git a/src/app/pipes/time-ago,pipe.ts b/src/app/pipes/time-ago.pipe.ts similarity index 100% rename from src/app/pipes/time-ago,pipe.ts rename to src/app/pipes/time-ago.pipe.ts diff --git a/src/app/providers/sound/sound.module.ts b/src/app/providers/sound/sound.module.ts new file mode 100644 index 0000000..d6d6b62 --- /dev/null +++ b/src/app/providers/sound/sound.module.ts @@ -0,0 +1,35 @@ +import {InjectionToken, Injector} from '@angular/core'; + +export const defaultSoundConfig: SoundServiceConfig = { + debounceInterval: 60, + maxCacheSize: 20, + defaultVolume: 1.0, + basePath: 'assets/audio/efx/' +}; + +export const SOUND_SERVICE_CONFIG = new InjectionToken('SOUND_SERVICE_CONFIG'); + +export interface SoundServiceConfig { + debounceInterval: number; + maxCacheSize: number; + defaultVolume: number; + basePath: string; +} + +type SoundModuleFactory = (injector: Injector) => SoundModule; + +type SoundModuleProvider = { + provide: InjectionToken; + useFactory: SoundModuleFactory; + deps?: any[]; +} + +export declare class SoundModule { + constructor(); + + playSound(sound: string): void; +} + +export declare function initializeSoundModule(config: SoundServiceConfig): SoundModule; + +export declare function provideSound(fn: () => SoundModule): SoundModuleProvider; diff --git a/src/app/services/auth.service.spec.ts b/src/app/services/auth.service.spec.ts index ee124a6..beab00c 100644 --- a/src/app/services/auth.service.spec.ts +++ b/src/app/services/auth.service.spec.ts @@ -1,12 +1,24 @@ import {TestBed} from '@angular/core/testing'; import {AuthService} from './auth.service'; +import {Auth} from '@angular/fire/auth'; +import {Router} from '@angular/router'; +import {LogService} from '../components/game/services/log.service'; describe('AuthService', () => { let service: AuthService; + const routerSpy = jasmine.createSpyObj('Router', ['navigate']); + const logServiceSpy = jasmine.createSpyObj('LogService', ['debug', 'info', 'warn', 'error']); + beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [ + {provide: Auth, useValue: {} as Auth}, + {provide: Router, useValue: routerSpy}, + {provide: LogService, useValue: logServiceSpy}, + ] + }); service = TestBed.inject(AuthService); }); diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index b2ccd9c..0f0e519 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -22,18 +22,23 @@ import {LogService} from '../components/game/services/log.service'; providedIn: 'root' }) export class AuthService { - private auth: Auth = inject(Auth); + private auth: Auth | null = inject(Auth, {optional: true}); - user$: Observable = user(this.auth); + user$: Observable = this.auth ? user(this.auth) : of(null); constructor( private router: Router, private readonly logger: LogService, private zone: NgZone ) { + const auth = this.auth; + if (!auth) { + this.logger.warn('Auth service initialized without Firebase Auth provider.'); + return; + } this.user$ = new Observable(observer => { - return onAuthStateChanged(this.auth, + return onAuthStateChanged(auth, user => this.zone.run(() => observer.next(user)), error => this.zone.run(() => observer.error(error)), () => this.zone.run(() => observer.complete()) @@ -44,6 +49,9 @@ export class AuthService { // Email & Password Sign In signInWithEmail(email: string, password: string): Observable { + if (!this.auth) { + return throwError(() => new Error('Firebase Auth is not initialized')); + } return from(signInWithEmailAndPassword(this.auth, email, password)).pipe( tap(result => this.logger.info('Signed in!', result.user)), catchError(error => { @@ -55,6 +63,9 @@ export class AuthService { // Email & Password Registration registerWithEmail(email: string, password: string): Observable { + if (!this.auth) { + return throwError(() => new Error('Firebase Auth is not initialized')); + } return from(createUserWithEmailAndPassword(this.auth, email, password)).pipe( switchMap(credentials => { // Send email verification @@ -71,6 +82,9 @@ export class AuthService { // Google Sign In (enhanced with Observable) loginWithGoogle(): Observable { + if (!this.auth) { + return throwError(() => new Error('Firebase Auth is not initialized')); + } const provider = new GoogleAuthProvider(); // Use signInWithPopup instead of redirect for more reliable behavior return from(signInWithPopup(this.auth, provider)); @@ -78,7 +92,11 @@ export class AuthService { // Add a method to handle redirect results handleRedirectResult(): Observable { - return from(this.zone.runOutsideAngular(() => getRedirectResult(this.auth))) + const auth = this.auth; + if (!auth) { + return of(null); + } + return from(this.zone.runOutsideAngular(() => getRedirectResult(auth))) .pipe( tap(result => { if (result) { @@ -95,6 +113,9 @@ export class AuthService { // Password Reset resetPassword(email: string): Observable { + if (!this.auth) { + return throwError(() => new Error('Firebase Auth is not initialized')); + } return from(sendPasswordResetEmail(this.auth, email)).pipe( catchError(error => { this.logger.error('Password reset failed:', error); @@ -105,6 +126,9 @@ export class AuthService { // Sign Out (enhanced with Observable) logout(): Observable { + if (!this.auth) { + return throwError(() => new Error('Firebase Auth is not initialized')); + } return from(signOut(this.auth)).pipe( tap(() => { this.logger.info('Signed out'); diff --git a/src/app/services/firebase/FirestoreTestUtils.ts b/src/app/services/firebase/FirestoreTestUtils.ts new file mode 100644 index 0000000..1f26ba3 --- /dev/null +++ b/src/app/services/firebase/FirestoreTestUtils.ts @@ -0,0 +1,60 @@ +import {FirestoreService} from './firestore.service'; + +/** + * Test utilities for Firestore service integration tests + */ +export class FirestoreTestUtils { + constructor(private firestoreService: FirestoreService) { + } + + /** + * Creates test data for documents + */ + createTestDocument(overrides: any = {}) { + return { + id: `test-${Date.now()}`, + name: 'Test Document', + status: 'active', + createdAt: new Date(), + ...overrides + }; + } + + /** + * Creates multiple test documents + */ + createTestDocuments(count: number, baseData: any = {}) { + return Array.from({length: count}, (_, index) => + this.createTestDocument({ + ...baseData, + name: `Test Document ${index + 1}`, + index: index + }) + ); + } + + /** + * Cleans up test data by deleting documents + */ + async cleanupTestDocuments(collectionPath: string, documentIds: string[]) { + const deletePromises = documentIds.map(id => + this.firestoreService.deleteDocument(collectionPath, id).toPromise() + ); + + await Promise.all(deletePromises); + } + + /** + * Creates a test file for storage operations + */ + createTestFile(filename: string = 'test.txt', content: string = 'test content') { + return new File([content], filename, {type: 'text/plain'}); + } + + /** + * Generates a unique collection name for tests + */ + getTestCollectionName(baseName: string = 'test') { + return `${baseName}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } +} diff --git a/src/app/services/firebase/firestore.service.spec.ts b/src/app/services/firebase/firestore.service.spec.ts new file mode 100644 index 0000000..10c5f13 --- /dev/null +++ b/src/app/services/firebase/firestore.service.spec.ts @@ -0,0 +1,607 @@ +import {TestBed} from '@angular/core/testing'; +import {Firestore} from '@angular/fire/firestore'; +import {Storage} from '@angular/fire/storage'; +import {FirestoreService, FirestoreDocument} from './firestore.service'; +import {of} from 'rxjs'; + +describe('FirestoreService', () => { + let service: FirestoreService; + let mockFirestore: jasmine.SpyObj; + let mockStorage: jasmine.SpyObj; + + // Mock implementations for Firebase functions + let mockDoc: jasmine.Spy; + let mockCollection: jasmine.Spy; + let mockSetDoc: jasmine.Spy; + let mockGetDoc: jasmine.Spy; + let mockUpdateDoc: jasmine.Spy; + let mockDeleteDoc: jasmine.Spy; + let mockGetDocs: jasmine.Spy; + let mockQuery: jasmine.Spy; + let mockWhere: jasmine.Spy; + let mockOrderBy: jasmine.Spy; + let mockLimit: jasmine.Spy; + let mockOnSnapshot: jasmine.Spy; + let mockServerTimestamp: jasmine.Spy; + let mockWriteBatch: jasmine.Spy; + + // Storage mocks + let mockRef: jasmine.Spy; + let mockUploadBytes: jasmine.Spy; + let mockGetDownloadURL: jasmine.Spy; + let mockDeleteObject: jasmine.Spy; + let mockUploadString: jasmine.Spy; + let mockUploadBytesResumable: jasmine.Spy; + + beforeEach(() => { + jasmine.getEnv().allowRespy(true); + + const firestoreSpy = jasmine.createSpyObj('Firestore', ['app']); + const storageSpy = jasmine.createSpyObj('Storage', ['app']); + + TestBed.configureTestingModule({ + providers: [ + FirestoreService, + {provide: Firestore, useValue: firestoreSpy}, + {provide: Storage, useValue: storageSpy} + ] + }); + + service = TestBed.inject(FirestoreService); + mockFirestore = TestBed.inject(Firestore) as jasmine.SpyObj; + mockStorage = TestBed.inject(Storage) as jasmine.SpyObj; + + // Setup Firebase function mocks + setupFirebaseMocks(); + }); + + function setupFirebaseMocks() { + // Mock Firestore functions + mockDoc = jasmine.createSpy('doc').and.returnValue({id: 'mock-ref'}); + mockCollection = jasmine.createSpy('collection').and.returnValue({id: 'mock-collection'}); + mockSetDoc = jasmine.createSpy('setDoc').and.returnValue(Promise.resolve()); + mockGetDoc = jasmine.createSpy('getDoc').and.returnValue(Promise.resolve({ + exists: () => true, + id: 'test-id', + data: () => ({name: 'Test Document'}) + })); + mockUpdateDoc = jasmine.createSpy('updateDoc').and.returnValue(Promise.resolve()); + mockDeleteDoc = jasmine.createSpy('deleteDoc').and.returnValue(Promise.resolve()); + mockGetDocs = jasmine.createSpy('getDocs').and.returnValue(Promise.resolve({ + docs: [ + {id: '1', data: () => ({name: 'Doc 1'})}, + {id: '2', data: () => ({name: 'Doc 2'})} + ] + })); + mockQuery = jasmine.createSpy('query').and.returnValue({id: 'mock-query'}); + mockWhere = jasmine.createSpy('where').and.returnValue({id: 'mock-where'}); + mockOrderBy = jasmine.createSpy('orderBy').and.returnValue({id: 'mock-orderby'}); + mockLimit = jasmine.createSpy('limit').and.returnValue({id: 'mock-limit'}); + mockOnSnapshot = jasmine.createSpy('onSnapshot').and.callFake((ref: any, callback: any) => { + setTimeout(() => callback({ + exists: () => true, + id: 'test-id', + data: () => ({name: 'Test Document'}) + }), 0); + return () => { + }; // unsubscribe function + }); + mockServerTimestamp = jasmine.createSpy('serverTimestamp').and.returnValue('mock-timestamp'); + mockWriteBatch = jasmine.createSpy('writeBatch').and.returnValue({ + set: jasmine.createSpy('set'), + update: jasmine.createSpy('update'), + delete: jasmine.createSpy('delete'), + commit: jasmine.createSpy('commit').and.returnValue(Promise.resolve()) + }); + + // Mock Storage functions + mockRef = jasmine.createSpy('ref').and.returnValue({id: 'mock-storage-ref'}); + mockUploadBytes = jasmine.createSpy('uploadBytes').and.returnValue(Promise.resolve({})); + mockGetDownloadURL = jasmine.createSpy('getDownloadURL').and.returnValue( + Promise.resolve('https://example.com/file.txt') + ); + mockDeleteObject = jasmine.createSpy('deleteObject').and.returnValue(Promise.resolve()); + mockUploadString = jasmine.createSpy('uploadString').and.returnValue(Promise.resolve({})); + mockUploadBytesResumable = jasmine.createSpy('uploadBytesResumable').and.returnValue({ + on: jasmine.createSpy('on').and.callFake((event: string, progress: any, error: any, complete: any) => { + setTimeout(() => progress({bytesTransferred: 50, totalBytes: 100}), 0); + setTimeout(() => complete(), 10); + }), + snapshot: {ref: {}} + }); + + // Replace the Firebase functions in the service + (service as any).doc = mockDoc; + (service as any).collection = mockCollection; + (service as any).setDoc = mockSetDoc; + (service as any).getDoc = mockGetDoc; + (service as any).updateDoc = mockUpdateDoc; + (service as any).deleteDoc = mockDeleteDoc; + (service as any).getDocs = mockGetDocs; + (service as any).query = mockQuery; + (service as any).where = mockWhere; + (service as any).orderBy = mockOrderBy; + (service as any).limit = mockLimit; + (service as any).onSnapshot = mockOnSnapshot; + (service as any).serverTimestamp = mockServerTimestamp; + (service as any).writeBatch = mockWriteBatch; + (service as any).ref = mockRef; + (service as any).uploadBytes = mockUploadBytes; + (service as any).getDownloadURL = mockGetDownloadURL; + (service as any).deleteObject = mockDeleteObject; + (service as any).uploadString = mockUploadString; + (service as any).uploadBytesResumable = mockUploadBytesResumable; + } + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('Document Operations', () => { + describe('saveDocument', () => { + beforeEach(() => { + spyOn(service as any, 'doc').and.returnValue({id: 'mock-ref'}); + spyOn(service as any, 'setDoc').and.returnValue(Promise.resolve()); + spyOn(service as any, 'serverTimestamp').and.returnValue('mock-timestamp'); + }); + + it('should save a document with generated ID', (done) => { + const testData: FirestoreDocument = { + name: 'Test Document' + }; + + service.saveDocument('test-collection', testData).subscribe({ + next: (docId) => { + expect(docId).toBeDefined(); + expect(typeof docId).toBe('string'); + done(); + }, + error: done.fail + }); + }); + + it('should save a document with provided ID', (done) => { + const testData: FirestoreDocument = { + name: 'Test Document' + }; + + service.saveDocument('test-collection', testData, 'custom-id').subscribe({ + next: (docId) => { + expect(docId).toBe('custom-id'); + done(); + }, + error: done.fail + }); + }); + + it('should handle save errors', (done) => { + spyOn(service as any, 'setDoc').and.returnValue( + Promise.reject(new Error('Save failed')) + ); + + const testData: FirestoreDocument = { + name: 'Test Document' + }; + + service.saveDocument('test-collection', testData).subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to save document'); + done(); + } + }); + }); + }); + + describe('getDocument', () => { + beforeEach(() => { + spyOn(service as any, 'doc').and.returnValue({id: 'mock-ref'}); + }); + + it('should retrieve an existing document', (done) => { + const mockSnapshot = { + exists: () => true, + id: 'test-id', + data: () => ({name: 'Test Document'}) + }; + + spyOn(service as any, 'getDoc').and.returnValue(Promise.resolve(mockSnapshot)); + + service.getDocument('test-collection', 'test-id').subscribe({ + next: (doc: any) => { + expect(doc).toBeDefined(); + expect(doc?.id).toBe('test-id'); + expect((doc as any)?.name).toBe('Test Document'); + done(); + }, + error: done.fail + }); + }); + + it('should return null for non-existent document', (done) => { + const mockSnapshot = { + exists: () => false + }; + + spyOn(service as any, 'getDoc').and.returnValue(Promise.resolve(mockSnapshot)); + + service.getDocument('test-collection', 'non-existent').subscribe({ + next: (doc) => { + expect(doc).toBeNull(); + done(); + }, + error: done.fail + }); + }); + + it('should handle get errors', (done) => { + spyOn(service as any, 'getDoc').and.returnValue( + Promise.reject(new Error('Get failed')) + ); + + service.getDocument('test-collection', 'test-id').subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to get document'); + done(); + } + }); + }); + }); + + describe('updateDocument', () => { + beforeEach(() => { + spyOn(service as any, 'doc').and.returnValue({id: 'mock-ref'}); + spyOn(service as any, 'serverTimestamp').and.returnValue('mock-timestamp'); + }); + + it('should update a document', (done) => { + spyOn(service as any, 'updateDoc').and.returnValue(Promise.resolve()); + + const updateData = {name: 'Updated Name'}; + + service.updateDocument('test-collection', 'test-id', updateData).subscribe({ + next: () => { + done(); + }, + error: done.fail + }); + }); + + it('should handle update errors', (done) => { + spyOn(service as any, 'updateDoc').and.returnValue( + Promise.reject(new Error('Update failed')) + ); + + service.updateDocument('test-collection', 'test-id', {name: 'Updated'}).subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to update document'); + done(); + } + }); + }); + }); + + describe('deleteDocument', () => { + beforeEach(() => { + spyOn(service as any, 'doc').and.returnValue({id: 'mock-ref'}); + }); + + it('should delete a document', (done) => { + spyOn(service as any, 'deleteDoc').and.returnValue(Promise.resolve()); + + service.deleteDocument('test-collection', 'test-id').subscribe({ + next: () => { + done(); + }, + error: done.fail + }); + }); + + it('should handle delete errors', (done) => { + spyOn(service as any, 'deleteDoc').and.returnValue( + Promise.reject(new Error('Delete failed')) + ); + + service.deleteDocument('test-collection', 'test-id').subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to delete document'); + done(); + } + }); + }); + }); + }); + + describe('Query Operations', () => { + describe('queryDocuments', () => { + beforeEach(() => { + spyOn(service as any, 'collection').and.returnValue({id: 'mock-collection'}); + spyOn(service as any, 'query').and.returnValue({id: 'mock-query'}); + }); + + it('should query documents without filters', (done) => { + const mockDocs = [ + {id: '1', data: () => ({name: 'Doc 1'})}, + {id: '2', data: () => ({name: 'Doc 2'})} + ]; + const mockSnapshot = {docs: mockDocs}; + + spyOn(service as any, 'getDocs').and.returnValue(Promise.resolve(mockSnapshot)); + + service.queryDocuments('test-collection').subscribe({ + next: (docs: any) => { + expect(docs.length).toBe(2); + expect(docs[0].id).toBe('1'); + expect(docs[1].id).toBe('2'); + done(); + }, + error: done.fail + }); + }); + + it('should query documents with filters', (done) => { + const mockDocs = [ + {id: '1', data: () => ({name: 'Doc 1', status: 'active'})} + ]; + const mockSnapshot = {docs: mockDocs}; + + spyOn(service as any, 'where').and.returnValue({id: 'mock-where'}); + spyOn(service as any, 'getDocs').and.returnValue(Promise.resolve(mockSnapshot)); + + const filters: [string, any, any][] = [['status', '==', 'active']]; + + service.queryDocuments('test-collection', filters).subscribe({ + next: (docs) => { + expect(docs.length).toBe(1); + expect((docs[0] as any).status).toBe('active'); + done(); + }, + error: done.fail + }); + }); + + it('should handle query errors', (done) => { + spyOn(service as any, 'getDocs').and.returnValue( + Promise.reject(new Error('Query failed')) + ); + + service.queryDocuments('test-collection').subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to query documents'); + done(); + } + }); + }); + }); + }); + + describe('Storage Operations', () => { + describe('uploadFile', () => { + beforeEach(() => { + spyOn(service as any, 'ref').and.returnValue({id: 'mock-storage-ref'}); + }); + + it('should upload a file and return download URL', (done) => { + const mockFile = new File(['test content'], 'test.txt', {type: 'text/plain'}); + const mockDownloadUrl = 'https://example.com/test.txt'; + + spyOn(service as any, 'uploadBytes').and.returnValue(Promise.resolve({})); + spyOn(service as any, 'getDownloadURL').and.returnValue(Promise.resolve(mockDownloadUrl)); + + service.uploadFile('test/path', mockFile).subscribe({ + next: (url) => { + expect(url).toBe(mockDownloadUrl); + done(); + }, + error: done.fail + }); + }); + + it('should handle upload errors', (done) => { + const mockFile = new File(['test content'], 'test.txt'); + + spyOn(service as any, 'uploadBytes').and.returnValue( + Promise.reject(new Error('Upload failed')) + ); + + service.uploadFile('test/path', mockFile).subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to upload file'); + done(); + } + }); + }); + }); + + describe('deleteFile', () => { + beforeEach(() => { + spyOn(service as any, 'ref').and.returnValue({id: 'mock-storage-ref'}); + }); + + it('should delete a file', (done) => { + spyOn(service as any, 'deleteObject').and.returnValue(Promise.resolve()); + + service.deleteFile('test/path').subscribe({ + next: () => { + done(); + }, + error: done.fail + }); + }); + + it('should handle delete file errors', (done) => { + spyOn(service as any, 'deleteObject').and.returnValue( + Promise.reject(new Error('Delete failed')) + ); + + service.deleteFile('test/path').subscribe({ + next: () => done.fail('Should have failed'), + error: (error) => { + expect(error.message).toContain('Failed to delete file'); + done(); + } + }); + }); + }); + + describe('uploadBase64', () => { + beforeEach(() => { + spyOn(service as any, 'ref').and.returnValue({id: 'mock-storage-ref'}); + }); + + it('should upload base64 string', (done) => { + const dataUrl = 'data:text/plain;base64,dGVzdCBjb250ZW50'; + const mockDownloadUrl = 'https://example.com/test.txt'; + + spyOn(service as any, 'uploadString').and.returnValue(Promise.resolve({})); + spyOn(service as any, 'getDownloadURL').and.returnValue(Promise.resolve(mockDownloadUrl)); + + service.uploadBase64('test/path', dataUrl).subscribe({ + next: (url) => { + expect(url).toBe(mockDownloadUrl); + done(); + }, + error: done.fail + }); + }); + }); + }); + + describe('User Operations', () => { + describe('saveUserSettings', () => { + it('should save user settings', (done) => { + spyOn(service, 'updateDocument').and.returnValue(of(void 0)); + + const settings = {theme: 'dark', notifications: true}; + + service.saveUserSettings('user-123', settings).subscribe({ + next: () => { + expect(service.updateDocument).toHaveBeenCalledWith( + 'users', 'user-123', {settings} + ); + done(); + }, + error: done.fail + }); + }); + }); + + describe('getUserSettings', () => { + it('should get user settings', (done) => { + const mockUser = { + id: 'user-123', + settings: {theme: 'dark', notifications: true} + }; + + spyOn(service, 'getDocument').and.returnValue(of(mockUser)); + + service.getUserSettings('user-123').subscribe({ + next: (settings) => { + expect(settings).toEqual(mockUser.settings); + done(); + }, + error: done.fail + }); + }); + + it('should return null if user has no settings', (done) => { + spyOn(service, 'getDocument').and.returnValue(of({id: 'user-123'})); + + service.getUserSettings('user-123').subscribe({ + next: (settings) => { + expect(settings).toBeNull(); + done(); + }, + error: done.fail + }); + }); + }); + + describe('createOrUpdateUser', () => { + it('should create or update a user', (done) => { + const userData = {name: 'John Doe', email: 'john@example.com'}; + + spyOn(service, 'saveDocument').and.returnValue(of('user-123')); + + service.createOrUpdateUser('user-123', userData).subscribe({ + next: () => { + expect(service.saveDocument).toHaveBeenCalledWith( + 'users', + {...userData, id: 'user-123'}, + 'user-123' + ); + done(); + }, + error: done.fail + }); + }); + }); + }); + + describe('Real-time Operations', () => { + describe('listenToDocument', () => { + it('should listen to document changes', (done) => { + const mockSnapshot = { + exists: () => true, + id: 'test-id', + data: () => ({name: 'Test Document'}) + }; + + spyOn(service as any, 'doc').and.returnValue({id: 'mock-ref'}); + spyOn(service as any, 'onSnapshot').and.callFake( + (docRef: any, callback: any) => { + setTimeout(() => callback(mockSnapshot), 0); + return () => { + }; // Return unsubscribe function + } + ); + + service.listenToDocument('test-collection', 'test-id').subscribe({ + next: (doc: any) => { + expect(doc).toBeDefined(); + expect(doc?.id).toBe('test-id'); + expect((doc as any)?.name).toBe('Test Document'); + done(); + }, + error: done.fail + }); + }); + }); + + describe('listenToCollection', () => { + it('should listen to collection changes', (done) => { + const mockDocs = [ + {id: '1', data: () => ({name: 'Doc 1'})}, + {id: '2', data: () => ({name: 'Doc 2'})} + ]; + const mockSnapshot = {docs: mockDocs}; + + spyOn(service as any, 'collection').and.returnValue({id: 'mock-collection'}); + spyOn(service as any, 'query').and.returnValue({id: 'mock-query'}); + spyOn(service as any, 'onSnapshot').and.callFake( + (query: any, callback: any) => { + setTimeout(() => callback(mockSnapshot), 0); + return () => { + }; + } + ); + + service.listenToCollection('test-collection').subscribe({ + next: (docs: any) => { + expect(docs.length).toBe(2); + expect(docs[0].id).toBe('1'); + expect(docs[1].id).toBe('2'); + done(); + }, + error: done.fail + }); + }); + }); + }); + +}); diff --git a/src/app/services/firebase/firestore.service.ts b/src/app/services/firebase/firestore.service.ts new file mode 100644 index 0000000..df538d3 --- /dev/null +++ b/src/app/services/firebase/firestore.service.ts @@ -0,0 +1,671 @@ +import {Injectable} from '@angular/core'; +import { + collection as collectionFn, + deleteDoc as deleteDocFn, + doc as docFn, + Firestore, + getDoc as getDocFn, + getDocs as getDocsFn, + limit as limitFn, + onSnapshot as onSnapshotFn, + orderBy as orderByFn, + query as queryFn, + serverTimestamp as serverTimestampFn, + setDoc as setDocFn, + Timestamp, + updateDoc as updateDocFn, + where as whereFn, + writeBatch as writeBatchFn +} from '@angular/fire/firestore'; +import { + deleteObject as deleteObjectFn, + getDownloadURL as getDownloadURLFn, + ref as storageRefFn, + Storage, + uploadBytes as uploadBytesFn, + uploadBytesResumable as uploadBytesResumableFn, + uploadString as uploadStringFn +} from '@angular/fire/storage'; +import {from, Observable, throwError, of} from 'rxjs'; +import {catchError, map, switchMap} from 'rxjs/operators'; +import {v4 as uuidv4} from 'uuid'; + + +export interface FirestoreDocument { + id?: string; + createdAt?: Timestamp; + updatedAt?: Timestamp; + + [key: string]: unknown; +} + +@Injectable({ + providedIn: 'root' +}) +export class FirestoreService { + constructor( + private firestore: Firestore, + private storage: Storage + ) { + } + + // Wrappers keep Firebase calls mockable in tests without changing runtime behavior. + private doc(...args: unknown[]): unknown { + return (docFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private collection(...args: unknown[]): unknown { + return (collectionFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private setDoc(...args: unknown[]): Promise { + return (setDocFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private getDoc(...args: unknown[]): Promise { + return (getDocFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private updateDoc(...args: unknown[]): Promise { + return (updateDocFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private deleteDoc(...args: unknown[]): Promise { + return (deleteDocFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private getDocs(...args: unknown[]): Promise { + return (getDocsFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private query(...args: unknown[]): unknown { + return (queryFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private where(...args: unknown[]): unknown { + return (whereFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private orderBy(...args: unknown[]): unknown { + return (orderByFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private limit(...args: unknown[]): unknown { + return (limitFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private onSnapshot(...args: unknown[]): () => void { + return (onSnapshotFn as (...innerArgs: unknown[]) => () => void)(...args); + } + + private serverTimestamp(...args: unknown[]): unknown { + return (serverTimestampFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private writeBatch(...args: unknown[]): { + set: (...batchArgs: unknown[]) => void; + update: (...batchArgs: unknown[]) => void; + delete: (...batchArgs: unknown[]) => void; + commit: () => Promise; + } { + return (writeBatchFn as (...innerArgs: unknown[]) => { + set: (...batchArgs: unknown[]) => void; + update: (...batchArgs: unknown[]) => void; + delete: (...batchArgs: unknown[]) => void; + commit: () => Promise; + })(...args); + } + + private ref(...args: unknown[]): unknown { + return (storageRefFn as (...innerArgs: unknown[]) => unknown)(...args); + } + + private uploadBytes(...args: unknown[]): Promise { + return (uploadBytesFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private uploadBytesResumable(...args: unknown[]): { + on: ( + event: string, + progress: (snapshot: { bytesTransferred: number; totalBytes: number }) => void, + error: (error: unknown) => void, + complete: () => void + ) => void; + snapshot: { ref: unknown }; + } { + return (uploadBytesResumableFn as (...innerArgs: unknown[]) => { + on: ( + event: string, + progress: (snapshot: { bytesTransferred: number; totalBytes: number }) => void, + error: (error: unknown) => void, + complete: () => void + ) => void; + snapshot: { ref: unknown }; + })(...args); + } + + private uploadString(...args: unknown[]): Promise { + return (uploadStringFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private getDownloadURL(...args: unknown[]): Promise { + return (getDownloadURLFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + private deleteObject(...args: unknown[]): Promise { + return (deleteObjectFn as (...innerArgs: unknown[]) => Promise)(...args); + } + + /** + * Creates or updates a document in Firestore + * @param collectionPath - Path to the collection + * @param data - Document data + * @param id - Optional document ID (will be generated if not provided) + * @returns Observable of the document reference + */ + saveDocument( + collectionPath: string, + data: T, + id?: string + ): Observable { + const docId = id || data.id || uuidv4(); + const docRef = this.doc(this.firestore, collectionPath, docId); + + // Add timestamps + const documentData = { + ...data, + updatedAt: this.serverTimestamp(), + createdAt: data.createdAt || this.serverTimestamp(), + id: docId + }; + + return from(this.setDoc(docRef, documentData, {merge: true})).pipe( + map(() => docId), + catchError(error => { + console.error(`Error saving document to ${collectionPath}:`, error); + return throwError(() => new Error(`Failed to save document: ${error.message}`)); + }) + ); + } + + /** + * Retrieves a document from Firestore + * @param collectionPath - Path to the collection + * @param id - Document ID + * @returns Observable of the document data + */ + getDocument(collectionPath: string, id: string): Observable { + const docRef = this.doc(this.firestore, collectionPath, id); + + return from(this.getDoc(docRef)).pipe( + map((snapshot: unknown) => { + const typedSnapshot = snapshot as { + exists: () => boolean; + id: string; + data: () => Record; + }; + + if (typedSnapshot.exists()) { + return {id: typedSnapshot.id, ...typedSnapshot.data()} as T; + } else { + return null; + } + }), + catchError(error => { + console.error(`Error getting document from ${collectionPath}:`, error); + return throwError(() => new Error(`Failed to get document: ${error.message}`)); + }) + ); + } + + /** + * Updates an existing document in Firestore + * @param collectionPath - Path to the collection + * @param id - Document ID + * @param data - Partial data to update + * @returns Observable of void + */ + updateDocument>( + collectionPath: string, + id: string, + data: T + ): Observable { + const docRef = this.doc(this.firestore, collectionPath, id); + + // Add updated timestamp + const updateData = { + ...data, + updatedAt: this.serverTimestamp() + }; + + return from(this.updateDoc(docRef, updateData)).pipe( + catchError(error => { + console.error(`Error updating document in ${collectionPath}:`, error); + return throwError(() => new Error(`Failed to update document: ${error.message}`)); + }) + ); + } + + /** + * Deletes a document from Firestore + * @param collectionPath - Path to the collection + * @param id - Document ID + * @returns Observable of void + */ + deleteDocument(collectionPath: string, id: string): Observable { + const docRef = this.doc(this.firestore, collectionPath, id); + + return from(this.deleteDoc(docRef)).pipe( + catchError(error => { + console.error(`Error deleting document from ${collectionPath}:`, error); + return throwError(() => new Error(`Failed to delete document: ${error.message}`)); + }) + ); + } + + /** + * Queries documents from a collection + * @param collectionPath - Path to the collection + * @param filters - Array of where conditions [field, operator, value] + * @param sortField - Optional field to sort by + * @param sortDirection - Optional sort direction ('asc' or 'desc') + * @param limitCount - Optional limit on number of results + * @returns Observable of array of documents + */ + queryDocuments( + collectionPath: string, + filters?: [string, any, any][], + sortField?: string, + sortDirection: 'asc' | 'desc' = 'desc', + limitCount?: number + ): Observable { + const collectionRef = this.collection(this.firestore, collectionPath); + + let q = this.query(collectionRef); + + // Apply filters if provided + if (filters && filters.length > 0) { + filters.forEach(filter => { + q = this.query(q, this.where(filter[0], filter[1], filter[2])); + }); + } + + // Apply sorting if provided + if (sortField) { + q = this.query(q, this.orderBy(sortField, sortDirection)); + } + + // Apply limit if provided + if (limitCount) { + q = this.query(q, this.limit(limitCount)); + } + + return from(this.getDocs(q)).pipe( + map((snapshot: unknown) => { + const typedSnapshot = snapshot as { + docs: Array<{ id: string; data: () => Record }>; + }; + + return typedSnapshot.docs.map((doc) => ({id: doc.id, ...doc.data()} as T)); + }), + catchError(error => { + console.error(`Error querying documents from ${collectionPath}:`, error); + return throwError(() => new Error(`Failed to query documents: ${error.message}`)); + }) + ); + } + + /** + * Listen to real-time updates on a document + * @param collectionPath - Path to the collection + * @param id - Document ID + * @returns Observable that emits the document data on changes + */ + listenToDocument(collectionPath: string, id: string): Observable { + const docRef = this.doc(this.firestore, collectionPath, id); + + return new Observable(observer => { + // Return the unsubscribe function to clean up when the observable is unsubscribed + return this.onSnapshot(docRef, + (snapshot: unknown) => { + const typedSnapshot = snapshot as { + exists: () => boolean; + id: string; + data: () => Record; + }; + + if (typedSnapshot.exists()) { + observer.next({id: typedSnapshot.id, ...typedSnapshot.data()} as T); + } else { + observer.next(null); + } + }, + (error: unknown) => { + console.error(`Error listening to document in ${collectionPath}:`, error); + observer.error(error); + } + ); + }); + } + + /** + * Listen to real-time updates on a collection + * @param collectionPath - Path to the collection + * @param filters - Optional array of where conditions + * @param sortField + * @param sortDirection + * @returns Observable that emits the collection data on changes + */ + listenToCollection( + collectionPath: string, + filters?: [string, any, any][], + sortField?: string, + sortDirection: 'asc' | 'desc' = 'desc' + ): Observable { + const collectionRef = this.collection(this.firestore, collectionPath); + + let q = this.query(collectionRef); + + // Apply filters if provided + if (filters && filters.length > 0) { + filters.forEach(filter => { + q = this.query(q, this.where(filter[0], filter[1], filter[2])); + }); + } + + // Apply sorting if provided + if (sortField) { + q = this.query(q, this.orderBy(sortField, sortDirection)); + } + + return new Observable(observer => { + // Return the unsubscribe function to clean up when the observable is unsubscribed + return this.onSnapshot(q, + (snapshot: unknown) => { + const typedSnapshot = snapshot as { + docs: Array<{ id: string; data: () => Record }>; + }; + const documents = typedSnapshot.docs.map((doc) => ({id: doc.id, ...doc.data()} as T)); + observer.next(documents); + }, + (error: unknown) => { + console.error(`Error listening to collection ${collectionPath}:`, error); + observer.error(error); + } + ); + }); + } + + /** + * Uploads a file to Firebase Storage + * @param path - Storage path + * @param file - File to upload + * @param metadata - Optional metadata + * @returns Observable of the download URL + */ + uploadFile(path: string, file: File | Blob, metadata?: any): Observable { + const storageRef = this.ref(this.storage, path); + + return from(this.uploadBytes(storageRef, file, metadata)).pipe( + switchMap(() => from(this.getDownloadURL(storageRef))), + catchError(error => { + console.error(`Error uploading file to ${path}:`, error); + return throwError(() => new Error(`Failed to upload file: ${error.message}`)); + }) + ); + } + + /** + * Uploads a file with progress tracking + * @param path - Storage path + * @param file - File to upload + * @param metadata - Optional metadata + * @returns Observable that emits upload progress and final URL + */ + uploadFileWithProgress(path: string, file: File | Blob, metadata?: any): Observable<{ + progress: number, + downloadUrl?: string + }> { + const storageRef = this.ref(this.storage, path); + const uploadTask = this.uploadBytesResumable(storageRef, file, metadata); + + return new Observable<{ progress: number, downloadUrl?: string }>(observer => { + uploadTask.on( + 'state_changed', + (snapshot: { bytesTransferred: number; totalBytes: number }) => { + const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + observer.next({progress}); + }, + (error: unknown) => { + console.error(`Error uploading file to ${path}:`, error); + observer.error(error); + }, + async () => { + try { + const downloadUrl = await this.getDownloadURL(uploadTask.snapshot.ref); + observer.next({progress: 100, downloadUrl}); + observer.complete(); + } catch (error) { + observer.error(error); + } + } + ); + }); + } + + /** + * Uploads a base64 string as a file + * @param path - Storage path + * @param dataUrl - Data URL string + * @param metadata - Optional metadata + * @returns Observable of the download URL + */ + uploadBase64(path: string, dataUrl: string, metadata?: any): Observable { + const storageRef = this.ref(this.storage, path); + + return from(this.uploadString(storageRef, dataUrl, 'data_url', metadata)).pipe( + switchMap(() => from(this.getDownloadURL(storageRef))), + catchError(error => { + console.error(`Error uploading base64 to ${path}:`, error); + return throwError(() => new Error(`Failed to upload base64: ${error.message}`)); + }) + ); + } + + /** + * Deletes a file from Firebase Storage + * @param path - Storage path + * @returns Observable of void + */ + deleteFile(path: string): Observable { + const storageRef = this.ref(this.storage, path); + + return from(this.deleteObject(storageRef)).pipe( + catchError(error => { + console.error(`Error deleting file from ${path}:`, error); + return throwError(() => new Error(`Failed to delete file: ${error.message}`)); + }) + ); + } + + /** + * Saves user settings + * @param userId - User ID + * @param settings - Settings object + * @returns Observable of void + */ + saveUserSettings(userId: string, settings: any): Observable { + return this.updateDocument('users', userId, {settings}).pipe( + map(() => void 0) + ); + } + + /** + * Gets user settings + * @param userId - User ID + * @returns Observable of settings object + */ + getUserSettings(userId: string): Observable { + return this.getDocument('users', userId).pipe( + map(user => user?.settings || null) + ); + } + + /** + * Saves a log entry + * @param logEntry - Log entry object + * @returns Observable of the log entry ID + */ + saveLogEntry(logEntry: { + level: 'info' | 'warn' | 'error' | 'debug'; + message: string; + userId?: string; + metadata?: any; + }): Observable { + return this.saveDocument('logs', { + ...logEntry, + timestamp: this.serverTimestamp() + }); + } + + /** + * Gets log entries + * @param userId - Optional user ID to filter by + * @param level - Optional log level to filter by + * @param limit - Optional limit on number of results + * @returns Observable of log entries + */ + getLogEntries( + userId?: string, + level?: 'info' | 'warn' | 'error' | 'debug', + limit?: number + ): Observable { + const filters: [string, any, any][] = []; + + if (userId) { + filters.push(['userId', '==', userId]); + } + + if (level) { + filters.push(['level', '==', level]); + } + + return this.queryDocuments( + 'logs', + filters, + 'timestamp', + 'desc', + limit + ); + } + + /** + * Saves user profile data + * @param userId - User ID + * @param profileData - Profile data + * @returns Observable of void + */ + saveUserProfile(userId: string, profileData: any): Observable { + return this.updateDocument('users', userId, profileData); + } + + /** + * Gets user profile data + * @param userId - User ID + * @returns Observable of user profile + */ + getUserProfile(userId: string): Observable { + return this.getDocument('users', userId); + } + + /** + * Creates or updates a user document + * @param userId - User ID + * @param userData - User data + * @returns Observable of void + */ + createOrUpdateUser(userId: string, userData: any): Observable { + return this.saveDocument('users', {...userData, id: userId}, userId).pipe( + map(() => void 0) + ); + } + + + /** + * Gets a storage file download URL + * @param path - Storage path + * @returns Observable of the download URL + */ + getFileUrl(path: string): Observable { + const storageRef = this.ref(this.storage, path); + + return from(this.getDownloadURL(storageRef)).pipe( + catchError(error => { + console.error(`Error getting download URL for ${path}:`, error); + return throwError(() => new Error(`Failed to get download URL: ${error.message}`)); + }) + ); + } + + /** + * Executes multiple write operations as a batch + * @param operations - Array of batch operations + * @returns Observable of void + */ + executeBatch(operations: Array<{ + type: 'set' | 'update' | 'delete'; + collection: string; + id: string; + data?: any; + }>): Observable { + const batch = this.writeBatch(this.firestore); + + operations.forEach(operation => { + const docRef = this.doc(this.firestore, operation.collection, operation.id); + + switch (operation.type) { + case 'set': + batch.set(docRef, { + ...operation.data, + updatedAt: this.serverTimestamp(), + createdAt: operation.data?.createdAt || this.serverTimestamp() + }); + break; + case 'update': + batch.update(docRef, { + ...operation.data, + updatedAt: this.serverTimestamp() + }); + break; + case 'delete': + batch.delete(docRef); + break; + } + }); + + return from(batch.commit()).pipe( + catchError(error => { + console.error('Error executing batch operations:', error); + return throwError(() => new Error(`Failed to execute batch operations: ${error.message}`)); + }) + ); + } + + /** + * Checks if a document exists + * @param collectionPath - Path to the collection + * @param id - Document ID + * @returns Observable of boolean + */ + documentExists(collectionPath: string, id: string): Observable { + const docRef = this.doc(this.firestore, collectionPath, id); + + return from(this.getDoc(docRef)).pipe( + map((snapshot: unknown) => (snapshot as { exists: () => boolean }).exists()), + catchError(error => { + console.error(`Error checking document existence in ${collectionPath}:`, error); + return of(false); + }) + ); + } +} diff --git a/src/app/services/firebase/realtime-db.service.spec.ts b/src/app/services/firebase/realtime-db.service.spec.ts new file mode 100644 index 0000000..bb1daa4 --- /dev/null +++ b/src/app/services/firebase/realtime-db.service.spec.ts @@ -0,0 +1,16 @@ +import {TestBed} from '@angular/core/testing'; + +import {RealtimeDbService} from './realtime-db.service'; + +describe('RealtimeDbService', () => { + let service: RealtimeDbService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(RealtimeDbService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/firebase/realtime-db.service.ts b/src/app/services/firebase/realtime-db.service.ts new file mode 100644 index 0000000..be20aa9 --- /dev/null +++ b/src/app/services/firebase/realtime-db.service.ts @@ -0,0 +1,345 @@ +import {Injectable} from '@angular/core'; +import { + Database, + ref, + push, + set, + get, + update, + remove, + onValue, + off, + query, + orderByChild, + orderByKey, + limitToFirst, + limitToLast, + startAt, + endAt, + getDatabase +} from '@angular/fire/database'; +import {Observable} from 'rxjs'; + +export interface DatabaseItem { + id?: string; + + [key: string]: any; +} + +@Injectable({ + providedIn: 'root' +}) +export class RealtimeDbService { + private db: Database | null = null; + + constructor() { + console.warn('RealtimeDbService is deprecated. Please use FirebaseService instead.'); + try { + this.db = getDatabase(); + console.log('Database service initialized successfully'); + } catch (error) { + console.error('Error in database service:', error); + } + + + } + + private requireDb(): Database { + if (!this.db) { + throw new Error('Realtime Database is not initialized'); + } + return this.db; + } + + // Create a new item + async create(path: string, data: Omit): Promise { + try { + const listRef = ref(this.requireDb(), path); + const newItemRef = push(listRef); + await set(newItemRef, { + ...data, + createdAt: Date.now(), + updatedAt: Date.now() + }); + return newItemRef.key!; + } catch (error) { + console.error('Error creating item:', error); + throw error; + } + } + + // Set item with custom ID + async setItem(path: string, id: string, data: Omit): Promise { + try { + const itemRef = ref(this.requireDb(), `${path}/${id}`); + await set(itemRef, { + ...data, + id, + createdAt: Date.now(), + updatedAt: Date.now() + }); + } catch (error) { + console.error('Error setting item:', error); + throw error; + } + } + + // Get a single item by ID + async getItem(path: string, id: string): Promise { + try { + const itemRef = ref(this.requireDb(), `${path}/${id}`); + const snapshot = await get(itemRef); + if (snapshot.exists()) { + return {id, ...snapshot.val()} as T; + } + return null; + } catch (error) { + console.error('Error getting item:', error); + throw error; + } + } + + // Get all items from a path + async getItems(path: string): Promise { + try { + const listRef = ref(this.requireDb(), path); + const snapshot = await get(listRef); + if (snapshot.exists()) { + const items: T[] = []; + snapshot.forEach((childSnapshot) => { + items.push({ + id: childSnapshot.key, + ...childSnapshot.val() + } as T); + }); + return items; + } + return []; + } catch (error) { + console.error('Error getting items:', error); + throw error; + } + } + + // Update an existing item + async updateItem(path: string, id: string, updates: Partial>): Promise { + try { + const itemRef = ref(this.requireDb(), `${path}/${id}`); + await update(itemRef, { + ...updates, + updatedAt: Date.now() + }); + } catch (error) { + console.error('Error updating item:', error); + throw error; + } + } + + // Delete an item + async deleteItem(path: string, id: string): Promise { + try { + const itemRef = ref(this.requireDb(), `${path}/${id}`); + await remove(itemRef); + } catch (error) { + console.error('Error deleting item:', error); + throw error; + } + } + + // Listen to real-time changes for a single item + watchItem(path: string, id: string): Observable { + return new Observable(observer => { + const itemRef = ref(this.requireDb(), `${path}/${id}`); + + const unsubscribe = onValue(itemRef, (snapshot) => { + if (snapshot.exists()) { + observer.next({id, ...snapshot.val()} as T); + } else { + observer.next(null); + } + }, (error) => { + observer.error(error); + }); + + // Return cleanup function + return () => off(itemRef, 'value', unsubscribe); + }); + } + + // Listen to real-time changes for a list of items + watchItems(path: string): Observable { + return new Observable(observer => { + const listRef = ref(this.requireDb(), path); + + const unsubscribe = onValue(listRef, (snapshot) => { + const items: T[] = []; + if (snapshot.exists()) { + snapshot.forEach((childSnapshot) => { + items.push({ + id: childSnapshot.key, + ...childSnapshot.val() + } as T); + }); + } + observer.next(items); + }, (error) => { + observer.error(error); + }); + + // Return cleanup function + return () => off(listRef, 'value', unsubscribe); + }); + } + + // Query items with filters + async queryItems( + path: string, + options: { + orderBy?: 'key' | string; + limitToFirst?: number; + limitToLast?: number; + startAt?: string | number; + endAt?: string | number; + equalTo?: string | number; + } = {} + ): Promise { + try { + const queryRef = ref(this.requireDb(), path); + let queryBuilder = query(queryRef); + + if (options.orderBy) { + if (options.orderBy === 'key') { + queryBuilder = query(queryBuilder, orderByKey()); + } else { + queryBuilder = query(queryBuilder, orderByChild(options.orderBy)); + } + } + + if (options.limitToFirst) { + queryBuilder = query(queryBuilder, limitToFirst(options.limitToFirst)); + } + + if (options.limitToLast) { + queryBuilder = query(queryBuilder, limitToLast(options.limitToLast)); + } + + if (options.startAt !== undefined) { + queryBuilder = query(queryBuilder, startAt(options.startAt)); + } + + if (options.endAt !== undefined) { + queryBuilder = query(queryBuilder, endAt(options.endAt)); + } + + const snapshot = await get(queryBuilder); + const items: T[] = []; + + if (snapshot.exists()) { + snapshot.forEach((childSnapshot) => { + items.push({ + id: childSnapshot.key, + ...childSnapshot.val() + } as T); + }); + } + + return items; + } catch (error) { + console.error('Error querying items:', error); + throw error; + } + } + + // Watch items with query filters + watchQuery( + path: string, + options: { + orderBy?: 'key' | string; + limitToFirst?: number; + limitToLast?: number; + startAt?: string | number; + endAt?: string | number; + } = {} + ): Observable { + return new Observable(observer => { + const queryRef = ref(this.requireDb(), path); + let queryBuilder = query(queryRef); + + if (options.orderBy) { + if (options.orderBy === 'key') { + queryBuilder = query(queryBuilder, orderByKey()); + } else { + queryBuilder = query(queryBuilder, orderByChild(options.orderBy)); + } + } + + if (options.limitToFirst) { + queryBuilder = query(queryBuilder, limitToFirst(options.limitToFirst)); + } + + if (options.limitToLast) { + queryBuilder = query(queryBuilder, limitToLast(options.limitToLast)); + } + + if (options.startAt !== undefined) { + queryBuilder = query(queryBuilder, startAt(options.startAt)); + } + + if (options.endAt !== undefined) { + queryBuilder = query(queryBuilder, endAt(options.endAt)); + } + + const unsubscribe = onValue(queryBuilder, (snapshot) => { + const items: T[] = []; + if (snapshot.exists()) { + snapshot.forEach((childSnapshot) => { + items.push({ + id: childSnapshot.key, + ...childSnapshot.val() + } as T); + }); + } + observer.next(items); + }, (error) => { + observer.error(error); + }); + + return () => off(queryBuilder, 'value', unsubscribe); + }); + } + + // Batch operations + async batchUpdate(updates: { [path: string]: any }): Promise { + try { + const dbRef = ref(this.requireDb()); + await update(dbRef, updates); + } catch (error) { + console.error('Error performing batch update:', error); + throw error; + } + } + + // Check if item exists + async exists(path: string, id: string): Promise { + try { + const itemRef = ref(this.requireDb(), `${path}/${id}`); + const snapshot = await get(itemRef); + return snapshot.exists(); + } catch (error) { + console.error('Error checking if item exists:', error); + throw error; + } + } + + // Get count of items + async getCount(path: string): Promise { + try { + const listRef = ref(this.requireDb(), path); + const snapshot = await get(listRef); + return snapshot.size; + } catch (error) { + console.error('Error getting count:', error); + throw error; + } + } +} diff --git a/src/assets/game/commands/level-1.commands.ts b/src/assets/game/commands/level-1.commands.ts index 93584bb..bb76687 100644 --- a/src/assets/game/commands/level-1.commands.ts +++ b/src/assets/game/commands/level-1.commands.ts @@ -6,7 +6,7 @@ export default function(): CLICommand[] { { name: 'aichat', description: 'Experimental AI ChatBot Service', - execute: (args: string[]) => { + execute: () => { return { status: 200, output: 'aichat > ' diff --git a/src/assets/game/commands/level-2.commands.ts b/src/assets/game/commands/level-2.commands.ts index 93584bb..bb76687 100644 --- a/src/assets/game/commands/level-2.commands.ts +++ b/src/assets/game/commands/level-2.commands.ts @@ -6,7 +6,7 @@ export default function(): CLICommand[] { { name: 'aichat', description: 'Experimental AI ChatBot Service', - execute: (args: string[]) => { + execute: () => { return { status: 200, output: 'aichat > ' diff --git a/src/assets/game/commands/level-3.commands.ts b/src/assets/game/commands/level-3.commands.ts index 93584bb..bb76687 100644 --- a/src/assets/game/commands/level-3.commands.ts +++ b/src/assets/game/commands/level-3.commands.ts @@ -6,7 +6,7 @@ export default function(): CLICommand[] { { name: 'aichat', description: 'Experimental AI ChatBot Service', - execute: (args: string[]) => { + execute: () => { return { status: 200, output: 'aichat > ' diff --git a/src/assets/game/commands/level-4.commands.ts b/src/assets/game/commands/level-4.commands.ts index 93584bb..bb76687 100644 --- a/src/assets/game/commands/level-4.commands.ts +++ b/src/assets/game/commands/level-4.commands.ts @@ -6,7 +6,7 @@ export default function(): CLICommand[] { { name: 'aichat', description: 'Experimental AI ChatBot Service', - execute: (args: string[]) => { + execute: () => { return { status: 200, output: 'aichat > ' diff --git a/src/environments/.env.example b/src/environments/.env.example new file mode 100644 index 0000000..e54722e --- /dev/null +++ b/src/environments/.env.example @@ -0,0 +1,12 @@ +APP_TITLE=Colin Michaels - Production +APP_API_URL=https://api.example.com +OPENAI_API_KEY=example_openai_api_key_value +OPEN_WEATHER_MAP_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +FIREBASE_API_KEY=example_firebase_web_api_key +FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +FIREBASE_DATABASE_URL=https://your-project-default-rtdb.firebaseio.com/ +FIREBASE_PROJECT_ID=your-project +FIREBASE_STORAGE_BUCKET=your-project.firebasestorage.app +FIREBASE_MESSAGING_SENDER_ID=123456789012 +FIREBASE_APP_ID=1:123456789012:web:abcdef1234567890 +FIREBASE_MEASUREMENT_ID=G-ABCDEFGH12 diff --git a/src/environments/environment.dev.ts b/src/environments/environment.dev.ts new file mode 100644 index 0000000..a56af2a --- /dev/null +++ b/src/environments/environment.dev.ts @@ -0,0 +1,17 @@ +export const environment = { + production: false, + title: '', + apiUrl: '', // your NestJS backend + openAiApiKey: '', + openWeatherMapApiKey: '', + firebaseConfig: { + apiKey: "", + authDomain: "", + projectId: "", + databaseURL: "", + storageBucket: "", + messagingSenderId: "", + appId: "", + measurementId: "" + } +}; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts new file mode 100644 index 0000000..cf62637 --- /dev/null +++ b/src/environments/environment.prod.ts @@ -0,0 +1,17 @@ +export const environment = { + production: true, + title: '', + apiUrl: '', // your NestJS backend + openAiApiKey: '', + openWeatherMapApiKey: '', + firebaseConfig: { + apiKey: "", + authDomain: "", + projectId: "", + databaseURL: "", + storageBucket: "", + messagingSenderId: "", + appId: "", + measurementId: "" + } +}; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index c60803a..a56af2a 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,8 +1,17 @@ export const environment = { production: false, - title: 'Colin Michaels', - apiUrl: 'http://localhost:3000', // your NestJS backend - openAiApiKey: '', - openWeatherMapApiKey: '', - firebaseConfig: {} + title: '', + apiUrl: '', // your NestJS backend + openAiApiKey: '', + openWeatherMapApiKey: '', + firebaseConfig: { + apiKey: "", + authDomain: "", + projectId: "", + databaseURL: "", + storageBucket: "", + messagingSenderId: "", + appId: "", + measurementId: "" + } }; diff --git a/src/environments/environments.example.ts b/src/environments/environments.example.ts new file mode 100644 index 0000000..a56af2a --- /dev/null +++ b/src/environments/environments.example.ts @@ -0,0 +1,17 @@ +export const environment = { + production: false, + title: '', + apiUrl: '', // your NestJS backend + openAiApiKey: '', + openWeatherMapApiKey: '', + firebaseConfig: { + apiKey: "", + authDomain: "", + projectId: "", + databaseURL: "", + storageBucket: "", + messagingSenderId: "", + appId: "", + measurementId: "" + } +};