bowler/src/app/Models/Theme.php

159 lines
4.6 KiB
PHP

<?php
namespace App\Models;
use App\Enums\NavGradientDirection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class Theme extends Model
{
protected $table = 'site_themes';
protected $guarded = ['id'];
protected $casts = [
'nav_gradient_direction' => NavGradientDirection::class,
];
/** Stock values — must match the migration defaults and the @theme tokens in app.css. */
public const DEFAULTS = [
'primary_color' => '#2563eb',
'secondary_color' => '#3b82f6',
'tertiary_color' => '#1d4ed8',
'nav_gradient_from' => '#ffffff',
'nav_gradient_mid' => null,
'nav_gradient_to' => null,
'nav_gradient_direction' => 'none',
];
protected static function booted(): void
{
static::saved(fn () => Cache::forget('site-theme'));
static::deleted(fn () => Cache::forget('site-theme'));
}
/** The single site-wide theme row, cached until the next save. */
public static function current(): self
{
// refresh() hydrates the DB column defaults into the fresh model.
return Cache::rememberForever(
'site-theme',
fn () => static::query()->first() ?? static::query()->create([])->refresh()
);
}
/** True when the theme matches the stock look exactly (no override needed). */
public function isDefault(): bool
{
if ($this->logo_path !== null) {
return false;
}
foreach (self::DEFAULTS as $key => $default) {
$value = $this->{$key};
if ($value instanceof \BackedEnum) {
$value = $value->value;
}
if ($value === null || $default === null) {
if ($value !== $default) {
return false;
}
continue;
}
if (strtolower((string) $value) !== strtolower((string) $default)) {
return false;
}
}
return true;
}
public function primaryHover(): string
{
return self::darken($this->primary_color, 0.15);
}
/** CSS background value for the top bar: solid color or gradient. */
public function navBackgroundCss(): string
{
$stops = $this->activeGradientStops();
if ($this->nav_gradient_direction === NavGradientDirection::None || count($stops) < 2) {
return $this->nav_gradient_from;
}
$list = implode(', ', $stops);
return $this->nav_gradient_direction === NavGradientDirection::Radial
? "radial-gradient(ellipse at top, {$list})"
: "linear-gradient({$this->nav_gradient_direction->cssKeyword()}, {$list})";
}
/** Whether the nav background is dark enough to need white text (WCAG contrast). */
public function navIsDark(): bool
{
$stops = $this->nav_gradient_direction === NavGradientDirection::None
? [$this->nav_gradient_from]
: $this->activeGradientStops();
$l = array_sum(array_map(self::luminance(...), $stops)) / count($stops);
// White text wins when its contrast beats gray-900's (luminance ~0.0106).
return (1.05 / ($l + 0.05)) >= (($l + 0.05) / 0.0606);
}
public function navLinkColor(): string
{
return $this->navIsDark() ? '#ffffff' : $this->primary_color;
}
public function navTextColor(): string
{
return $this->navIsDark() ? '#ffffff' : '#111827';
}
public function navMutedColor(): string
{
return $this->navIsDark() ? 'rgba(255, 255, 255, 0.75)' : '#6b7280';
}
/** @return list<string> */
private function activeGradientStops(): array
{
return array_values(array_filter([
$this->nav_gradient_from,
$this->nav_gradient_mid,
$this->nav_gradient_to,
]));
}
/** WCAG relative luminance of a #rrggbb color. */
private static function luminance(string $hex): float
{
[$r, $g, $b] = sscanf($hex, '#%02x%02x%02x');
$lin = function (int $c): float {
$c = $c / 255;
return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * $lin($r) + 0.7152 * $lin($g) + 0.0722 * $lin($b);
}
private static function darken(string $hex, float $amount): string
{
[$r, $g, $b] = sscanf($hex, '#%02x%02x%02x');
return sprintf(
'#%02x%02x%02x',
(int) round($r * (1 - $amount)),
(int) round($g * (1 - $amount)),
(int) round($b * (1 - $amount))
);
}
}