From 9532aae37eeac0abc29a6de35097821259c22832 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 23 Aug 2026 10:17:26 +0100 Subject: [PATCH] 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); + } +} -- 2.55.0