Make theme switching browser-based and instant.
This commit is contained in:
parent
5a73a41024
commit
de656fbaa2
|
@ -39,9 +39,6 @@ return static function (RouteCollectorProxy $app) {
|
|||
|
||||
$group->get('/profile', Controller\Frontend\Profile\IndexAction::class)
|
||||
->setName('profile:index');
|
||||
|
||||
$group->get('/profile/theme', Controller\Frontend\Profile\ThemeAction::class)
|
||||
->setName('profile:theme');
|
||||
}
|
||||
)->add(Middleware\EnableView::class)
|
||||
->add(Middleware\RequireLogin::class);
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
/*!
|
||||
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
|
||||
* Copyright 2011-2023 The Bootstrap Authors
|
||||
* Licensed under the Creative Commons Attribution 3.0 Unported License.
|
||||
*/
|
||||
|
||||
(() => {
|
||||
'use strict'
|
||||
|
||||
const getStoredTheme = () => localStorage.getItem('theme')
|
||||
const setStoredTheme = theme => localStorage.setItem('theme', theme)
|
||||
|
||||
const getPreferredTheme = () => {
|
||||
const storedTheme = getStoredTheme()
|
||||
if (storedTheme) {
|
||||
return storedTheme
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
const setTheme = theme => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
|
||||
document.documentElement.dispatchEvent(new CustomEvent(
|
||||
"theme-change",
|
||||
{
|
||||
detail: theme
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
if (currentTheme !== 'light' && currentTheme !== 'dark') {
|
||||
setTheme(getPreferredTheme())
|
||||
}
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
const storedTheme = getStoredTheme()
|
||||
if (storedTheme !== 'light' && storedTheme !== 'dark') {
|
||||
setTheme(getPreferredTheme())
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.theme-switcher').forEach(
|
||||
toggle => {
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const currentTheme = getPreferredTheme();
|
||||
if (currentTheme === 'light') {
|
||||
setStoredTheme('dark');
|
||||
setTheme('dark');
|
||||
} else {
|
||||
setStoredTheme('light');
|
||||
setTheme('light');
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})()
|
|
@ -1,10 +1,6 @@
|
|||
@at-root {
|
||||
$theme: 'light';
|
||||
@import 'common';
|
||||
}
|
||||
|
||||
@at-root {
|
||||
$theme: 'light';
|
||||
@import 'common-colors';
|
||||
}
|
||||
|
||||
|
@ -12,10 +8,3 @@
|
|||
$theme: 'dark';
|
||||
@import 'common-colors';
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
[data-theme="browser"] {
|
||||
$theme: 'dark';
|
||||
@import 'common-colors';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,23 +48,6 @@
|
|||
</b-wrapped-form-group>
|
||||
</b-col>
|
||||
<b-col md="6">
|
||||
<b-wrapped-form-group
|
||||
id="edit_form_theme"
|
||||
:field="form.theme"
|
||||
>
|
||||
<template #label>
|
||||
{{ $gettext('Site Theme') }}
|
||||
</template>
|
||||
<template #default="slotProps">
|
||||
<b-form-radio-group
|
||||
:id="slotProps.id"
|
||||
v-model="slotProps.field.$model"
|
||||
stacked
|
||||
:options="themeOptions"
|
||||
/>
|
||||
</template>
|
||||
</b-wrapped-form-group>
|
||||
|
||||
<b-wrapped-form-group
|
||||
id="edit_form_show_24_hour_time"
|
||||
:field="form.show_24_hour_time"
|
||||
|
@ -115,23 +98,6 @@ const localeOptions = computed(() => {
|
|||
return localeOptions;
|
||||
});
|
||||
|
||||
const themeOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
text: $gettext('Prefer System Default'),
|
||||
value: 'browser'
|
||||
},
|
||||
{
|
||||
text: $gettext('Light'),
|
||||
value: 'light'
|
||||
},
|
||||
{
|
||||
text: $gettext('Dark'),
|
||||
value: 'dark'
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
const show24hourOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
|
|
|
@ -46,14 +46,12 @@ const {form, resetForm, v$, ifValid} = useVuelidateOnForm(
|
|||
name: {},
|
||||
email: {required, email},
|
||||
locale: {required},
|
||||
theme: {required},
|
||||
show_24_hour_time: {}
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
email: '',
|
||||
locale: 'default',
|
||||
theme: 'browser',
|
||||
show_24_hour_time: null,
|
||||
}
|
||||
);
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
v-model="textValue"
|
||||
basic
|
||||
:lang="lang"
|
||||
:dark="dark"
|
||||
:dark="isDark"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
@ -13,7 +13,7 @@ import {useVModel} from "@vueuse/core";
|
|||
import {computed} from "vue";
|
||||
import {css} from "@codemirror/lang-css";
|
||||
import {javascript} from "@codemirror/lang-javascript";
|
||||
import {useAzuraCast} from "~/vendor/azuracast";
|
||||
import useGetTheme from "~/functions/useGetTheme";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
@ -39,11 +39,7 @@ const lang = computed(() => {
|
|||
return null;
|
||||
});
|
||||
|
||||
const {theme} = useAzuraCast();
|
||||
|
||||
const dark = computed(() => {
|
||||
return theme === 'dark';
|
||||
})
|
||||
const {isDark} = useGetTheme();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
|
|
|
@ -11,9 +11,9 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import {onMounted, provide, ref, shallowRef} from "vue";
|
||||
import {onMounted, provide, ref, shallowRef, watch} from "vue";
|
||||
import L from "~/vendor/leaflet";
|
||||
import {useAzuraCast} from "~/vendor/azuracast";
|
||||
import useGetTheme from "~/functions/useGetTheme";
|
||||
|
||||
const props = defineProps({
|
||||
attribution: {
|
||||
|
@ -27,7 +27,7 @@ const $map = shallowRef();
|
|||
|
||||
provide('map', $map);
|
||||
|
||||
const {theme} = useAzuraCast();
|
||||
const {theme} = useGetTheme();
|
||||
|
||||
onMounted(() => {
|
||||
// Init map
|
||||
|
@ -41,10 +41,17 @@ onMounted(() => {
|
|||
const tileAttribution = 'Map tiles by Carto, under CC BY 3.0. Data by OpenStreetMap, under ODbL.';
|
||||
|
||||
L.tileLayer(tileUrl, {
|
||||
theme: theme,
|
||||
theme: theme.value,
|
||||
attribution: tileAttribution,
|
||||
}).addTo(map);
|
||||
|
||||
watch(theme, (newTheme) => {
|
||||
L.tileLayer(tileUrl, {
|
||||
theme: newTheme,
|
||||
attribution: tileAttribution,
|
||||
}).addTo(map);
|
||||
});
|
||||
|
||||
/*
|
||||
// Add fullscreen control
|
||||
const fullscreenControl = new L.Control.Fullscreen();
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import {computed, onMounted, ref} from "vue";
|
||||
import {useEventListener} from "@vueuse/core";
|
||||
|
||||
export default function useGetTheme() {
|
||||
const htmlElement = document.documentElement;
|
||||
const theme = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
theme.value = htmlElement.getAttribute('data-theme');
|
||||
});
|
||||
|
||||
useEventListener(htmlElement, 'theme-change', (evt) => {
|
||||
theme.value = evt.detail;
|
||||
});
|
||||
|
||||
const isDark = computed(() => theme.value === 'dark');
|
||||
const isLight = computed(() => theme.value === 'light');
|
||||
|
||||
return {
|
||||
theme,
|
||||
isDark,
|
||||
isLight
|
||||
};
|
||||
}
|
|
@ -10,7 +10,6 @@ export function useAzuraCast() {
|
|||
localeShort: App.locale_short ?? 'en',
|
||||
localeWithDashes: App.locale_with_dashes ?? 'en-US',
|
||||
timeConfig: App.time_config ?? {},
|
||||
apiCsrf: App.api_csrf ?? null,
|
||||
theme: App.theme ?? 'light'
|
||||
apiCsrf: App.api_csrf ?? null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Frontend\Profile;
|
||||
|
||||
use App\Container\EntityManagerAwareTrait;
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Enums\SupportedThemes;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class ThemeAction implements SingleActionInterface
|
||||
{
|
||||
use EntityManagerAwareTrait;
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
$user = $request->getUser();
|
||||
|
||||
$currentTheme = $user->getTheme();
|
||||
$newTheme = match ($currentTheme) {
|
||||
SupportedThemes::Dark => SupportedThemes::Light,
|
||||
default => SupportedThemes::Dark
|
||||
};
|
||||
$user->setTheme($newTheme);
|
||||
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
$referrer = $request->getHeaderLine('Referer');
|
||||
return $response->withRedirect(
|
||||
$referrer ?: $request->getRouter()->named('dashboard')
|
||||
);
|
||||
}
|
||||
}
|
|
@ -28,9 +28,7 @@ final class Customization
|
|||
|
||||
private SupportedLocales $locale;
|
||||
|
||||
private SupportedThemes $theme;
|
||||
|
||||
private SupportedThemes $publicTheme;
|
||||
private ?SupportedThemes $publicTheme;
|
||||
|
||||
private string $instanceName;
|
||||
|
||||
|
@ -42,7 +40,6 @@ final class Customization
|
|||
$this->instanceName = $this->settings->getInstanceName() ?? '';
|
||||
|
||||
$this->user = null;
|
||||
$this->theme = SupportedThemes::default();
|
||||
$this->publicTheme = $this->settings->getPublicTheme();
|
||||
|
||||
$this->locale = SupportedLocales::default();
|
||||
|
@ -57,51 +54,24 @@ final class Customization
|
|||
$this->user = $request->getAttribute(ServerRequest::ATTR_USER);
|
||||
|
||||
// Register current theme
|
||||
$this->theme = $this->determineTheme($request);
|
||||
$this->publicTheme = $this->determineTheme($request, true);
|
||||
$queryParams = $request->getQueryParams();
|
||||
if (!empty($queryParams['theme'])) {
|
||||
$theme = SupportedThemes::tryFrom($queryParams['theme']);
|
||||
if (null !== $theme && $theme !== SupportedThemes::Browser) {
|
||||
$this->publicTheme = $theme;
|
||||
}
|
||||
}
|
||||
|
||||
// Register locale
|
||||
$this->locale = SupportedLocales::createFromRequest($this->environment, $request);
|
||||
}
|
||||
}
|
||||
|
||||
private function determineTheme(
|
||||
ServerRequestInterface $request,
|
||||
bool $isPublicTheme = false
|
||||
): SupportedThemes {
|
||||
$queryParams = $request->getQueryParams();
|
||||
if (!empty($queryParams['theme'])) {
|
||||
$theme = SupportedThemes::tryFrom($queryParams['theme']);
|
||||
if (null !== $theme) {
|
||||
return $theme;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $this->user) {
|
||||
$userTheme = $this->user->getTheme();
|
||||
if (null !== $userTheme) {
|
||||
return $userTheme;
|
||||
}
|
||||
}
|
||||
|
||||
return ($isPublicTheme)
|
||||
? $this->settings->getPublicTheme()
|
||||
: SupportedThemes::default();
|
||||
}
|
||||
|
||||
public function getLocale(): SupportedLocales
|
||||
{
|
||||
return $this->locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user-customized or system default theme.
|
||||
*/
|
||||
public function getTheme(): SupportedThemes
|
||||
{
|
||||
return $this->theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the instance name for this AzuraCast instance.
|
||||
*/
|
||||
|
@ -113,9 +83,11 @@ final class Customization
|
|||
/**
|
||||
* Get the theme name to be used in public (non-logged-in) pages.
|
||||
*/
|
||||
public function getPublicTheme(): SupportedThemes
|
||||
public function getPublicTheme(): ?SupportedThemes
|
||||
{
|
||||
return $this->publicTheme;
|
||||
return (SupportedThemes::Browser !== $this->publicTheme)
|
||||
? $this->publicTheme
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -6,7 +6,6 @@ namespace App\Entity\Fixture;
|
|||
|
||||
use App\Entity\Role;
|
||||
use App\Entity\User;
|
||||
use App\Enums\SupportedThemes;
|
||||
use Doctrine\Common\DataFixtures\AbstractFixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
@ -36,7 +35,6 @@ final class UserFixture extends AbstractFixture implements DependentFixtureInter
|
|||
$adminUser->setEmail($adminEmail);
|
||||
$adminUser->setName('System Administrator');
|
||||
$adminUser->setNewPassword($adminPassword);
|
||||
$adminUser->setTheme(SupportedThemes::Browser);
|
||||
|
||||
/** @var Role $adminRole */
|
||||
$adminRole = $this->getReference('admin_role');
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20230626102616 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Store theme at the browser level, not the user level.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE users DROP theme');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE users ADD theme VARCHAR(25) DEFAULT NULL');
|
||||
}
|
||||
}
|
|
@ -316,9 +316,9 @@ class Settings implements Stringable
|
|||
]
|
||||
protected ?SupportedThemes $public_theme = null;
|
||||
|
||||
public function getPublicTheme(): SupportedThemes
|
||||
public function getPublicTheme(): ?SupportedThemes
|
||||
{
|
||||
return $this->public_theme ?? SupportedThemes::default();
|
||||
return $this->public_theme;
|
||||
}
|
||||
|
||||
public function setPublicTheme(?SupportedThemes $publicTheme): void
|
||||
|
|
|
@ -7,7 +7,6 @@ namespace App\Entity;
|
|||
use App\Auth;
|
||||
use App\Entity\Interfaces\EntityGroupsInterface;
|
||||
use App\Entity\Interfaces\IdentifiableEntityInterface;
|
||||
use App\Enums\SupportedThemes;
|
||||
use App\Normalizer\Attributes\DeepNormalize;
|
||||
use App\OpenApi;
|
||||
use App\Utilities\Strings;
|
||||
|
@ -73,14 +72,6 @@ class User implements Stringable, IdentifiableEntityInterface
|
|||
]
|
||||
protected ?string $locale = null;
|
||||
|
||||
#[
|
||||
OA\Property(example: "dark"),
|
||||
ORM\Column(type: 'string', length: 25, nullable: true, enumType: SupportedThemes::class),
|
||||
Attributes\AuditIgnore,
|
||||
Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])
|
||||
]
|
||||
protected ?SupportedThemes $theme = null;
|
||||
|
||||
#[
|
||||
OA\Property(example: true),
|
||||
ORM\Column(nullable: true),
|
||||
|
@ -225,16 +216,6 @@ class User implements Stringable, IdentifiableEntityInterface
|
|||
$this->locale = $locale;
|
||||
}
|
||||
|
||||
public function getTheme(): ?SupportedThemes
|
||||
{
|
||||
return $this->theme;
|
||||
}
|
||||
|
||||
public function setTheme(?SupportedThemes $theme = null): void
|
||||
{
|
||||
$this->theme = $theme;
|
||||
}
|
||||
|
||||
public function getShow24HourTime(): ?bool
|
||||
{
|
||||
return $this->show_24_hour_time;
|
||||
|
|
|
@ -18,7 +18,7 @@ $title ??= null;
|
|||
$header ??= null;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?= $customization->getLocale()->getHtmlLang() ?>" data-theme="<?= $customization->getTheme()->value ?>">
|
||||
<html lang="<?= $customization->getLocale()->getHtmlLang() ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
@ -115,7 +115,7 @@ endif; ?>">
|
|||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="<?= $router->named('profile:theme') ?>">
|
||||
<a class="dropdown-item theme-switcher" href="javascript:">
|
||||
<i class="material-icons" aria-hidden="true">invert_colors</i>
|
||||
<?= __('Switch Theme') ?>
|
||||
</a>
|
||||
|
|
|
@ -17,7 +17,7 @@ $hide_footer ??= false;
|
|||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?= $customization->getLocale()->getHtmlLang() ?>"
|
||||
data-theme="<?= $customization->getPublicTheme()->value ?>">
|
||||
data-theme="<?= $customization->getPublicTheme()?->value ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
|
|
@ -55,11 +55,4 @@ $app = [
|
|||
<?php endif; ?>
|
||||
|
||||
var App = <?=json_encode($app, JSON_THROW_ON_ERROR) ?>;
|
||||
|
||||
let currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
if (currentTheme === 'browser') {
|
||||
currentTheme = (window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
App.theme = currentTheme;
|
||||
</script>
|
||||
|
|
Loading…
Reference in New Issue