fix: missing translations for expiring trials (#1800)

- Removed translation function calls from the UI components for reboot
type text, replacing them with direct references to the computed
properties.
- Enhanced ineligible update messages by integrating localization for
various conditions, ensuring clearer user feedback regarding update
eligibility.
- Added new localization strings for ineligible update scenarios in the
English locale file.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added new localization keys for OS update eligibility, reboot labels,
changelog link, and expanded uptime/trial expiry messages.

* **Bug Fixes**
* Restored translated strings and added locale-aware release date
formatting for update/ineligible messaging and badges.

* **Theme & UI**
* Streamlined theme initialization and server-driven theme application;
removed legacy CSS-variable persistence and adjusted dark/banner
behavior.

* **Tests**
* Added i18n and date/locale formatting tests and improved
local-storage-like test mocks.

* **Chores**
* Removed an auto-registered global component and strengthened
script/theme initialization and CSS-variable validation.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-11-20 19:30:39 -05:00
committed by GitHub
parent dc9a036c73
commit 36c104915e
20 changed files with 820 additions and 492 deletions

View File

@@ -6,11 +6,6 @@
/* Default/White Theme */
.Theme--white {
--header-text-primary: #ffffff;
--header-text-secondary: #999999;
--header-background-color: #1c1b1b;
--header-gradient-start: rgba(28, 27, 27, 0);
--header-gradient-end: rgba(28, 27, 27, 0.7);
--color-border: #383735;
--color-alpha: #ff8c2f;
--color-beta: #1c1b1b;
@@ -21,11 +16,6 @@
/* Black Theme */
.Theme--black,
.Theme--black.dark {
--header-text-primary: #1c1b1b;
--header-text-secondary: #999999;
--header-background-color: #f2f2f2;
--header-gradient-start: rgba(242, 242, 242, 0);
--header-gradient-end: rgba(242, 242, 242, 0.7);
--color-border: #e0e0e0;
--color-alpha: #ff8c2f;
--color-beta: #f2f2f2;
@@ -35,11 +25,6 @@
/* Gray Theme */
.Theme--gray {
--header-text-primary: #ffffff;
--header-text-secondary: #999999;
--header-background-color: #1c1b1b;
--header-gradient-start: rgba(28, 27, 27, 0);
--header-gradient-end: rgba(28, 27, 27, 0.7);
--color-border: #383735;
--color-alpha: #ff8c2f;
--color-beta: #383735;
@@ -49,11 +34,6 @@
/* Azure Theme */
.Theme--azure {
--header-text-primary: #1c1b1b;
--header-text-secondary: #999999;
--header-background-color: #f2f2f2;
--header-gradient-start: rgba(242, 242, 242, 0);
--header-gradient-end: rgba(242, 242, 242, 0.7);
--color-border: #5a8bb8;
--color-alpha: #ff8c2f;
--color-beta: #e7f2f8;
@@ -65,33 +45,3 @@
.dark {
--color-border: #383735;
}
/*
* Dynamic color variables for user overrides from GraphQL
* These are set via JavaScript and override the theme defaults
* Using :root with class for higher specificity to override theme classes
*/
:root.has-custom-header-text {
--header-text-primary: var(--custom-header-text-primary);
--color-header-text-primary: var(--custom-header-text-primary);
}
:root.has-custom-header-meta {
--header-text-secondary: var(--custom-header-text-secondary);
--color-header-text-secondary: var(--custom-header-text-secondary);
}
:root.has-custom-header-bg,
.has-custom-header-bg.Theme--black,
.has-custom-header-bg.Theme--black.dark,
.has-custom-header-bg.Theme--white,
.has-custom-header-bg.Theme--white.dark,
.has-custom-header-bg.Theme--gray,
.has-custom-header-bg.Theme--azure {
--header-background-color: var(--custom-header-background-color);
--color-header-background: var(--custom-header-background-color);
--header-gradient-start: var(--custom-header-gradient-start);
--header-gradient-end: var(--custom-header-gradient-end);
--color-header-gradient-start: var(--custom-header-gradient-start);
--color-header-gradient-end: var(--custom-header-gradient-end);
}

View File

@@ -1,5 +1,11 @@
<?php
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
if (!class_exists('ThemeHelper')) {
$themeHelperPath = $docroot . '/plugins/dynamix/include/ThemeHelper.php';
if (is_readable($themeHelperPath)) {
require_once $themeHelperPath;
}
}
class WebComponentsExtractor
{
@@ -148,22 +154,169 @@ class WebComponentsExtractor
return $files;
}
private function normalizeHex(?string $color): ?string
{
if (!is_string($color) || trim($color) === '') {
return null;
}
$color = trim($color);
if ($color[0] !== '#') {
$color = '#' . ltrim($color, '#');
}
$hex = substr($color, 1);
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (!ctype_xdigit($hex) || strlen($hex) !== 6) {
return null;
}
return '#' . strtolower($hex);
}
private function hexToRgba(string $hex, float $alpha): string
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
return sprintf('rgba(%d, %d, %d, %.3f)', $r, $g, $b, max(0, min(1, $alpha)));
}
/**
* Attempt to build CSS variables from PHP display data (server-rendered settings).
*
* @return array{vars: array<string,string>, classes: string[], diagnostics: array}|null
*/
private function getDisplayThemeVars(): ?array
{
if (!isset($GLOBALS['display']) || !is_array($GLOBALS['display'])) {
return null;
}
$display = $GLOBALS['display'];
$vars = [];
$textPrimary = $this->normalizeHex($display['header'] ?? null);
if ($textPrimary) {
$vars['--header-text-primary'] = $textPrimary;
}
$textSecondary = $this->normalizeHex($display['headermetacolor'] ?? null);
if ($textSecondary) {
$vars['--header-text-secondary'] = $textSecondary;
}
$theme = strtolower(trim($display['theme'] ?? ''));
if ($theme === 'white') {
if (!$textPrimary) {
$vars['--header-text-primary'] = 'var(--inverse-text-color, #ffffff)';
}
if (!$textSecondary) {
$vars['--header-text-secondary'] = 'var(--alt-text-color, #999999)';
}
}
$bgColor = $this->normalizeHex($display['background'] ?? null);
if ($bgColor) {
$vars['--header-background-color'] = $bgColor;
$vars['--header-gradient-start'] = $this->hexToRgba($bgColor, 0);
$vars['--header-gradient-end'] = $this->hexToRgba($bgColor, 0.7);
}
$shouldShowBannerGradient = ($display['showBannerGradient'] ?? '') === 'yes';
if ($shouldShowBannerGradient) {
$start = $vars['--header-gradient-start'] ?? 'rgba(0, 0, 0, 0)';
$end = $vars['--header-gradient-end'] ?? 'rgba(0, 0, 0, 0.7)';
$vars['--banner-gradient'] = sprintf(
'linear-gradient(90deg, %s 0, %s 90%%)',
$start,
$end
);
}
if (empty($vars)) {
return null;
}
return [
'vars' => $vars,
'diagnostics' => [
'theme' => $display['theme'] ?? null,
],
];
}
private function renderThemeVars(array $cssVars, string $source, array $diagnostics = []): string
{
$cssRules = [];
foreach ($cssVars as $key => $value) {
if (!is_string($key) || !is_string($value) || $value === '') {
continue;
}
if (!preg_match('/^--[A-Za-z0-9_-]+$/', $key)) {
continue;
}
$safeKey = htmlspecialchars($key, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$safeValue = str_replace('</style>', '<\/style>', $value);
$cssRules[] = sprintf(
' %s: %s;',
$safeKey,
$safeValue
);
}
if (empty($cssRules)) {
return '';
}
return '<style id="unraid-theme-css-vars">
:root {
' . implode("\n", $cssRules) . '
}
</style>';
}
private function getThemeInitScript(): string
{
$displayTheme = $this->getDisplayThemeVars();
if ($displayTheme) {
return $this->renderThemeVars(
$displayTheme['vars'],
'display',
$displayTheme['diagnostics'] ?? []
);
}
return '';
}
private static bool $scriptsOutput = false;
public function getScriptTagHtml(): string
{
// Use a static flag to ensure scripts are only output once per request
static $scriptsOutput = false;
if ($scriptsOutput) {
if (self::$scriptsOutput) {
return '<!-- Resources already loaded -->';
}
try {
$scriptsOutput = true;
return $this->processManifestFiles();
self::$scriptsOutput = true;
$themeScript = $this->getThemeInitScript();
$manifestScripts = $this->processManifestFiles();
return $themeScript . "\n" . $manifestScripts;
} catch (\Exception $e) {
error_log("Error in WebComponentsExtractor::getScriptTagHtml: " . $e->getMessage());
$scriptsOutput = false; // Reset on error
self::$scriptsOutput = false; // Reset on error
return "";
}
}
public static function resetScriptsOutput(): void
{
self::$scriptsOutput = false;
}
}

View File

@@ -120,10 +120,14 @@ class ExtractorTest {
file_put_contents($this->testDir . '/extractor.php', $extractorContent);
}
private function getExtractorOutput() {
private function getExtractorOutput($resetStatic = false) {
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
require_once $this->testDir . '/extractor.php';
if ($resetStatic && class_exists('WebComponentsExtractor')) {
WebComponentsExtractor::resetScriptsOutput();
}
$extractor = WebComponentsExtractor::getInstance();
return $extractor->getScriptTagHtml();
}
@@ -299,6 +303,11 @@ class ExtractorTest {
preg_match('/<link[^>]+id="unraid-[^"]*-css-[^"]+"[^>]+data-unraid="1"/', $output) > 0
);
// Test: CSS Variable Validation
echo "\nTest: CSS Variable Validation\n";
echo "------------------------------\n";
$this->testCssVariableValidation();
// Test: Duplicate Prevention
echo "\nTest: Duplicate Prevention\n";
echo "---------------------------\n";
@@ -317,6 +326,114 @@ class ExtractorTest {
);
}
private function testCssVariableValidation() {
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
require_once $this->testDir . '/extractor.php';
$extractor = WebComponentsExtractor::getInstance();
$reflection = new ReflectionClass('WebComponentsExtractor');
$method = $reflection->getMethod('renderThemeVars');
$method->setAccessible(true);
// Test valid CSS variable names
$validVars = [
'--header-text-primary' => '#ffffff',
'--header-text-secondary' => '#cccccc',
'--header-background-color' => '#000000',
'--test-var' => 'value',
'--test_var' => 'value',
'--test123' => 'value',
'--A-Z_a-z0-9' => 'value',
];
$output = $method->invoke($extractor, $validVars, 'test');
$this->test(
"Accepts valid CSS variable names starting with --",
strpos($output, '--header-text-primary') !== false &&
strpos($output, '--test-var') !== false &&
strpos($output, '--test_var') !== false &&
strpos($output, '--test123') !== false
);
// Test invalid CSS variable names (should be rejected)
$invalidVars = [
'not-a-var' => 'value',
'-not-a-var' => 'value',
'--var with spaces' => 'value',
'--var<script>' => 'value',
'--var"quote' => 'value',
'--var\'quote' => 'value',
'--var;injection' => 'value',
'--var:colon' => 'value',
'--var.value' => 'value',
'--var/value' => 'value',
'--var\\backslash' => 'value',
'' => 'value',
'--' => 'value',
];
$output = $method->invoke($extractor, $invalidVars, 'test');
$this->test(
"Rejects CSS variable names without -- prefix",
strpos($output, 'not-a-var') === false &&
strpos($output, '-not-a-var') === false
);
$this->test(
"Rejects CSS variable names with spaces",
strpos($output, 'var with spaces') === false
);
$this->test(
"Rejects CSS variable names with script tags",
strpos($output, '<script>') === false &&
strpos($output, 'var<script>') === false
);
$this->test(
"Rejects CSS variable names with quotes",
strpos($output, 'var"quote') === false &&
strpos($output, "var'quote") === false
);
$this->test(
"Rejects CSS variable names with semicolons",
strpos($output, 'var;injection') === false
);
$this->test(
"Rejects CSS variable names with dots",
strpos($output, 'var.value') === false
);
$this->test(
"Rejects empty or minimal invalid keys",
strpos($output, ': --;') === false
);
// Test mixed valid and invalid (only valid should appear)
$mixedVars = [
'--valid-var' => 'value1',
'invalid-var' => 'value2',
'--another-valid' => 'value3',
'--invalid<script>' => 'value4',
];
$output = $method->invoke($extractor, $mixedVars, 'test');
$this->test(
"Accepts valid variables and rejects invalid ones in mixed input",
strpos($output, '--valid-var') !== false &&
strpos($output, '--another-valid') !== false &&
strpos($output, 'invalid-var') === false &&
strpos($output, '<script>') === false
);
// Test non-string keys (should be rejected)
$nonStringKeys = [
'--valid' => 'value',
123 => 'value',
true => 'value',
null => 'value',
];
$output = $method->invoke($extractor, $nonStringKeys, 'test');
$this->test(
"Rejects non-string keys",
strpos($output, '--valid') !== false &&
strpos($output, '123') === false
);
}
private function test($name, $condition) {
if ($condition) {
echo " " . self::GREEN . "" . self::NC . " " . $name . "\n";
@@ -352,6 +469,19 @@ class ExtractorTest {
return preg_replace('/[^a-zA-Z0-9-]/', '-', $input);
}
private function resetExtractor() {
// Reset singleton instance
if (class_exists('WebComponentsExtractor')) {
$reflection = new ReflectionClass('WebComponentsExtractor');
$instance = $reflection->getProperty('instance');
$instance->setAccessible(true);
$instance->setValue(null, null);
// Reset static flag
WebComponentsExtractor::resetScriptsOutput();
}
}
private function reportResults() {
echo "\n";
echo "========================================\n";

View File

@@ -35,6 +35,13 @@ vi.mock('@vueuse/core', () => ({
isSupported: mockIsSupported,
};
},
useLocalStorage: <T>(key: string, initialValue: T) => {
const storage = new Map<string, T>();
if (!storage.has(key)) {
storage.set(key, initialValue);
}
return ref(storage.get(key) ?? initialValue);
},
}));
vi.mock('@unraid/ui', () => ({

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { createTestI18n } from '../utils/i18n';
describe('Trial Translation Keys', () => {
it('should load all trial-related translation keys', () => {
const i18n = createTestI18n();
const { t } = i18n.global;
const trialKeys = [
'registration.trialExpiration',
'server.actions.extendTrial',
'server.actions.startTrial',
'server.state.trial.humanReadable',
'server.state.trial.messageEligibleInsideRenewal',
'server.state.trial.messageEligibleOutsideRenewal',
'server.state.trial.messageIneligibleInsideRenewal',
'server.state.trial.messageIneligibleOutsideRenewal',
'server.state.trialExpired.heading',
'server.state.trialExpired.humanReadable',
'server.state.trialExpired.messageEligible',
'server.state.trialExpired.messageIneligible',
'userProfile.trial.trialKeyCreated',
'userProfile.trial.trialKeyCreationFailed',
'userProfile.trial.startingYourFreeDayTrial',
'userProfile.trial.extendingYourFreeTrialByDays',
'userProfile.trial.errorCreatiingATrialKeyPlease',
'userProfile.trial.pleaseKeepThisWindowOpen',
'userProfile.trial.pleaseWaitWhileThePageReloads',
'userProfile.uptimeExpire.trialKeyExpired',
'userProfile.uptimeExpire.trialKeyExpiredAt',
'userProfile.uptimeExpire.trialKeyExpiresAt',
'userProfile.uptimeExpire.trialKeyExpiresIn',
'userProfile.callbackFeedback.calculatingTrialExpiration',
'userProfile.callbackFeedback.installingExtendedTrial',
'userProfile.callbackFeedback.yourFreeTrialKeyProvidesAll',
'userProfile.callbackFeedback.yourTrialKeyHasBeenExtended',
'userProfile.dropdownTrigger.trialExpiredSeeOptionsBelow',
];
for (const key of trialKeys) {
const translation = t(key);
expect(translation).toBeTruthy();
expect(translation).not.toBe(key);
expect(typeof translation).toBe('string');
}
});
it('should translate trial expiration keys with parameters', () => {
const i18n = createTestI18n();
const { t } = i18n.global;
const testDate = '2024-01-15 10:30:00';
const testDuration = '5 days';
expect(t('userProfile.uptimeExpire.trialKeyExpired', [testDuration])).toContain(testDuration);
expect(t('userProfile.uptimeExpire.trialKeyExpiredAt', [testDate])).toContain(testDate);
expect(t('userProfile.uptimeExpire.trialKeyExpiresAt', [testDate])).toContain(testDate);
expect(t('userProfile.uptimeExpire.trialKeyExpiresIn', [testDuration])).toContain(testDuration);
});
it('should have all required trial state messages', () => {
const i18n = createTestI18n();
const { t } = i18n.global;
const stateMessages = [
'server.state.trial.messageEligibleInsideRenewal',
'server.state.trial.messageEligibleOutsideRenewal',
'server.state.trial.messageIneligibleInsideRenewal',
'server.state.trial.messageIneligibleOutsideRenewal',
'server.state.trialExpired.messageEligible',
'server.state.trialExpired.messageIneligible',
];
for (const key of stateMessages) {
const message = t(key);
expect(message).toBeTruthy();
expect(message.length).toBeGreaterThan(0);
expect(message).toMatch(/<p>/);
}
});
it('should have trial action translations', () => {
const i18n = createTestI18n();
const { t } = i18n.global;
expect(t('server.actions.extendTrial')).toBe('Extend Trial');
expect(t('server.actions.startTrial')).toBe('Start Free 30 Day Trial');
});
});

View File

@@ -6,13 +6,10 @@ import { createApp, nextTick, ref } from 'vue';
import { setActivePinia } from 'pinia';
import { defaultColors } from '~/themes/default';
import hexToRgba from 'hex-to-rgba';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Theme } from '~/themes/types';
import { globalPinia } from '~/store/globalPinia';
import { THEME_STORAGE_KEY, useThemeStore } from '~/store/theme';
import { useThemeStore } from '~/store/theme';
vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
@@ -23,15 +20,10 @@ vi.mock('@vue/apollo-composable', () => ({
}),
}));
vi.mock('hex-to-rgba', () => ({
default: vi.fn((hex, opacity) => `rgba(mock-${hex}-${opacity})`),
}));
describe('Theme Store', () => {
const originalAddClassFn = document.body.classList.add;
const originalRemoveClassFn = document.body.classList.remove;
const originalStyleCssText = document.body.style.cssText;
const originalDocumentElementSetProperty = document.documentElement.style.setProperty;
const originalDocumentElementAddClass = document.documentElement.classList.add;
const originalDocumentElementRemoveClass = document.documentElement.classList.remove;
@@ -49,7 +41,6 @@ describe('Theme Store', () => {
document.body.classList.add = vi.fn();
document.body.classList.remove = vi.fn();
document.body.style.cssText = '';
document.documentElement.style.setProperty = vi.fn();
document.documentElement.classList.add = vi.fn();
document.documentElement.classList.remove = vi.fn();
@@ -70,7 +61,6 @@ describe('Theme Store', () => {
document.body.classList.add = originalAddClassFn;
document.body.classList.remove = originalRemoveClassFn;
document.body.style.cssText = originalStyleCssText;
document.documentElement.style.setProperty = originalDocumentElementSetProperty;
document.documentElement.classList.add = originalDocumentElementAddClass;
document.documentElement.classList.remove = originalDocumentElementRemoveClass;
vi.restoreAllMocks();
@@ -88,8 +78,6 @@ describe('Theme Store', () => {
it('should initialize with default theme', () => {
const store = createStore();
expect(typeof store.$persist).toBe('function');
expect(store.theme).toEqual({
name: 'white',
banner: false,
@@ -191,93 +179,62 @@ describe('Theme Store', () => {
await nextTick();
// activeColorVariables now contains the theme defaults from defaultColors
// Custom values are applied as CSS variables on the documentElement
// The white theme's --color-beta is a reference to var(--header-text-primary)
expect(store.activeColorVariables['--color-beta']).toBe('var(--header-text-primary)');
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--custom-header-text-primary',
'#333333'
);
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--custom-header-text-secondary',
'#666666'
);
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--custom-header-background-color',
'#ffffff'
);
});
it('should handle banner gradient correctly', async () => {
it('should apply dark mode classes when theme changes', async () => {
const store = createStore();
const mockHexToRgba = vi.mocked(hexToRgba);
mockHexToRgba.mockClear();
store.setTheme({
...store.theme,
banner: true,
bannerGradient: true,
bgColor: '#112233',
name: 'black',
});
await nextTick();
expect(mockHexToRgba).toHaveBeenCalledWith('#112233', 0);
expect(mockHexToRgba).toHaveBeenCalledWith('#112233', 0.7);
// Banner gradient values are now set as custom CSS variables on documentElement
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--custom-header-gradient-start',
'rgba(mock-#112233-0)'
);
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--custom-header-gradient-end',
'rgba(mock-#112233-0.7)'
);
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--banner-gradient',
'linear-gradient(90deg, rgba(mock-#112233-0) 0, rgba(mock-#112233-0.7) 90%)'
);
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
});
it('should hydrate theme from cache when available', () => {
const cachedTheme = {
it('should apply dark mode classes to all .unapi elements', async () => {
const store = createStore();
const unapiElement1 = document.createElement('div');
unapiElement1.classList.add('unapi');
document.body.appendChild(unapiElement1);
const unapiElement2 = document.createElement('div');
unapiElement2.classList.add('unapi');
document.body.appendChild(unapiElement2);
const addSpy1 = vi.spyOn(unapiElement1.classList, 'add');
const addSpy2 = vi.spyOn(unapiElement2.classList, 'add');
const removeSpy1 = vi.spyOn(unapiElement1.classList, 'remove');
const removeSpy2 = vi.spyOn(unapiElement2.classList, 'remove');
store.setTheme({
...store.theme,
name: 'black',
banner: true,
bannerGradient: false,
bgColor: '#222222',
descriptionShow: true,
metaColor: '#aaaaaa',
textColor: '#ffffff',
} satisfies Theme;
});
window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ theme: cachedTheme }));
const store = createStore();
expect(store.theme).toEqual(cachedTheme);
});
it('should persist server theme responses to cache', async () => {
const store = createStore();
const serverTheme = {
name: 'gray',
banner: false,
bannerGradient: false,
bgColor: '#111111',
descriptionShow: false,
metaColor: '#999999',
textColor: '#eeeeee',
} satisfies Theme;
store.setTheme(serverTheme, { source: 'server' });
await nextTick();
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toEqual(
JSON.stringify({ theme: serverTheme })
);
expect(addSpy1).toHaveBeenCalledWith('dark');
expect(addSpy2).toHaveBeenCalledWith('dark');
store.setTheme({
...store.theme,
name: 'white',
});
await nextTick();
expect(removeSpy1).toHaveBeenCalledWith('dark');
expect(removeSpy2).toHaveBeenCalledWith('dark');
document.body.removeChild(unapiElement1);
document.body.removeChild(unapiElement2);
});
});
});

View File

@@ -10,6 +10,7 @@ import type { ExternalUpdateOsAction } from '@unraid/shared-callbacks';
import type { Release } from '~/store/updateOsActions';
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
import { testTranslate } from '../utils/i18n';
vi.mock('~/helpers/urls', () => ({
WEBGUI_TOOLS_UPDATE: 'https://webgui/tools/update',
@@ -48,20 +49,34 @@ vi.mock('~/store/account', () => ({
}),
}));
const mockServerStore = {
guid: 'test-guid',
keyfile: 'test-keyfile',
osVersion: '6.12.4',
osVersionBranch: 'stable',
regUpdatesExpired: false,
regTy: 'Plus',
locale: 'en_US' as string | undefined,
rebootType: '',
updateOsResponse: null as { date: string } | null,
};
vi.mock('~/store/server', () => ({
useServerStore: () => ({
guid: 'test-guid',
keyfile: 'test-keyfile',
osVersion: '6.12.4',
osVersionBranch: 'stable',
regUpdatesExpired: false,
rebootType: '',
}),
useServerStore: () => mockServerStore,
}));
const mockUpdateOsStore = {
available: '6.12.5',
availableWithRenewal: false,
};
vi.mock('~/store/updateOs', () => ({
useUpdateOsStore: () => ({
available: '6.12.5',
useUpdateOsStore: () => mockUpdateOsStore,
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: testTranslate,
}),
}));
@@ -70,6 +85,19 @@ describe('UpdateOsActions Store', () => {
beforeEach(() => {
setActivePinia(createPinia());
// Reset mocks to default values
mockServerStore.guid = 'test-guid';
mockServerStore.keyfile = 'test-keyfile';
mockServerStore.osVersion = '6.12.4';
mockServerStore.osVersionBranch = 'stable';
mockServerStore.regUpdatesExpired = false;
mockServerStore.regTy = 'Plus';
mockServerStore.locale = 'en_US';
mockServerStore.rebootType = '';
mockServerStore.updateOsResponse = null;
mockUpdateOsStore.available = '6.12.5';
mockUpdateOsStore.availableWithRenewal = false;
store = useUpdateOsActionsStore();
vi.clearAllMocks();
@@ -417,4 +445,106 @@ describe('UpdateOsActions Store', () => {
expect(store.status).toBe('updating');
});
});
describe('formattedReleaseDate', () => {
it('should return empty string when no release date is available', () => {
mockUpdateOsStore.availableWithRenewal = false;
mockServerStore.updateOsResponse = null;
store = useUpdateOsActionsStore();
expect(store.formattedReleaseDate).toBe('');
});
it('should format date correctly with locale from server store', () => {
mockUpdateOsStore.availableWithRenewal = true;
mockServerStore.updateOsResponse = { date: '2023-10-15' };
mockServerStore.locale = 'en_US';
store = useUpdateOsActionsStore();
const formatted = store.formattedReleaseDate;
expect(formatted).toBeTruthy();
expect(formatted).toContain('2023');
expect(formatted).toContain('October');
expect(formatted).toContain('15');
});
it('should normalize locale underscores to hyphens', () => {
mockUpdateOsStore.availableWithRenewal = true;
mockServerStore.updateOsResponse = { date: '2023-10-15' };
mockServerStore.locale = 'fr_FR';
store = useUpdateOsActionsStore();
const formatted = store.formattedReleaseDate;
expect(formatted).toBeTruthy();
expect(typeof formatted).toBe('string');
expect(formatted.length).toBeGreaterThan(0);
});
it('should fall back to navigator.language when locale is missing', () => {
const originalLanguage = navigator.language;
Object.defineProperty(navigator, 'language', {
value: 'de-DE',
configurable: true,
});
mockUpdateOsStore.availableWithRenewal = true;
mockServerStore.updateOsResponse = { date: '2023-10-15' };
mockServerStore.locale = undefined;
store = useUpdateOsActionsStore();
const formatted = store.formattedReleaseDate;
expect(formatted).toBeTruthy();
expect(typeof formatted).toBe('string');
Object.defineProperty(navigator, 'language', {
value: originalLanguage,
configurable: true,
});
});
it('should fall back to en-US when locale and navigator.language are missing', () => {
const originalLanguage = navigator.language;
Object.defineProperty(navigator, 'language', {
value: undefined,
configurable: true,
});
mockUpdateOsStore.availableWithRenewal = true;
mockServerStore.updateOsResponse = { date: '2023-10-15' };
mockServerStore.locale = undefined;
store = useUpdateOsActionsStore();
const formatted = store.formattedReleaseDate;
expect(formatted).toBeTruthy();
expect(formatted).toContain('2023');
expect(formatted).toContain('October');
expect(formatted).toContain('15');
Object.defineProperty(navigator, 'language', {
value: originalLanguage,
configurable: true,
});
});
it('should parse date correctly to avoid off-by-one errors', () => {
mockUpdateOsStore.availableWithRenewal = true;
mockServerStore.updateOsResponse = { date: '2023-01-01' };
mockServerStore.locale = 'en-US';
store = useUpdateOsActionsStore();
const formatted = store.formattedReleaseDate;
expect(formatted).toContain('January');
expect(formatted).toContain('1');
});
});
describe('ineligibleText', () => {
it('should return empty string when eligible', () => {
mockServerStore.guid = 'test-guid';
mockServerStore.keyfile = 'test-keyfile';
mockServerStore.osVersion = '6.12.4';
mockServerStore.regUpdatesExpired = false;
store = useUpdateOsActionsStore();
expect(store.ineligibleText).toBe('');
});
});
});

View File

@@ -35,30 +35,30 @@ type LocaleMessages = typeof enUS;
const localeMessages: Record<string, LocaleMessages> = {
en_US: enUS,
ar: ar as LocaleMessages,
bn: bn as LocaleMessages,
ca: ca as LocaleMessages,
cs: cs as LocaleMessages,
da: da as LocaleMessages,
de: de as LocaleMessages,
es: es as LocaleMessages,
fr: fr as LocaleMessages,
hi: hi as LocaleMessages,
hr: hr as LocaleMessages,
hu: hu as LocaleMessages,
it: it as LocaleMessages,
ja: ja as LocaleMessages,
ko: ko as LocaleMessages,
lv: lv as LocaleMessages,
nl: nl as LocaleMessages,
no: no as LocaleMessages,
pl: pl as LocaleMessages,
pt: pt as LocaleMessages,
ro: ro as LocaleMessages,
ru: ru as LocaleMessages,
sv: sv as LocaleMessages,
uk: uk as LocaleMessages,
zh: zh as LocaleMessages,
ar: ar as unknown as LocaleMessages,
bn: bn as unknown as LocaleMessages,
ca: ca as unknown as LocaleMessages,
cs: cs as unknown as LocaleMessages,
da: da as unknown as LocaleMessages,
de: de as unknown as LocaleMessages,
es: es as unknown as LocaleMessages,
fr: fr as unknown as LocaleMessages,
hi: hi as unknown as LocaleMessages,
hr: hr as unknown as LocaleMessages,
hu: hu as unknown as LocaleMessages,
it: it as unknown as LocaleMessages,
ja: ja as unknown as LocaleMessages,
ko: ko as unknown as LocaleMessages,
lv: lv as unknown as LocaleMessages,
nl: nl as unknown as LocaleMessages,
no: no as unknown as LocaleMessages,
pl: pl as unknown as LocaleMessages,
pt: pt as unknown as LocaleMessages,
ro: ro as unknown as LocaleMessages,
ru: ru as unknown as LocaleMessages,
sv: sv as unknown as LocaleMessages,
uk: uk as unknown as LocaleMessages,
zh: zh as unknown as LocaleMessages,
};
type AnyObject = Record<string, unknown>;

1
web/components.d.ts vendored
View File

@@ -47,7 +47,6 @@ declare module 'vue' {
'DevThemeSwitcher.standalone': typeof import('./src/components/DevThemeSwitcher.standalone.vue')['default']
Downgrade: typeof import('./src/components/UpdateOs/Downgrade.vue')['default']
'DowngradeOs.standalone': typeof import('./src/components/DowngradeOs.standalone.vue')['default']
'DownloadApiLogs.standalone': typeof import('./src/components/DownloadApiLogs.standalone.vue')['default']
DropdownConnectStatus: typeof import('./src/components/UserProfile/DropdownConnectStatus.vue')['default']
DropdownContent: typeof import('./src/components/UserProfile/DropdownContent.vue')['default']
DropdownError: typeof import('./src/components/UserProfile/DropdownError.vue')['default']

View File

@@ -52,7 +52,6 @@ const updateTheme = (themeName: string, skipUrlUpdate = false) => {
}
themeStore.setTheme({ name: themeName });
themeStore.setCssVars();
const linkId = 'dev-theme-css-link';
let themeLink = document.getElementById(linkId) as HTMLLinkElement | null;
@@ -101,7 +100,6 @@ onMounted(() => {
updateTheme(initialTheme, true);
} else {
themeStore.setTheme({ name: initialTheme });
themeStore.setCssVars();
}
});

View File

@@ -198,7 +198,7 @@ const navigateToRegistration = () => {
variant="yellow"
:icon="() => h(ExclamationTriangleIcon, { style: 'width: 16px; height: 16px;' })"
>
{{ t(rebootTypeText) }}
{{ rebootTypeText }}
</Badge>
</template>

View File

@@ -18,7 +18,7 @@ const { rebootTypeText } = storeToRefs(useUpdateOsActionsStore());
<div class="grid gap-y-4">
<h3 class="flex flex-row items-center gap-2 text-xl leading-normal font-semibold">
<ExclamationTriangleIcon class="w-5 shrink-0" />
{{ t(rebootTypeText) }}
{{ rebootTypeText }}
</h3>
<div class="text-base leading-relaxed whitespace-normal opacity-75">
<p>

View File

@@ -21,7 +21,7 @@ const serverStore = useServerStore();
const updateOsStore = useUpdateOsStore();
const updateOsActionsStore = useUpdateOsActionsStore();
const { dateTimeFormat, regTy, renewAction, updateOsResponse } = storeToRefs(serverStore);
const { dateTimeFormat, renewAction, updateOsResponse } = storeToRefs(serverStore);
const { availableWithRenewal } = storeToRefs(updateOsStore);
const { ineligibleText } = storeToRefs(updateOsActionsStore);
@@ -43,7 +43,7 @@ const heading = computed((): string => {
});
const text = computed(() => {
return t(ineligibleText.value, [regTy.value, formattedReleaseDate.value]);
return ineligibleText.value;
});
const updateButton = ref<UserProfileLink | undefined>();

View File

@@ -58,12 +58,20 @@ const output = computed(() => {
return {
title:
state.value === 'EEXPIRED'
? t(props.shortText ? 'Expired at {0}' : 'Trial Key Expired at {0}', [formatted.value])
: t(props.shortText ? 'Expires at {0}' : 'Trial Key Expires at {0}', [formatted.value]),
? props.shortText
? t('userProfile.uptimeExpire.expiredAt', [formatted.value])
: t('userProfile.uptimeExpire.trialKeyExpiredAt', [formatted.value])
: props.shortText
? t('userProfile.uptimeExpire.expiresAt', [formatted.value])
: t('userProfile.uptimeExpire.trialKeyExpiresAt', [formatted.value]),
text:
state.value === 'EEXPIRED'
? t(props.shortText ? 'Expired {0}' : 'Trial Key Expired {0}', [readableDiff.value])
: t(props.shortText ? 'Expires in {0}' : 'Trial Key Expires in {0}', [readableDiff.value]),
? props.shortText
? t('userProfile.uptimeExpire.expired', [readableDiff.value])
: t('userProfile.uptimeExpire.trialKeyExpired', [readableDiff.value])
: props.shortText
? t('userProfile.uptimeExpire.expiresIn', [readableDiff.value])
: t('userProfile.uptimeExpire.trialKeyExpiresIn', [readableDiff.value]),
};
}
return {

View File

@@ -10,7 +10,6 @@ import { createI18nInstance, ensureLocale, getWindowLocale } from '~/helpers/i18
// Import Pinia for use in Vue apps
import { globalPinia } from '~/store/globalPinia';
import { useThemeStore } from '~/store/theme';
// Ensure Apollo client is singleton
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
@@ -91,8 +90,6 @@ export async function mountUnifiedApp() {
app.use(ui);
app.provide(DefaultApolloClient, apolloClient);
const themeStore = useThemeStore();
// Mount the app to establish context
let rootElement = document.getElementById('unraid-unified-root');
if (!rootElement) {
@@ -189,9 +186,6 @@ export async function mountUnifiedApp() {
});
});
// Re-apply theme classes/variables now that new scoped roots exist
themeStore.setCssVars();
console.debug(`[UnifiedMount] Mounted ${mountedComponents.length} components`);
return app;

View File

@@ -20,7 +20,12 @@ export const createHtmlEntityDecoder = () => {
const parser = new DOMParser();
return <T>(translated: T) => {
if (typeof translated !== 'string') return translated;
const decoded = parser.parseFromString(translated, 'text/html').documentElement.textContent;
return decoded ?? translated;
const doc = parser.parseFromString(translated, 'text/html');
const bodyContent = doc.body.innerHTML;
const hasHtmlTags = /<[^>]+>/.test(bodyContent);
if (hasHtmlTags) {
return bodyContent;
}
return doc.documentElement.textContent ?? translated;
};
};

View File

@@ -55,13 +55,6 @@
"connectSettings.updatedApiSettingsToast": "Updated API Settings",
"downgradeOs.downgradeUnraidOs": "Downgrade Unraid OS",
"downgradeOs.pleaseFinishTheInitiatedUpdateTo": "Please finish the initiated update to enable a downgrade.",
"downloadApiLogs.downloadUnraidApiLogs": "Download unraid-api Logs",
"downloadApiLogs.ifYouAreAskedToSupply": "If you are asked to supply logs, please open a support request on our Contact Page and reply to the email message you receive with your logs attached.",
"downloadApiLogs.theLogsMayContainSensitiveInformation": "The logs may contain sensitive information so do not post them publicly.",
"downloadApiLogs.thePrimaryMethodOfSupportFor": "The primary method of support for Unraid Connect is through our forums and Discord.",
"downloadApiLogs.unraidConnectForums": "Unraid Connect Forums",
"downloadApiLogs.unraidContactPage": "Unraid Contact Page",
"downloadApiLogs.unraidDiscord": "Unraid Discord",
"headerOsVersion.apiVersionCopiedToClipboard": "API version copied to clipboard",
"headerOsVersion.osVersionCopiedToClipboard": "OS version copied to clipboard",
"headerOsVersion.unraidApi": "Unraid API",
@@ -473,12 +466,20 @@
"updateOs.downgrade.releaseNotes": "{0} Release Notes",
"updateOs.ignoredRelease.remove": "Remove",
"updateOs.ignoredRelease.removeFromIgnoreList": "Remove from ignore list",
"updateOs.ineligible.guidRequired": "A valid GUID is required to check for OS updates.",
"updateOs.ineligible.keyfileRequired": "A valid keyfile is required to check for OS updates.",
"updateOs.ineligible.osVersionRequired": "A valid OS version is required to check for OS updates.",
"updateOs.ineligible.updatesExpired": "Your {0} license included one year of free updates at the time of purchase. You are now eligible to extend your license and access the latest OS updates.",
"updateOs.ineligible.updatesExpiredWithAvailable": "Your {0} license included one year of free updates at the time of purchase. You are now eligible to extend your license and access the latest OS updates. You are still eligible to access OS updates that were published on or before {1}.",
"updateOs.pleaseFinishTheInitiatedDowngradeTo": "Please finish the initiated downgrade to enable updates.",
"updateOs.rawChangelogRenderer.errorParsingChangelog": "Error Parsing Changelog • {0}",
"updateOs.rawChangelogRenderer.itSHighlyRecommendedToReview": "It's highly recommended to review the changelog before continuing your update",
"updateOs.rawChangelogRenderer.loadingChangelog": "Loading changelog...",
"updateOs.rawChangelogRenderer.noChangelogContentAvailable": "No changelog content available",
"updateOs.rawChangelogRenderer.viewChangelogOnDocs": "View Changelog on Docs",
"updateOs.reboot.downgrade": "Reboot Required for Downgrade",
"updateOs.reboot.thirdPartyDriversDownloading": "Updating 3rd party drivers",
"updateOs.reboot.update": "Reboot Required for Update",
"updateOs.status.cancel": "Cancel {0}",
"updateOs.status.checking": "Checking...",
"updateOs.status.downgrade": "Downgrade",
@@ -594,7 +595,15 @@
"userProfile.trial.startingYourFreeDayTrial": "Starting your free 30 day trial",
"userProfile.trial.trialKeyCreated": "Trial Key Created",
"userProfile.trial.trialKeyCreationFailed": "Trial Key Creation Failed",
"userProfile.uptimeExpire.expired": "Expired {0}",
"userProfile.uptimeExpire.expiredAt": "Expired at {0}",
"userProfile.uptimeExpire.expiresAt": "Expires at {0}",
"userProfile.uptimeExpire.expiresIn": "Expires in {0}",
"userProfile.uptimeExpire.serverUpSince": "Server Up Since {0}",
"userProfile.uptimeExpire.trialKeyExpired": "Trial Key Expired {0}",
"userProfile.uptimeExpire.trialKeyExpiredAt": "Trial Key Expired at {0}",
"userProfile.uptimeExpire.trialKeyExpiresAt": "Trial Key Expires at {0}",
"userProfile.uptimeExpire.trialKeyExpiresIn": "Trial Key Expires in {0}",
"userProfile.uptimeExpire.uptime": "Uptime {0}",
"wanIpCheck.checkingWanIps": "Checking WAN IPs…",
"wanIpCheck.dnsIssueUnableToResolveWanip4": "DNS issue, unable to resolve wanip4.unraid.net",

View File

@@ -3,7 +3,6 @@ import { defineStore } from 'pinia';
import { useQuery } from '@vue/apollo-composable';
import { defaultColors } from '~/themes/default';
import hexToRgba from 'hex-to-rgba';
import type { GetThemeQuery } from '~/composables/gql/graphql';
import type { Theme, ThemeVariables } from '~/themes/types';
@@ -27,8 +26,6 @@ export const GET_THEME_QUERY = graphql(`
}
`);
export const THEME_STORAGE_KEY = 'unraid.theme.publicTheme';
const DEFAULT_THEME: Theme = {
name: 'white',
banner: false,
@@ -41,6 +38,55 @@ const DEFAULT_THEME: Theme = {
type ThemeSource = 'local' | 'server';
let pendingDarkModeHandler: ((event: Event) => void) | null = null;
const syncBodyDarkClass = (method: 'add' | 'remove'): boolean => {
const body = typeof document !== 'undefined' ? document.body : null;
if (!body) {
return false;
}
body.classList[method]('dark');
return true;
};
const applyDarkClass = (isDark: boolean) => {
if (typeof document === 'undefined') return;
const method: 'add' | 'remove' = isDark ? 'add' : 'remove';
document.documentElement.classList[method]('dark');
const unapiElements = document.querySelectorAll('.unapi');
unapiElements.forEach((element) => {
element.classList[method]('dark');
});
if (pendingDarkModeHandler) {
document.removeEventListener('DOMContentLoaded', pendingDarkModeHandler);
pendingDarkModeHandler = null;
}
if (syncBodyDarkClass(method)) {
return;
}
const handler = () => {
if (syncBodyDarkClass(method)) {
const unapiElementsOnLoad = document.querySelectorAll('.unapi');
unapiElementsOnLoad.forEach((element) => {
element.classList[method]('dark');
});
document.removeEventListener('DOMContentLoaded', handler);
if (pendingDarkModeHandler === handler) {
pendingDarkModeHandler = null;
}
}
};
pendingDarkModeHandler = handler;
document.addEventListener('DOMContentLoaded', handler);
};
const sanitizeTheme = (data: Partial<Theme> | null | undefined): Theme | null => {
if (!data || typeof data !== 'object') {
return null;
@@ -59,290 +105,124 @@ const sanitizeTheme = (data: Partial<Theme> | null | undefined): Theme | null =>
};
};
const DYNAMIC_VAR_KEYS = [
'--custom-header-text-primary',
'--custom-header-text-secondary',
'--custom-header-background-color',
'--custom-header-gradient-start',
'--custom-header-gradient-end',
'--header-background-color',
'--header-gradient-start',
'--header-gradient-end',
'--color-header-background',
'--color-header-gradient-start',
'--color-header-gradient-end',
'--banner-gradient',
] as const;
export const useThemeStore = defineStore('theme', () => {
// State
const theme = ref<Theme>({ ...DEFAULT_THEME });
type DynamicVarKey = (typeof DYNAMIC_VAR_KEYS)[number];
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
const hasServerTheme = ref(false);
const devOverride = ref(false);
export const useThemeStore = defineStore(
'theme',
() => {
// State
const theme = ref<Theme>({ ...DEFAULT_THEME });
const { result, onResult, onError } = useQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
});
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
const hasServerTheme = ref(false);
const devOverride = ref(false);
const mapPublicTheme = (publicTheme?: GetThemeQuery['publicTheme'] | null): Theme | null => {
if (!publicTheme) {
return null;
}
const { result, onResult, onError } = useQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
return sanitizeTheme({
name: publicTheme.name?.toLowerCase(),
banner: publicTheme.showBannerImage,
bannerGradient: publicTheme.showBannerGradient,
bgColor: publicTheme.headerBackgroundColor ?? undefined,
descriptionShow: publicTheme.showHeaderDescription,
metaColor: publicTheme.headerSecondaryTextColor ?? undefined,
textColor: publicTheme.headerPrimaryTextColor ?? undefined,
});
};
const mapPublicTheme = (publicTheme?: GetThemeQuery['publicTheme'] | null): Theme | null => {
if (!publicTheme) {
return null;
}
const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => {
const sanitized = mapPublicTheme(publicTheme);
if (!sanitized) {
return;
}
return sanitizeTheme({
name: publicTheme.name?.toLowerCase(),
banner: publicTheme.showBannerImage,
bannerGradient: publicTheme.showBannerGradient,
bgColor: publicTheme.headerBackgroundColor ?? undefined,
descriptionShow: publicTheme.showHeaderDescription,
metaColor: publicTheme.headerSecondaryTextColor ?? undefined,
textColor: publicTheme.headerPrimaryTextColor ?? undefined,
});
};
setTheme(sanitized, { source: 'server' });
};
const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => {
const sanitized = mapPublicTheme(publicTheme);
if (!sanitized) {
onResult(({ data }) => {
if (data?.publicTheme) {
applyThemeFromQuery(data.publicTheme);
}
});
if (result.value?.publicTheme) {
applyThemeFromQuery(result.value.publicTheme);
}
onError((err) => {
console.warn('Failed to load theme from server, keeping existing theme:', err);
});
// Getters
// Apply dark mode for gray and black themes
const darkMode = computed<boolean>(() =>
DARK_UI_THEMES.includes(theme.value?.name as (typeof DARK_UI_THEMES)[number])
);
const bannerGradient = computed(() => {
if (!theme.value?.banner || !theme.value?.bannerGradient) {
return undefined;
}
const start = theme.value?.bgColor ? 'var(--header-gradient-start)' : 'rgba(0, 0, 0, 0)';
const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)';
return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`;
});
// Actions
function setTheme(data?: Partial<Theme>, options: { source?: ThemeSource } = {}) {
if (data) {
const { source = 'local' } = options;
if (source === 'server') {
hasServerTheme.value = true;
} else if (hasServerTheme.value && !devOverride.value) {
return;
}
setTheme(sanitized, { source: 'server' });
};
onResult(({ data }) => {
if (data?.publicTheme) {
applyThemeFromQuery(data.publicTheme);
}
});
if (result.value?.publicTheme) {
applyThemeFromQuery(result.value.publicTheme);
}
onError((err) => {
console.warn('Failed to load theme from server, keeping existing theme:', err);
});
// Getters
// Apply dark mode for gray and black themes
const darkMode = computed<boolean>(() =>
DARK_UI_THEMES.includes(theme.value?.name as (typeof DARK_UI_THEMES)[number])
);
const bannerGradient = computed(() => {
if (!theme.value?.banner || !theme.value?.bannerGradient) {
return undefined;
}
const start = theme.value?.bgColor ? 'var(--header-gradient-start)' : 'rgba(0, 0, 0, 0)';
const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)';
return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`;
});
// Actions
const setTheme = (data?: Partial<Theme>, options: { source?: ThemeSource } = {}) => {
if (data) {
const { source = 'local' } = options;
if (source === 'server') {
hasServerTheme.value = true;
} else if (hasServerTheme.value && !devOverride.value) {
return;
}
const sanitized = sanitizeTheme({
...theme.value,
...data,
});
if (sanitized) {
theme.value = sanitized;
}
}
};
const setDevOverride = (enabled: boolean) => {
devOverride.value = enabled;
};
const setCssVars = () => {
const selectedTheme = theme.value.name;
// Check if Unraid PHP has already set a Theme-- class
const hasExistingThemeClass =
typeof document !== 'undefined' &&
Array.from(document.documentElement.classList).some((cls) => cls.startsWith('Theme--'));
// Prepare Tailwind v4 theme classes
const themeClasses: string[] = [];
const customClasses: string[] = [];
// Apply dark/light mode using Tailwind v4 theme switching
if (darkMode.value) {
themeClasses.push('dark');
}
// Only apply theme-specific class if Unraid PHP hasn't already set it
if (!hasExistingThemeClass) {
themeClasses.push(`Theme--${selectedTheme}`);
}
// Only set CSS variables for dynamic/user-configured values from GraphQL
// Static theme values are handled by Tailwind v4 theme classes in @tailwind-shared
const dynamicVars: Partial<Record<DynamicVarKey, string>> = {};
// User-configured colors from webGUI @ /Settings/DisplaySettings
if (theme.value.textColor) {
dynamicVars['--custom-header-text-primary'] = theme.value.textColor;
customClasses.push('has-custom-header-text');
}
if (theme.value.metaColor) {
dynamicVars['--custom-header-text-secondary'] = theme.value.metaColor;
customClasses.push('has-custom-header-meta');
}
if (theme.value.bgColor) {
const gradientStart = hexToRgba(theme.value.bgColor, 0);
const gradientEnd = hexToRgba(theme.value.bgColor, 0.7);
dynamicVars['--custom-header-background-color'] = theme.value.bgColor;
dynamicVars['--custom-header-gradient-start'] = gradientStart;
dynamicVars['--custom-header-gradient-end'] = gradientEnd;
// Apply the resolved values directly to ensure they override base theme vars
dynamicVars['--header-background-color'] = theme.value.bgColor;
dynamicVars['--header-gradient-start'] = gradientStart;
dynamicVars['--header-gradient-end'] = gradientEnd;
dynamicVars['--color-header-background'] = theme.value.bgColor;
dynamicVars['--color-header-gradient-start'] = gradientStart;
dynamicVars['--color-header-gradient-end'] = gradientEnd;
customClasses.push('has-custom-header-bg');
}
// Set banner gradient if needed
if (theme.value.banner && theme.value.bannerGradient) {
const start = theme.value.bgColor
? hexToRgba(theme.value.bgColor, 0)
: 'var(--header-gradient-start)';
const end = theme.value.bgColor
? hexToRgba(theme.value.bgColor, 0.7)
: 'var(--header-gradient-end)';
dynamicVars['--banner-gradient'] = `linear-gradient(90deg, ${start} 0, ${end} 90%)`;
customClasses.push('has-banner-gradient');
}
requestAnimationFrame(() => {
const scopedTargets: HTMLElement[] = [
document.documentElement,
...Array.from(document.querySelectorAll<HTMLElement>('.unapi')),
];
const styleTargets = [...scopedTargets, document.body].filter(Boolean) as HTMLElement[];
const cleanClassList = (classList: string, isDocumentElement: boolean) => {
// Don't remove Theme-- classes from documentElement if Unraid PHP set them
if (isDocumentElement && hasExistingThemeClass) {
return classList
.split(' ')
.filter((c) => c !== 'dark' && !c.startsWith('has-custom-') && c !== 'has-banner-gradient')
.filter(Boolean)
.join(' ');
}
// For .unapi roots or when we're managing the theme class, clean everything
return classList
.split(' ')
.filter(
(c) =>
!c.startsWith('Theme--') &&
c !== 'dark' &&
!c.startsWith('has-custom-') &&
c !== 'has-banner-gradient'
)
.filter(Boolean)
.join(' ');
};
// Apply theme and custom classes to html element and all .unapi roots
scopedTargets.forEach((target) => {
const isDocumentElement = target === document.documentElement;
target.className = cleanClassList(target.className, isDocumentElement);
[...themeClasses, ...customClasses].forEach((cls) => target.classList.add(cls));
if (darkMode.value) {
target.classList.add('dark');
} else {
target.classList.remove('dark');
}
});
// Maintain dark mode flag on body for legacy components
if (darkMode.value) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
// Only apply dynamic CSS variables for custom user values
// All theme defaults are handled by classes in @tailwind-shared/theme-variants.css
const activeDynamicKeys = Object.keys(dynamicVars) as DynamicVarKey[];
styleTargets.forEach((target) => {
activeDynamicKeys.forEach((key) => {
const value = dynamicVars[key];
if (value !== undefined) {
target.style.setProperty(key, value);
}
});
DYNAMIC_VAR_KEYS.forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(dynamicVars, key)) {
target.style.removeProperty(key);
}
});
});
// Store active variables for reference (from defaultColors for compatibility)
const customTheme = { ...defaultColors[selectedTheme] };
activeColorVariables.value = customTheme;
const sanitized = sanitizeTheme({
...theme.value,
...data,
});
};
watch(
theme,
() => {
setCssVars();
},
{ immediate: true }
);
return {
// state
activeColorVariables,
bannerGradient,
darkMode,
theme,
// actions
setTheme,
setCssVars,
setDevOverride,
};
},
{
persist: {
key: THEME_STORAGE_KEY,
pick: ['theme'],
afterHydrate: (ctx) => {
const store = ctx.store as ReturnType<typeof useThemeStore>;
store.setTheme(store.theme);
store.setCssVars();
},
},
if (sanitized) {
theme.value = sanitized;
const fallbackTheme = defaultColors[sanitized.name as keyof typeof defaultColors];
activeColorVariables.value = {
...(fallbackTheme ?? defaultColors.white),
};
}
}
}
);
const setDevOverride = (enabled: boolean) => {
devOverride.value = enabled;
};
const setCssVars = () => {
applyDarkClass(darkMode.value);
};
watch(
theme,
() => {
setCssVars();
},
{ immediate: true }
);
return {
// state
activeColorVariables,
bannerGradient,
darkMode,
theme,
// actions
setTheme,
setCssVars,
setDevOverride,
};
});

View File

@@ -31,9 +31,6 @@ export async function initializeTheme(): Promise<void> {
// Load theme from GraphQL
await themeStore.setTheme();
// Apply CSS variables and Tailwind classes
themeStore.setCssVars();
isThemeInitialized = true;
console.debug('[ThemeInitializer] Theme initialized successfully');

View File

@@ -1,4 +1,5 @@
import { computed, ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { defineStore } from 'pinia';
import { ArrowPathIcon, BellAlertIcon } from '@heroicons/vue/24/solid';
@@ -32,6 +33,7 @@ export interface Release {
}
export const useUpdateOsActionsStore = defineStore('updateOsActions', () => {
const { t } = useI18n();
const accountStore = useAccountStore();
// const errorsStore = useErrorsStore();
const serverStore = useServerStore();
@@ -48,8 +50,13 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => {
const osVersion = computed(() => serverStore.osVersion);
const osVersionBranch = computed(() => serverStore.osVersionBranch);
const regUpdatesExpired = computed(() => serverStore.regUpdatesExpired);
const regTy = computed(() => serverStore.regTy);
const locale = computed(() => serverStore.locale);
const updateOsAvailable = computed(() => updateOsStore.available);
const availableWithRenewalRelease = computed(() =>
updateOsStore.availableWithRenewal ? serverStore.updateOsResponse : undefined
);
/** used when coming back from callback, this will be the release to install */
const status = ref<
| 'confirming'
@@ -65,14 +72,13 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => {
const callbackUpdateRelease = ref<Release | null>(null);
const rebootType = computed(() => serverStore.rebootType);
const rebootTypeText = computed(() => {
/** translations are handled by rendering template's `t()` */
switch (rebootType.value) {
case 'thirdPartyDriversDownloading':
return 'Updating 3rd party drivers';
return t('updateOs.reboot.thirdPartyDriversDownloading');
case 'downgrade':
return 'Reboot Required for Downgrade';
return t('updateOs.reboot.downgrade');
case 'update':
return 'Reboot Required for Update';
return t('updateOs.reboot.update');
default:
return '';
}
@@ -81,23 +87,37 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => {
const ineligible = computed(
() => !guid.value || !keyfile.value || !osVersion.value || regUpdatesExpired.value
);
const formattedReleaseDate = computed(() => {
if (!availableWithRenewalRelease.value?.date) return '';
const dateStr = availableWithRenewalRelease.value.date;
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
const userLocale = locale.value?.replace('_', '-') || navigator.language || 'en-US';
return new Intl.DateTimeFormat(userLocale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
});
const ineligibleText = computed(() => {
// translated in components
if (!guid.value) {
return 'A valid GUID is required to check for OS updates.';
return t('updateOs.ineligible.guidRequired');
}
if (!keyfile.value) {
return 'A valid keyfile is required to check for OS updates.';
return t('updateOs.ineligible.keyfileRequired');
}
if (!osVersion.value) {
return 'A valid OS version is required to check for OS updates.';
return t('updateOs.ineligible.osVersionRequired');
}
if (regUpdatesExpired.value) {
const base =
'Your {0} license included one year of free updates at the time of purchase. You are now eligible to extend your license and access the latest OS updates.';
const addtlText =
'You are still eligible to access OS updates that were published on or before {1}.';
return updateOsAvailable.value ? `${base} ${addtlText}` : base;
if (updateOsAvailable.value) {
return t('updateOs.ineligible.updatesExpiredWithAvailable', [
regTy.value,
formattedReleaseDate.value,
]);
}
return t('updateOs.ineligible.updatesExpired', [regTy.value]);
}
return '';
});
@@ -241,6 +261,7 @@ export const useUpdateOsActionsStore = defineStore('updateOsActions', () => {
status,
ineligible,
ineligibleText,
formattedReleaseDate,
toolsRegistrationAction,
// Actions
actOnUpdateOsAction,