From 4c22df78e9fc4b8700bd5bcc706332a0867ca8eb Mon Sep 17 00:00:00 2001 From: Bruce Date: Wed, 29 Jul 2026 11:05:40 +0200 Subject: [PATCH 01/39] Theme selector implementation --- cms/src/components/modals/LModal.vue | 2 +- .../components/modals/ThemeSelectorModal.vue | 72 +++++++++++++++++++ cms/src/components/navigation/SideBar.vue | 16 +++++ cms/src/globalConfig.ts | 55 ++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 cms/src/components/modals/ThemeSelectorModal.vue diff --git a/cms/src/components/modals/LModal.vue b/cms/src/components/modals/LModal.vue index fcfa3ab6bd..ad771780e2 100644 --- a/cms/src/components/modals/LModal.vue +++ b/cms/src/components/modals/LModal.vue @@ -69,7 +69,7 @@ const isMobileScreen = breakpoints.smaller("sm"); ref="modalRef" data-test="modal-content" :class="[ - 'relative z-50 flex flex-col rounded-lg bg-white/90 p-5 shadow-xl focus:outline-none', + 'relative z-50 flex max-h-[100dvh] flex-col rounded-lg bg-white/90 p-5 shadow-xl focus:outline-none dark:bg-slate-900', isMobileScreen && stickToEdges ? 'h-[100dvh] w-[100vw] max-w-none rounded-none' : largeModal diff --git a/cms/src/components/modals/ThemeSelectorModal.vue b/cms/src/components/modals/ThemeSelectorModal.vue new file mode 100644 index 0000000000..78113fc472 --- /dev/null +++ b/cms/src/components/modals/ThemeSelectorModal.vue @@ -0,0 +1,72 @@ + + + diff --git a/cms/src/components/navigation/SideBar.vue b/cms/src/components/navigation/SideBar.vue index ef4eec6f72..4d5396fea6 100644 --- a/cms/src/components/navigation/SideBar.vue +++ b/cms/src/components/navigation/SideBar.vue @@ -17,6 +17,7 @@ import { UserIcon, } from "@heroicons/vue/20/solid"; +import { SunIcon } from "@heroicons/vue/24/outline"; import { appName, cmsLanguageIdAsRef, @@ -42,6 +43,7 @@ import { isAuthBypassed, isAuthPluginInstalled, useAuth } from "@/auth"; import OnlineIndicator from "../OnlineIndicator.vue"; import LanguageModal from "../modals/LanguageModal.vue"; import LDialog from "../common/LDialog.vue"; +import ThemeSelectorModal from "../modals/ThemeSelectorModal.vue"; type NavigationEntry = { name: string; @@ -202,6 +204,7 @@ const currentLanguageName = computed( ); const showLanguageModal = ref(false); +const showThemeModal = ref(false); const showLogoutDialog = ref(false); const showInstallInstructions = ref(false); @@ -340,6 +343,18 @@ const navItemClass = computed(() => [ + + [ + { ua.includes("ipad") ); }); + +const _theme = ref(localStorage.getItem("theme") || "system"); + +/** + * The selected theme as Vue ref. + */ +export const theme = computed<"system" | "dark" | "light">({ + get: () => { + if (_theme.value != "dark" && _theme.value != "light") return "system"; + return _theme.value; + }, + set: (value) => { + _theme.value = value; + localStorage.setItem("theme", value); + }, +}); + +/** + * Returns true if the theme is dark, false if it is light. When the theme is set to "System", it returns true if the system's preferred color scheme is dark. + */ +export const isDarkTheme = ref(document.documentElement.classList.contains("dark")); + +// Live-syncs with OS `prefers-color-scheme` while theme === "system". The matchMedia check inside the watcher alone only runs on in-app theme changes — without this listener, a user flipping OS dark mode mid-session wouldn't update the app. +const systemThemeQuery = window.matchMedia("(prefers-color-scheme: dark)"); + +const applySystemTheme = () => { + if (systemThemeQuery.matches) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + isDarkTheme.value = document.documentElement.classList.contains("dark"); +}; + +// Watch the theme and apply to the document's CSS classes +watch( + theme, + (t) => { + // Remove any existing system theme listener before re-evaluating + systemThemeQuery.removeEventListener("change", applySystemTheme); + + if (t === "system") { + applySystemTheme(); + // Keep dark class in sync when the OS theme changes + systemThemeQuery.addEventListener("change", applySystemTheme); + } else if (t === "dark") { + document.documentElement.classList.add("dark"); + isDarkTheme.value = true; + } else { + document.documentElement.classList.remove("dark"); + isDarkTheme.value = false; + } + }, + { immediate: true }, +); From 68844aaced7de09f287781031b3fbb09ad7beb9d Mon Sep 17 00:00:00 2001 From: Christian Touoyim Date: Wed, 29 Jul 2026 12:14:05 +0200 Subject: [PATCH 02/39] Dark mode implementation --- cms/src/globalConfig.ts | 5 ++++- cms/tailwind.config.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cms/src/globalConfig.ts b/cms/src/globalConfig.ts index 5119051977..cc0a6198e1 100644 --- a/cms/src/globalConfig.ts +++ b/cms/src/globalConfig.ts @@ -190,7 +190,10 @@ export const theme = computed<"system" | "dark" | "light">({ export const isDarkTheme = ref(document.documentElement.classList.contains("dark")); // Live-syncs with OS `prefers-color-scheme` while theme === "system". The matchMedia check inside the watcher alone only runs on in-app theme changes — without this listener, a user flipping OS dark mode mid-session wouldn't update the app. -const systemThemeQuery = window.matchMedia("(prefers-color-scheme: dark)"); +const systemThemeQuery = + typeof window !== "undefined" && window.matchMedia + ? window.matchMedia("(prefers-color-scheme: dark)") + : ({ matches: false, addEventListener: () => {}, removeEventListener: () => {} } as any); const applySystemTheme = () => { if (systemThemeQuery.matches) { diff --git a/cms/tailwind.config.ts b/cms/tailwind.config.ts index 269358f7b0..8805328db3 100644 --- a/cms/tailwind.config.ts +++ b/cms/tailwind.config.ts @@ -2,6 +2,7 @@ import type { Config } from "tailwindcss"; const defaultTheme = require("tailwindcss/defaultTheme"); export default { + darkMode: "class", content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], theme: { extend: { From 7448448ade2f9c926da4ab698e46b469420a4562 Mon Sep 17 00:00:00 2001 From: Bruce Date: Thu, 30 Jul 2026 08:57:05 +0200 Subject: [PATCH 03/39] New changes regarding the dark mode --- cms/src/components/BasePage.vue | 4 +- cms/src/components/common/LDialog.vue | 4 +- cms/src/components/modals/LModal.vue | 2 +- cms/src/components/modals/LanguageModal.vue | 6 +-- cms/src/components/navigation/SideBar.vue | 51 +++++++++++++++------ 5 files changed, 45 insertions(+), 22 deletions(-) diff --git a/cms/src/components/BasePage.vue b/cms/src/components/BasePage.vue index b52a66bcf8..513c46028a 100644 --- a/cms/src/components/BasePage.vue +++ b/cms/src/components/BasePage.vue @@ -78,7 +78,7 @@ const handleMobileSidebarToggle = () => {
(), { :stickToEdges="stickToEdges" :showClosingButton="showClosingButton" > -

{{ description }}

+

+ {{ description }} +

diff --git a/cms/src/components/modals/LModal.vue b/cms/src/components/modals/LModal.vue index ad771780e2..313f845453 100644 --- a/cms/src/components/modals/LModal.vue +++ b/cms/src/components/modals/LModal.vue @@ -69,7 +69,7 @@ const isMobileScreen = breakpoints.smaller("sm"); ref="modalRef" data-test="modal-content" :class="[ - 'relative z-50 flex max-h-[100dvh] flex-col rounded-lg bg-white/90 p-5 shadow-xl focus:outline-none dark:bg-slate-900', + 'relative z-50 flex max-h-[100dvh] flex-col rounded-lg bg-white/90 p-5 shadow-xl focus:outline-none dark:bg-slate-800', isMobileScreen && stickToEdges ? 'h-[100dvh] w-[100vw] max-w-none rounded-none' : largeModal diff --git a/cms/src/components/modals/LanguageModal.vue b/cms/src/components/modals/LanguageModal.vue index 2226e8d690..dd262a9759 100644 --- a/cms/src/components/modals/LanguageModal.vue +++ b/cms/src/components/modals/LanguageModal.vue @@ -27,14 +27,14 @@ const setLanguage = (id: string) => { diff --git a/cms/src/components/navigation/SideBar.vue b/cms/src/components/navigation/SideBar.vue index 4d5396fea6..5ff9b3351d 100644 --- a/cms/src/components/navigation/SideBar.vue +++ b/cms/src/components/navigation/SideBar.vue @@ -3,20 +3,26 @@ import { RouterLink } from "vue-router"; import { DocumentDuplicateIcon, TagIcon, - HomeIcon, + HomeIcon as FilledHomeIcon, ChevronRightIcon, ChevronLeftIcon, - GlobeEuropeAfricaIcon, + GlobeEuropeAfricaIcon as FilledGlobeEuropeAfricaIcon, ArrowUturnRightIcon, - CloudIcon, + CloudIcon as FilledCloudIcon, ShieldCheckIcon, - Cog6ToothIcon, + Cog6ToothIcon as FilledCog6ToothIcon, LanguageIcon, ArrowRightEndOnRectangleIcon, ArrowDownTrayIcon, UserIcon, } from "@heroicons/vue/20/solid"; +import { + HomeIcon, + GlobeEuropeAfricaIcon, + CloudIcon, + Cog6ToothIcon, +} from "@heroicons/vue/24/outline"; import { SunIcon } from "@heroicons/vue/24/outline"; import { appName, @@ -66,7 +72,13 @@ const isCollapsed = computed(() => collapsed.value && !isMobileScreen.value); const { isInstallable, manualInstallInstructions, promptInstall } = useInstallPrompt(); const navigation = computed(() => [ - { name: "Dashboard", to: { name: "dashboard" }, icon: HomeIcon, visible: true }, + { + name: "Dashboard", + to: { name: "dashboard" }, + icon: HomeIcon, + activeIcon: FilledHomeIcon, + visible: true, + }, { name: "Posts", icon: DocumentDuplicateIcon, @@ -97,12 +109,14 @@ const navigation = computed(() => [ name: "Languages", to: { name: "languages" }, icon: GlobeEuropeAfricaIcon, + activeIcon: FilledGlobeEuropeAfricaIcon, visible: hasAnyPermission(DocType.Language, AclPermission.CmsView), }, { name: "Storage", to: { name: "storage" }, icon: CloudIcon, + activeIcon: FilledCloudIcon, visible: hasAnyPermission(DocType.Storage, AclPermission.CmsView), }, { @@ -222,7 +236,7 @@ const confirmLogout = () => { const navIconClass = "h-5 w-5 shrink-0"; // When collapsed (desktop only) nav rows center their icon and drop the label gap/padding. const navItemClass = computed(() => [ - "mb-1 flex rounded-md text-sm font-medium text-zinc-600 hover:bg-zinc-200", + "mb-1 flex rounded-md text-sm font-medium text-zinc-600 hover:bg-zinc-200 dark:hover:bg-slate-700 dark:text-slate-100", isCollapsed.value ? "justify-center p-2.5" : "items-center gap-3 px-3 py-2.5", ]); @@ -240,7 +254,7 @@ const navItemClass = computed(() => [
@@ -395,7 +416,7 @@ const navItemClass = computed(() => [