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