Compare commits

...
Author SHA1 Message Date
483a7f9ded Merge pull request '[MTM] Fronte end tweaks' (#129) from develop into main
Reviewed-on: #129
2026-08-23 11:25:15 +02:00
9532aae37e
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113BVPXDpEk9HPEqXvSG2WQ
2026-08-23 10:17:26 +01:00
e9d8f4c74b
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fn4aNjE3ofpDM78F4qmdCV
2026-08-22 18:14:08 +01:00
be2bc267ad
Remove snow-fall 2026-08-22 17:39:31 +01:00
18 changed files with 327 additions and 144 deletions

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SettingsController extends Controller
{
public function show(): View
{
$settings = Setting::first();
return view('admin.settings.show', [
'settings' => $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');
}
}

18
app/Models/Setting.php Normal file
View file

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
use HasFactory;
/**
* @var array<string, string>
*/
protected $casts = [
'winter_effect_enabled' => 'boolean',
];
}

View file

@ -2,10 +2,12 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
@ -63,5 +65,10 @@ class AppServiceProvider extends ServiceProvider
// Force HTTPS URL generation in production // Force HTTPS URL generation in production
URL::forceHttps($this->app->isProduction()); 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);
});
} }
} }

View file

@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Setting>
*/
class SettingFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'winter_effect_enabled' => false,
];
}
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->boolean('winter_effect_enabled')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('settings');
}
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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;s<a.length;s++)i[s]=a.charCodeAt(s);return o}bufferToBase64URLString(r){let e=new Uint8Array(r),t="";for(let a of e)t+=String.fromCharCode(a);return btoa(t).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{constructor(){this.widget=document.querySelector("#theme-selector")}setupEventListeners(){this.widget.querySelectorAll("#theme-selector-dropdown input").forEach(r=>r.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<n.length;c++)o[c]=n.charCodeAt(c);return a}bufferToBase64URLString(t){let e=new Uint8Array(t),r="";for(let n of e)r+=String.fromCharCode(n);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{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.btnLight.addEventListener("click",()=>{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 //# sourceMappingURL=app.js.map

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View file

@ -1,70 +1,66 @@
@layer components { @layer components {
#theme-selector { #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 { button {
display: grid;
place-items: center;
appearance: none; appearance: none;
border: none; border: none;
background: none; border-radius: 999px;
padding: .375rem;
background: transparent;
color: inherit;
transition: background-color .2s, color .2s;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
} }
&:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 2px;
} }
/* This is the element with the [popover] attribute */ svg {
#theme-selector-dropdown { width: 1.5rem;
height: 1.5rem;
fill: currentcolor;
}
@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 {
position: absolute; position: absolute;
position-area: block-end span-inline-start; width: 1px;
margin: 0; height: 1px;
border: 2px solid var(--clr-border); padding: 0;
background-color: var(--clr-background); margin: -1px;
color: var(--clr-text); overflow: hidden;
clip-path: inset(50%);
fieldset { white-space: nowrap;
border: 0; border: 0;
display: flex;
flex-direction: row;
gap: 1ch;
input {
display: none;
}
}
}
/*
* 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;
/*
* Start styles for the opening transition.
* Added to :popover-open, but after the opened styles.
*/
@starting-style {
transform: translateY(30px) scale(0);
opacity: 0;
}
}
/*
* End styles for the closing transition.
*/
transform: translateY(0) scale(0);
opacity: 0;
/*
* Enumerate transitioning properties, including display and overlay.
*/
transition: transform, opacity, display allow-discrete, overlay allow-discrete;
transition-duration: 0.5s;
transform-origin: top right;
}
}
} }
::view-transition-group(changing-theme) { ::view-transition-group(changing-theme) {

View file

@ -1,69 +1,60 @@
class ThemeSelector { class ThemeSelector {
constructor() { constructor() {
this.widget = document.querySelector('#theme-selector'); 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() { setupEventListeners() {
this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => { this.btnLight.addEventListener('click', () => {
let theme = e.target.value; 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';
}
}); });
// Set the theme this.btnDark.addEventListener('click', () => {
let selectedTheme; this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');
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 systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
const html = document.querySelector('html'); const html = document.documentElement;
let currentTheme = html.style.getPropertyValue('color-scheme'); let currentColorScheme = html.style.getPropertyValue('color-scheme');
if (currentTheme === '') { if (currentColorScheme === '') {
currentTheme = 'light dark'; currentColorScheme = 'light dark';
} }
/* do we need to transition */ /* do we need to transition */
let doTransition = false; let doTransition = false;
if (selectedTheme !== currentTheme) { if (selectedTheme !== currentColorScheme) {
if (selectedTheme === 'light dark') { if (selectedTheme === 'light dark') {
doTransition = currentTheme !== systemTheme; doTransition = currentColorScheme !== systemTheme;
} else if (currentTheme === 'light dark') { } else if (currentColorScheme === 'light dark') {
doTransition = selectedTheme !== systemTheme; doTransition = selectedTheme !== systemTheme;
} else { } else {
doTransition = true; 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'; html.style.viewTransitionName = 'changing-theme';
if (doTransition && document.startViewTransition) { if (doTransition && document.startViewTransition) {
document.startViewTransition(() => { document.startViewTransition(applyChange);
// Close the popover
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
});
} else { } else {
// Close the popover applyChange();
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
} }
}));
} }
} }

View file

@ -0,0 +1,26 @@
@extends('master')
@section('title')Site Settings « Admin CP « @stop
@section('content')
<h1>Site settings</h1>
<form action="/admin/settings" method="post" accept-charset="utf-8" class="admin-form form">
{{ csrf_field() }}
{{ method_field('PUT') }}
<div>
<label for="winter_effect_enabled">
<input
type="checkbox"
name="winter_effect_enabled"
id="winter_effect_enabled"
value="1"
@checked(old('winter_effect_enabled', $settings?->winter_effect_enabled))
>
Show winter snow effect
</label>
</div>
<div>
<button type="submit" name="save">Save</button>
</div>
</form>
@stop

View file

@ -61,4 +61,9 @@
<p> <p>
Manager <a href="/admin/passkeys">your passkeys</a>. Manager <a href="/admin/passkeys">your passkeys</a>.
</p> </p>
<h2>Settings</h2>
<p>
Edit <a href="/admin/settings">site settings</a>.
</p>
@stop @stop

View file

@ -38,35 +38,14 @@
<a href="/projects">Projects</a> <a href="/projects">Projects</a>
<a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.json-feed', ['title' => 'JSON Feed'])</a> <a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.json-feed', ['title' => 'JSON Feed'])</a>
</nav> </nav>
<div id="theme-selector"> <div id="theme-selector" role="region" aria-label="Theme switcher">
<button class="toggle" popovertarget="theme-selector-dropdown"> <button id="btn-light" aria-label="Light mode" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="system" style=""><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="dark" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="light" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
</button> </button>
<div id="theme-selector-dropdown" popover> <button id="btn-dark" aria-label="Dark mode" aria-pressed="false">
<fieldset> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
<legend>Select theme:</legend> </button>
<div> <span id="theme-status" class="sr-only" aria-live="polite"></span>
<input type="radio" id="theme-system" name="theme" value="system" checked>
<label for="theme-system">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg>
</label>
</div>
<div>
<input type="radio" id="theme-dark" name="theme" value="dark">
<label for="theme-dark">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
</label>
</div>
<div>
<input type="radio" id="theme-light" name="theme" value="light">
<label for="theme-light">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
</label>
</div>
</fieldset>
</div>
</div> </div>
</header> </header>
@ -102,6 +81,7 @@
@section('scripts') @section('scripts')
<script type="module" src="/assets/js/app.js"></script> <script type="module" src="/assets/js/app.js"></script>
@if($winterEffectEnabled ?? false)
<script type="module" src="/assets/js/is-land.min.js"></script> <script type="module" src="/assets/js/is-land.min.js"></script>
<script type="module" src="/assets/js/winter.js"></script> <script type="module" src="/assets/js/winter.js"></script>
<is-land on:media="(prefers-reduced-motion: no-preference)"> <is-land on:media="(prefers-reduced-motion: no-preference)">
@ -110,6 +90,7 @@
style="--snow-fall-color: var(--clr-snow-fall)" style="--snow-fall-color: var(--clr-snow-fall)"
></snow-fall> ></snow-fall>
</is-land> </is-land>
@endif
@show @show
</body> </body>
</html> </html>

View file

@ -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\NotesController as AdminNotesController;
use App\Http\Controllers\Admin\PasskeysController; use App\Http\Controllers\Admin\PasskeysController;
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController; 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\SyndicationTargetsController;
use App\Http\Controllers\Admin\TokensController; use App\Http\Controllers\Admin\TokensController;
use App\Http\Controllers\ArticlesController; use App\Http\Controllers\ArticlesController;
@ -160,6 +161,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
Route::put('/', [BioController::class, 'update']); 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 // Passkeys
Route::prefix('passkeys')->group(function () { Route::prefix('passkeys')->group(function () {
Route::get('/', [PasskeysController::class, 'index']); Route::get('/', [PasskeysController::class, 'index']);

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Admin;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class SettingsTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function admin_settings_page_loads(): void
{
$user = User::factory()->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);
}
}