Bring back winter snow effect, gated by an admin setting #128
18 changed files with 377 additions and 0 deletions
32
app/Http/Controllers/Admin/SettingsController.php
Normal file
32
app/Http/Controllers/Admin/SettingsController.php
Normal 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
18
app/Models/Setting.php
Normal 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',
|
||||
];
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
database/factories/SettingFactory.php
Normal file
24
database/factories/SettingFactory.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
public/assets/js/is-land.min.js
vendored
Normal file
2
public/assets/js/is-land.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
public/assets/js/is-land.min.js.br
Normal file
BIN
public/assets/js/is-land.min.js.br
Normal file
Binary file not shown.
BIN
public/assets/js/is-land.min.js.zst
Normal file
BIN
public/assets/js/is-land.min.js.zst
Normal file
Binary file not shown.
125
public/assets/js/winter.js
Normal file
125
public/assets/js/winter.js
Normal file
|
|
@ -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<k; j++) {
|
||||
shadowroot.appendChild(d.cloneNode(true));
|
||||
}
|
||||
|
||||
shadowroot.appendChild(document.createElement("slot"));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("snow-fall", Snow);
|
||||
BIN
public/assets/js/winter.js.br
Normal file
BIN
public/assets/js/winter.js.br
Normal file
Binary file not shown.
BIN
public/assets/js/winter.js.zst
Normal file
BIN
public/assets/js/winter.js.zst
Normal file
Binary file not shown.
26
resources/views/admin/settings/show.blade.php
Normal file
26
resources/views/admin/settings/show.blade.php
Normal 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
|
||||
|
|
@ -61,4 +61,9 @@
|
|||
<p>
|
||||
Manager <a href="/admin/passkeys">your passkeys</a>.
|
||||
</p>
|
||||
|
||||
<h2>Settings</h2>
|
||||
<p>
|
||||
Edit <a href="/admin/settings">site settings</a>.
|
||||
</p>
|
||||
@stop
|
||||
|
|
|
|||
|
|
@ -80,6 +80,17 @@
|
|||
<!--scripts go here when needed-->
|
||||
@section('scripts')
|
||||
<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/winter.js"></script>
|
||||
<is-land on:media="(prefers-reduced-motion: no-preference)">
|
||||
<snow-fall
|
||||
count="150"
|
||||
style="--snow-fall-color: var(--clr-snow-fall)"
|
||||
></snow-fall>
|
||||
</is-land>
|
||||
@endif
|
||||
@show
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
68
tests/Feature/Admin/SettingsTest.php
Normal file
68
tests/Feature/Admin/SettingsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue