generated from Nodarx/template
Merge pull request 'feat: add theme selection and customization' (#1) from feat/themer-module into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
# Frontend development
|
||||
node_modules/
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
.cache/
|
||||
.vite/
|
||||
.temp/
|
||||
.tmp/
|
||||
|
||||
# Frontend build
|
||||
/static/
|
||||
|
||||
# Backend development
|
||||
/lib/vendor/
|
||||
coverage/
|
||||
phpunit.xml.cache
|
||||
.phpunit.result.cache
|
||||
.php-cs-fixer.cache
|
||||
.phpstan.cache
|
||||
.phpactor/
|
||||
|
||||
# Editors
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "ktxm/themer",
|
||||
"type": "project",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"KTXM\\Themer\\": "lib/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"admin": {
|
||||
"lockHint": "When locked, users keep their light/dark/system choice but cannot change colors or font.",
|
||||
"lockLabel": "Lock users to the tenant theme",
|
||||
"lockTitle": "Enforcement",
|
||||
"modeHint": "Initial mode for users who have not chosen one themselves.",
|
||||
"modeTitle": "Default mode",
|
||||
"noPermission": "You do not have permission to manage tenant appearance settings.",
|
||||
"presetsTitle": "Default theme",
|
||||
"title": "Appearance"
|
||||
},
|
||||
"editor": {
|
||||
"baseLabel": "Start from preset",
|
||||
"cancel": "Cancel",
|
||||
"coreColors": "Core colors",
|
||||
"darkVariant": "Dark",
|
||||
"extendedColors": "Extended palette",
|
||||
"lightVariant": "Light",
|
||||
"lockedNotice": "Theme selection is managed by your administrator; a saved custom theme will not be applied while the lock is active.",
|
||||
"previewAlert": "Informational alert on the themed background.",
|
||||
"previewCardText": "Body text on a surface, with medium emphasis details.",
|
||||
"previewCardTitle": "Sample card",
|
||||
"previewError": "Error",
|
||||
"previewField": "Text field",
|
||||
"previewInfo": "Info",
|
||||
"previewOutlined": "Outlined",
|
||||
"previewPrimary": "Primary",
|
||||
"previewSecondary": "Secondary",
|
||||
"previewSuccess": "Success",
|
||||
"previewSwitch": "Switch",
|
||||
"previewTitle": "Preview",
|
||||
"previewWarning": "Warning",
|
||||
"reset": "Reset to preset",
|
||||
"save": "Save & Apply",
|
||||
"saved": "Custom theme saved and applied",
|
||||
"title": "Customize Theme"
|
||||
},
|
||||
"font": {
|
||||
"default": "Default",
|
||||
"label": "Font family"
|
||||
},
|
||||
"mode": {
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"system": "System"
|
||||
},
|
||||
"nav": {
|
||||
"appearance": "Appearance"
|
||||
},
|
||||
"presets": {
|
||||
"amber": "Amber",
|
||||
"custom": "Custom",
|
||||
"default": "Indigo",
|
||||
"emerald": "Emerald",
|
||||
"graphite": "Graphite",
|
||||
"ocean": "Ocean",
|
||||
"rose": "Rose"
|
||||
},
|
||||
"settings": {
|
||||
"createCustom": "Customize",
|
||||
"deleteCustom": "Delete Custom Theme",
|
||||
"editCustom": "Edit Custom Theme",
|
||||
"fontTitle": "Font",
|
||||
"lockedNotice": "Theme selection is managed by your administrator. You can still choose your preferred mode.",
|
||||
"modeHint": "System follows your operating system preference.",
|
||||
"modeTitle": "Mode",
|
||||
"presetsTitle": "Themes",
|
||||
"title": "Appearance"
|
||||
},
|
||||
"tokens": {
|
||||
"background": "Background",
|
||||
"error": "Error",
|
||||
"info": "Info",
|
||||
"primary": "Primary",
|
||||
"secondary": "Secondary",
|
||||
"success": "Success",
|
||||
"surface": "Surface",
|
||||
"warning": "Warning"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"nav": {
|
||||
"appearance": "Appearance"
|
||||
},
|
||||
"mode": {
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"presets": {
|
||||
"default": "Indigo",
|
||||
"emerald": "Emerald",
|
||||
"rose": "Rose",
|
||||
"amber": "Amber",
|
||||
"ocean": "Ocean",
|
||||
"graphite": "Graphite",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"tokens": {
|
||||
"primary": "Primary",
|
||||
"secondary": "Secondary",
|
||||
"background": "Background",
|
||||
"surface": "Surface",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
"success": "Success"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace KTXM\Themer;
|
||||
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
|
||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||
{
|
||||
public function __construct()
|
||||
{ }
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
return 'themer';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Appearance';
|
||||
}
|
||||
|
||||
public function author(): string
|
||||
{
|
||||
return 'Ktrix';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Theme selection and customization for users and tenants.';
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
return '0.0.1';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return [
|
||||
'themer' => [
|
||||
'label' => 'Access Appearance Settings',
|
||||
'description' => 'View and access the appearance settings module',
|
||||
'group' => 'Themer'
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function registerBI(): array
|
||||
{
|
||||
return [
|
||||
'handle' => $this->handle(),
|
||||
'namespace' => 'Themer',
|
||||
'version' => $this->version(),
|
||||
'label' => $this->label(),
|
||||
'author' => $this->author(),
|
||||
'description' => $this->description(),
|
||||
'boot' => 'static/module.mjs',
|
||||
];
|
||||
}
|
||||
}
|
||||
Generated
+1910
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "themer",
|
||||
"description": "Ktrix Themer Module - Theme Selection and Customization",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "Ktrix",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build --mode production --config vite.config.ts",
|
||||
"dev": "vite build --mode development --config vite.config.ts",
|
||||
"watch": "vite build --mode development --watch --config vite.config.ts",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^5.0.0",
|
||||
"vuetify": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"enabledManagers": ["npm", "composer", "github-actions"],
|
||||
"timezone": "UTC",
|
||||
"schedule": ["* 0-3 * * *"],
|
||||
"dependencyDashboard": true,
|
||||
"prConcurrentLimit": 5,
|
||||
"prHourlyLimit": 2
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
function onPick(value: string | null): void {
|
||||
if (value) emit('update:modelValue', value.toUpperCase())
|
||||
}
|
||||
|
||||
function onType(value: string): void {
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(value)) emit('update:modelValue', value.toUpperCase())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VTextField
|
||||
:label="label"
|
||||
:model-value="modelValue"
|
||||
density="comfortable"
|
||||
@update:model-value="onType"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VMenu :close-on-content-click="false" location="bottom start">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<div
|
||||
v-bind="activatorProps"
|
||||
class="color-swatch rounded cursor-pointer"
|
||||
:style="{ backgroundColor: modelValue }"
|
||||
/>
|
||||
</template>
|
||||
<VColorPicker
|
||||
:model-value="modelValue"
|
||||
mode="hex"
|
||||
:modes="['hex', 'rgb', 'hsl']"
|
||||
show-swatches
|
||||
@update:model-value="onPick"
|
||||
/>
|
||||
</VMenu>
|
||||
</template>
|
||||
</VTextField>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.color-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.4);
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
import { useL10n } from '@KTXC'
|
||||
import { FONT_OPTIONS, DEFAULT_FONT } from '@/presets'
|
||||
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useL10n('themer')
|
||||
|
||||
const items = FONT_OPTIONS.map(font => ({
|
||||
value: font,
|
||||
title: font === DEFAULT_FONT ? `${font} (${t('font.default', 'Default')})` : font,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VSelect
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
:label="t('font.label', 'Font family')"
|
||||
:disabled="disabled"
|
||||
density="comfortable"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<VListItem v-bind="itemProps" :style="{ fontFamily: `'${item.value}', sans-serif` }" />
|
||||
</template>
|
||||
</VSelect>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import { useL10n } from '@KTXC'
|
||||
import type { ThemerMode } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
modelValue: ThemerMode
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: ThemerMode): void
|
||||
}>()
|
||||
|
||||
const { t } = useL10n('themer')
|
||||
|
||||
const modes: Array<{ value: ThemerMode; icon: string; l10n: string; label: string }> = [
|
||||
{ value: 'light', icon: 'mdi-weather-sunny', l10n: 'mode.light', label: 'Light' },
|
||||
{ value: 'dark', icon: 'mdi-weather-night', l10n: 'mode.dark', label: 'Dark' },
|
||||
{ value: 'system', icon: 'mdi-theme-light-dark', l10n: 'mode.system', label: 'System' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VBtnToggle
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
mandatory
|
||||
divided
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
@update:model-value="emit('update:modelValue', $event as ThemerMode)"
|
||||
>
|
||||
<VBtn
|
||||
v-for="mode in modes"
|
||||
:key="mode.value"
|
||||
:value="mode.value"
|
||||
:prepend-icon="mode.icon"
|
||||
>
|
||||
{{ t(mode.l10n, mode.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts" setup>
|
||||
import { t as tGlobal } from '@KTXC'
|
||||
import { useThemerStore } from '@/stores/themerStore'
|
||||
import type { ThemerPreset, ThemerVariant } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
preset: ThemerPreset
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'select', id: string): void
|
||||
}>()
|
||||
|
||||
const themerStore = useThemerStore()
|
||||
|
||||
const SWATCH_TOKENS = ['primary', 'secondary', 'surface', 'background']
|
||||
const VARIANTS: ThemerVariant[] = ['light', 'dark']
|
||||
|
||||
function swatchColor(variant: ThemerVariant, token: string): string {
|
||||
return themerStore.resolveToken(props.preset, variant, token)
|
||||
}
|
||||
|
||||
function title(): string {
|
||||
return props.preset.l10n
|
||||
? tGlobal(`themer.${props.preset.l10n}`, props.preset.label)
|
||||
: props.preset.label
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
:disabled="disabled"
|
||||
:variant="active ? 'outlined' : 'elevated'"
|
||||
:color="active ? 'primary' : undefined"
|
||||
class="preset-card"
|
||||
@click="emit('select', preset.id)"
|
||||
>
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-flex align-center text-body-1 font-weight-medium">
|
||||
{{ title() }}
|
||||
<VSpacer />
|
||||
<VIcon v-if="active" color="primary" size="20">mdi-check-circle</VIcon>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<div
|
||||
v-for="variant in VARIANTS"
|
||||
:key="variant"
|
||||
class="d-flex align-center mb-2"
|
||||
>
|
||||
<VIcon size="14" class="mr-2 text-medium-emphasis">
|
||||
{{ variant === 'light' ? 'mdi-weather-sunny' : 'mdi-weather-night' }}
|
||||
</VIcon>
|
||||
<div
|
||||
class="swatch-strip d-flex flex-grow-1 rounded overflow-hidden"
|
||||
:style="{ border: '1px solid rgba(var(--v-border-color), 0.24)' }"
|
||||
>
|
||||
<div
|
||||
v-for="token in SWATCH_TOKENS"
|
||||
:key="token"
|
||||
class="swatch flex-grow-1"
|
||||
:style="{ backgroundColor: swatchColor(variant, token) }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preset-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
.swatch-strip {
|
||||
height: 22px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts" setup>
|
||||
import { useL10n } from '@KTXC'
|
||||
|
||||
defineProps<{
|
||||
themeName: string
|
||||
}>()
|
||||
|
||||
const { t } = useL10n('themer')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VThemeProvider :theme="themeName" with-background class="rounded-lg pa-4 preview-surface">
|
||||
<p class="text-subtitle-2 mb-3">{{ t('editor.previewTitle', 'Preview') }}</p>
|
||||
<VCard class="mb-4">
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('editor.previewCardTitle', 'Sample card') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
{{ t('editor.previewCardText', 'Body text on a surface, with medium emphasis details.') }}
|
||||
<div class="d-flex flex-wrap ga-2 mt-4">
|
||||
<VBtn color="primary">{{ t('editor.previewPrimary', 'Primary') }}</VBtn>
|
||||
<VBtn color="secondary" variant="tonal">{{ t('editor.previewSecondary', 'Secondary') }}</VBtn>
|
||||
<VBtn variant="outlined">{{ t('editor.previewOutlined', 'Outlined') }}</VBtn>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap ga-2 mt-4">
|
||||
<VChip color="success" size="small">{{ t('editor.previewSuccess', 'Success') }}</VChip>
|
||||
<VChip color="info" size="small">{{ t('editor.previewInfo', 'Info') }}</VChip>
|
||||
<VChip color="warning" size="small">{{ t('editor.previewWarning', 'Warning') }}</VChip>
|
||||
<VChip color="error" size="small">{{ t('editor.previewError', 'Error') }}</VChip>
|
||||
</div>
|
||||
<VTextField
|
||||
class="mt-4"
|
||||
:label="t('editor.previewField', 'Text field')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
/>
|
||||
<VSwitch
|
||||
color="primary"
|
||||
:label="t('editor.previewSwitch', 'Switch')"
|
||||
:model-value="true"
|
||||
hide-details
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VAlert type="info" variant="tonal" density="compact">
|
||||
{{ t('editor.previewAlert', 'Informational alert on the themed background.') }}
|
||||
</VAlert>
|
||||
</VThemeProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preview-surface {
|
||||
border: 1px solid rgba(var(--v-border-color), 0.24);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
|
||||
|
||||
const integrations: ModuleIntegrations = {
|
||||
// User personal settings
|
||||
user_settings_menu: [
|
||||
{
|
||||
id: 'appearance',
|
||||
label: 'Appearance',
|
||||
l10n: 'nav.appearance',
|
||||
path: '/settings',
|
||||
icon: 'mdi-palette-outline',
|
||||
priority: 25,
|
||||
},
|
||||
],
|
||||
|
||||
// Tenant administration
|
||||
admin_settings_menu: [
|
||||
{
|
||||
id: 'appearance',
|
||||
label: 'Appearance',
|
||||
l10n: 'nav.appearance',
|
||||
path: '/admin',
|
||||
icon: 'mdi-palette-outline',
|
||||
priority: 60,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default integrations;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import routes from '@/routes'
|
||||
import integrations from '@/integrations'
|
||||
|
||||
// The themer module is UI only: it reads/writes the appearance settings
|
||||
// (`theme_*` system keys plus its own `themer_*` selection state). Applying
|
||||
// those settings is a core system function (@KTXC useThemeStore), so there is
|
||||
// no install/boot step here.
|
||||
|
||||
// Replaced with the hashed stylesheet path at build time (vite.config.ts)
|
||||
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
||||
|
||||
// Module ships translation catalogs at static/l10n/{locale}.json
|
||||
export const l10n = true
|
||||
|
||||
export { routes, integrations }
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
import type { ThemerPreset } from '@/types'
|
||||
|
||||
export const DEFAULT_PRESET_ID = 'default'
|
||||
export const DEFAULT_FONT = 'Public Sans'
|
||||
|
||||
/**
|
||||
* Preset palettes are sparse overrides: any token not listed falls back to the
|
||||
* app-default palette (captured by the themer store at boot). Curated presets
|
||||
* define their own `on-*` accents where the default white foreground would not
|
||||
* hold enough contrast.
|
||||
*/
|
||||
export const PRESETS: ThemerPreset[] = [
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Indigo',
|
||||
l10n: 'presets.default',
|
||||
light: { colors: {} },
|
||||
dark: { colors: {} },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'emerald',
|
||||
label: 'Emerald',
|
||||
l10n: 'presets.emerald',
|
||||
light: {
|
||||
colors: {
|
||||
'primary': '#059669',
|
||||
'primary-darken-1': '#047857',
|
||||
'primary-lighten-1': '#34D399',
|
||||
'secondary': '#0D9488',
|
||||
'secondary-darken-1': '#0F766E',
|
||||
'secondary-lighten-1': '#2DD4BF',
|
||||
'background': '#F4FAF7',
|
||||
'surface-variant': '#EBF4EF',
|
||||
'grey-lighten-4': '#EBF4EF',
|
||||
'grey-lighten-5': '#F4FAF7',
|
||||
'track-bg': '#EBF4EF',
|
||||
},
|
||||
variables: { 'border-color': '#DCE8E2' },
|
||||
},
|
||||
dark: {
|
||||
colors: {
|
||||
'primary': '#34D399',
|
||||
'on-primary': '#052E1C',
|
||||
'primary-darken-1': '#10B981',
|
||||
'primary-lighten-1': '#6EE7B7',
|
||||
'secondary': '#2DD4BF',
|
||||
'on-secondary': '#042F2E',
|
||||
'secondary-darken-1': '#14B8A6',
|
||||
'secondary-lighten-1': '#5EEAD4',
|
||||
'background': '#0B1512',
|
||||
'surface': '#14201B',
|
||||
'surface-bright': '#1E2E27',
|
||||
'surface-variant': '#14201B',
|
||||
'grey-lighten-3': '#24352E',
|
||||
'grey-lighten-4': '#14201B',
|
||||
'grey-lighten-5': '#0B1512',
|
||||
'perfect-scrollbar-thumb': '#2F443B',
|
||||
'track-bg': '#1E2E27',
|
||||
},
|
||||
variables: { 'border-color': '#24352E' },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'rose',
|
||||
label: 'Rose',
|
||||
l10n: 'presets.rose',
|
||||
light: {
|
||||
colors: {
|
||||
'primary': '#E11D48',
|
||||
'primary-darken-1': '#BE123C',
|
||||
'primary-lighten-1': '#FB7185',
|
||||
'secondary': '#DB2777',
|
||||
'secondary-darken-1': '#BE185D',
|
||||
'secondary-lighten-1': '#F472B6',
|
||||
'background': '#FDF7F8',
|
||||
'surface-variant': '#FBEFF2',
|
||||
'grey-lighten-4': '#FBEFF2',
|
||||
'grey-lighten-5': '#FDF7F8',
|
||||
'track-bg': '#FBEFF2',
|
||||
},
|
||||
variables: { 'border-color': '#F1DFE4' },
|
||||
},
|
||||
dark: {
|
||||
colors: {
|
||||
'primary': '#FB7185',
|
||||
'on-primary': '#40060F',
|
||||
'primary-darken-1': '#F43F5E',
|
||||
'primary-lighten-1': '#FDA4AF',
|
||||
'secondary': '#F472B6',
|
||||
'on-secondary': '#3E0524',
|
||||
'secondary-darken-1': '#EC4899',
|
||||
'secondary-lighten-1': '#F9A8D4',
|
||||
'background': '#171013',
|
||||
'surface': '#231A1E',
|
||||
'surface-bright': '#32232A',
|
||||
'surface-variant': '#231A1E',
|
||||
'grey-lighten-3': '#3A2A31',
|
||||
'grey-lighten-4': '#231A1E',
|
||||
'grey-lighten-5': '#171013',
|
||||
'perfect-scrollbar-thumb': '#4C323A',
|
||||
'track-bg': '#32232A',
|
||||
},
|
||||
variables: { 'border-color': '#3A2A31' },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'amber',
|
||||
label: 'Amber',
|
||||
l10n: 'presets.amber',
|
||||
light: {
|
||||
colors: {
|
||||
'primary': '#D97706',
|
||||
'primary-darken-1': '#B45309',
|
||||
'primary-lighten-1': '#F59E0B',
|
||||
'secondary': '#EA580C',
|
||||
'secondary-darken-1': '#C2410C',
|
||||
'secondary-lighten-1': '#FB923C',
|
||||
'warning': '#EAB308',
|
||||
'background': '#FCFAF5',
|
||||
'surface-variant': '#F8F1E7',
|
||||
'grey-lighten-4': '#F8F1E7',
|
||||
'grey-lighten-5': '#FCFAF5',
|
||||
'track-bg': '#F8F1E7',
|
||||
},
|
||||
variables: { 'border-color': '#EDE3D0' },
|
||||
},
|
||||
dark: {
|
||||
colors: {
|
||||
'primary': '#FBBF24',
|
||||
'on-primary': '#422006',
|
||||
'primary-darken-1': '#F59E0B',
|
||||
'primary-lighten-1': '#FCD34D',
|
||||
'secondary': '#FB923C',
|
||||
'on-secondary': '#431407',
|
||||
'secondary-darken-1': '#F97316',
|
||||
'secondary-lighten-1': '#FDBA74',
|
||||
'warning': '#FACC15',
|
||||
'background': '#15120B',
|
||||
'surface': '#211C10',
|
||||
'surface-bright': '#2E2718',
|
||||
'surface-variant': '#211C10',
|
||||
'grey-lighten-3': '#383018',
|
||||
'grey-lighten-4': '#211C10',
|
||||
'grey-lighten-5': '#15120B',
|
||||
'perfect-scrollbar-thumb': '#4A3F21',
|
||||
'track-bg': '#2E2718',
|
||||
},
|
||||
variables: { 'border-color': '#383018' },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'ocean',
|
||||
label: 'Ocean',
|
||||
l10n: 'presets.ocean',
|
||||
light: {
|
||||
colors: {
|
||||
'primary': '#0284C7',
|
||||
'primary-darken-1': '#0369A1',
|
||||
'primary-lighten-1': '#38BDF8',
|
||||
'secondary': '#0891B2',
|
||||
'secondary-darken-1': '#0E7490',
|
||||
'secondary-lighten-1': '#22D3EE',
|
||||
'background': '#F4F9FC',
|
||||
'surface-variant': '#E9F3F9',
|
||||
'grey-lighten-4': '#E9F3F9',
|
||||
'grey-lighten-5': '#F4F9FC',
|
||||
'track-bg': '#E9F3F9',
|
||||
},
|
||||
variables: { 'border-color': '#DBE9F1' },
|
||||
},
|
||||
dark: {
|
||||
colors: {
|
||||
'primary': '#38BDF8',
|
||||
'on-primary': '#082F49',
|
||||
'primary-darken-1': '#0EA5E9',
|
||||
'primary-lighten-1': '#7DD3FC',
|
||||
'secondary': '#22D3EE',
|
||||
'on-secondary': '#083344',
|
||||
'secondary-darken-1': '#06B6D4',
|
||||
'secondary-lighten-1': '#67E8F9',
|
||||
'background': '#0B141B',
|
||||
'surface': '#132029',
|
||||
'surface-bright': '#1C2E3A',
|
||||
'surface-variant': '#132029',
|
||||
'grey-lighten-3': '#223648',
|
||||
'grey-lighten-4': '#132029',
|
||||
'grey-lighten-5': '#0B141B',
|
||||
'perfect-scrollbar-thumb': '#2E4557',
|
||||
'track-bg': '#1C2E3A',
|
||||
},
|
||||
variables: { 'border-color': '#223648' },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'graphite',
|
||||
label: 'Graphite',
|
||||
l10n: 'presets.graphite',
|
||||
light: {
|
||||
colors: {
|
||||
'primary': '#334155',
|
||||
'primary-darken-1': '#1E293B',
|
||||
'primary-lighten-1': '#64748B',
|
||||
'secondary': '#64748B',
|
||||
'secondary-darken-1': '#475569',
|
||||
'secondary-lighten-1': '#94A3B8',
|
||||
'background': '#F6F7F9',
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
colors: {
|
||||
'primary': '#94A3B8',
|
||||
'on-primary': '#0F172A',
|
||||
'primary-darken-1': '#64748B',
|
||||
'primary-lighten-1': '#CBD5E1',
|
||||
'secondary': '#CBD5E1',
|
||||
'on-secondary': '#0F172A',
|
||||
'secondary-darken-1': '#94A3B8',
|
||||
'secondary-lighten-1': '#E2E8F0',
|
||||
'background': '#0B0F17',
|
||||
'surface': '#151A23',
|
||||
'surface-bright': '#232A36',
|
||||
'surface-variant': '#151A23',
|
||||
'grey-lighten-3': '#2A3240',
|
||||
'grey-lighten-4': '#151A23',
|
||||
'grey-lighten-5': '#0B0F17',
|
||||
'perfect-scrollbar-thumb': '#3A4353',
|
||||
'track-bg': '#232A36',
|
||||
},
|
||||
variables: { 'border-color': '#2A3240' },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function getPreset(id: string | null | undefined): ThemerPreset | undefined {
|
||||
if (!id) return undefined
|
||||
return PRESETS.find(preset => preset.id === id)
|
||||
}
|
||||
|
||||
/** Curated font choices; only Public Sans is bundled, the rest are web-safe stacks. */
|
||||
export const FONT_OPTIONS = [
|
||||
'Public Sans',
|
||||
'Arial',
|
||||
'Verdana',
|
||||
'Tahoma',
|
||||
'Trebuchet MS',
|
||||
'Georgia',
|
||||
'Times New Roman',
|
||||
'Courier New',
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
const routes = [
|
||||
{
|
||||
name: 'settings',
|
||||
path: '/settings',
|
||||
component: () => import('@/views/ThemerSettingsPage.vue'),
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'customize',
|
||||
path: '/settings/customize',
|
||||
component: () => import('@/views/ThemerCustomizePage.vue'),
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'admin',
|
||||
path: '/admin',
|
||||
component: () => import('@/views/ThemerAdminPage.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
permission: 'tenant.settings.update'
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
export default routes
|
||||
@@ -0,0 +1,219 @@
|
||||
import { computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useUserStore, useTenantStore, useLayoutStore, useThemeStore, DEFAULT_FONT, encodePaletteSet } from '@KTXC'
|
||||
import type { ThemePaletteSet } from '@KTXC'
|
||||
import { getPreset, DEFAULT_PRESET_ID, PRESETS } from '@/presets'
|
||||
import type { ThemerCustomTheme, ThemerMode, ThemerPreset, ThemerVariant } from '@/types'
|
||||
|
||||
/** Tokens exposed in the custom-theme editor */
|
||||
export const EDITABLE_TOKENS = [
|
||||
'primary',
|
||||
'secondary',
|
||||
'background',
|
||||
'surface',
|
||||
'error',
|
||||
'warning',
|
||||
'info',
|
||||
'success',
|
||||
] as const
|
||||
|
||||
export const PREVIEW_THEME_NAMES: Record<ThemerVariant, string> = {
|
||||
light: 'themer-preview-light',
|
||||
dark: 'themer-preview-dark',
|
||||
}
|
||||
|
||||
const VARIANTS: ThemerVariant[] = ['light', 'dark']
|
||||
|
||||
/**
|
||||
* UI-side store of the themer module.
|
||||
*
|
||||
* Theming itself is a system function: the core theme store (@KTXC
|
||||
* useThemeStore) applies the `theme_*` settings. This store only translates
|
||||
* UI intents (pick preset, save custom theme, tenant defaults) into settings
|
||||
* writes, and keeps the module's own selection state (`themer_preset`,
|
||||
* `themer_custom`, `themer_default_preset`) alongside.
|
||||
*/
|
||||
export const useThemerStore = defineStore('themer', () => {
|
||||
const userStore = useUserStore()
|
||||
const tenantStore = useTenantStore()
|
||||
const layoutStore = useLayoutStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// ===========================================================================
|
||||
// State
|
||||
// ===========================================================================
|
||||
|
||||
const isLocked = computed(() => themeStore.isLocked)
|
||||
const mode = computed<ThemerMode>(() => layoutStore.theme as ThemerMode)
|
||||
const userPresetId = computed(() => userStore.getSetting('themer_preset') as string | null)
|
||||
const customTheme = computed<ThemerCustomTheme | null>(() => {
|
||||
// Persisted as a JSON string: settings values must stay scalar (nested
|
||||
// objects are flattened into dot-notation keys by the settings store)
|
||||
const raw = userStore.getSetting('themer_custom')
|
||||
if (!raw || typeof raw !== 'string') return null
|
||||
try {
|
||||
return JSON.parse(raw) as ThemerCustomTheme
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
const font = computed<string>(() => themeStore.effectiveFont)
|
||||
const tenantPresetId = computed(() => tenantStore.getSetting('themer_default_preset') as string | null)
|
||||
const tenantMode = computed(() => tenantStore.getSetting('theme_default_mode') as ThemerMode | null)
|
||||
|
||||
/** The selection shown as active in the gallery, after lock + fallback rules */
|
||||
const activePresetId = computed<string>(() => {
|
||||
if (isLocked.value) {
|
||||
return tenantPresetId.value ?? DEFAULT_PRESET_ID
|
||||
}
|
||||
const chosen = userPresetId.value ?? tenantPresetId.value ?? DEFAULT_PRESET_ID
|
||||
if (chosen === 'custom' && !customTheme.value) return DEFAULT_PRESET_ID
|
||||
return chosen
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers
|
||||
// ===========================================================================
|
||||
|
||||
function paletteSetOf(source: ThemerPreset | ThemerCustomTheme): ThemePaletteSet {
|
||||
// Deep copy so a plain JSON object is persisted
|
||||
return JSON.parse(JSON.stringify({ light: source.light, dark: source.dark }))
|
||||
}
|
||||
|
||||
/** Resolve a token for display (e.g. gallery swatches): source value or app default */
|
||||
function resolveToken(source: ThemerPreset | ThemerCustomTheme, variant: ThemerVariant, token: string): string {
|
||||
return source[variant].colors[token]
|
||||
?? (themeStore.defaults[variant].colors as Record<string, string>)[token]
|
||||
?? '#000000'
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// User actions
|
||||
// ===========================================================================
|
||||
|
||||
function setMode(value: ThemerMode): void {
|
||||
layoutStore.setTheme(value)
|
||||
}
|
||||
|
||||
function setPreset(id: string): void {
|
||||
if (isLocked.value) return
|
||||
if (id === 'custom') {
|
||||
if (!customTheme.value) return
|
||||
userStore.setSetting('themer_preset', 'custom')
|
||||
userStore.setSetting('theme_palette', encodePaletteSet(paletteSetOf(customTheme.value)))
|
||||
return
|
||||
}
|
||||
const preset = getPreset(id)
|
||||
if (!preset) return
|
||||
userStore.setSetting('themer_preset', preset.id)
|
||||
userStore.setSetting(
|
||||
'theme_palette',
|
||||
preset.id === DEFAULT_PRESET_ID ? null : encodePaletteSet(paletteSetOf(preset)),
|
||||
)
|
||||
}
|
||||
|
||||
function setFont(value: string | null): void {
|
||||
userStore.setSetting('theme_font', !value || value === DEFAULT_FONT ? null : value)
|
||||
}
|
||||
|
||||
function saveCustom(draft: ThemerCustomTheme): void {
|
||||
const custom: ThemerCustomTheme = JSON.parse(JSON.stringify(draft))
|
||||
userStore.setSetting('themer_custom', JSON.stringify(custom))
|
||||
userStore.setSetting('themer_preset', 'custom')
|
||||
userStore.setSetting('theme_palette', encodePaletteSet(paletteSetOf(custom)))
|
||||
}
|
||||
|
||||
function clearCustom(): void {
|
||||
userStore.setSetting('themer_custom', null)
|
||||
if (userPresetId.value === 'custom') {
|
||||
userStore.setSetting('themer_preset', null)
|
||||
userStore.setSetting('theme_palette', null)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tenant (admin) actions — server enforces tenant.settings.update
|
||||
// ===========================================================================
|
||||
|
||||
function setTenantDefaultPreset(id: string | null): void {
|
||||
const preset = getPreset(id)
|
||||
tenantStore.setSetting('themer_default_preset', preset?.id ?? null)
|
||||
tenantStore.setSetting(
|
||||
'theme_palette',
|
||||
preset && preset.id !== DEFAULT_PRESET_ID ? encodePaletteSet(paletteSetOf(preset)) : null,
|
||||
)
|
||||
}
|
||||
|
||||
function setTenantDefaultMode(value: ThemerMode | null): void {
|
||||
tenantStore.setSetting('theme_default_mode', value)
|
||||
}
|
||||
|
||||
function setTenantLock(locked: boolean): void {
|
||||
tenantStore.setSetting('theme_lock', locked)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Editor support
|
||||
// ===========================================================================
|
||||
|
||||
/** Full editable draft derived from a preset (or the stored custom theme) */
|
||||
function createDraft(baseId: string): ThemerCustomTheme {
|
||||
const stored = customTheme.value
|
||||
if (baseId === 'custom' && stored) {
|
||||
return JSON.parse(JSON.stringify(stored))
|
||||
}
|
||||
const preset = getPreset(baseId) ?? getPreset(DEFAULT_PRESET_ID)!
|
||||
const variantDraft = (variant: ThemerVariant) => {
|
||||
const colors: Record<string, string> = { ...preset[variant].colors }
|
||||
for (const token of EDITABLE_TOKENS) {
|
||||
colors[token] = resolveToken(preset, variant, token)
|
||||
}
|
||||
return { colors, variables: { ...(preset[variant].variables ?? {}) } }
|
||||
}
|
||||
return { base: preset.id, light: variantDraft('light'), dark: variantDraft('dark') }
|
||||
}
|
||||
|
||||
/** Register/update the throwaway preview themes used by the editor */
|
||||
function updatePreviewThemes(draft: ThemerCustomTheme): void {
|
||||
for (const variant of VARIANTS) {
|
||||
themeStore.setPreviewTheme(PREVIEW_THEME_NAMES[variant], variant, draft[variant])
|
||||
}
|
||||
}
|
||||
|
||||
function removePreviewThemes(): void {
|
||||
for (const variant of VARIANTS) {
|
||||
themeStore.removePreviewTheme(PREVIEW_THEME_NAMES[variant])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
presets: PRESETS,
|
||||
isLocked,
|
||||
mode,
|
||||
font,
|
||||
userPresetId,
|
||||
customTheme,
|
||||
tenantPresetId,
|
||||
tenantMode,
|
||||
activePresetId,
|
||||
|
||||
// User actions
|
||||
setMode,
|
||||
setPreset,
|
||||
setFont,
|
||||
saveCustom,
|
||||
clearCustom,
|
||||
|
||||
// Tenant actions
|
||||
setTenantDefaultPreset,
|
||||
setTenantDefaultMode,
|
||||
setTenantLock,
|
||||
|
||||
// Editor support
|
||||
resolveToken,
|
||||
createDraft,
|
||||
updatePreviewThemes,
|
||||
removePreviewThemes,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ThemePalette } from '@KTXC'
|
||||
|
||||
export type ThemerVariant = 'light' | 'dark'
|
||||
export type ThemerMode = 'light' | 'dark' | 'system'
|
||||
|
||||
export interface ThemerPreset {
|
||||
id: string
|
||||
label: string
|
||||
/** Catalog key within the themer namespace, e.g. 'presets.emerald' */
|
||||
l10n?: string
|
||||
font?: string
|
||||
/** Sparse overrides over the app-default palettes */
|
||||
light: ThemePalette
|
||||
dark: ThemePalette
|
||||
}
|
||||
|
||||
/** The single user-customized theme slot, persisted as the `themer_custom` user setting */
|
||||
export interface ThemerCustomTheme {
|
||||
/** Preset id the custom theme was derived from */
|
||||
base: string
|
||||
light: ThemePalette
|
||||
dark: ThemePalette
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useL10n, useUserStore } from '@KTXC'
|
||||
import { useThemerStore } from '@/stores/themerStore'
|
||||
import ModeSelector from '@/components/ModeSelector.vue'
|
||||
import PresetCard from '@/components/PresetCard.vue'
|
||||
import { DEFAULT_PRESET_ID } from '@/presets'
|
||||
|
||||
const { t } = useL10n('themer')
|
||||
const userStore = useUserStore()
|
||||
const themerStore = useThemerStore()
|
||||
|
||||
// Server-side the write is enforced by tenant.settings.update; this guard
|
||||
// only keeps the page from rendering a form that would be rejected.
|
||||
const canManage = computed(() => userStore.hasPermission('tenant.settings.update'))
|
||||
|
||||
const selectedPresetId = computed(() => themerStore.tenantPresetId ?? DEFAULT_PRESET_ID)
|
||||
|
||||
function selectPreset(id: string): void {
|
||||
themerStore.setTenantDefaultPreset(id === DEFAULT_PRESET_ID ? null : id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PerfectScrollbar
|
||||
class="pa-4"
|
||||
style="height: calc(100vh - 64px);"
|
||||
:options="{ wheelPropagation: false }"
|
||||
>
|
||||
<h5 class="text-h5 mb-4">{{ t('admin.title', 'Appearance') }}</h5>
|
||||
|
||||
<VAlert
|
||||
v-if="!canManage"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
:text="t('admin.noPermission', 'You do not have permission to manage tenant appearance settings.')"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VCard :title="t('admin.modeTitle', 'Default mode')">
|
||||
<VCardText>
|
||||
<ModeSelector
|
||||
:model-value="themerStore.tenantMode ?? 'light'"
|
||||
@update:model-value="themerStore.setTenantDefaultMode"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mt-3 mb-0">
|
||||
{{ t('admin.modeHint', 'Initial mode for users who have not chosen one themselves.') }}
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VCard :title="t('admin.lockTitle', 'Enforcement')">
|
||||
<VCardText>
|
||||
<VSwitch
|
||||
:model-value="themerStore.isLocked"
|
||||
color="primary"
|
||||
:label="t('admin.lockLabel', 'Lock users to the tenant theme')"
|
||||
hide-details
|
||||
@update:model-value="themerStore.setTenantLock($event === true)"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mt-3 mb-0">
|
||||
{{ t('admin.lockHint', 'When locked, users keep their light/dark/system choice but cannot change colors or font.') }}
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<h6 class="text-h6 mt-6 mb-3">{{ t('admin.presetsTitle', 'Default theme') }}</h6>
|
||||
|
||||
<VRow>
|
||||
<VCol
|
||||
v-for="preset in themerStore.presets"
|
||||
:key="preset.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<PresetCard
|
||||
:preset="preset"
|
||||
:active="selectedPresetId === preset.id"
|
||||
@select="selectPreset"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
</PerfectScrollbar>
|
||||
</template>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useL10n, useSnackbar } from '@KTXC'
|
||||
import { useThemerStore, EDITABLE_TOKENS, PREVIEW_THEME_NAMES } from '@/stores/themerStore'
|
||||
import ColorField from '@/components/ColorField.vue'
|
||||
import ThemePreview from '@/components/ThemePreview.vue'
|
||||
import type { ThemerCustomTheme, ThemerVariant } from '@/types'
|
||||
|
||||
const { t } = useL10n('themer')
|
||||
const router = useRouter()
|
||||
const { showSnackbar } = useSnackbar()
|
||||
const themerStore = useThemerStore()
|
||||
|
||||
const CORE_TOKENS: ReadonlyArray<(typeof EDITABLE_TOKENS)[number]> = ['primary', 'secondary']
|
||||
const EXTENDED_TOKENS: ReadonlyArray<(typeof EDITABLE_TOKENS)[number]> =
|
||||
['background', 'surface', 'error', 'warning', 'info', 'success']
|
||||
|
||||
const TOKEN_LABELS: Record<string, { l10n: string; label: string }> = {
|
||||
primary: { l10n: 'tokens.primary', label: 'Primary' },
|
||||
secondary: { l10n: 'tokens.secondary', label: 'Secondary' },
|
||||
background: { l10n: 'tokens.background', label: 'Background' },
|
||||
surface: { l10n: 'tokens.surface', label: 'Surface' },
|
||||
error: { l10n: 'tokens.error', label: 'Error' },
|
||||
warning: { l10n: 'tokens.warning', label: 'Warning' },
|
||||
info: { l10n: 'tokens.info', label: 'Info' },
|
||||
success: { l10n: 'tokens.success', label: 'Success' },
|
||||
}
|
||||
|
||||
// Start from the stored custom theme when one exists, else the active preset
|
||||
const initialBase = themerStore.customTheme ? 'custom' : themerStore.activePresetId
|
||||
const baseId = ref(initialBase === 'custom' ? themerStore.customTheme!.base : initialBase)
|
||||
const draft = reactive<ThemerCustomTheme>(themerStore.createDraft(initialBase))
|
||||
const activeVariant = ref<ThemerVariant>('light')
|
||||
|
||||
const previewThemeName = computed(() => PREVIEW_THEME_NAMES[activeVariant.value])
|
||||
|
||||
const baseOptions = computed(() =>
|
||||
themerStore.presets.map(preset => ({ value: preset.id, title: preset.label })),
|
||||
)
|
||||
|
||||
function rebuildFromBase(id: string): void {
|
||||
const fresh = themerStore.createDraft(id)
|
||||
draft.base = fresh.base
|
||||
draft.light = fresh.light
|
||||
draft.dark = fresh.dark
|
||||
}
|
||||
|
||||
function setToken(variant: ThemerVariant, token: string, value: string): void {
|
||||
draft[variant].colors[token] = value
|
||||
// The edited token's stale foreground pair would break contrast; dropping
|
||||
// it makes the system theming engine regenerate it.
|
||||
delete draft[variant].colors[`on-${token}`]
|
||||
}
|
||||
|
||||
watch(draft, () => themerStore.updatePreviewThemes(draft), { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
themerStore.removePreviewThemes()
|
||||
})
|
||||
|
||||
function save(): void {
|
||||
themerStore.saveCustom(draft)
|
||||
showSnackbar({ message: t('editor.saved', 'Custom theme saved and applied'), color: 'success' })
|
||||
router.push({ name: 'themer.settings' })
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
router.push({ name: 'themer.settings' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PerfectScrollbar
|
||||
class="pa-4"
|
||||
style="height: calc(100vh - 64px);"
|
||||
:options="{ wheelPropagation: false }"
|
||||
>
|
||||
<div class="d-flex align-center mb-4">
|
||||
<VBtn icon="mdi-arrow-left" variant="text" size="small" class="mr-2" @click="cancel" />
|
||||
<h5 class="text-h5 mb-0">{{ t('editor.title', 'Customize Theme') }}</h5>
|
||||
</div>
|
||||
|
||||
<VAlert
|
||||
v-if="themerStore.isLocked"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
:text="t('editor.lockedNotice', 'Theme selection is managed by your administrator; a saved custom theme will not be applied while the lock is active.')"
|
||||
/>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="7">
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VSelect
|
||||
v-model="baseId"
|
||||
:items="baseOptions"
|
||||
:label="t('editor.baseLabel', 'Start from preset')"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
@update:model-value="rebuildFromBase"
|
||||
/>
|
||||
|
||||
<VTabs v-model="activeVariant" class="v-tabs-pill mb-4">
|
||||
<VTab value="light">
|
||||
<VIcon size="18" start>mdi-weather-sunny</VIcon>
|
||||
{{ t('editor.lightVariant', 'Light') }}
|
||||
</VTab>
|
||||
<VTab value="dark">
|
||||
<VIcon size="18" start>mdi-weather-night</VIcon>
|
||||
{{ t('editor.darkVariant', 'Dark') }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<p class="text-subtitle-2 mb-2">{{ t('editor.coreColors', 'Core colors') }}</p>
|
||||
<VRow dense>
|
||||
<VCol v-for="token in CORE_TOKENS" :key="token" cols="12" sm="6">
|
||||
<ColorField
|
||||
:label="t(TOKEN_LABELS[token].l10n, TOKEN_LABELS[token].label)"
|
||||
:model-value="draft[activeVariant].colors[token]"
|
||||
@update:model-value="setToken(activeVariant, token, $event)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<p class="text-subtitle-2 mb-2 mt-4">{{ t('editor.extendedColors', 'Extended palette') }}</p>
|
||||
<VRow dense>
|
||||
<VCol v-for="token in EXTENDED_TOKENS" :key="token" cols="12" sm="6">
|
||||
<ColorField
|
||||
:label="t(TOKEN_LABELS[token].l10n, TOKEN_LABELS[token].label)"
|
||||
:model-value="draft[activeVariant].colors[token]"
|
||||
@update:model-value="setToken(activeVariant, token, $event)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="px-4 pb-4">
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" @click="save">
|
||||
{{ t('editor.save', 'Save & Apply') }}
|
||||
</VBtn>
|
||||
<VBtn variant="text" @click="rebuildFromBase(baseId)">
|
||||
{{ t('editor.reset', 'Reset to preset') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="cancel">
|
||||
{{ t('editor.cancel', 'Cancel') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="5">
|
||||
<ThemePreview :theme-name="previewThemeName" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</PerfectScrollbar>
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useL10n } from '@KTXC'
|
||||
import { useThemerStore } from '@/stores/themerStore'
|
||||
import ModeSelector from '@/components/ModeSelector.vue'
|
||||
import FontSelect from '@/components/FontSelect.vue'
|
||||
import PresetCard from '@/components/PresetCard.vue'
|
||||
import type { ThemerPreset } from '@/types'
|
||||
|
||||
const { t } = useL10n('themer')
|
||||
const router = useRouter()
|
||||
const themerStore = useThemerStore()
|
||||
|
||||
const isLocked = computed(() => themerStore.isLocked)
|
||||
|
||||
// The stored custom theme rendered as a pseudo-preset for the gallery card
|
||||
const customPreset = computed<ThemerPreset | null>(() => {
|
||||
const custom = themerStore.customTheme
|
||||
if (!custom) return null
|
||||
return {
|
||||
id: 'custom',
|
||||
label: 'Custom',
|
||||
l10n: 'presets.custom',
|
||||
light: custom.light,
|
||||
dark: custom.dark,
|
||||
}
|
||||
})
|
||||
|
||||
function openEditor(): void {
|
||||
router.push({ name: 'themer.customize' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PerfectScrollbar
|
||||
class="pa-4"
|
||||
style="height: calc(100vh - 64px);"
|
||||
:options="{ wheelPropagation: false }"
|
||||
>
|
||||
<h5 class="text-h5 mb-4">{{ t('settings.title', 'Appearance') }}</h5>
|
||||
|
||||
<VAlert
|
||||
v-if="isLocked"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
:text="t('settings.lockedNotice', 'Theme selection is managed by your administrator. You can still choose your preferred mode.')"
|
||||
/>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VCard :title="t('settings.modeTitle', 'Mode')">
|
||||
<VCardText>
|
||||
<ModeSelector
|
||||
:model-value="themerStore.mode"
|
||||
@update:model-value="themerStore.setMode"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mt-3 mb-0">
|
||||
{{ t('settings.modeHint', 'System follows your operating system preference.') }}
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VCard :title="t('settings.fontTitle', 'Font')">
|
||||
<VCardText>
|
||||
<FontSelect
|
||||
:model-value="themerStore.font"
|
||||
:disabled="isLocked"
|
||||
@update:model-value="themerStore.setFont"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div class="d-flex align-center mt-6 mb-3">
|
||||
<h6 class="text-h6">{{ t('settings.presetsTitle', 'Themes') }}</h6>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="customPreset"
|
||||
variant="text"
|
||||
color="error"
|
||||
prepend-icon="mdi-delete-outline"
|
||||
class="mr-2"
|
||||
:disabled="isLocked"
|
||||
@click="themerStore.clearCustom"
|
||||
>
|
||||
{{ t('settings.deleteCustom', 'Delete Custom Theme') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-palette-advanced"
|
||||
:disabled="isLocked"
|
||||
@click="openEditor"
|
||||
>
|
||||
{{ customPreset ? t('settings.editCustom', 'Edit Custom Theme') : t('settings.createCustom', 'Customize') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol
|
||||
v-for="preset in themerStore.presets"
|
||||
:key="preset.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<PresetCard
|
||||
:preset="preset"
|
||||
:active="themerStore.activePresetId === preset.id"
|
||||
:disabled="isLocked"
|
||||
@select="themerStore.setPreset"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="customPreset" cols="12" sm="6" md="4" lg="3">
|
||||
<PresetCard
|
||||
:preset="customPreset"
|
||||
:active="themerStore.activePresetId === 'custom'"
|
||||
:disabled="isLocked"
|
||||
@select="themerStore.setPreset"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</PerfectScrollbar>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@KTXC/*": ["../../core/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineConfig, type PluginOption } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
// Copy translation catalogs (l10n/*.json, except the extractor-only
|
||||
// en.manual.json) into static/l10n/ so the core shell can fetch them at
|
||||
// /modules/{handle}/static/l10n/{locale}.json
|
||||
const copyL10nCatalogs = (): PluginOption => ({
|
||||
name: 'copy-l10n-catalogs',
|
||||
apply: 'build',
|
||||
closeBundle() {
|
||||
const sourceDir = path.resolve(__dirname, 'l10n')
|
||||
const outDir = path.resolve(__dirname, 'static/l10n')
|
||||
if (!fs.existsSync(sourceDir)) return
|
||||
fs.mkdirSync(outDir, { recursive: true })
|
||||
for (const file of fs.readdirSync(sourceDir)) {
|
||||
if (file.endsWith('.json') && !file.endsWith('.manual.json')) {
|
||||
fs.copyFileSync(path.join(sourceDir, file), path.join(outDir, file))
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Rewrite the __CSS_FILENAME_PLACEHOLDER__ export in main.ts with the actual
|
||||
// hashed stylesheet name, so the core loader can inject it
|
||||
const injectCssFilename = (): PluginOption => ({
|
||||
name: 'inject-css-filename',
|
||||
enforce: 'post',
|
||||
generateBundle(_options, bundle) {
|
||||
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
|
||||
if (!cssFile) return
|
||||
for (const fileName of Object.keys(bundle)) {
|
||||
const chunk = bundle[fileName]
|
||||
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
|
||||
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), injectCssFilename(), copyL10nCatalogs()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@KTXC': path.resolve(__dirname, '../../core/src')
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'static',
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/main.ts'),
|
||||
formats: ['es'],
|
||||
fileName: () => 'module.mjs',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['vue', 'vue-router', 'pinia', '@KTXC'],
|
||||
output: {
|
||||
paths: (id) => (id === '@KTXC' ? '/js/ktxc.mjs' : id),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user