feat(frontend): add light/dark theme switcher
Add persisted theme mode (light, dark, system) with a header menu. Rework the light and dark palettes for stronger contrast and drop the high-contrast theme.
This commit is contained in:
+33
-1
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import MainView from './pages/MainView.vue'
|
||||
import { useThemeMode } from './composables/useThemeMode'
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
@@ -10,6 +11,11 @@ interface NavItem {
|
||||
|
||||
const drawer = ref(false)
|
||||
|
||||
const { mode, themeOptions } = useThemeMode()
|
||||
const activeTheme = computed(
|
||||
() => themeOptions.find((option) => option.value === mode.value) ?? themeOptions[2]!,
|
||||
)
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ title: 'Misja', href: '#misja', icon: 'mdi-compass-outline' },
|
||||
{ title: 'Jak to działa', href: '#jak-to-dziala', icon: 'mdi-sitemap-outline' },
|
||||
@@ -29,6 +35,32 @@ const navItems: NavItem[] = [
|
||||
/>
|
||||
<v-app-bar-title>TarninaHub</v-app-bar-title>
|
||||
<v-spacer />
|
||||
|
||||
<v-menu location="bottom end">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
:prepend-icon="activeTheme.icon"
|
||||
:aria-label="`Motyw: ${activeTheme.title}. Zmień motyw`"
|
||||
color="on-primary"
|
||||
variant="text"
|
||||
class="me-1"
|
||||
>
|
||||
{{ activeTheme.title }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list aria-label="Wybór motywu">
|
||||
<v-list-item
|
||||
v-for="option in themeOptions"
|
||||
:key="option.value"
|
||||
:prepend-icon="option.icon"
|
||||
:title="option.title"
|
||||
:active="mode === option.value"
|
||||
@click="mode = option.value"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<nav aria-label="Menu główne" class="d-none d-md-block">
|
||||
<v-btn
|
||||
v-for="item in navItems"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { computed, watch } from 'vue'
|
||||
import { usePreferredDark, useStorage } from '@vueuse/core'
|
||||
import { useTheme } from 'vuetify'
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
export type ResolvedTheme = 'light' | 'dark'
|
||||
|
||||
export interface ThemeOption {
|
||||
value: ThemeMode
|
||||
title: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const themeOptions: ThemeOption[] = [
|
||||
{ value: 'light', title: 'Jasny', icon: 'mdi-white-balance-sunny' },
|
||||
{ value: 'dark', title: 'Ciemny', icon: 'mdi-weather-night' },
|
||||
{ value: 'system', title: 'Systemowy', icon: 'mdi-theme-light-dark' },
|
||||
]
|
||||
|
||||
export function useThemeMode() {
|
||||
const theme = useTheme()
|
||||
const preferredDark = usePreferredDark()
|
||||
const mode = useStorage<ThemeMode>('tarninahub-theme', 'system')
|
||||
|
||||
const resolved = computed<ResolvedTheme>(() =>
|
||||
mode.value === 'system' ? (preferredDark.value ? 'dark' : 'light') : mode.value,
|
||||
)
|
||||
|
||||
watch(resolved, (value) => theme.change(value), { immediate: true })
|
||||
|
||||
return { mode, resolved, preferredDark, themeOptions }
|
||||
}
|
||||
@@ -5,66 +5,81 @@ import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
import { en, pl } from 'vuetify/locale'
|
||||
|
||||
const light: ThemeDefinition = {
|
||||
export const light: ThemeDefinition = {
|
||||
dark: false,
|
||||
colors: {
|
||||
background: '#FFFFFF',
|
||||
background: '#FBF8FF',
|
||||
'on-background': '#1A1030',
|
||||
surface: '#FFFFFF',
|
||||
'surface-variant': '#4D4D4D',
|
||||
primary: '#1565C0',
|
||||
'on-surface': '#1A1030',
|
||||
'surface-bright': '#FFFFFF',
|
||||
'on-surface-bright': '#1A1030',
|
||||
'surface-light': '#F1EAFB',
|
||||
'on-surface-light': '#1A1030',
|
||||
'surface-variant': '#ECE4FA',
|
||||
'on-surface-variant': '#1A1030',
|
||||
|
||||
primary: '#5B21B6',
|
||||
'on-primary': '#FFFFFF',
|
||||
secondary: '#455A64',
|
||||
secondary: '#6A1B9A',
|
||||
'on-secondary': '#FFFFFF',
|
||||
info: '#0B4F9E',
|
||||
'on-info': '#FFFFFF',
|
||||
success: '#166534',
|
||||
'on-success': '#FFFFFF',
|
||||
warning: '#7C4A00',
|
||||
'on-warning': '#FFFFFF',
|
||||
error: '#B3261E',
|
||||
'on-error': '#FFFFFF',
|
||||
success: '#1B5E20',
|
||||
'on-success': '#FFFFFF',
|
||||
warning: '#8A4B00',
|
||||
'on-warning': '#FFFFFF',
|
||||
info: '#01579B',
|
||||
'on-info': '#FFFFFF',
|
||||
|
||||
outline: '#6E5D93',
|
||||
focus: '#4C1D95',
|
||||
},
|
||||
variables: {
|
||||
'border-color': '#6E5D93',
|
||||
'border-opacity': 1,
|
||||
'high-emphasis-opacity': 1,
|
||||
'medium-emphasis-opacity': 0.8,
|
||||
'disabled-opacity': 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
const dark: ThemeDefinition = {
|
||||
export const dark: ThemeDefinition = {
|
||||
dark: true,
|
||||
colors: {
|
||||
background: '#121212',
|
||||
surface: '#1E1E1E',
|
||||
'surface-variant': '#CFCFCF',
|
||||
primary: '#90CAF9',
|
||||
'on-primary': '#0D1B2A',
|
||||
secondary: '#B0BEC5',
|
||||
'on-secondary': '#10181C',
|
||||
background: '#151020',
|
||||
'on-background': '#F1EBFF',
|
||||
surface: '#1E1730',
|
||||
'on-surface': '#F1EBFF',
|
||||
'surface-bright': '#2A2142',
|
||||
'on-surface-bright': '#F1EBFF',
|
||||
'surface-light': '#2A2142',
|
||||
'on-surface-light': '#F1EBFF',
|
||||
'surface-variant': '#2A2142',
|
||||
'on-surface-variant': '#F1EBFF',
|
||||
|
||||
primary: '#B79CFF',
|
||||
'on-primary': '#240850',
|
||||
secondary: '#E3AEF2',
|
||||
'on-secondary': '#3A0F4A',
|
||||
info: '#9EC5FF',
|
||||
'on-info': '#0A2447',
|
||||
success: '#86DDA0',
|
||||
'on-success': '#08311A',
|
||||
warning: '#FFC766',
|
||||
'on-warning': '#3A2600',
|
||||
error: '#FFB4AB',
|
||||
'on-error': '#410002',
|
||||
success: '#A5D6A7',
|
||||
'on-success': '#0B2A0E',
|
||||
warning: '#FFCC80',
|
||||
'on-warning': '#2E1A00',
|
||||
info: '#81D4FA',
|
||||
'on-info': '#00263A',
|
||||
},
|
||||
}
|
||||
'on-error': '#4A0A06',
|
||||
|
||||
const highContrast: ThemeDefinition = {
|
||||
dark: true,
|
||||
colors: {
|
||||
background: '#000000',
|
||||
surface: '#000000',
|
||||
'surface-variant': '#FFFFFF',
|
||||
primary: '#FFFF00',
|
||||
'on-primary': '#000000',
|
||||
secondary: '#00FFFF',
|
||||
'on-secondary': '#000000',
|
||||
error: '#FF8A80',
|
||||
'on-error': '#000000',
|
||||
success: '#69F0AE',
|
||||
'on-success': '#000000',
|
||||
warning: '#FFD740',
|
||||
'on-warning': '#000000',
|
||||
info: '#80D8FF',
|
||||
'on-info': '#000000',
|
||||
outline: '#9A8FB5',
|
||||
focus: '#FFD54F',
|
||||
},
|
||||
variables: {
|
||||
'border-color': '#9A8FB5',
|
||||
'border-opacity': 1,
|
||||
'high-emphasis-opacity': 1,
|
||||
'medium-emphasis-opacity': 0.8,
|
||||
'disabled-opacity': 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -72,7 +87,7 @@ export default createVuetify({
|
||||
components,
|
||||
directives,
|
||||
locale: { locale: 'pl', fallback: 'en', messages: { pl, en } },
|
||||
theme: { defaultTheme: 'system', themes: { light, dark, highContrast } },
|
||||
theme: { defaultTheme: 'system', themes: { light, dark } },
|
||||
defaults: {
|
||||
global: { ripple: false },
|
||||
VBtn: { size: 'large', variant: 'flat', color: 'primary' },
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
.v-theme--light {
|
||||
--a11y-focus: #0b3d91;
|
||||
--a11y-focus: #4c1d95;
|
||||
}
|
||||
.v-theme--dark {
|
||||
--a11y-focus: #ffd54f;
|
||||
}
|
||||
.v-theme--highContrast {
|
||||
--a11y-focus: #ffff00;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 112.5%;
|
||||
|
||||
Reference in New Issue
Block a user