Files
openobserve/web/src/components/PredefinedThemes.vue
T

528 lines
20 KiB
Vue

<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<ODrawer
data-test="predefined-themes-drawer"
v-model:open="dialogOpen"
size="sm"
seamless
:title="t('components.predefinedThemes.title')"
>
<template #header-right>
<OButton
data-test="predefined-themes-reset-btn"
variant="ghost-destructive"
size="xs"
@click="resetToDefaultTheme"
>
<template #icon-left><OIcon name="refresh" size="xs" /></template>
{{ t("common.reset") }}
</OButton>
</template>
<!-- Light / Dark segmented mode toggle -->
<OCardSection class="px-2 pt-2">
<OToggleGroup
:model-value="activeTab"
type="single"
class="w-full"
@update:model-value="(v) => (activeTab = v as string)"
>
<OToggleGroupItem
data-test="predefined-themes-tab-light"
value="light"
icon-left="light-mode"
class="flex-1"
>{{ t("components.predefinedThemes.light") }}</OToggleGroupItem
>
<OToggleGroupItem
data-test="predefined-themes-tab-dark"
value="dark"
icon-left="dark-mode"
class="flex-1"
>{{ t("components.predefinedThemes.dark") }}</OToggleGroupItem
>
</OToggleGroup>
</OCardSection>
<!-- Theme list for the active mode. Selecting a row applies it immediately;
the applied row is highlighted rather than carrying an Apply button. -->
<!-- eslint-disable-next-line local/no-hardcoded-px -- mixed with vh/vw — vh tracks the window while rem tracks font-size; keep the expression unit-consistent -->
<OCardSection class="max-h-[calc(100vh-100px)] overflow-y-auto px-2 py-2">
<ul class="m-0 flex list-none flex-col gap-2 p-0">
<li v-for="theme in predefinedThemes" :key="theme.id">
<button
type="button"
:data-test="`predefined-themes-apply-btn-${mode}-${themeNameSlug(theme.name)}`"
class="rounded-default focus-visible:ring-accent/40 flex w-full cursor-pointer items-center border px-3 py-2 transition-[border-color,background-color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:outline-none"
:class="
isThemeApplied(theme, mode)
? 'border-accent ring-accent bg-card-glass-tint-soft ring-1 ring-inset'
: 'border-card-glass-border bg-card-glass-bg hover:border-accent hover:bg-card-glass-tint-subtle'
"
:aria-pressed="isThemeApplied(theme, mode)"
:aria-label="t('common.applyTheme', { name: themeDisplayName(theme.name) })"
@click="applyTheme(theme, mode)"
>
<span
class="rounded-default border-card-glass-border relative h-8 w-8 shrink-0 border"
:style="swatchStyle(theme[mode])"
/>
<span class="ms-2 min-w-0 flex-1 text-left">
<span class="block truncate text-sm font-medium">{{
themeDisplayName(theme.name)
}}</span>
<span class="text-text-secondary block truncate text-xs">{{
theme[mode].themeColor
}}</span>
</span>
<OTag
v-if="isThemeApplied(theme, mode)"
:data-test="`predefined-themes-applied-badge-${mode}-${themeNameSlug(theme.name)}`"
type="themeApplied"
value="applied"
/>
</button>
</li>
<!-- Custom Color — clicking the row opens the color picker -->
<li>
<button
type="button"
:data-test="`predefined-themes-card-${mode}-custom-color`"
class="rounded-default focus-visible:ring-accent/40 flex w-full cursor-pointer items-center border border-dashed px-3 py-2 transition-[border-color,background-color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:outline-none"
:class="
isCustomThemeApplied(mode)
? 'border-accent ring-accent bg-card-glass-tint-soft ring-1 ring-inset'
: 'border-card-glass-border bg-card-glass-bg hover:border-accent hover:bg-card-glass-tint-subtle'
"
:aria-pressed="isCustomThemeApplied(mode)"
:aria-label="t('components.predefinedThemes.customThemeColorAriaLabel')"
@click="openColorPicker(mode)"
>
<span
:data-test="`predefined-themes-custom-color-preview-${mode}`"
class="rounded-default border-card-glass-border relative h-8 w-8 shrink-0 border"
:style="{ backgroundColor: mode === 'light' ? customLightColor : customDarkColor }"
>
<OIcon
name="colorize"
size="sm"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
/>
</span>
<span class="ms-2 min-w-0 flex-1 text-left">
<span class="block truncate text-sm font-medium">{{
t("components.predefinedThemes.customColor")
}}</span>
<span class="text-text-secondary block truncate text-xs">
{{
isCustomThemeApplied(mode)
? mode === "light"
? customLightColor
: customDarkColor
: t("components.predefinedThemes.pickAnyBrandHex")
}}
</span>
</span>
<OTag
v-if="isCustomThemeApplied(mode)"
:data-test="`predefined-themes-applied-badge-${mode}-custom-color`"
type="themeApplied"
value="applied"
/>
</button>
</li>
</ul>
</OCardSection>
<!-- Note at the bottom -->
<OCardSection class="px-2 pt-0 pb-2">
<OSeparator class="mb-2" />
<div class="text-text-secondary flex items-start gap-1 text-xs italic">
<OIcon name="info-outline" size="xs" class="mt-0.5" />
<span>{{ t("components.predefinedThemes.themeSyncNote") }}</span>
</div>
</OCardSection>
<!-- Color Picker Dialog -->
<ODialog
data-test="predefined-themes-color-picker-dialog"
v-model:open="showColorPicker"
size="sm"
:title="t('components.predefinedThemes.pickCustomColorTitle')"
:primary-button-label="t('common.apply')"
:neutral-button-label="t('common.cancel')"
@click:primary="confirmCustomColor"
@click:neutral="showColorPicker = false"
>
<OColor v-model="tempColor" @update:model-value="updateCustomColor" />
</ODialog>
</ODrawer>
</template>
<script setup lang="ts">
import OCardSection from "@/lib/core/Card/OCardSection.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { usePredefinedThemes } from "@/composables/usePredefinedThemes";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OColor from "@/lib/forms/Color/OColor.vue";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import { useStore } from "vuex";
import { useTheme } from "@/composables/useTheme";
import { applyThemeColors, switchThemeMode } from "@/utils/theme";
import { applyThemeForMode, applyCurrentTheme } from "@/utils/themeManager";
import {
PREDEFINED_THEMES,
CUSTOM_THEME_NAME,
THEME_STORAGE_KEYS,
getDefaultTheme,
themeNameSlug,
themeDisplayName,
type PredefinedTheme,
type ThemeModeColors,
} from "@/constants/themes";
import { toast } from "@/lib/feedback/Toast/useToast";
import { useI18nTyped } from "@/types/i18n";
const { t } = useI18nTyped();
const store = useStore();
const { isDark } = useTheme();
const { isOpen } = usePredefinedThemes();
const dialogOpen = ref(false);
const activeTab = ref("light");
// Strongly-typed mode derived from the toggle selection — used for indexing
// theme color maps and for the mode-aware data-test/handler calls.
const mode = computed<"light" | "dark">(() => (activeTab.value === "dark" ? "dark" : "light"));
// Predefined themes list (used by the template) — sourced from the shared registry.
const predefinedThemes = PREDEFINED_THEMES;
// Default colors come from the registry default theme (O2 Signature).
const DEFAULT_LIGHT_COLOR = getDefaultTheme().light.themeColor;
const DEFAULT_DARK_COLOR = getDefaultTheme().dark.themeColor;
// Track the SELECTED theme by NAME for each mode.
// Holds a predefined theme name, CUSTOM_THEME_NAME for a custom color, or null
// when nothing has been explicitly selected (resolution falls back to default).
const appliedLightThemeName = ref<string | null>(
localStorage.getItem(THEME_STORAGE_KEYS.light.appliedName),
);
const appliedDarkThemeName = ref<string | null>(
localStorage.getItem(THEME_STORAGE_KEYS.dark.appliedName),
);
// Custom color state for the color picker in the PredefinedThemes dialog.
// Source of truth for the custom theme; for other themes it is a render-cache.
// Priority order (highest to lowest):
// 1. Vuex store tempThemeColors (live preview from General Settings)
// 2. localStorage custom color (saved color cache)
// 3. Organization settings (backend default for the organization)
// 4. Default theme color (O2 Signature)
const customLightColor = ref(
store.state.tempThemeColors?.light ||
localStorage.getItem(THEME_STORAGE_KEYS.light.color) ||
store.state?.organizationData?.organizationSettings?.light_mode_theme_color ||
DEFAULT_LIGHT_COLOR,
);
const customDarkColor = ref(
store.state.tempThemeColors?.dark ||
localStorage.getItem(THEME_STORAGE_KEYS.dark.color) ||
store.state?.organizationData?.organizationSettings?.dark_mode_theme_color ||
DEFAULT_DARK_COLOR,
);
// Color picker dialog state
const showColorPicker = ref(false); // Controls dialog visibility
const currentPickerMode = ref<"light" | "dark">("light"); // Which mode is being edited
const tempColor = ref(customLightColor.value); // Bound to the color component
/**
* Build the swatch background for a theme mode: a 3-color diagonal gradient when
* the theme defines semantic colors, otherwise a flat fill of the theme color.
*/
const swatchStyle = (m: ThemeModeColors) =>
m.semanticColors
? {
background: `linear-gradient(135deg, ${m.themeColor} 33%, ${m.semanticColors.error} 33% 66%, ${m.semanticColors.success} 66%)`,
}
: { backgroundColor: m.themeColor };
// Watch isOpen from composable
watch(isOpen, (val) => {
dialogOpen.value = val;
// When dialog opens, sync activeTab with current store theme
if (val) {
activeTab.value = isDark.value ? "dark" : "light";
}
});
// Watch dialogOpen to sync back
watch(dialogOpen, (val) => {
isOpen.value = val;
});
// Watch activeTab changes and update store.state.theme accordingly
watch(activeTab, (newTab) => {
const newTheme = newTab === "dark" ? "dark" : "light";
// eslint-disable-next-line no-restricted-syntax -- theme-setting guard, not a theme read: compares the current mode against the target before switching. useTheme().isDark is a boolean and cannot express "is it already this specific mode".
if (store.state.theme !== newTheme) {
localStorage.setItem("theme", newTheme);
// Update store and toggle .dark on <html> (Tailwind dark variant) inside
// switchThemeMode so the mode flip cross-fades as one frame.
switchThemeMode(newTheme, () => {
store.dispatch("appTheme", newTheme);
document.documentElement.classList.toggle("dark", newTheme === "dark");
});
}
});
// Watch store.state.theme changes and update activeTab accordingly
watch(
() => store.state.theme,
(newTheme) => {
const newTab = newTheme === "dark" ? "dark" : "light";
if (activeTab.value !== newTab) {
activeTab.value = newTab;
}
},
);
// Watch for organization settings changes (loaded after init or updated by an admin).
// Refresh the custom-color chips when the user has no saved custom color, then
// re-resolve and apply the effective theme (which respects the existing priority).
watch(
() => store.state?.organizationData?.organizationSettings,
(newSettings, oldSettings) => {
if (!newSettings || newSettings === oldSettings) return;
// Don't override a live preview coming from General Settings.
const hasTempColors = store.state.tempThemeColors?.light || store.state.tempThemeColors?.dark;
if (hasTempColors) return;
if (
!localStorage.getItem(THEME_STORAGE_KEYS.light.color) &&
newSettings.light_mode_theme_color
) {
customLightColor.value = newSettings.light_mode_theme_color;
}
if (!localStorage.getItem(THEME_STORAGE_KEYS.dark.color) && newSettings.dark_mode_theme_color) {
customDarkColor.value = newSettings.dark_mode_theme_color;
}
applyCurrentTheme(store);
},
{ deep: true },
);
// MutationObserver for watching body class changes (theme mode switches)
// This will be initialized in onMounted and cleaned up in onUnmounted
let observer: MutationObserver | null = null;
/**
* Component mounted lifecycle hook.
* Applies the resolved theme for the current mode and watches body class changes
* (light <-> dark toggle) to reapply the correct colors.
*/
onMounted(() => {
// Resolve & apply the effective theme for the current mode (preview > selected
// name > custom color > org settings > default), handled by the theme manager.
applyCurrentTheme(store);
// Reapply the resolved theme whenever the body theme-mode class toggles,
// unless a live preview from General Settings is active.
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "class") {
const hasTempColors =
store.state.tempThemeColors?.light || store.state.tempThemeColors?.dark;
if (hasTempColors) return;
const bodyMode = document.documentElement.classList.contains("dark") ? "dark" : "light";
applyThemeForMode(bodyMode, store);
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
});
onUnmounted(() => {
if (observer) {
observer.disconnect();
}
});
/**
* Apply a predefined theme to a mode.
* The SELECTION is persisted by name; the resolved color + semantic colors are
* also cached so direct localStorage readers (charts) stay in sync. Because the
* selection is stored by name, a future release that changes this theme's colors
* will automatically apply on next load.
*/
const applyTheme = (theme: PredefinedTheme, themeMode: "light" | "dark") => {
const modeColors = themeMode === "light" ? theme.light : theme.dark;
const keys = THEME_STORAGE_KEYS[themeMode];
// Apply immediately (predefined themes are never "default")
applyThemeColors(modeColors.themeColor, themeMode, false, modeColors.semanticColors);
// Persist selection by name + refresh the render-cache
localStorage.setItem(keys.appliedName, theme.name);
localStorage.setItem(keys.color, modeColors.themeColor);
if (modeColors.semanticColors) {
localStorage.setItem(keys.semantic, JSON.stringify(modeColors.semanticColors));
} else {
localStorage.removeItem(keys.semantic);
}
if (themeMode === "light") {
appliedLightThemeName.value = theme.name;
customLightColor.value = modeColors.themeColor;
} else {
appliedDarkThemeName.value = theme.name;
customDarkColor.value = modeColors.themeColor;
}
toast({
variant: "success",
message: t("toastMessages.components.appliedToModeSuccessfully", {
theme: themeDisplayName(theme.name),
mode: themeMode,
}),
});
};
const isThemeApplied = (theme: PredefinedTheme, themeMode: "light" | "dark"): boolean =>
(themeMode === "light" ? appliedLightThemeName.value : appliedDarkThemeName.value) === theme.name;
// Custom theme functions
const openColorPicker = (themeMode: "light" | "dark") => {
currentPickerMode.value = themeMode;
tempColor.value = themeMode === "light" ? customLightColor.value : customDarkColor.value;
showColorPicker.value = true;
};
/**
* Check if the custom theme is currently applied for the given mode.
* Returns false while a live preview from General Settings is active (so the
* "Applied" badge doesn't appear during preview).
*/
const isCustomThemeApplied = (themeMode: "light" | "dark"): boolean => {
const hasTempColors = store.state.tempThemeColors?.light || store.state.tempThemeColors?.dark;
if (hasTempColors) return false;
return (
(themeMode === "light" ? appliedLightThemeName.value : appliedDarkThemeName.value) ===
CUSTOM_THEME_NAME
);
};
const updateCustomColor = () => {
// Just update the preview color as user picks
if (currentPickerMode.value === "light") {
customLightColor.value = tempColor.value;
} else {
customDarkColor.value = tempColor.value;
}
};
/**
* Apply a custom color to a mode. Custom themes are persisted by COLOR (the hex
* is the source of truth), marked with CUSTOM_THEME_NAME.
*/
const applyCustomTheme = (themeMode: "light" | "dark") => {
const color = themeMode === "light" ? customLightColor.value : customDarkColor.value;
const keys = THEME_STORAGE_KEYS[themeMode];
// Apply immediately (custom theme is never default)
applyThemeColors(color, themeMode, false);
localStorage.setItem(keys.appliedName, CUSTOM_THEME_NAME);
localStorage.setItem(keys.color, color);
// Custom colors have no semantic palette — clear any stale one from a prior theme
localStorage.removeItem(keys.semantic);
if (themeMode === "light") {
appliedLightThemeName.value = CUSTOM_THEME_NAME;
} else {
appliedDarkThemeName.value = CUSTOM_THEME_NAME;
}
toast({
variant: "success",
message: t("toastMessages.components.customColorAppliedToModeSuccessfully", {
mode: themeMode,
}),
});
};
/**
* Confirm the picked custom color: apply it to the mode being edited and close
* the picker dialog.
*/
const confirmCustomColor = () => {
applyCustomTheme(currentPickerMode.value);
showColorPicker.value = false;
};
/**
* Reset both modes by clearing the explicit selection + caches, so resolution
* falls back to organization settings (if configured) or the default theme
* (O2 Signature).
*/
const resetToDefaultTheme = () => {
const org = store.state?.organizationData?.organizationSettings;
const orgLightColor = org?.light_mode_theme_color;
const orgDarkColor = org?.dark_mode_theme_color;
(["light", "dark"] as const).forEach((m) => {
const keys = THEME_STORAGE_KEYS[m];
localStorage.removeItem(keys.appliedName);
localStorage.removeItem(keys.color);
localStorage.removeItem(keys.semantic);
});
appliedLightThemeName.value = null;
appliedDarkThemeName.value = null;
// Update the custom-color chips to reflect the reset target
customLightColor.value = orgLightColor || DEFAULT_LIGHT_COLOR;
customDarkColor.value = orgDarkColor || DEFAULT_DARK_COLOR;
// Re-resolve & apply the effective theme for the current mode
applyCurrentTheme(store);
toast({
variant: "success",
message:
orgLightColor || orgDarkColor
? t("toastMessages.components.themeResetToOrganization")
: t("toastMessages.components.themeResetToDefault"),
});
};
</script>