From be2bc267adce0fb8b73498825ac951678d286e6f Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 22 Aug 2026 17:39:31 +0100 Subject: [PATCH 1/3] Remove snow-fall --- package-lock.json | 20 ----- package.json | 4 - public/assets/js/is-land.min.js | 2 - public/assets/js/is-land.min.js.br | Bin 1834 -> 0 bytes public/assets/js/is-land.min.js.zst | Bin 2082 -> 0 bytes public/assets/js/snow-fall.js.br | Bin 1117 -> 0 bytes public/assets/js/snow-fall.js.zst | Bin 1329 -> 0 bytes public/assets/js/winter.js | 125 ---------------------------- public/assets/js/winter.js.br | Bin 1117 -> 0 bytes public/assets/js/winter.js.zst | Bin 1329 -> 0 bytes resources/views/master.blade.php | 9 -- 11 files changed, 160 deletions(-) delete mode 100644 public/assets/js/is-land.min.js delete mode 100644 public/assets/js/is-land.min.js.br delete mode 100644 public/assets/js/is-land.min.js.zst delete mode 100644 public/assets/js/snow-fall.js.br delete mode 100644 public/assets/js/snow-fall.js.zst delete mode 100644 public/assets/js/winter.js delete mode 100644 public/assets/js/winter.js.br delete mode 100644 public/assets/js/winter.js.zst diff --git a/package-lock.json b/package-lock.json index 05c9ad3c..50949213 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,6 @@ "name": "jbuk-frontend", "version": "0.0.1", "license": "CC0-1.0", - "dependencies": { - "@11ty/is-land": "^5.0.0", - "@zachleat/snow-fall": "^1.0.3" - }, "devDependencies": { "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.1.0", @@ -24,16 +20,6 @@ "stylelint-config-standard": "^40.0.0" } }, - "node_modules/@11ty/is-land": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@11ty/is-land/-/is-land-5.0.1.tgz", - "integrity": "sha512-Rh/sLhE4vrc2JaSjeY385v2UxnDY9BhnQtitETb3SKyr0A48Q5Vn06q2AvDBHObtk9+dcFWsoZX4jhT+O9g+xQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/11ty" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1032,12 +1018,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@zachleat/snow-fall": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@zachleat/snow-fall/-/snow-fall-1.0.3.tgz", - "integrity": "sha512-Y9srRbmO+k31vSm+eINYRV9DRoeWGV5/hlAn9o34bLpoWo+T5945v6XGBrFzQYjhyEGB4j/4zXuTW1zTxp2Reg==", - "license": "MIT" - }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", diff --git a/package.json b/package.json index b0ac9882..9c08f32a 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,6 @@ "compress": "./scripts/compress.sh", "build": "npm run lint && npm run build-css && npm run build-js && npm run compress" }, - "dependencies": { - "@11ty/is-land": "^5.0.0", - "@zachleat/snow-fall": "^1.0.3" - }, "allowScripts": { "esbuild@0.27.7": true, "lightningcss-cli@1.32.0": true diff --git a/public/assets/js/is-land.min.js b/public/assets/js/is-land.min.js deleted file mode 100644 index 17203312..00000000 --- a/public/assets/js/is-land.min.js +++ /dev/null @@ -1,2 +0,0 @@ -//! -const e=window,t=e.document,a=e.navigator;function i(){let e,t;return{promise:new Promise((a,i)=>{e=a,t=i}),resolve:e,reject:t}}class r extends HTMLElement{static attributePrefix="on:";static attr={template:"data-island",ready:"ready",defer:"defer-hydration",type:"type",import:"import"};static _tagNames=new Set;static _once=new Map;static ctm(){return"undefined"!=typeof globalThis}static define(t=e.customElements){let a="is-land";this.ctm()&&!t.get(a)&&t.define(a,this)}static _initTypes={default:async e=>{await import(e.getAttribute(r.attr.import))}};static addInitType(e,t){this._initTypes[e]=t}static _fallback={};static addFallback(e,a){this._fallback[e]=a;let i=Array.from(this._tagNames);i.length&&t.querySelectorAll(i.map(e=>`${e}:defined`).join(",")).forEach(e=>{e.replaceFallbackContent()})}getFallback(){return Object.assign({[`:not(:defined):not(${this.localName}):not([${r.attr.defer}])`]:(e,t)=>{let a=r.renameNode(e,t+e.localName);return()=>{a.shadowRoot&&e.shadowRoot.append(...a.shadowRoot.childNodes),e.append(...a.childNodes),a.replaceWith(e)}}},r._fallback)}static renameNode(e,a){let i=t.createElement(a);for(let t of e.getAttributeNames())i.setAttribute(t,e.getAttribute(t));let r=e.shadowRoot;if(!r){let t=e.querySelector(":scope > template[shadowrootmode], :scope > template[shadowroot]");if(t){let a=t.getAttribute("shadowrootmode")||t.getAttribute("shadowroot")||"closed";r=e.attachShadow({mode:a}),r.appendChild(t.content.cloneNode(!0))}}return r&&i.attachShadow({mode:r.mode}).append(...r.childNodes),i.append(...e.childNodes),e.replaceWith(i),i}constructor(){super(),this._ready=i(),this._fallbackExec={},r._tagNames.add(this.localName)}getParents(e,a=!1){let i=[];for(;(e=e.parentNode)&&e&&e!==t.body;)if(e.matches&&e.matches(this.localName)){if(a&&e===a)break;s.hasConditions(e,r.attributePrefix)&&i.push(e)}return i}replaceTemplates(){let e=this.querySelectorAll(`template[${r.attr.template}]`);for(let t of e){if(this.getParents(t,this).length>0)continue;let e=t.getAttribute(r.attr.template);if("replace"===e){let e=Array.from(this.childNodes);for(let t of e)this.removeChild(t);this.appendChild(t.content);break}{let a=t.innerHTML;if("once"===e&&a){if(r._once.has(a))return void t.remove();r._once.set(a,!0)}t.replaceWith(t.content)}}}async beforeReady(){let e,t=this.getAttribute(r.attr.type);t?e=r._initTypes[t]:this.getAttribute(r.attr.import)&&(e=r._initTypes.default),e&&await e(this)}async ready(e,t){return Array.isArray(t)||(t=this.getParents(e)),Promise.all(t.map(e=>e.wait()))}replaceFallbackContent(){let e=`${this.localName}--`;for(let[t,a]of Object.entries(this.getFallback())){if(this._fallbackExec[t])continue;let i=Array.from(this.querySelectorAll(t)).reverse(),r=[];for(let t of i){if(!t.isConnected)continue;let i=this.getParents(t);if(i[0]===this){let s=a(t,e);r.push({node:t,parents:i,returned:s})}}for(let{node:e,parents:t,returned:a}of r.reverse())this.ready(e,t).then(a);this._fallbackExec[t]=!0}}wait(){return this._ready.promise}async connectedCallback(){s.hasConditions(this,r.attributePrefix)&&this.replaceFallbackContent(),await this.hydrate()}async hydrate(){let e=[],t=this.getParents(this);t.length&&e.push(t[0].wait()),e.push(...s.getConditions(this,r.attributePrefix)),await Promise.all(e),this.replaceTemplates(),await this.beforeReady(),this._ready.resolve();let{ready:a,defer:i}=r.attr;this.setAttribute(a,""),this.querySelectorAll(`[${i}]`).forEach(e=>e.removeAttribute(i))}}class s{static _media={};static map={visible:s.visible,idle:s.idle,load:s.pageLoad,interaction:s.interaction,media:s.media,"save-data":s.saveData};static getMap(e=""){return Object.keys(s.map).map(t=>e+t)}static hasConditions(e,t){for(let a of s.getMap(t))if(e.hasAttribute(a))return!0;return!1}static getConditions(e,t){let a=[];for(let i of s.getMap()){let r=t+i;if(e.hasAttribute(r)){let t=e.getAttribute(r);a.push(s.map[i](t,e))}}return a}static visible(t,a){let{promise:r,resolve:s}=i();if("IntersectionObserver"in e){let e=new IntersectionObserver(t=>{let[a]=t;a.isIntersecting&&(e.unobserve(a.target),s())});e.observe(a)}else s();return r}static pageLoad(){if(s._cacheLoad)return s._cacheLoad;let{promise:a,resolve:r}=i();return"complete"===t.readyState?r():e.addEventListener("load",()=>r(),{once:!0}),s._cacheLoad=a,a}static idle(){if(s._cacheIdle)return s._cacheIdle;let{promise:t,resolve:a}=i();return"requestIdleCallback"in e?requestIdleCallback(()=>a()):a(),s._cacheIdle=Promise.all([s.pageLoad(),t]),s._cacheIdle}static interaction(e,t){let a=(e||"click,touchstart").split(",").map(e=>e.trim()),{promise:r,resolve:s}=i();function o(e){s();for(let e of a)t.removeEventListener(e,o)}for(let e of a)t.addEventListener(e,o,{once:!0,passive:!0});return r}static media(t){if(s._media[t])return s._media[t];let{promise:a,resolve:r}=i(),o={matches:!0};return t&&"matchMedia"in e&&(o=e.matchMedia(t)),o.matches?r():o.addListener(e=>{e.matches&&r()}),s._media[t]=a,a}static saveData(e){let{promise:t,resolve:r}=i();return"connection"in a&&a.connection.saveData!==("false"!==e)||r(),t}}new URL(import.meta.url).searchParams.has("nodefine")||r.define(),e.Island=r;export{r as Island}; \ No newline at end of file diff --git a/public/assets/js/is-land.min.js.br b/public/assets/js/is-land.min.js.br deleted file mode 100644 index 34fe76f2943ce1586ecfa2013bf766c68599d5b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1834 zcmcckVF5#eNMH5((-)$2g1MD9PwQF{ztZb@JOejZ$z9JfnVYdui#LaytumkLV$&g8 zkyn`K`Q~^)ttQ6~7i({}rTu&U-e2i?d{MiVm?G?{`@U%Xa75|BXx7huTPIEPWifvm$n&cefND7o3L!6 zw4MHu+Y5DO1XfN5G8X8_TxqeHxZI@J;N)GqQ!&#yCwp*7 zY}YuMFje9AdFe0TY*bIPtlV1oDkbYw!2;&gC6oW|sD2Q?Q_u9~$+K_w2OnL))zaiu zcAd*5Li=X2)SlJN*>Cf|m5bgo`2H_oszT738((@)PrU!tvv2OXODdOVOQ#t%{;l+k z@0utpCb`$@tn(h9az5jgFDGo3ss0(>^LN$lOQ4;(mh1Dzt1P*2W0RG&b%Du8hEq%rqLbGO{@l2% z&X&WGSD&l$jaAO;Ka59|cZwf6G4b%!!|JQ-HgikQem(oRJE!ft`K@Me<4mfAnr|iE zuDX+9;;{btuBUGjjCihk7CrM2d@oS`!#8quOU}*tFZk@=9<^S2*8HW(`b*I*lYZ2d zteAhG@=fT^Kl^+Qm_8}VZ|jClu$-mEF2ERZp+jv0m2t#e`B>6p4vWWKF~9c5Z|lGR`9#S6Vh+Q5uKb#YpVew+9r;(VGrwm| z^8U!I904^S2K~M3`>jx{&T9-*=rnUa|M}Jrk>hylTeDK&rpK9H; z%gOY)J-5DEO-(x%ExA>K=QkZ&}DPfnQ}z?G8D= z`oP3R=dX$c3RSOgI5YFojF}!KI`-im1$Nx~og#}GK3)^;I_^I4da$eYFO9+jf2BI> z{`K~2na!Sip`TyrnER*FDNhA-WS5^1S?%)kJD-$c)U%7Hzp99TD0c|5xXi}E<96}> z&Ah7-d~pxno>*_u@wYqahg7agLcaJEN0)XlZKp$%@0uj_1uc1?DsLUjR~{$v(%^dl zYt6oQMLbLNosKMACh~yq^z+wKN~6nP7V6w!3(K0}yF+m2)ivcCJFic3iOeY1xEUX=R_dyo^t=4%`do-nYNAeZz~q2F>oTn`UV;dDLIZ zI%t%Z@$K@<$Nz3d$xUB=>Asrea-|=JPmX#7FI_4Br~A?kNzDn7s)E)lrawO4HEn&{ zY~|K<@y?7g4lZBn3Zk#ce@ltwR@6`V=5^|h%LdE6QhQ^Roz|P{wk$hbYq0cf_Q71{ zoVPchhS|U0{&RY0__^uZOLJ}-3KuV)ePiW5Mc?V=JDq3dzdltopGQ*S=*BR9!>$uE zv>D@7#FV+}T=njpsQDhUv(@7Mhi(szr^_El+eII~d!;hzgo-z3;-ZId?b&*k@$^28 z^fmW;KH=W7%0{0BFaP%+5NcNrUM*Vl^jXHqsved0!-*oD6X)v0uK(8%#98#b)=%z( z0o$ZW@pU>+5~|jI-v8z5`c)N=_N{Ps-okYz$1kMN%>2G;X*B!p``HV49QbW~-v279 z((&6_ZM!Dw0+&cc+w8C@7oFB?IVWySzt76~=uKYV&6RzXFW=m2mpb_~A}6)G^L(*y`@z z_Ly@%bWYLyQ}cTT&z|G@`u6yycVENgH*RLK)+*y!XgvFwu@1NH2UC}slV|&?Za=g9 OQkCavKJV~_Hp~D5aDl`C diff --git a/public/assets/js/is-land.min.js.zst b/public/assets/js/is-land.min.js.zst deleted file mode 100644 index f878449071c3b6d393e2a050c9071f3b6bd645eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2082 zcmdPcs{c1dUU;bkLsm!&pDM%TJDICJPEL7yD3(bv?cx{HkNbU}=cmuC<$v!V|K#MK z|Mx==vOHjT#Bhs2e2Ij7>YCR-Y`5p1`Q732NiK=UXz8+c z)}BjA*Uern?ozdy^!Yc_rDYXNhxZ3cR^Ll^bv?f7to1RyTqEW;ANOr}B2jH7wB6e6 zF@wGLo0rd5F8ZaV%I++jvV6t0 zdb_N?vg`iY%RF!2y~c6h@C`R(Udf%P*3TC|WlF7lc~$l9ud)}h*7q-O)wL=**1x2* zvBoZWQQOn(6Yo-k!%UW5|8V#Cl$1XWZ*8U;GdWC9@n<%Xvk+uz66zFpd6LcjC+6*= z`LoYyCMhbOzVY9v>#@cZe;JnLt0gbCZ*6-&v8+?z%iVJS_&d4h%rka=E#AF5>UG)a znkx^=yq9XGDb>9<^(+hhnBDp0`Ia9ugwkI;-lnT?mhqXw17t;)nZ*Z$ykh|`F z^}FAS?lZTPabH}$Re>or{a@wN_g?ZVV{5d&e=52!5yYX%I$2|D&wJ&IcegFOH|yK> z-xu{KA9|-gwXxCb?%i*{e(t>6c7NZ+9dEyxc0O6S=dj;~7u#w|MQ(lT6Iq@%{dVy* zk0)QI8HHtRH=Uf7e{8|Flsil<>jSb_gLzB^CoWJtA ztdl!;NoFN~V#@048>C%kO1La2cAczJC*C8qMA7N`RM+)e_Z)loB6|MufT=3}B8L{f z`N4W=f^)~W8&i6M-o#AooHKjh+&fi&eD{haP7KW6yi7PgqS@e6gi@mB^K1*%87`m1 z=e(P}tf+MP>!*2NZslDLy!iid$i?k%=FU%3%HH?7Bgy;}%dYh8h29mn&hbB#*j20d zTq674ruoM@rmxMMpi=iY=W|QlzwZC@1dH1iv03^qU-w(;)~jnj<+p3DZJ%gjx8%c- zi4!Zc_pEGPr0LSVA?W|HDHe$t53NO5<$fK@Vi0xtCjT`1KkGi-+>*<(J)Q;zb<^0Q z4o|T>eoudKcLq)B_br(Ps?iEuIc+kB6R)uxX{!)6HtJ2|Jk6ug#`m{y zl0d4GXxE~fch)bk4OC&wams2ERgnGUA>=%x(Z|E^*10Uv7n_r2g>RgwJ)Jv)Q|9h* zr#E?<*>hNrg|6!@Jj$|0cb1Y-y}MXclEmGQvWG9OSm*Lx_JZ(~cubjHqX%QO}d%<+u52=gGR;A9X8M+Ug`?#a-L2lB$Crno-9n|@G$tqaj-KQz{xP!jAOw&9(U(;yb7a{o> zD;7up%zI;z|HtIuU#~=$>R^vwAGZ9TuJS!}%18Alh1Fe#U7NY~UY}+3aLLE5`&HFX zIO-*NGoN*zX?^#JVath-E2~6O&-+i*w6)2&#`e5t;e*K2E;+&`3@6MpX6TwGG{yvM zyLWLv&yx*J7T>+i_>Su;Di$OMbaIC#C)pG=M_u#X_HK6Du2~I$B>{}SULdh7n3vX^@+KX2M$t^IsU{`BZSx>HMc-H_NI<|4rr zwl!i}^L4}N{4B+1HrYJ+^8RRNdGWro-Ru6iY~}q@D0!g2?80`2lG`(P=TvULqPxh8 zS1y}N#z%jxduA}}(xQa4%MKPcqnkhG~LB4L|Xkn_FN%v2lRpNzh7b6d=+{F#dsr0HeqYlmGw# diff --git a/public/assets/js/snow-fall.js.br b/public/assets/js/snow-fall.js.br deleted file mode 100644 index b44b667033265b528eb2721dddf1dddd7e7345ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1117 zcmX^3Ab~-_&i?N2;!Qr=uPA){rv3BT`{AG~sY`N4r7GGR}La zn6yNy1quxO_gro(-I>YL%6`7Mq0cO^lq>#H(UjQNj@~CHUC*ujvpMKX;~btzTTcYI zue;K;{lJzbA=#NWxAwf^`toe|{J^b&D&=bu`wwo^IbLHXa{0c-d64_h^V>}eKF1773D=^5Ye5CYi z(H5U2?56U?vuD3LYP7fN-<{j}Z09Adb8{BhFLkxn?t6Z5=cA9uH;d<-<~q#Hc{FwJ z2ZzTqx)yhAwK*+*Ky6L=6s1?6Tg-e8IeV(eZz!CQsw&s#K8x3IrP%FNt?M7Wy}f;f zd*+MZYrj;i(sM79E#W^U{$XX#+x)A&I#Ig+mwsXF3Hi&AZB}yQ2}kCoWm^j7pGdhc zuWrBN`>&8GHs_*`wX%21PMfG89dzWd@P2WzTga9J=w{()UADchj2;$y<3$9 zTi)){;1F;%t8#q1wQFOzZu-LzPa}~j3MAIIGvd*3kG-nI@d*5tIddthgpqH_tZD zI^n~5Tx3E1mPJlFC8gZo+V|Nswb^>H83kJ0UcDyaT>tNtk!`p4#ndvFh3&R{ylL^p zd2L^6bSEBj=O~=YH_NkrrO8=k<49)KfKmtT(^KwnE$Td2`^%cw#rD#vUfX;RSL@Y6 z?tfEsTIS2$YLC&JF}rTZ38R&LO656UteCnAR-c=;TzyV}lhoho3K|Q3{=9D=b3Fg~ z#Rp47E<3GU_m^+B##|QzlMg+emI2;pCT~y`J;3HvI%Ad?%lh5s7r%Z~3srJh{Z-Jv zwnW8Kj>FvIyut4s4~;9{|BQanz`Dg<)8+n=F1bl1-W{TgKCFx0=jr7=X|JQJhhW9Q zwL01+E6==GQY`#UC*oxN^Q|xW?y!HjrDVVj;)L p=QnYEeDvv*^1LUON7oq5IQ5j>bz;!}Z0H zh*f6K=bFVZpJ6G(IjNroMt4`m?pw1iRcF3jbYVeO=&{~*dGBMtUqz_CtcbX~W#y)c zfop5$N$-u4{-O9ZjpxZf>685Df9R$^*z;;z3}5}1f-gBn3(xSz$xL5lRyA+!KJ)E5 z(J$?%ln8pVE^@fEPd4I3;P*=38;3hpZ{+T>z07f>@ur*Z=INC=9@i)F?{`y~`}bMT z^5!E)jvl^t=?xiT}1u`fO6vwYAO(MH^0XWG>;8^|^S^@nEx@#4f?FFT>uP)l^G%xv)|E=l%4xT6yo+u4+>~b-yS< zScymZWxS1gbmqT_mYNrxRl9?&wfsH>Xr4sE-w+@Ao~4M)2wp&xee)_ zH_TlhJxIw&Vt6U0J*889OI8kh&!PQ)xEzF=pU){4?)nn5{v_b^*ze1Gt3?y$~%|4OtI`@;g%E5EsD&d8INmk zzIZrI{P@bWf2Et{I?R9dFAgsF-5tf2Xgu}#&INaLm#>)kvGUJ^m8QAV@?Gk3UwzBG zR%89XDRP2e{^7kB!&YyIQGXxS5vum@tn;P%w)8XREa6z5r$SA3sc1DcxMNMy$y3WqM1uU=Gik&%W_b)m7yageJ)C`oE@<+py_IP|Mw{xyxou zP&>bAse6;0@AvYkBKKun&C_LMD`ekm+;QMFKT??LWU-L_+&+=UTGt7k(&rBs-Db1c z)D)%j@L*2l{$?R`>8$hFCKMhKK~-`ybIH7smfCqK<20+7rHut|bQ( z(mI^?Gb)}n^E>{!E!K^hyZ2O|SQ9(v?yicSr}Z=b$*$WyF-a=>&OvbJzFAMom|UW4*@PZ(>(FIh3(FxAHy!%+Y(d diff --git a/public/assets/js/winter.js b/public/assets/js/winter.js deleted file mode 100644 index c28d74d5..00000000 --- a/public/assets/js/winter.js +++ /dev/null @@ -1,125 +0,0 @@ -class Snow extends HTMLElement { - static random(min, max) { - return min + Math.floor(Math.random() * (max - min) + 1); - } - - static attrs = { - count: "count", // default: 100 - mode: "mode", - text: "text", // text in snow flake (emoji, too) - } - - generateCss(mode, count) { - let css = []; - css.push(` -:host([mode="element"]) { - display: block; - position: relative; - overflow: hidden; -} -:host([mode="page"]) { - position: fixed; - top: 0; - left: 0; - right: 0; -} -:host([mode="page"]), -:host([mode="element"]) > * { - pointer-events: none; -} -:host([mode="element"]) ::slotted(*) { - pointer-events: all; -} -* { - position: absolute; -} -:host([text]) * { - font-size: var(--snow-fall-size, 1em); -} -:host(:not([text])) * { - width: var(--snow-fall-size, 10px); - height: var(--snow-fall-size, 10px); - background: var(--snow-fall-color, rgba(255,255,255,.5)); - border-radius: 50%; -} -`); - - // using vw units (max 100) - let dimensions = { width: 100, height: 100 }; - let units = { x: "vw", y: "vh"}; - - if(mode === "element") { - dimensions = { - width: this.firstElementChild.clientWidth, - height: this.firstElementChild.clientHeight, - }; - units = { x: "px", y: "px"}; - } - - // Thank you @alphardex: https://codepen.io/alphardex/pen/dyPorwJ - for(let j = 1; j<= count; j++ ) { - let x = Snow.random(1, 100) * dimensions.width/100; // vw - let offset = Snow.random(-10, 10) * dimensions.width/100; // vw - - let yoyo = Math.round(Snow.random(30, 100)); // % time - let yStart = yoyo * dimensions.height/100; // vh - let yEnd = dimensions.height; // vh - - let scale = Snow.random(1, 10000) * .0001; - let duration = Snow.random(10, 30); - let delay = Snow.random(0, 30) * -1; - - css.push(` -:nth-child(${j}) { - opacity: ${Snow.random(0, 1000) * 0.001}; - transform: translate(${x}${units.x}, -10px) scale(${scale}); - animation: fall-${j} ${duration}s ${delay}s linear infinite; -} - -@keyframes fall-${j} { - ${yoyo}% { - transform: translate(${x + offset}${units.x}, ${yStart}${units.y}) scale(${scale}); - } - - to { - transform: translate(${x + offset / 2}${units.x}, ${yEnd}${units.y}) scale(${scale}); - } -}`) - } - return css.join("\n"); - } - - connectedCallback() { - // https://caniuse.com/mdn-api_cssstylesheet_replacesync - if(this.shadowRoot || !("replaceSync" in CSSStyleSheet.prototype)) { - return; - } - - let count = parseInt(this.getAttribute(Snow.attrs.count)) || 100; - - let mode; - if(this.hasAttribute(Snow.attrs.mode)) { - mode = this.getAttribute(Snow.attrs.mode); - } else { - mode = this.firstElementChild ? "element" : "page"; - this.setAttribute(Snow.attrs.mode, mode); - } - - let sheet = new CSSStyleSheet(); - sheet.replaceSync(this.generateCss(mode, count)); - - let shadowroot = this.attachShadow({ mode: "open" }); - shadowroot.adoptedStyleSheets = [sheet]; - - let d = document.createElement("div"); - let text = this.getAttribute(Snow.attrs.text); - d.innerText = text || ""; - for(let j = 0, k = count; j`{AG~sY`N4r7GGR}La zn6yNy1quxO_gro(-I>YL%6`7Mq0cO^lq>#H(UjQNj@~CHUC*ujvpMKX;~btzTTcYI zue;K;{lJzbA=#NWxAwf^`toe|{J^b&D&=bu`wwo^IbLHXa{0c-d64_h^V>}eKF1773D=^5Ye5CYi z(H5U2?56U?vuD3LYP7fN-<{j}Z09Adb8{BhFLkxn?t6Z5=cA9uH;d<-<~q#Hc{FwJ z2ZzTqx)yhAwK*+*Ky6L=6s1?6Tg-e8IeV(eZz!CQsw&s#K8x3IrP%FNt?M7Wy}f;f zd*+MZYrj;i(sM79E#W^U{$XX#+x)A&I#Ig+mwsXF3Hi&AZB}yQ2}kCoWm^j7pGdhc zuWrBN`>&8GHs_*`wX%21PMfG89dzWd@P2WzTga9J=w{()UADchj2;$y<3$9 zTi)){;1F;%t8#q1wQFOzZu-LzPa}~j3MAIIGvd*3kG-nI@d*5tIddthgpqH_tZD zI^n~5Tx3E1mPJlFC8gZo+V|Nswb^>H83kJ0UcDyaT>tNtk!`p4#ndvFh3&R{ylL^p zd2L^6bSEBj=O~=YH_NkrrO8=k<49)KfKmtT(^KwnE$Td2`^%cw#rD#vUfX;RSL@Y6 z?tfEsTIS2$YLC&JF}rTZ38R&LO656UteCnAR-c=;TzyV}lhoho3K|Q3{=9D=b3Fg~ z#Rp47E<3GU_m^+B##|QzlMg+emI2;pCT~y`J;3HvI%Ad?%lh5s7r%Z~3srJh{Z-Jv zwnW8Kj>FvIyut4s4~;9{|BQanz`Dg<)8+n=F1bl1-W{TgKCFx0=jr7=X|JQJhhW9Q zwL01+E6==GQY`#UC*oxN^Q|xW?y!HjrDVVj;)L p=QnYEeDvv*^1LUON7oq5IQ5j>bz;!}Z0H zh*f6K=bFVZpJ6G(IjNroMt4`m?pw1iRcF3jbYVeO=&{~*dGBMtUqz_CtcbX~W#y)c zfop5$N$-u4{-O9ZjpxZf>685Df9R$^*z;;z3}5}1f-gBn3(xSz$xL5lRyA+!KJ)E5 z(J$?%ln8pVE^@fEPd4I3;P*=38;3hpZ{+T>z07f>@ur*Z=INC=9@i)F?{`y~`}bMT z^5!E)jvl^t=?xiT}1u`fO6vwYAO(MH^0XWG>;8^|^S^@nEx@#4f?FFT>uP)l^G%xv)|E=l%4xT6yo+u4+>~b-yS< zScymZWxS1gbmqT_mYNrxRl9?&wfsH>Xr4sE-w+@Ao~4M)2wp&xee)_ zH_TlhJxIw&Vt6U0J*889OI8kh&!PQ)xEzF=pU){4?)nn5{v_b^*ze1Gt3?y$~%|4OtI`@;g%E5EsD&d8INmk zzIZrI{P@bWf2Et{I?R9dFAgsF-5tf2Xgu}#&INaLm#>)kvGUJ^m8QAV@?Gk3UwzBG zR%89XDRP2e{^7kB!&YyIQGXxS5vum@tn;P%w)8XREa6z5r$SA3sc1DcxMNMy$y3WqM1uU=Gik&%W_b)m7yageJ)C`oE@<+py_IP|Mw{xyxou zP&>bAse6;0@AvYkBKKun&C_LMD`ekm+;QMFKT??LWU-L_+&+=UTGt7k(&rBs-Db1c z)D)%j@L*2l{$?R`>8$hFCKMhKK~-`ybIH7smfCqK<20+7rHut|bQ( z(mI^?Gb)}n^E>{!E!K^hyZ2O|SQ9(v?yicSr}Z=b$*$WyF-a=>&OvbJzFAMom|UW4*@PZ(>(FIh3(FxAHy!%+Y(d diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 3c82845b..8e6a18f3 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -101,15 +101,6 @@ @section('scripts') - - - - - - @show From e9d8f4c74bb1a467c645316f73c05fa0ae1b8f97 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 22 Aug 2026 18:14:08 +0100 Subject: [PATCH 2/3] Redesign theme toggle as a two-icon light/dark switch Replace the single-icon popover with a dropdown of radio inputs with a compact two-button toggle (light/dark), inspired by vale.rocks' design: clicking an icon forces that theme, clicking the active icon again returns to following the system preference. Keeps the existing light-dark() colour-scheme mechanism and view-transition wipe animation, and reuses the project's existing Fluent UI sun/moon icons. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Fn4aNjE3ofpDM78F4qmdCV --- public/assets/css/app.css | 2 +- public/assets/css/app.css.map | 2 +- public/assets/js/app.js | 2 +- public/assets/js/app.js.map | 6 +- resources/css/theme-selector.css | 94 +++++++++++++++---------------- resources/js/theme-selector.js | 97 +++++++++++++++----------------- resources/views/master.blade.php | 35 +++--------- 7 files changed, 102 insertions(+), 136 deletions(-) diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 2d06d701..5c50c0b4 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1,2 +1,2 @@ -@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:1/-1;grid-template-columns:subgrid;display:grid;&>*{grid-column:2/3}&>.full-bleed{grid-column:1/-1}}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform, opacity, display allow-discrete, overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list-wrapper{border:1px solid var(--clr-border);background:light-dark(oklch(99% .02 var(--primary-hue)),oklch(22% .05 var(--primary-hue)));border-radius:16px;margin-block-start:1em;overflow:auto hidden}.token-list{table-layout:fixed;border-collapse:collapse;width:100%;min-width:640px;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.9em 1.2em}& th{text-transform:uppercase;letter-spacing:.08em;opacity:.7;font-size:.8em;font-weight:700}& tr.is-revoked{opacity:.6}& a{word-break:break-all}& th:first-child,& td:first-child{width:31%}& th:nth-child(2),& td:nth-child(2){width:24%}& th:nth-child(3),& td:nth-child(3){width:15%}& th:nth-child(4),& td:nth-child(4){width:16%}& th:nth-child(5),& td:nth-child(5){text-align:right;width:14%}& tr:last-child td{border-bottom:none}}.scope-chips{flex-wrap:wrap;gap:.4em;display:flex}.scope-chip{background:light-dark(oklch(92% .05 var(--primary-hue)),oklch(35% .08 var(--primary-hue)));white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;display:inline-block}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;align-items:center;gap:.4em;padding:.3em .8em;font-size:.85em;font-weight:600;line-height:1.5;display:inline-flex}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145));& .dot{background:currentColor;border-radius:50%;width:6px;height:6px}}.badge-revoked{color:var(--clr-text);opacity:.7}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}} +@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:1/-1;grid-template-columns:subgrid;display:grid;&>*{grid-column:2/3}&>.full-bleed{grid-column:1/-1}}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{background-color:color-mix(in oklch, var(--clr-border) 40%, transparent);border-radius:999px;justify-self:start;align-items:center;gap:.25rem;padding:.25rem;display:flex;& button{appearance:none;color:inherit;background:0 0;border:none;border-radius:999px;place-items:center;padding:.375rem;transition:background-color .2s,color .2s;display:grid;&:hover{cursor:pointer}&:focus-visible{outline:2px solid var(--clr-text);outline-offset:2px}& svg{fill:currentColor;width:1.5rem;height:1.5rem}@media (hover:hover){&:hover{background-color:color-mix(in oklch, var(--clr-border) 70%, transparent)}&[aria-pressed=true]:hover{background-color:var(--clr-text)}}&[aria-pressed=true]{background-color:var(--clr-text);color:var(--clr-background)}}}.sr-only{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list-wrapper{border:1px solid var(--clr-border);background:light-dark(oklch(99% .02 var(--primary-hue)),oklch(22% .05 var(--primary-hue)));border-radius:16px;margin-block-start:1em;overflow:auto hidden}.token-list{table-layout:fixed;border-collapse:collapse;width:100%;min-width:640px;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.9em 1.2em}& th{text-transform:uppercase;letter-spacing:.08em;opacity:.7;font-size:.8em;font-weight:700}& tr.is-revoked{opacity:.6}& a{word-break:break-all}& th:first-child,& td:first-child{width:31%}& th:nth-child(2),& td:nth-child(2){width:24%}& th:nth-child(3),& td:nth-child(3){width:15%}& th:nth-child(4),& td:nth-child(4){width:16%}& th:nth-child(5),& td:nth-child(5){text-align:right;width:14%}& tr:last-child td{border-bottom:none}}.scope-chips{flex-wrap:wrap;gap:.4em;display:flex}.scope-chip{background:light-dark(oklch(92% .05 var(--primary-hue)),oklch(35% .08 var(--primary-hue)));white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;display:inline-block}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;align-items:center;gap:.4em;padding:.3em .8em;font-size:.85em;font-weight:600;line-height:1.5;display:inline-flex}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145));& .dot{background:currentColor;border-radius:50%;width:6px;height:6px}}.badge-revoked{color:var(--clr-text);opacity:.7}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}} /*# sourceMappingURL=/assets/css/app.css.map */ diff --git a/public/assets/css/app.css.map b/public/assets/css/app.css.map index 88c58430..fef4e32c 100644 --- a/public/assets/css/app.css.map +++ b/public/assets/css/app.css.map @@ -1 +1 @@ -{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YEAE,oaAwBA,kECxBA,0BAIA,oBAIA,iIAME,0BAIA,mEAKE,oBAIA,gCAKF,iDAIE,yEAME,uBAON,oDHjDF,kBGyDE,SACE,oBC1DF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CGrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MD/BA,sFAME,+CENF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,0KAahB,iEAIA,kGAKA,4DAIA,qDAIA,mIHrFA,iNAWA,mFAME,6GAQA,6FAQA,2BAIA,yBAIA,4CAKA,8CAKA,8CAKA,8CAKA,+DAMA,uCAKF,kDAMA,qMAYA,0LAaA,uCAKA,sEAGE,uEAQF,gDAKA,kNAQA","sources":["resources/css/app.css","resources/css/colours.css","resources/css/reset.css","resources/css/layout.css","resources/css/admin-tokens.css","resources/css/header.css","resources/css/pagination.css","resources/css/notes.css","resources/css/theme-selector.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n@import url('admin-tokens.css');\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 1/-1;\n display: grid;\n grid-template-columns: subgrid;\n\n > * {\n grid-column: 2/3;\n }\n\n > .full-bleed {\n grid-column: 1/-1;\n }\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n .token-list-wrapper {\n margin-block-start: 1em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n overflow: auto hidden;\n background: light-dark(\n oklch(99% 0.02 var(--primary-hue)),\n oklch(22% 0.05 var(--primary-hue))\n );\n }\n\n .token-list {\n width: 100%;\n min-width: 640px;\n table-layout: fixed;\n border-collapse: collapse;\n\n th,\n td {\n text-align: left;\n padding: 0.9em 1.2em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.8em;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n\n th:nth-child(1),\n td:nth-child(1) {\n width: 31%;\n }\n\n th:nth-child(2),\n td:nth-child(2) {\n width: 24%;\n }\n\n th:nth-child(3),\n td:nth-child(3) {\n width: 15%;\n }\n\n th:nth-child(4),\n td:nth-child(4) {\n width: 16%;\n }\n\n th:nth-child(5),\n td:nth-child(5) {\n width: 14%;\n text-align: right;\n }\n\n tr:last-child td {\n border-bottom: none;\n }\n }\n\n .scope-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 0.4em;\n }\n\n .scope-chip {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n background: light-dark(\n oklch(92% 0.05 var(--primary-hue)),\n oklch(35% 0.08 var(--primary-hue))\n );\n font-size: 0.85em;\n white-space: nowrap;\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-flex;\n align-items: center;\n gap: 0.4em;\n padding: 0.3em 0.8em;\n border-radius: 999px;\n font-size: 0.85em;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n\n .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentcolor;\n }\n }\n\n .badge-revoked {\n color: var(--clr-text);\n opacity: 0.7;\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n}\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n","@layer components {\n #theme-selector {\n button {\n appearance: none;\n border: none;\n background: none;\n\n &:hover {\n cursor: pointer;\n }\n }\n\n /* This is the element with the [popover] attribute */\n #theme-selector-dropdown {\n position: absolute;\n position-area: block-end span-inline-start;\n margin: 0;\n border: 2px solid var(--clr-border);\n background-color: var(--clr-background);\n color: var(--clr-text);\n\n fieldset {\n border: 0;\n display: flex;\n flex-direction: row;\n gap: 1ch;\n\n input {\n display: none;\n }\n }\n }\n\n /*\n * This is the element with the [popover] attribute\n * Here we are styling the open and closing animations\n */\n @media (prefers-reduced-motion: no-preference) {\n #theme-selector-dropdown {\n &:popover-open {\n transform: translateY(0) scale(1);\n opacity: 1;\n\n /*\n * Start styles for the opening transition.\n * Added to :popover-open, but after the opened styles.\n */\n @starting-style {\n transform: translateY(30px) scale(0);\n opacity: 0;\n }\n }\n\n /*\n * End styles for the closing transition.\n */\n transform: translateY(0) scale(0);\n opacity: 0;\n\n /*\n * Enumerate transitioning properties, including display and overlay.\n */\n transition: transform, opacity, display allow-discrete, overlay allow-discrete;\n transition-duration: 0.5s;\n transform-origin: top right;\n }\n }\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n"],"names":[]} \ No newline at end of file +{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YOAE,oaAwBA,kELxBA,0BAIA,oBAIA,iIAME,0BAIA,mEAKE,oBAIA,gCAKF,iDAIE,yEAME,uBAON,oDFjDF,kBEyDE,SACE,oBG1DF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CGrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4ML/BA,sFAME,+CGNF,0LASE,gLAWE,uBAIA,qEAKA,mDAMA,qBACE,iFAIA,6DAKF,oFAOJ,uIAYA,iEAIA,kGAKA,4DAIA,qDAIA,mIFjFA,iNAWA,mFAME,6GAQA,6FAQA,2BAIA,yBAIA,4CAKA,8CAKA,8CAKA,8CAKA,+DAMA,uCAKF,kDAMA,qMAYA,0LAaA,uCAKA,sEAGE,uEAQF,gDAKA,kNAQA","sources":["resources/css/app.css","resources/css/theme-selector.css","resources/css/layout.css","resources/css/pagination.css","resources/css/admin-tokens.css","resources/css/header.css","resources/css/colours.css","resources/css/reset.css","resources/css/notes.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n@import url('admin-tokens.css');\n","@layer components {\n #theme-selector {\n display: flex;\n justify-self: start;\n align-items: center;\n gap: .25rem;\n padding: .25rem;\n border-radius: 999px;\n background-color: color-mix(in oklch, var(--clr-border) 40%, transparent);\n\n button {\n display: grid;\n place-items: center;\n appearance: none;\n border: none;\n border-radius: 999px;\n padding: .375rem;\n background: transparent;\n color: inherit;\n transition: background-color .2s, color .2s;\n\n &:hover {\n cursor: pointer;\n }\n\n &:focus-visible {\n outline: 2px solid var(--clr-text);\n outline-offset: 2px;\n }\n\n svg {\n width: 1.5rem;\n height: 1.5rem;\n fill: currentcolor;\n }\n\n @media (hover: hover) {\n &:hover {\n background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);\n }\n\n &[aria-pressed=\"true\"]:hover {\n background-color: var(--clr-text);\n }\n }\n\n &[aria-pressed=\"true\"] {\n background-color: var(--clr-text);\n color: var(--clr-background);\n }\n }\n }\n\n .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 1/-1;\n display: grid;\n grid-template-columns: subgrid;\n\n > * {\n grid-column: 2/3;\n }\n\n > .full-bleed {\n grid-column: 1/-1;\n }\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .token-list-wrapper {\n margin-block-start: 1em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n overflow: auto hidden;\n background: light-dark(\n oklch(99% 0.02 var(--primary-hue)),\n oklch(22% 0.05 var(--primary-hue))\n );\n }\n\n .token-list {\n width: 100%;\n min-width: 640px;\n table-layout: fixed;\n border-collapse: collapse;\n\n th,\n td {\n text-align: left;\n padding: 0.9em 1.2em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.8em;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n\n th:nth-child(1),\n td:nth-child(1) {\n width: 31%;\n }\n\n th:nth-child(2),\n td:nth-child(2) {\n width: 24%;\n }\n\n th:nth-child(3),\n td:nth-child(3) {\n width: 15%;\n }\n\n th:nth-child(4),\n td:nth-child(4) {\n width: 16%;\n }\n\n th:nth-child(5),\n td:nth-child(5) {\n width: 14%;\n text-align: right;\n }\n\n tr:last-child td {\n border-bottom: none;\n }\n }\n\n .scope-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 0.4em;\n }\n\n .scope-chip {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n background: light-dark(\n oklch(92% 0.05 var(--primary-hue)),\n oklch(35% 0.08 var(--primary-hue))\n );\n font-size: 0.85em;\n white-space: nowrap;\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-flex;\n align-items: center;\n gap: 0.4em;\n padding: 0.3em 0.8em;\n border-radius: 999px;\n font-size: 0.85em;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n\n .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentcolor;\n }\n }\n\n .badge-revoked {\n color: var(--clr-text);\n opacity: 0.7;\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n}\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n"],"names":[]} \ No newline at end of file diff --git a/public/assets/js/app.js b/public/assets/js/app.js index d19f257c..297c3aa1 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -1,2 +1,2 @@ -(()=>{var l=class{constructor(){}async register(){let r=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(r.challenge),rp:{id:r.rp.id,name:r.rp.name},user:{id:new TextEncoder().encode(window.atob(r.user.id)),name:r.user.name,displayName:r.user.displayName},pubKeyCredParams:r.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:r.authenticatorSelection,timeout:6e4},t=await navigator.credentials.create({publicKey:e});if(!t)throw new Error("Error generating a passkey");let n={id:t.id?t.id:null,type:t.type?t.type:null,rawId:t.rawId?this.bufferToBase64URLString(t.rawId):null,response:{attestationObject:t.response.attestationObject?this.bufferToBase64URLString(t.response.attestationObject):null,clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(n),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let r=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(r.challenge),userVerification:r.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let t={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(t),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(r){let e=r.replace(/-/g,"+").replace(/_/g,"/"),t=(4-e.length%4)%4,n=e.padEnd(e.length+t,"="),a=window.atob(n),o=new ArrayBuffer(a.length),i=new Uint8Array(o);for(let s=0;sr.addEventListener("input",e=>{let t=e.target.value;this.widget.querySelectorAll(".toggle svg").forEach(u=>{u.classList.contains(t)?u.style.display="":u.style.display="none"});let n;switch(t){case"dark":case"light":n=t;break;default:n="light dark"}let a=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",o=document.querySelector("html"),i=o.style.getPropertyValue("color-scheme");i===""&&(i="light dark");let s=!1;n!==i&&(n==="light dark"?s=i!==a:i==="light dark"?s=n!==a:s=!0),o.style.viewTransitionName="changing-theme",s&&document.startViewTransition?document.startViewTransition(()=>{document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n)}):(document.querySelector("#theme-selector-dropdown").togglePopover(),o.style.setProperty("color-scheme",n))}))}};var h=new l;document.querySelectorAll(".add-passkey").forEach(c=>{c.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(c=>{c.addEventListener("click",()=>{h.login()})});var p=new d;p.setupEventListeners();})(); +(()=>{var l=class{constructor(){}async register(){let t=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(t.challenge),rp:{id:t.rp.id,name:t.rp.name},user:{id:new TextEncoder().encode(window.atob(t.user.id)),name:t.user.name,displayName:t.user.displayName},pubKeyCredParams:t.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:t.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:e});if(!r)throw new Error("Error generating a passkey");let s={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(s),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let t=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(t.challenge),userVerification:t.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let r={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),r=(4-e.length%4)%4,s=e.padEnd(e.length+r,"="),n=window.atob(s),a=new ArrayBuffer(n.length),o=new Uint8Array(a);for(let c=0;c{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(t){let e=t==="light"||t==="dark"?t:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",s=document.documentElement,n=s.style.getPropertyValue("color-scheme");n===""&&(n="light dark");let a=!1;e!==n&&(e==="light dark"?a=n!==r:n==="light dark"?a=e!==r:a=!0);let o=()=>{this.currentTheme=t,s.style.setProperty("color-scheme",e),this.btnLight.setAttribute("aria-pressed",String(t==="light")),this.btnDark.setAttribute("aria-pressed",String(t==="dark")),this.status.textContent=t==="system"?"Theme set to system default":""};s.style.viewTransitionName="changing-theme",a&&document.startViewTransition?document.startViewTransition(o):o()}};var h=new l;document.querySelectorAll(".add-passkey").forEach(i=>{i.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(i=>{i.addEventListener("click",()=>{h.login()})});var u=new d;u.setupEventListeners();})(); //# sourceMappingURL=app.js.map diff --git a/public/assets/js/app.js.map b/public/assets/js/app.js.map index 79c78c67..a2fb0730 100644 --- a/public/assets/js/app.js.map +++ b/public/assets/js/app.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["../../../resources/js/auth.js", "../../../resources/js/theme-selector.js", "../../../resources/js/app.js"], - "sourcesContent": ["class Auth {\n constructor() {}\n\n async register() {\n const createOptions = await this.getCreateOptions();\n\n const publicKeyCredentialCreationOptions = {\n challenge: this.base64URLStringToBuffer(createOptions.challenge),\n rp: {\n id: createOptions.rp.id,\n name: createOptions.rp.name,\n },\n user: {\n id: new TextEncoder().encode(window.atob(createOptions.user.id)),\n name: createOptions.user.name,\n displayName: createOptions.user.displayName,\n },\n pubKeyCredParams: createOptions.pubKeyCredParams,\n excludeCredentials: [],\n authenticatorSelection: createOptions.authenticatorSelection,\n timeout: 60000,\n };\n\n const credential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!credential) {\n throw new Error('Error generating a passkey');\n }\n\n const authenticatorAttestationResponse = {\n id: credential.id ? credential.id : null,\n type: credential.type ? credential.type : null,\n rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,\n response: {\n attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,\n clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,\n }\n };\n\n const registerCredential = await window.fetch('/admin/passkeys/register', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n cache: 'no-cache',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!registerCredential.ok) {\n throw new Error('Error saving the passkey');\n }\n\n window.location.reload();\n }\n\n async getCreateOptions() {\n const response = await fetch('/admin/passkeys/register', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n async login() {\n const loginData = await this.getLoginData();\n\n const publicKeyCredential = await navigator.credentials.get({\n publicKey: {\n challenge: this.base64URLStringToBuffer(loginData.challenge),\n userVerification: loginData.userVerification,\n timeout: 60000,\n }\n });\n\n if (!publicKeyCredential) {\n throw new Error('Authentication failed');\n }\n\n const authenticatorAttestationResponse = {\n id: publicKeyCredential.id ? publicKeyCredential.id : '',\n type: publicKeyCredential.type ? publicKeyCredential.type : '',\n rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',\n response: {\n authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',\n clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',\n signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',\n userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',\n },\n };\n\n const loginAttempt = await window.fetch('/login/passkey', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!loginAttempt.ok) {\n throw new Error('Login failed');\n }\n\n window.location.assign('/admin');\n }\n\n async getLoginData() {\n const response = await fetch('/login/passkey', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n /**\n * Convert a base64 URL string to a buffer.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8\n *\n * @param {string} base64URLString\n * @returns {ArrayBuffer}\n */\n base64URLStringToBuffer(base64URLString) {\n // Convert from Base64URL to Base64\n const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');\n /**\n * Pad with '=' until it's a multiple of four\n * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding\n * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding\n * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding\n * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding\n */\n const padLength = (4 - (base64.length % 4)) % 4;\n const padded = base64.padEnd(base64.length + padLength, '=');\n // Convert to a binary string\n const binary = window.atob(padded);\n // Convert binary string to buffer\n const buffer = new ArrayBuffer(binary.length);\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return buffer;\n }\n\n /**\n * Convert a buffer to a base64 URL string.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7\n *\n * @param {ArrayBuffer} buffer\n * @returns {string}\n */\n bufferToBase64URLString(buffer) {\n const bytes = new Uint8Array(buffer);\n let str = '';\n for (const charCode of bytes) {\n str += String.fromCharCode(charCode);\n }\n const base64String = btoa(str);\n return base64String.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n }\n}\n\nexport { Auth };\n", "class ThemeSelector {\n constructor() {\n this.widget = document.querySelector('#theme-selector');\n }\n\n setupEventListeners() {\n this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => {\n let theme = e.target.value;\n\n // Update current icon\n this.widget.querySelectorAll('.toggle svg').forEach((svg) => {\n if (svg.classList.contains(theme)) {\n svg.style.display = '';\n } else {\n svg.style.display = 'none';\n }\n });\n\n // Set the theme\n let selectedTheme;\n switch (theme) {\n case 'dark':\n case 'light':\n selectedTheme = theme;\n break;\n default:\n selectedTheme = 'light dark';\n }\n\n const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';\n const html = document.querySelector('html');\n let currentTheme = html.style.getPropertyValue('color-scheme');\n if (currentTheme === '') {\n currentTheme = 'light dark';\n }\n\n /* do we need to transition */\n let doTransition = false;\n\n if (selectedTheme !== currentTheme) {\n if (selectedTheme === 'light dark') {\n doTransition = currentTheme !== systemTheme;\n } else if (currentTheme === 'light dark') {\n doTransition = selectedTheme !== systemTheme;\n } else {\n doTransition = true;\n }\n }\n\n html.style.viewTransitionName = 'changing-theme';\n if (doTransition && document.startViewTransition) {\n document.startViewTransition(() => {\n // Close the popover\n document.querySelector('#theme-selector-dropdown').togglePopover();\n\n // Set the colour theme\n html.style.setProperty('color-scheme', selectedTheme);\n });\n } else {\n // Close the popover\n document.querySelector('#theme-selector-dropdown').togglePopover();\n\n // Set the colour theme\n html.style.setProperty('color-scheme', selectedTheme);\n }\n }));\n }\n}\n\nexport { ThemeSelector };\n", "import { Auth } from './auth.js';\nimport { ThemeSelector } from './theme-selector.js';\n\nlet auth = new Auth();\ndocument.querySelectorAll('.add-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});\n\ndocument.querySelectorAll('.login-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.login();\n });\n});\n\nlet themeSelector = new ThemeSelector();\nthemeSelector.setupEventListeners();\n"], - "mappings": "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", - "names": ["Auth", "createOptions", "publicKeyCredentialCreationOptions", "credential", "authenticatorAttestationResponse", "loginData", "publicKeyCredential", "base64URLString", "base64", "padLength", "padded", "binary", "buffer", "bytes", "i", "str", "charCode", "ThemeSelector", "radioInput", "theme", "svg", "selectedTheme", "systemTheme", "html", "currentTheme", "doTransition", "auth", "Auth", "el", "themeSelector", "ThemeSelector"] + "sourcesContent": ["class Auth {\n constructor() {}\n\n async register() {\n const createOptions = await this.getCreateOptions();\n\n const publicKeyCredentialCreationOptions = {\n challenge: this.base64URLStringToBuffer(createOptions.challenge),\n rp: {\n id: createOptions.rp.id,\n name: createOptions.rp.name,\n },\n user: {\n id: new TextEncoder().encode(window.atob(createOptions.user.id)),\n name: createOptions.user.name,\n displayName: createOptions.user.displayName,\n },\n pubKeyCredParams: createOptions.pubKeyCredParams,\n excludeCredentials: [],\n authenticatorSelection: createOptions.authenticatorSelection,\n timeout: 60000,\n };\n\n const credential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!credential) {\n throw new Error('Error generating a passkey');\n }\n\n const authenticatorAttestationResponse = {\n id: credential.id ? credential.id : null,\n type: credential.type ? credential.type : null,\n rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,\n response: {\n attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,\n clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,\n }\n };\n\n const registerCredential = await window.fetch('/admin/passkeys/register', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n cache: 'no-cache',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!registerCredential.ok) {\n throw new Error('Error saving the passkey');\n }\n\n window.location.reload();\n }\n\n async getCreateOptions() {\n const response = await fetch('/admin/passkeys/register', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n async login() {\n const loginData = await this.getLoginData();\n\n const publicKeyCredential = await navigator.credentials.get({\n publicKey: {\n challenge: this.base64URLStringToBuffer(loginData.challenge),\n userVerification: loginData.userVerification,\n timeout: 60000,\n }\n });\n\n if (!publicKeyCredential) {\n throw new Error('Authentication failed');\n }\n\n const authenticatorAttestationResponse = {\n id: publicKeyCredential.id ? publicKeyCredential.id : '',\n type: publicKeyCredential.type ? publicKeyCredential.type : '',\n rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',\n response: {\n authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',\n clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',\n signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',\n userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',\n },\n };\n\n const loginAttempt = await window.fetch('/login/passkey', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!loginAttempt.ok) {\n throw new Error('Login failed');\n }\n\n window.location.assign('/admin');\n }\n\n async getLoginData() {\n const response = await fetch('/login/passkey', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n /**\n * Convert a base64 URL string to a buffer.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8\n *\n * @param {string} base64URLString\n * @returns {ArrayBuffer}\n */\n base64URLStringToBuffer(base64URLString) {\n // Convert from Base64URL to Base64\n const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');\n /**\n * Pad with '=' until it's a multiple of four\n * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding\n * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding\n * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding\n * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding\n */\n const padLength = (4 - (base64.length % 4)) % 4;\n const padded = base64.padEnd(base64.length + padLength, '=');\n // Convert to a binary string\n const binary = window.atob(padded);\n // Convert binary string to buffer\n const buffer = new ArrayBuffer(binary.length);\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return buffer;\n }\n\n /**\n * Convert a buffer to a base64 URL string.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7\n *\n * @param {ArrayBuffer} buffer\n * @returns {string}\n */\n bufferToBase64URLString(buffer) {\n const bytes = new Uint8Array(buffer);\n let str = '';\n for (const charCode of bytes) {\n str += String.fromCharCode(charCode);\n }\n const base64String = btoa(str);\n return base64String.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n }\n}\n\nexport { Auth };\n", "class ThemeSelector {\n constructor() {\n this.widget = document.querySelector('#theme-selector');\n this.btnLight = this.widget.querySelector('#btn-light');\n this.btnDark = this.widget.querySelector('#btn-dark');\n this.status = this.widget.querySelector('#theme-status');\n this.currentTheme = 'system';\n }\n\n setupEventListeners() {\n this.btnLight.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');\n });\n\n this.btnDark.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');\n });\n }\n\n applyTheme(theme) {\n const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';\n\n const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';\n const html = document.documentElement;\n let currentColorScheme = html.style.getPropertyValue('color-scheme');\n if (currentColorScheme === '') {\n currentColorScheme = 'light dark';\n }\n\n /* do we need to transition */\n let doTransition = false;\n\n if (selectedTheme !== currentColorScheme) {\n if (selectedTheme === 'light dark') {\n doTransition = currentColorScheme !== systemTheme;\n } else if (currentColorScheme === 'light dark') {\n doTransition = selectedTheme !== systemTheme;\n } else {\n doTransition = true;\n }\n }\n\n const applyChange = () => {\n this.currentTheme = theme;\n html.style.setProperty('color-scheme', selectedTheme);\n\n this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));\n this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));\n this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';\n };\n\n html.style.viewTransitionName = 'changing-theme';\n if (doTransition && document.startViewTransition) {\n document.startViewTransition(applyChange);\n } else {\n applyChange();\n }\n }\n}\n\nexport { ThemeSelector };\n", "import { Auth } from './auth.js';\nimport { ThemeSelector } from './theme-selector.js';\n\nlet auth = new Auth();\ndocument.querySelectorAll('.add-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});\n\ndocument.querySelectorAll('.login-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.login();\n });\n});\n\nlet themeSelector = new ThemeSelector();\nthemeSelector.setupEventListeners();\n"], + "mappings": "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", + "names": ["Auth", "createOptions", "publicKeyCredentialCreationOptions", "credential", "authenticatorAttestationResponse", "loginData", "publicKeyCredential", "base64URLString", "base64", "padLength", "padded", "binary", "buffer", "bytes", "i", "str", "charCode", "ThemeSelector", "theme", "selectedTheme", "systemTheme", "html", "currentColorScheme", "doTransition", "applyChange", "auth", "Auth", "el", "themeSelector", "ThemeSelector"] } diff --git a/resources/css/theme-selector.css b/resources/css/theme-selector.css index 333639a4..f718b4b0 100644 --- a/resources/css/theme-selector.css +++ b/resources/css/theme-selector.css @@ -1,72 +1,68 @@ @layer components { #theme-selector { + display: flex; + justify-self: start; + align-items: center; + gap: .25rem; + padding: .25rem; + border-radius: 999px; + background-color: color-mix(in oklch, var(--clr-border) 40%, transparent); + button { + display: grid; + place-items: center; appearance: none; border: none; - background: none; + border-radius: 999px; + padding: .375rem; + background: transparent; + color: inherit; + transition: background-color .2s, color .2s; &:hover { cursor: pointer; } - } - /* This is the element with the [popover] attribute */ - #theme-selector-dropdown { - position: absolute; - position-area: block-end span-inline-start; - margin: 0; - border: 2px solid var(--clr-border); - background-color: var(--clr-background); - color: var(--clr-text); - - fieldset { - border: 0; - display: flex; - flex-direction: row; - gap: 1ch; - - input { - display: none; - } + &:focus-visible { + outline: 2px solid var(--clr-text); + outline-offset: 2px; } - } - /* - * This is the element with the [popover] attribute - * Here we are styling the open and closing animations - */ - @media (prefers-reduced-motion: no-preference) { - #theme-selector-dropdown { - &:popover-open { - transform: translateY(0) scale(1); - opacity: 1; + svg { + width: 1.5rem; + height: 1.5rem; + fill: currentcolor; + } - /* - * Start styles for the opening transition. - * Added to :popover-open, but after the opened styles. - */ - @starting-style { - transform: translateY(30px) scale(0); - opacity: 0; - } + @media (hover: hover) { + &:hover { + background-color: color-mix(in oklch, var(--clr-border) 70%, transparent); } - /* - * End styles for the closing transition. - */ - transform: translateY(0) scale(0); - opacity: 0; + &[aria-pressed="true"]:hover { + background-color: var(--clr-text); + } + } - /* - * Enumerate transitioning properties, including display and overlay. - */ - transition: transform, opacity, display allow-discrete, overlay allow-discrete; - transition-duration: 0.5s; - transform-origin: top right; + &[aria-pressed="true"] { + background-color: var(--clr-text); + color: var(--clr-background); } } } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } + ::view-transition-group(changing-theme) { animation-duration: 1.25s; } diff --git a/resources/js/theme-selector.js b/resources/js/theme-selector.js index 3248b9bf..374fdc8c 100644 --- a/resources/js/theme-selector.js +++ b/resources/js/theme-selector.js @@ -1,69 +1,60 @@ class ThemeSelector { constructor() { this.widget = document.querySelector('#theme-selector'); + this.btnLight = this.widget.querySelector('#btn-light'); + this.btnDark = this.widget.querySelector('#btn-dark'); + this.status = this.widget.querySelector('#theme-status'); + this.currentTheme = 'system'; } setupEventListeners() { - this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => { - let theme = e.target.value; + this.btnLight.addEventListener('click', () => { + this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light'); + }); - // Update current icon - this.widget.querySelectorAll('.toggle svg').forEach((svg) => { - if (svg.classList.contains(theme)) { - svg.style.display = ''; - } else { - svg.style.display = 'none'; - } - }); + this.btnDark.addEventListener('click', () => { + this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark'); + }); + } - // Set the theme - let selectedTheme; - switch (theme) { - case 'dark': - case 'light': - selectedTheme = theme; - break; - default: - selectedTheme = 'light dark'; - } + applyTheme(theme) { + const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark'; - const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; - const html = document.querySelector('html'); - let currentTheme = html.style.getPropertyValue('color-scheme'); - if (currentTheme === '') { - currentTheme = 'light dark'; - } + const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + const html = document.documentElement; + let currentColorScheme = html.style.getPropertyValue('color-scheme'); + if (currentColorScheme === '') { + currentColorScheme = 'light dark'; + } - /* do we need to transition */ - let doTransition = false; + /* do we need to transition */ + let doTransition = false; - if (selectedTheme !== currentTheme) { - if (selectedTheme === 'light dark') { - doTransition = currentTheme !== systemTheme; - } else if (currentTheme === 'light dark') { - doTransition = selectedTheme !== systemTheme; - } else { - doTransition = true; - } - } - - html.style.viewTransitionName = 'changing-theme'; - if (doTransition && document.startViewTransition) { - document.startViewTransition(() => { - // Close the popover - document.querySelector('#theme-selector-dropdown').togglePopover(); - - // Set the colour theme - html.style.setProperty('color-scheme', selectedTheme); - }); + if (selectedTheme !== currentColorScheme) { + if (selectedTheme === 'light dark') { + doTransition = currentColorScheme !== systemTheme; + } else if (currentColorScheme === 'light dark') { + doTransition = selectedTheme !== systemTheme; } else { - // Close the popover - document.querySelector('#theme-selector-dropdown').togglePopover(); - - // Set the colour theme - html.style.setProperty('color-scheme', selectedTheme); + doTransition = true; } - })); + } + + const applyChange = () => { + this.currentTheme = theme; + html.style.setProperty('color-scheme', selectedTheme); + + this.btnLight.setAttribute('aria-pressed', String(theme === 'light')); + this.btnDark.setAttribute('aria-pressed', String(theme === 'dark')); + this.status.textContent = theme === 'system' ? 'Theme set to system default' : ''; + }; + + html.style.viewTransitionName = 'changing-theme'; + if (doTransition && document.startViewTransition) { + document.startViewTransition(applyChange); + } else { + applyChange(); + } } } diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 8e6a18f3..cb15e685 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -38,35 +38,14 @@ Projects @include('icons.json-feed', ['title' => 'JSON Feed']) -
- -
-
- Select theme: -
- - -
-
- - -
-
- - -
-
-
+ +
From 9532aae37eeac0abc29a6de35097821259c22832 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 23 Aug 2026 10:17:26 +0100 Subject: [PATCH 3/3] Bring back winter snow effect, gated by an admin setting The site has flip-flopped on the snow effect every winter (add it, remove it, repeat), meaning a PR round-trip each time. Instead, restore the winter.js/is-land assets and gate them behind a new admin-toggleable setting so the effect can be switched on/off from /admin/settings without touching code. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0113BVPXDpEk9HPEqXvSG2WQ --- .../Controllers/Admin/SettingsController.php | 32 +++++ app/Models/Setting.php | 18 +++ app/Providers/AppServiceProvider.php | 7 + database/factories/SettingFactory.php | 24 ++++ ...026_08_23_101358_create_settings_table.php | 28 ++++ package-lock.json | 20 +++ package.json | 4 + public/assets/js/is-land.min.js | 2 + public/assets/js/is-land.min.js.br | Bin 0 -> 1834 bytes public/assets/js/is-land.min.js.zst | Bin 0 -> 2082 bytes public/assets/js/winter.js | 125 ++++++++++++++++++ public/assets/js/winter.js.br | Bin 0 -> 1117 bytes public/assets/js/winter.js.zst | Bin 0 -> 1329 bytes resources/views/admin/settings/show.blade.php | 26 ++++ resources/views/admin/welcome.blade.php | 5 + resources/views/master.blade.php | 11 ++ routes/web.php | 7 + tests/Feature/Admin/SettingsTest.php | 68 ++++++++++ 18 files changed, 377 insertions(+) create mode 100644 app/Http/Controllers/Admin/SettingsController.php create mode 100644 app/Models/Setting.php create mode 100644 database/factories/SettingFactory.php create mode 100644 database/migrations/2026_08_23_101358_create_settings_table.php create mode 100644 public/assets/js/is-land.min.js create mode 100644 public/assets/js/is-land.min.js.br create mode 100644 public/assets/js/is-land.min.js.zst create mode 100644 public/assets/js/winter.js create mode 100644 public/assets/js/winter.js.br create mode 100644 public/assets/js/winter.js.zst create mode 100644 resources/views/admin/settings/show.blade.php create mode 100644 tests/Feature/Admin/SettingsTest.php diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php new file mode 100644 index 00000000..99d283bb --- /dev/null +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -0,0 +1,32 @@ + $settings, + ]); + } + + public function update(Request $request): RedirectResponse + { + $settings = Setting::firstOrNew(); + $settings->winter_effect_enabled = $request->boolean('winter_effect_enabled'); + $settings->save(); + + return redirect()->route('admin.settings.show'); + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 00000000..0c84ea36 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,18 @@ + + */ + protected $casts = [ + 'winter_effect_enabled' => 'boolean', + ]; +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 68367a97..a40ae43f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,12 @@ namespace App\Providers; +use App\Models\Setting; use Illuminate\Database\Eloquent\Model; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; @@ -63,5 +65,10 @@ class AppServiceProvider extends ServiceProvider // Force HTTPS URL generation in production URL::forceHttps($this->app->isProduction()); + + // Share whether the winter snow effect is enabled with the base layout + View::composer('master', function ($view) { + $view->with('winterEffectEnabled', Setting::first()?->winter_effect_enabled ?? false); + }); } } diff --git a/database/factories/SettingFactory.php b/database/factories/SettingFactory.php new file mode 100644 index 00000000..8df15756 --- /dev/null +++ b/database/factories/SettingFactory.php @@ -0,0 +1,24 @@ + + */ +class SettingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'winter_effect_enabled' => false, + ]; + } +} diff --git a/database/migrations/2026_08_23_101358_create_settings_table.php b/database/migrations/2026_08_23_101358_create_settings_table.php new file mode 100644 index 00000000..a4ea86d2 --- /dev/null +++ b/database/migrations/2026_08_23_101358_create_settings_table.php @@ -0,0 +1,28 @@ +id(); + $table->boolean('winter_effect_enabled')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/package-lock.json b/package-lock.json index 50949213..05c9ad3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "jbuk-frontend", "version": "0.0.1", "license": "CC0-1.0", + "dependencies": { + "@11ty/is-land": "^5.0.0", + "@zachleat/snow-fall": "^1.0.3" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.1.0", @@ -20,6 +24,16 @@ "stylelint-config-standard": "^40.0.0" } }, + "node_modules/@11ty/is-land": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@11ty/is-land/-/is-land-5.0.1.tgz", + "integrity": "sha512-Rh/sLhE4vrc2JaSjeY385v2UxnDY9BhnQtitETb3SKyr0A48Q5Vn06q2AvDBHObtk9+dcFWsoZX4jhT+O9g+xQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/11ty" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1018,6 +1032,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@zachleat/snow-fall": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@zachleat/snow-fall/-/snow-fall-1.0.3.tgz", + "integrity": "sha512-Y9srRbmO+k31vSm+eINYRV9DRoeWGV5/hlAn9o34bLpoWo+T5945v6XGBrFzQYjhyEGB4j/4zXuTW1zTxp2Reg==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", diff --git a/package.json b/package.json index 9c08f32a..b0ac9882 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ "compress": "./scripts/compress.sh", "build": "npm run lint && npm run build-css && npm run build-js && npm run compress" }, + "dependencies": { + "@11ty/is-land": "^5.0.0", + "@zachleat/snow-fall": "^1.0.3" + }, "allowScripts": { "esbuild@0.27.7": true, "lightningcss-cli@1.32.0": true diff --git a/public/assets/js/is-land.min.js b/public/assets/js/is-land.min.js new file mode 100644 index 00000000..17203312 --- /dev/null +++ b/public/assets/js/is-land.min.js @@ -0,0 +1,2 @@ +//! +const e=window,t=e.document,a=e.navigator;function i(){let e,t;return{promise:new Promise((a,i)=>{e=a,t=i}),resolve:e,reject:t}}class r extends HTMLElement{static attributePrefix="on:";static attr={template:"data-island",ready:"ready",defer:"defer-hydration",type:"type",import:"import"};static _tagNames=new Set;static _once=new Map;static ctm(){return"undefined"!=typeof globalThis}static define(t=e.customElements){let a="is-land";this.ctm()&&!t.get(a)&&t.define(a,this)}static _initTypes={default:async e=>{await import(e.getAttribute(r.attr.import))}};static addInitType(e,t){this._initTypes[e]=t}static _fallback={};static addFallback(e,a){this._fallback[e]=a;let i=Array.from(this._tagNames);i.length&&t.querySelectorAll(i.map(e=>`${e}:defined`).join(",")).forEach(e=>{e.replaceFallbackContent()})}getFallback(){return Object.assign({[`:not(:defined):not(${this.localName}):not([${r.attr.defer}])`]:(e,t)=>{let a=r.renameNode(e,t+e.localName);return()=>{a.shadowRoot&&e.shadowRoot.append(...a.shadowRoot.childNodes),e.append(...a.childNodes),a.replaceWith(e)}}},r._fallback)}static renameNode(e,a){let i=t.createElement(a);for(let t of e.getAttributeNames())i.setAttribute(t,e.getAttribute(t));let r=e.shadowRoot;if(!r){let t=e.querySelector(":scope > template[shadowrootmode], :scope > template[shadowroot]");if(t){let a=t.getAttribute("shadowrootmode")||t.getAttribute("shadowroot")||"closed";r=e.attachShadow({mode:a}),r.appendChild(t.content.cloneNode(!0))}}return r&&i.attachShadow({mode:r.mode}).append(...r.childNodes),i.append(...e.childNodes),e.replaceWith(i),i}constructor(){super(),this._ready=i(),this._fallbackExec={},r._tagNames.add(this.localName)}getParents(e,a=!1){let i=[];for(;(e=e.parentNode)&&e&&e!==t.body;)if(e.matches&&e.matches(this.localName)){if(a&&e===a)break;s.hasConditions(e,r.attributePrefix)&&i.push(e)}return i}replaceTemplates(){let e=this.querySelectorAll(`template[${r.attr.template}]`);for(let t of e){if(this.getParents(t,this).length>0)continue;let e=t.getAttribute(r.attr.template);if("replace"===e){let e=Array.from(this.childNodes);for(let t of e)this.removeChild(t);this.appendChild(t.content);break}{let a=t.innerHTML;if("once"===e&&a){if(r._once.has(a))return void t.remove();r._once.set(a,!0)}t.replaceWith(t.content)}}}async beforeReady(){let e,t=this.getAttribute(r.attr.type);t?e=r._initTypes[t]:this.getAttribute(r.attr.import)&&(e=r._initTypes.default),e&&await e(this)}async ready(e,t){return Array.isArray(t)||(t=this.getParents(e)),Promise.all(t.map(e=>e.wait()))}replaceFallbackContent(){let e=`${this.localName}--`;for(let[t,a]of Object.entries(this.getFallback())){if(this._fallbackExec[t])continue;let i=Array.from(this.querySelectorAll(t)).reverse(),r=[];for(let t of i){if(!t.isConnected)continue;let i=this.getParents(t);if(i[0]===this){let s=a(t,e);r.push({node:t,parents:i,returned:s})}}for(let{node:e,parents:t,returned:a}of r.reverse())this.ready(e,t).then(a);this._fallbackExec[t]=!0}}wait(){return this._ready.promise}async connectedCallback(){s.hasConditions(this,r.attributePrefix)&&this.replaceFallbackContent(),await this.hydrate()}async hydrate(){let e=[],t=this.getParents(this);t.length&&e.push(t[0].wait()),e.push(...s.getConditions(this,r.attributePrefix)),await Promise.all(e),this.replaceTemplates(),await this.beforeReady(),this._ready.resolve();let{ready:a,defer:i}=r.attr;this.setAttribute(a,""),this.querySelectorAll(`[${i}]`).forEach(e=>e.removeAttribute(i))}}class s{static _media={};static map={visible:s.visible,idle:s.idle,load:s.pageLoad,interaction:s.interaction,media:s.media,"save-data":s.saveData};static getMap(e=""){return Object.keys(s.map).map(t=>e+t)}static hasConditions(e,t){for(let a of s.getMap(t))if(e.hasAttribute(a))return!0;return!1}static getConditions(e,t){let a=[];for(let i of s.getMap()){let r=t+i;if(e.hasAttribute(r)){let t=e.getAttribute(r);a.push(s.map[i](t,e))}}return a}static visible(t,a){let{promise:r,resolve:s}=i();if("IntersectionObserver"in e){let e=new IntersectionObserver(t=>{let[a]=t;a.isIntersecting&&(e.unobserve(a.target),s())});e.observe(a)}else s();return r}static pageLoad(){if(s._cacheLoad)return s._cacheLoad;let{promise:a,resolve:r}=i();return"complete"===t.readyState?r():e.addEventListener("load",()=>r(),{once:!0}),s._cacheLoad=a,a}static idle(){if(s._cacheIdle)return s._cacheIdle;let{promise:t,resolve:a}=i();return"requestIdleCallback"in e?requestIdleCallback(()=>a()):a(),s._cacheIdle=Promise.all([s.pageLoad(),t]),s._cacheIdle}static interaction(e,t){let a=(e||"click,touchstart").split(",").map(e=>e.trim()),{promise:r,resolve:s}=i();function o(e){s();for(let e of a)t.removeEventListener(e,o)}for(let e of a)t.addEventListener(e,o,{once:!0,passive:!0});return r}static media(t){if(s._media[t])return s._media[t];let{promise:a,resolve:r}=i(),o={matches:!0};return t&&"matchMedia"in e&&(o=e.matchMedia(t)),o.matches?r():o.addListener(e=>{e.matches&&r()}),s._media[t]=a,a}static saveData(e){let{promise:t,resolve:r}=i();return"connection"in a&&a.connection.saveData!==("false"!==e)||r(),t}}new URL(import.meta.url).searchParams.has("nodefine")||r.define(),e.Island=r;export{r as Island}; \ No newline at end of file diff --git a/public/assets/js/is-land.min.js.br b/public/assets/js/is-land.min.js.br new file mode 100644 index 0000000000000000000000000000000000000000..34fe76f2943ce1586ecfa2013bf766c68599d5b7 GIT binary patch literal 1834 zcmcckVF5#eNMH5((-)$2g1MD9PwQF{ztZb@JOejZ$z9JfnVYdui#LaytumkLV$&g8 zkyn`K`Q~^)ttQ6~7i({}rTu&U-e2i?d{MiVm?G?{`@U%Xa75|BXx7huTPIEPWifvm$n&cefND7o3L!6 zw4MHu+Y5DO1XfN5G8X8_TxqeHxZI@J;N)GqQ!&#yCwp*7 zY}YuMFje9AdFe0TY*bIPtlV1oDkbYw!2;&gC6oW|sD2Q?Q_u9~$+K_w2OnL))zaiu zcAd*5Li=X2)SlJN*>Cf|m5bgo`2H_oszT738((@)PrU!tvv2OXODdOVOQ#t%{;l+k z@0utpCb`$@tn(h9az5jgFDGo3ss0(>^LN$lOQ4;(mh1Dzt1P*2W0RG&b%Du8hEq%rqLbGO{@l2% z&X&WGSD&l$jaAO;Ka59|cZwf6G4b%!!|JQ-HgikQem(oRJE!ft`K@Me<4mfAnr|iE zuDX+9;;{btuBUGjjCihk7CrM2d@oS`!#8quOU}*tFZk@=9<^S2*8HW(`b*I*lYZ2d zteAhG@=fT^Kl^+Qm_8}VZ|jClu$-mEF2ERZp+jv0m2t#e`B>6p4vWWKF~9c5Z|lGR`9#S6Vh+Q5uKb#YpVew+9r;(VGrwm| z^8U!I904^S2K~M3`>jx{&T9-*=rnUa|M}Jrk>hylTeDK&rpK9H; z%gOY)J-5DEO-(x%ExA>K=QkZ&}DPfnQ}z?G8D= z`oP3R=dX$c3RSOgI5YFojF}!KI`-im1$Nx~og#}GK3)^;I_^I4da$eYFO9+jf2BI> z{`K~2na!Sip`TyrnER*FDNhA-WS5^1S?%)kJD-$c)U%7Hzp99TD0c|5xXi}E<96}> z&Ah7-d~pxno>*_u@wYqahg7agLcaJEN0)XlZKp$%@0uj_1uc1?DsLUjR~{$v(%^dl zYt6oQMLbLNosKMACh~yq^z+wKN~6nP7V6w!3(K0}yF+m2)ivcCJFic3iOeY1xEUX=R_dyo^t=4%`do-nYNAeZz~q2F>oTn`UV;dDLIZ zI%t%Z@$K@<$Nz3d$xUB=>Asrea-|=JPmX#7FI_4Br~A?kNzDn7s)E)lrawO4HEn&{ zY~|K<@y?7g4lZBn3Zk#ce@ltwR@6`V=5^|h%LdE6QhQ^Roz|P{wk$hbYq0cf_Q71{ zoVPchhS|U0{&RY0__^uZOLJ}-3KuV)ePiW5Mc?V=JDq3dzdltopGQ*S=*BR9!>$uE zv>D@7#FV+}T=njpsQDhUv(@7Mhi(szr^_El+eII~d!;hzgo-z3;-ZId?b&*k@$^28 z^fmW;KH=W7%0{0BFaP%+5NcNrUM*Vl^jXHqsved0!-*oD6X)v0uK(8%#98#b)=%z( z0o$ZW@pU>+5~|jI-v8z5`c)N=_N{Ps-okYz$1kMN%>2G;X*B!p``HV49QbW~-v279 z((&6_ZM!Dw0+&cc+w8C@7oFB?IVWySzt76~=uKYV&6RzXFW=m2mpb_~A}6)G^L(*y`@z z_Ly@%bWYLyQ}cTT&z|G@`u6yycVENgH*RLK)+*y!XgvFwu@1NH2UC}slV|&?Za=g9 OQkCavKJV~_Hp~D5aDl`C literal 0 HcmV?d00001 diff --git a/public/assets/js/is-land.min.js.zst b/public/assets/js/is-land.min.js.zst new file mode 100644 index 0000000000000000000000000000000000000000..f878449071c3b6d393e2a050c9071f3b6bd645eb GIT binary patch literal 2082 zcmdPcs{c1dUU;bkLsm!&pDM%TJDICJPEL7yD3(bv?cx{HkNbU}=cmuC<$v!V|K#MK z|Mx==vOHjT#Bhs2e2Ij7>YCR-Y`5p1`Q732NiK=UXz8+c z)}BjA*Uern?ozdy^!Yc_rDYXNhxZ3cR^Ll^bv?f7to1RyTqEW;ANOr}B2jH7wB6e6 zF@wGLo0rd5F8ZaV%I++jvV6t0 zdb_N?vg`iY%RF!2y~c6h@C`R(Udf%P*3TC|WlF7lc~$l9ud)}h*7q-O)wL=**1x2* zvBoZWQQOn(6Yo-k!%UW5|8V#Cl$1XWZ*8U;GdWC9@n<%Xvk+uz66zFpd6LcjC+6*= z`LoYyCMhbOzVY9v>#@cZe;JnLt0gbCZ*6-&v8+?z%iVJS_&d4h%rka=E#AF5>UG)a znkx^=yq9XGDb>9<^(+hhnBDp0`Ia9ugwkI;-lnT?mhqXw17t;)nZ*Z$ykh|`F z^}FAS?lZTPabH}$Re>or{a@wN_g?ZVV{5d&e=52!5yYX%I$2|D&wJ&IcegFOH|yK> z-xu{KA9|-gwXxCb?%i*{e(t>6c7NZ+9dEyxc0O6S=dj;~7u#w|MQ(lT6Iq@%{dVy* zk0)QI8HHtRH=Uf7e{8|Flsil<>jSb_gLzB^CoWJtA ztdl!;NoFN~V#@048>C%kO1La2cAczJC*C8qMA7N`RM+)e_Z)loB6|MufT=3}B8L{f z`N4W=f^)~W8&i6M-o#AooHKjh+&fi&eD{haP7KW6yi7PgqS@e6gi@mB^K1*%87`m1 z=e(P}tf+MP>!*2NZslDLy!iid$i?k%=FU%3%HH?7Bgy;}%dYh8h29mn&hbB#*j20d zTq674ruoM@rmxMMpi=iY=W|QlzwZC@1dH1iv03^qU-w(;)~jnj<+p3DZJ%gjx8%c- zi4!Zc_pEGPr0LSVA?W|HDHe$t53NO5<$fK@Vi0xtCjT`1KkGi-+>*<(J)Q;zb<^0Q z4o|T>eoudKcLq)B_br(Ps?iEuIc+kB6R)uxX{!)6HtJ2|Jk6ug#`m{y zl0d4GXxE~fch)bk4OC&wams2ERgnGUA>=%x(Z|E^*10Uv7n_r2g>RgwJ)Jv)Q|9h* zr#E?<*>hNrg|6!@Jj$|0cb1Y-y}MXclEmGQvWG9OSm*Lx_JZ(~cubjHqX%QO}d%<+u52=gGR;A9X8M+Ug`?#a-L2lB$Crno-9n|@G$tqaj-KQz{xP!jAOw&9(U(;yb7a{o> zD;7up%zI;z|HtIuU#~=$>R^vwAGZ9TuJS!}%18Alh1Fe#U7NY~UY}+3aLLE5`&HFX zIO-*NGoN*zX?^#JVath-E2~6O&-+i*w6)2&#`e5t;e*K2E;+&`3@6MpX6TwGG{yvM zyLWLv&yx*J7T>+i_>Su;Di$OMbaIC#C)pG=M_u#X_HK6Du2~I$B>{}SULdh7n3vX^@+KX2M$t^IsU{`BZSx>HMc-H_NI<|4rr zwl!i}^L4}N{4B+1HrYJ+^8RRNdGWro-Ru6iY~}q@D0!g2?80`2lG`(P=TvULqPxh8 zS1y}N#z%jxduA}}(xQa4%MKPcqnkhG~LB4L|Xkn_FN%v2lRpNzh7b6d=+{F#dsr0HeqYlmGw# literal 0 HcmV?d00001 diff --git a/public/assets/js/winter.js b/public/assets/js/winter.js new file mode 100644 index 00000000..c28d74d5 --- /dev/null +++ b/public/assets/js/winter.js @@ -0,0 +1,125 @@ +class Snow extends HTMLElement { + static random(min, max) { + return min + Math.floor(Math.random() * (max - min) + 1); + } + + static attrs = { + count: "count", // default: 100 + mode: "mode", + text: "text", // text in snow flake (emoji, too) + } + + generateCss(mode, count) { + let css = []; + css.push(` +:host([mode="element"]) { + display: block; + position: relative; + overflow: hidden; +} +:host([mode="page"]) { + position: fixed; + top: 0; + left: 0; + right: 0; +} +:host([mode="page"]), +:host([mode="element"]) > * { + pointer-events: none; +} +:host([mode="element"]) ::slotted(*) { + pointer-events: all; +} +* { + position: absolute; +} +:host([text]) * { + font-size: var(--snow-fall-size, 1em); +} +:host(:not([text])) * { + width: var(--snow-fall-size, 10px); + height: var(--snow-fall-size, 10px); + background: var(--snow-fall-color, rgba(255,255,255,.5)); + border-radius: 50%; +} +`); + + // using vw units (max 100) + let dimensions = { width: 100, height: 100 }; + let units = { x: "vw", y: "vh"}; + + if(mode === "element") { + dimensions = { + width: this.firstElementChild.clientWidth, + height: this.firstElementChild.clientHeight, + }; + units = { x: "px", y: "px"}; + } + + // Thank you @alphardex: https://codepen.io/alphardex/pen/dyPorwJ + for(let j = 1; j<= count; j++ ) { + let x = Snow.random(1, 100) * dimensions.width/100; // vw + let offset = Snow.random(-10, 10) * dimensions.width/100; // vw + + let yoyo = Math.round(Snow.random(30, 100)); // % time + let yStart = yoyo * dimensions.height/100; // vh + let yEnd = dimensions.height; // vh + + let scale = Snow.random(1, 10000) * .0001; + let duration = Snow.random(10, 30); + let delay = Snow.random(0, 30) * -1; + + css.push(` +:nth-child(${j}) { + opacity: ${Snow.random(0, 1000) * 0.001}; + transform: translate(${x}${units.x}, -10px) scale(${scale}); + animation: fall-${j} ${duration}s ${delay}s linear infinite; +} + +@keyframes fall-${j} { + ${yoyo}% { + transform: translate(${x + offset}${units.x}, ${yStart}${units.y}) scale(${scale}); + } + + to { + transform: translate(${x + offset / 2}${units.x}, ${yEnd}${units.y}) scale(${scale}); + } +}`) + } + return css.join("\n"); + } + + connectedCallback() { + // https://caniuse.com/mdn-api_cssstylesheet_replacesync + if(this.shadowRoot || !("replaceSync" in CSSStyleSheet.prototype)) { + return; + } + + let count = parseInt(this.getAttribute(Snow.attrs.count)) || 100; + + let mode; + if(this.hasAttribute(Snow.attrs.mode)) { + mode = this.getAttribute(Snow.attrs.mode); + } else { + mode = this.firstElementChild ? "element" : "page"; + this.setAttribute(Snow.attrs.mode, mode); + } + + let sheet = new CSSStyleSheet(); + sheet.replaceSync(this.generateCss(mode, count)); + + let shadowroot = this.attachShadow({ mode: "open" }); + shadowroot.adoptedStyleSheets = [sheet]; + + let d = document.createElement("div"); + let text = this.getAttribute(Snow.attrs.text); + d.innerText = text || ""; + for(let j = 0, k = count; j`{AG~sY`N4r7GGR}La zn6yNy1quxO_gro(-I>YL%6`7Mq0cO^lq>#H(UjQNj@~CHUC*ujvpMKX;~btzTTcYI zue;K;{lJzbA=#NWxAwf^`toe|{J^b&D&=bu`wwo^IbLHXa{0c-d64_h^V>}eKF1773D=^5Ye5CYi z(H5U2?56U?vuD3LYP7fN-<{j}Z09Adb8{BhFLkxn?t6Z5=cA9uH;d<-<~q#Hc{FwJ z2ZzTqx)yhAwK*+*Ky6L=6s1?6Tg-e8IeV(eZz!CQsw&s#K8x3IrP%FNt?M7Wy}f;f zd*+MZYrj;i(sM79E#W^U{$XX#+x)A&I#Ig+mwsXF3Hi&AZB}yQ2}kCoWm^j7pGdhc zuWrBN`>&8GHs_*`wX%21PMfG89dzWd@P2WzTga9J=w{()UADchj2;$y<3$9 zTi)){;1F;%t8#q1wQFOzZu-LzPa}~j3MAIIGvd*3kG-nI@d*5tIddthgpqH_tZD zI^n~5Tx3E1mPJlFC8gZo+V|Nswb^>H83kJ0UcDyaT>tNtk!`p4#ndvFh3&R{ylL^p zd2L^6bSEBj=O~=YH_NkrrO8=k<49)KfKmtT(^KwnE$Td2`^%cw#rD#vUfX;RSL@Y6 z?tfEsTIS2$YLC&JF}rTZ38R&LO656UteCnAR-c=;TzyV}lhoho3K|Q3{=9D=b3Fg~ z#Rp47E<3GU_m^+B##|QzlMg+emI2;pCT~y`J;3HvI%Ad?%lh5s7r%Z~3srJh{Z-Jv zwnW8Kj>FvIyut4s4~;9{|BQanz`Dg<)8+n=F1bl1-W{TgKCFx0=jr7=X|JQJhhW9Q zwL01+E6==GQY`#UC*oxN^Q|xW?y!HjrDVVj;)L p=QnYEeDvv*^1LUON7oq5IQ5j>bz;!}Z0H zh*f6K=bFVZpJ6G(IjNroMt4`m?pw1iRcF3jbYVeO=&{~*dGBMtUqz_CtcbX~W#y)c zfop5$N$-u4{-O9ZjpxZf>685Df9R$^*z;;z3}5}1f-gBn3(xSz$xL5lRyA+!KJ)E5 z(J$?%ln8pVE^@fEPd4I3;P*=38;3hpZ{+T>z07f>@ur*Z=INC=9@i)F?{`y~`}bMT z^5!E)jvl^t=?xiT}1u`fO6vwYAO(MH^0XWG>;8^|^S^@nEx@#4f?FFT>uP)l^G%xv)|E=l%4xT6yo+u4+>~b-yS< zScymZWxS1gbmqT_mYNrxRl9?&wfsH>Xr4sE-w+@Ao~4M)2wp&xee)_ zH_TlhJxIw&Vt6U0J*889OI8kh&!PQ)xEzF=pU){4?)nn5{v_b^*ze1Gt3?y$~%|4OtI`@;g%E5EsD&d8INmk zzIZrI{P@bWf2Et{I?R9dFAgsF-5tf2Xgu}#&INaLm#>)kvGUJ^m8QAV@?Gk3UwzBG zR%89XDRP2e{^7kB!&YyIQGXxS5vum@tn;P%w)8XREa6z5r$SA3sc1DcxMNMy$y3WqM1uU=Gik&%W_b)m7yageJ)C`oE@<+py_IP|Mw{xyxou zP&>bAse6;0@AvYkBKKun&C_LMD`ekm+;QMFKT??LWU-L_+&+=UTGt7k(&rBs-Db1c z)D)%j@L*2l{$?R`>8$hFCKMhKK~-`ybIH7smfCqK<20+7rHut|bQ( z(mI^?Gb)}n^E>{!E!K^hyZ2O|SQ9(v?yicSr}Z=b$*$WyF-a=>&OvbJzFAMom|UW4*@PZ(>(FIh3(FxAHy!%+Y(d literal 0 HcmV?d00001 diff --git a/resources/views/admin/settings/show.blade.php b/resources/views/admin/settings/show.blade.php new file mode 100644 index 00000000..801171e1 --- /dev/null +++ b/resources/views/admin/settings/show.blade.php @@ -0,0 +1,26 @@ +@extends('master') + +@section('title')Site Settings « Admin CP « @stop + +@section('content') +

Site settings

+
+ {{ csrf_field() }} + {{ method_field('PUT') }} +
+ +
+
+ +
+
+@stop diff --git a/resources/views/admin/welcome.blade.php b/resources/views/admin/welcome.blade.php index 663cfdc4..03112665 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -61,4 +61,9 @@

Manager your passkeys.

+ +

Settings

+

+ Edit site settings. +

@stop diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index cb15e685..543f4255 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -80,6 +80,17 @@ @section('scripts') + + @if($winterEffectEnabled ?? false) + + + + + + @endif @show diff --git a/routes/web.php b/routes/web.php index b929a2de..3b6fa7d2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Admin\LikesController as AdminLikesController; use App\Http\Controllers\Admin\NotesController as AdminNotesController; use App\Http\Controllers\Admin\PasskeysController; use App\Http\Controllers\Admin\PlacesController as AdminPlacesController; +use App\Http\Controllers\Admin\SettingsController; use App\Http\Controllers\Admin\SyndicationTargetsController; use App\Http\Controllers\Admin\TokensController; use App\Http\Controllers\ArticlesController; @@ -160,6 +161,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () { Route::put('/', [BioController::class, 'update']); }); + // Settings + Route::prefix('settings')->group(function () { + Route::get('/', [SettingsController::class, 'show'])->name('admin.settings.show'); + Route::put('/', [SettingsController::class, 'update']); + }); + // Passkeys Route::prefix('passkeys')->group(function () { Route::get('/', [PasskeysController::class, 'index']); diff --git a/tests/Feature/Admin/SettingsTest.php b/tests/Feature/Admin/SettingsTest.php new file mode 100644 index 00000000..bf7d068b --- /dev/null +++ b/tests/Feature/Admin/SettingsTest.php @@ -0,0 +1,68 @@ +make(); + + $response = $this->actingAs($user) + ->get('/admin/settings'); + $response->assertSeeText('Site settings'); + } + + #[Test] + public function admin_can_enable_winter_effect(): void + { + $user = User::factory()->make(); + + $this->actingAs($user) + ->post('/admin/settings', [ + '_method' => 'PUT', + 'winter_effect_enabled' => '1', + ]); + $this->assertDatabaseHas('settings', ['winter_effect_enabled' => true]); + } + + #[Test] + public function admin_can_disable_winter_effect(): void + { + $user = User::factory()->make(); + Setting::factory()->create(['winter_effect_enabled' => true]); + + $this->actingAs($user) + ->post('/admin/settings', [ + '_method' => 'PUT', + ]); + $this->assertDatabaseHas('settings', ['winter_effect_enabled' => false]); + } + + #[Test] + public function winter_effect_markup_is_hidden_when_disabled(): void + { + $response = $this->get('/'); + $response->assertDontSee('snow-fall'); + } + + #[Test] + public function winter_effect_markup_is_shown_when_enabled(): void + { + Setting::factory()->create(['winter_effect_enabled' => true]); + + $response = $this->get('/'); + $response->assertSee('snow-fall', false); + } +}