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">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import MainView from './pages/MainView.vue'
|
import MainView from './pages/MainView.vue'
|
||||||
|
import { useThemeMode } from './composables/useThemeMode'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
title: string
|
title: string
|
||||||
@@ -10,6 +11,11 @@ interface NavItem {
|
|||||||
|
|
||||||
const drawer = ref(false)
|
const drawer = ref(false)
|
||||||
|
|
||||||
|
const { mode, themeOptions } = useThemeMode()
|
||||||
|
const activeTheme = computed(
|
||||||
|
() => themeOptions.find((option) => option.value === mode.value) ?? themeOptions[2]!,
|
||||||
|
)
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ title: 'Misja', href: '#misja', icon: 'mdi-compass-outline' },
|
{ title: 'Misja', href: '#misja', icon: 'mdi-compass-outline' },
|
||||||
{ title: 'Jak to działa', href: '#jak-to-dziala', icon: 'mdi-sitemap-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-app-bar-title>TarninaHub</v-app-bar-title>
|
||||||
<v-spacer />
|
<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">
|
<nav aria-label="Menu główne" class="d-none d-md-block">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-for="item in navItems"
|
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 * as directives from 'vuetify/directives'
|
||||||
import { en, pl } from 'vuetify/locale'
|
import { en, pl } from 'vuetify/locale'
|
||||||
|
|
||||||
const light: ThemeDefinition = {
|
export const light: ThemeDefinition = {
|
||||||
dark: false,
|
dark: false,
|
||||||
colors: {
|
colors: {
|
||||||
background: '#FFFFFF',
|
background: '#FBF8FF',
|
||||||
|
'on-background': '#1A1030',
|
||||||
surface: '#FFFFFF',
|
surface: '#FFFFFF',
|
||||||
'surface-variant': '#4D4D4D',
|
'on-surface': '#1A1030',
|
||||||
primary: '#1565C0',
|
'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',
|
'on-primary': '#FFFFFF',
|
||||||
secondary: '#455A64',
|
secondary: '#6A1B9A',
|
||||||
'on-secondary': '#FFFFFF',
|
'on-secondary': '#FFFFFF',
|
||||||
|
info: '#0B4F9E',
|
||||||
|
'on-info': '#FFFFFF',
|
||||||
|
success: '#166534',
|
||||||
|
'on-success': '#FFFFFF',
|
||||||
|
warning: '#7C4A00',
|
||||||
|
'on-warning': '#FFFFFF',
|
||||||
error: '#B3261E',
|
error: '#B3261E',
|
||||||
'on-error': '#FFFFFF',
|
'on-error': '#FFFFFF',
|
||||||
success: '#1B5E20',
|
|
||||||
'on-success': '#FFFFFF',
|
outline: '#6E5D93',
|
||||||
warning: '#8A4B00',
|
focus: '#4C1D95',
|
||||||
'on-warning': '#FFFFFF',
|
},
|
||||||
info: '#01579B',
|
variables: {
|
||||||
'on-info': '#FFFFFF',
|
'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,
|
dark: true,
|
||||||
colors: {
|
colors: {
|
||||||
background: '#121212',
|
background: '#151020',
|
||||||
surface: '#1E1E1E',
|
'on-background': '#F1EBFF',
|
||||||
'surface-variant': '#CFCFCF',
|
surface: '#1E1730',
|
||||||
primary: '#90CAF9',
|
'on-surface': '#F1EBFF',
|
||||||
'on-primary': '#0D1B2A',
|
'surface-bright': '#2A2142',
|
||||||
secondary: '#B0BEC5',
|
'on-surface-bright': '#F1EBFF',
|
||||||
'on-secondary': '#10181C',
|
'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',
|
error: '#FFB4AB',
|
||||||
'on-error': '#410002',
|
'on-error': '#4A0A06',
|
||||||
success: '#A5D6A7',
|
|
||||||
'on-success': '#0B2A0E',
|
|
||||||
warning: '#FFCC80',
|
|
||||||
'on-warning': '#2E1A00',
|
|
||||||
info: '#81D4FA',
|
|
||||||
'on-info': '#00263A',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const highContrast: ThemeDefinition = {
|
outline: '#9A8FB5',
|
||||||
dark: true,
|
focus: '#FFD54F',
|
||||||
colors: {
|
},
|
||||||
background: '#000000',
|
variables: {
|
||||||
surface: '#000000',
|
'border-color': '#9A8FB5',
|
||||||
'surface-variant': '#FFFFFF',
|
'border-opacity': 1,
|
||||||
primary: '#FFFF00',
|
'high-emphasis-opacity': 1,
|
||||||
'on-primary': '#000000',
|
'medium-emphasis-opacity': 0.8,
|
||||||
secondary: '#00FFFF',
|
'disabled-opacity': 0.5,
|
||||||
'on-secondary': '#000000',
|
|
||||||
error: '#FF8A80',
|
|
||||||
'on-error': '#000000',
|
|
||||||
success: '#69F0AE',
|
|
||||||
'on-success': '#000000',
|
|
||||||
warning: '#FFD740',
|
|
||||||
'on-warning': '#000000',
|
|
||||||
info: '#80D8FF',
|
|
||||||
'on-info': '#000000',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +87,7 @@ export default createVuetify({
|
|||||||
components,
|
components,
|
||||||
directives,
|
directives,
|
||||||
locale: { locale: 'pl', fallback: 'en', messages: { pl, en } },
|
locale: { locale: 'pl', fallback: 'en', messages: { pl, en } },
|
||||||
theme: { defaultTheme: 'system', themes: { light, dark, highContrast } },
|
theme: { defaultTheme: 'system', themes: { light, dark } },
|
||||||
defaults: {
|
defaults: {
|
||||||
global: { ripple: false },
|
global: { ripple: false },
|
||||||
VBtn: { size: 'large', variant: 'flat', color: 'primary' },
|
VBtn: { size: 'large', variant: 'flat', color: 'primary' },
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
.v-theme--light {
|
.v-theme--light {
|
||||||
--a11y-focus: #0b3d91;
|
--a11y-focus: #4c1d95;
|
||||||
}
|
}
|
||||||
.v-theme--dark {
|
.v-theme--dark {
|
||||||
--a11y-focus: #ffd54f;
|
--a11y-focus: #ffd54f;
|
||||||
}
|
}
|
||||||
.v-theme--highContrast {
|
|
||||||
--a11y-focus: #ffff00;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 112.5%;
|
font-size: 112.5%;
|
||||||
|
|||||||
Reference in New Issue
Block a user