feat(quick-261016-0fm): manual theme switch on the rail
- the rail's top button cycles system, light and dark and names the current choice; light and dark ignore prefers-color-scheme - usePreferences applies the cached theme before the first await of boot, saves the choice through PUT /account/preferences and reverts with a toast when the save fails; me() applies the stored preference
This commit is contained in:
@@ -1,15 +1,32 @@
|
||||
// Colour scheme (D-06, flagged assumption A6): the admin follows the system
|
||||
// preference only, with no toggle. The .dark class on the document element
|
||||
// swaps the design's dark tokens in main.css; the sidebar stays dark in both
|
||||
// modes and gains its #222b40 right border in dark mode.
|
||||
// Colour scheme (D-06; assumption A6 superseded). The admin follows the
|
||||
// system preference by default; light and dark are manual choices the admin
|
||||
// makes on the rail's top button and keeps per account (usePreferences). The
|
||||
// .dark class on the document element swaps the design's dark tokens in
|
||||
// main.css; the sidebar stays dark in both modes and gains its #222b40 right
|
||||
// border in dark mode.
|
||||
export const DARK_QUERY = '(prefers-color-scheme: dark)'
|
||||
|
||||
/** The admin's theme: follow the system, or always light or dark. */
|
||||
export type ThemeChoice = 'system' | 'light' | 'dark'
|
||||
|
||||
/** The theme choices in the order the rail button cycles through them. */
|
||||
export const THEME_CHOICES: readonly ThemeChoice[] = ['system', 'light', 'dark']
|
||||
|
||||
export function isThemeChoice(value: unknown): value is ThemeChoice {
|
||||
return typeof value === 'string' && (THEME_CHOICES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the current preference and follows changes. Returns the
|
||||
* unsubscribe function.
|
||||
* Applies a theme choice. light and dark set the class once and subscribe
|
||||
* to nothing; system applies the current preference and follows changes.
|
||||
* Returns the unsubscribe function.
|
||||
*/
|
||||
export function applyColorScheme(win: Window = window): () => void {
|
||||
export function applyColorScheme(win: Window = window, choice: ThemeChoice = 'system'): () => void {
|
||||
const root = win.document.documentElement
|
||||
if (choice !== 'system') {
|
||||
root.classList.toggle('dark', choice === 'dark')
|
||||
return () => undefined
|
||||
}
|
||||
if (typeof win.matchMedia !== 'function') {
|
||||
root.classList.remove('dark')
|
||||
return () => undefined
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { PanelLeftOpen, Settings, Sun } from '@lucide/vue'
|
||||
import { Moon, PanelLeftOpen, Settings, Sun, SunMoon } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { controllerIdFromPath } from '../../app/controllerRoutes'
|
||||
import { iconFor } from '../../app/icons'
|
||||
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
|
||||
import { cycleTheme, themeChoice } from '../../state/usePreferences'
|
||||
import { hasSettings, settingsLinkFor } from '../../state/useSettings'
|
||||
import { useSidebar } from '../../state/useSidebar'
|
||||
import SectionFlyout from './SectionFlyout.vue'
|
||||
@@ -23,6 +24,13 @@ const FLYOUT_CLOSE_DELAY = 200
|
||||
const route = useRoute()
|
||||
const { collapsed, narrow, setCollapsed } = useSidebar()
|
||||
|
||||
// The top button cycles the theme (system, light, dark) and names the
|
||||
// current choice; the icon shows it.
|
||||
const themeIcon = computed(() => ({ system: SunMoon, light: Sun, dark: Moon })[themeChoice.value])
|
||||
const themeLabel = computed(() =>
|
||||
t('backend::lang.nav.theme', { mode: t(`backend::lang.nav.theme_${themeChoice.value}`) }),
|
||||
)
|
||||
|
||||
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
|
||||
|
||||
// Ustawienia (D-21) is pinned to the bottom and shown only when the admin may
|
||||
@@ -152,9 +160,16 @@ onBeforeUnmount(cancelClose)
|
||||
:aria-label="t('backend::lang.nav.plugins')"
|
||||
class="sticky top-0 flex h-screen w-rail shrink-0 flex-col items-center gap-1.5 self-start overflow-y-auto border-r border-side-border bg-side pt-3.5 pb-3"
|
||||
>
|
||||
<span class="mb-3.5 flex size-10 items-center justify-center rounded-inner bg-accent-soft" title="SummerCMS">
|
||||
<Sun :size="22" class="text-accent" aria-hidden="true" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle
|
||||
:aria-label="themeLabel"
|
||||
:title="themeLabel"
|
||||
class="mb-3.5 flex size-10 shrink-0 items-center justify-center rounded-inner bg-accent-soft transition-colors duration-150 ease-out hover:bg-side-hover"
|
||||
@click="cycleTheme"
|
||||
>
|
||||
<component :is="themeIcon" :size="22" class="text-accent" aria-hidden="true" />
|
||||
</button>
|
||||
<RouterLink
|
||||
v-for="item in items"
|
||||
:key="item.entry.code"
|
||||
|
||||
@@ -4,15 +4,17 @@ import { createAdminRouter } from './app/router'
|
||||
import { onUnauthorized } from './api/client'
|
||||
import { loadFieldTypes } from './app/fieldTypes'
|
||||
import { loadStrings } from './app/i18n'
|
||||
import { applyColorScheme } from './app/theme'
|
||||
import { expireSession, me } from './state/useAuth'
|
||||
import { loadNavigation } from './state/useNavigation'
|
||||
import { applyTheme, cachedTheme } from './state/usePreferences'
|
||||
import { loadSettings } from './state/useSettings'
|
||||
import './styles/main.css'
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
// Dark mode follows the system preference before anything renders (A6).
|
||||
applyColorScheme()
|
||||
// The last theme choice (system unless the admin picked light or dark) is
|
||||
// applied before the first await, so nothing renders in the wrong theme;
|
||||
// me() then applies the admin's stored preference.
|
||||
applyTheme(cachedTheme())
|
||||
// Boot order (research Pattern 7): the public strings bundle first, so the
|
||||
// login screen is translated, then the session, then navigation and the
|
||||
// plugin field types.
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||
import { api, onRefreshed, refreshSession } from '../api/client'
|
||||
import type { AdminProfile } from '../api/types'
|
||||
import { setListStateOwner } from './listState'
|
||||
import { applyProfilePreferences } from './usePreferences'
|
||||
import { setNavigation } from './useNavigation'
|
||||
import { setSettings } from './useSettings'
|
||||
|
||||
@@ -52,11 +53,13 @@ export async function login(identifier: string, password: string): Promise<boole
|
||||
* Loads the signed-in admin; null when the session is missing or expired.
|
||||
* The stored list queries follow the answer: a profile keeps only that
|
||||
* admin's (another admin's are removed, T-16.1-03), no profile removes all.
|
||||
* The profile's UI preferences (theme) are applied before it returns.
|
||||
*/
|
||||
export async function me(): Promise<AdminProfile | null> {
|
||||
const { data, response } = await api.GET('/auth/me')
|
||||
user.value = response.ok && data ? data.data : null
|
||||
setListStateOwner(user.value?.id ?? null)
|
||||
await applyProfilePreferences(user.value)
|
||||
return user.value
|
||||
}
|
||||
|
||||
|
||||
124
admin/src/state/usePreferences.ts
Normal file
124
admin/src/state/usePreferences.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// The signed-in admin's UI preferences: the theme (system, light or dark).
|
||||
// The server keeps them per admin (GET /auth/me preferences, PUT
|
||||
// /account/preferences); the last choice is also cached in localStorage under
|
||||
// THEME_KEY, so boot applies it before the first await and the shell never
|
||||
// renders in the wrong theme. Only the choice's code is stored; tokens and
|
||||
// record data never are (T-261016-0fm-08).
|
||||
import { readonly, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminProfile } from '../api/types'
|
||||
import { t } from '../app/i18n'
|
||||
import { applyColorScheme, isThemeChoice, THEME_CHOICES, type ThemeChoice } from '../app/theme'
|
||||
import { showToast } from './useToasts'
|
||||
|
||||
export const THEME_KEY = 'summer-admin.theme'
|
||||
|
||||
const theme = ref<ThemeChoice>('system')
|
||||
let stopTheme: () => void = () => undefined
|
||||
|
||||
/** The theme currently applied. */
|
||||
export const themeChoice = readonly(theme)
|
||||
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function cache(key: string, value: string | null): void {
|
||||
try {
|
||||
if (value === null) {
|
||||
storage()?.removeItem(key)
|
||||
} else {
|
||||
storage()?.setItem(key, value)
|
||||
}
|
||||
} catch {
|
||||
// Storage may be full or disabled; the choice still holds for the session.
|
||||
}
|
||||
}
|
||||
|
||||
/** Caches a theme; system, the default, is stored as an absent key. */
|
||||
function cacheTheme(choice: ThemeChoice): void {
|
||||
cache(THEME_KEY, choice === 'system' ? null : choice)
|
||||
}
|
||||
|
||||
/** The cached theme when it is a valid choice, else system. */
|
||||
export function cachedTheme(): ThemeChoice {
|
||||
try {
|
||||
const value = storage()?.getItem(THEME_KEY)
|
||||
return isThemeChoice(value) ? value : 'system'
|
||||
} catch {
|
||||
return 'system'
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a theme, replacing the previous one's media subscription. */
|
||||
export function applyTheme(choice: ThemeChoice): void {
|
||||
stopTheme()
|
||||
theme.value = choice
|
||||
stopTheme = typeof window === 'undefined' ? () => undefined : applyColorScheme(window, choice)
|
||||
}
|
||||
|
||||
/** The choice after current in the rail button's cycle. */
|
||||
export function nextTheme(current: ThemeChoice): ThemeChoice {
|
||||
const index = THEME_CHOICES.indexOf(current)
|
||||
return THEME_CHOICES[(index + 1) % THEME_CHOICES.length] ?? 'system'
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies and caches a theme at once and saves it on the server. When the
|
||||
* save fails the previous theme comes back and a danger toast says so.
|
||||
*/
|
||||
export async function setTheme(choice: ThemeChoice): Promise<void> {
|
||||
const previous = theme.value
|
||||
applyTheme(choice)
|
||||
cacheTheme(choice)
|
||||
let ok = false
|
||||
try {
|
||||
const { response } = await api.PUT('/account/preferences', { body: { theme: choice } })
|
||||
ok = response.ok
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
if (ok) {
|
||||
return
|
||||
}
|
||||
// A later click owns the theme now; only this request's choice is undone.
|
||||
if (theme.value === choice) {
|
||||
applyTheme(previous)
|
||||
cacheTheme(previous)
|
||||
}
|
||||
showToast(t('backend::lang.myaccount.preferences_failed'), 'danger')
|
||||
}
|
||||
|
||||
/** Moves to the next theme: system, light, dark, system. */
|
||||
export function cycleTheme(): Promise<void> {
|
||||
return setTheme(nextTheme(theme.value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the preferences of a freshly loaded profile (boot and sign-in), so
|
||||
* a second browser of the same admin shows the same theme.
|
||||
*/
|
||||
export async function applyProfilePreferences(profile: AdminProfile | null): Promise<void> {
|
||||
const prefs = (profile as { preferences?: Partial<AdminProfile['preferences']> } | null)?.preferences
|
||||
if (!prefs) {
|
||||
return
|
||||
}
|
||||
if (isThemeChoice(prefs.theme)) {
|
||||
if (prefs.theme !== theme.value) {
|
||||
applyTheme(prefs.theme)
|
||||
}
|
||||
cacheTheme(prefs.theme)
|
||||
}
|
||||
}
|
||||
|
||||
export function usePreferences() {
|
||||
return {
|
||||
theme: themeChoice,
|
||||
setTheme,
|
||||
cycleTheme,
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
// D-06, D-10). The section panel hides below about 1100px or when the admin
|
||||
// collapses it. The viewport only forces the collapse and never overwrites
|
||||
// the admin's own choice, which persists in localStorage as a boolean under
|
||||
// SIDEBAR_KEY. The only other writer of browser storage is listState.ts,
|
||||
// which keeps list query parameters per list; tokens and record data are
|
||||
// never stored (T-10-22, T-16.1-03).
|
||||
// SIDEBAR_KEY. The other writers of browser storage are listState.ts,
|
||||
// which keeps list query parameters per list, and usePreferences.ts, which
|
||||
// caches the theme and locale codes; tokens and record data are never stored
|
||||
// (T-10-22, T-16.1-03).
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
|
||||
export const SIDEBAR_KEY = 'summer-admin.sidebar'
|
||||
|
||||
@@ -35,6 +35,8 @@ A form whose schema carries `preview` has a read-only record screen at `<control
|
||||
|
||||
The user menu in the top bar has a My account item that opens `<prefix>/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, uploads and removes their avatar, shows the login read-only, changes the password when a new one is typed together with the current one, and manages personal API tokens (list, create with a show-once plaintext, revoke, show revoked). Server validation errors show on their fields, and a successful save updates the name and the avatar in the top bar without a reload. See [My account](users-and-permissions.md#my-account) and [API tokens](users-and-permissions.md#api-tokens).
|
||||
|
||||
The button at the top of the plugin rail switches the theme: each click moves from system (follow the browser's `prefers-color-scheme`) to light, to dark and back to system, and its icon and label name the current choice. The choice is saved for the signed-in administrator through `PUT <prefix>/api/v1/account/preferences` and comes back as `preferences.theme` in `GET .../auth/me`, so another browser shows the same theme after sign-in. The SPA also caches the last choice in `localStorage` (`summer-admin.theme`) and applies it before anything renders. When the save fails the previous theme returns with an error message. See [My account](users-and-permissions.md#my-account).
|
||||
|
||||
## Types from OpenAPI
|
||||
|
||||
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. Type overrides for swag live in the root `.swaggo` (it documents `json.RawMessage` as an untyped value, which swag cannot resolve on its own). `--check` fails when either committed file is out of date:
|
||||
|
||||
Reference in New Issue
Block a user