fix: enhance dark mode support in theme handling (#1808)

- Added PHP logic to determine if the current theme is dark and set a
CSS variable accordingly.
- Introduced a new function to retrieve the dark mode state from the CSS
variable in JavaScript.
- Updated the theme store to initialize dark mode based on the CSS
variable, ensuring consistent theme application across the application.

This improves user experience by ensuring the correct theme is applied
based on user preferences.

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

* **New Features**
* Server-persisted theme mutation and client action to fetch/apply
themes

* **Improvements**
* Safer theme parsing and multi-source initialization (CSS var, storage,
cookie, server)
* Robust dark-mode detection and propagation across document, modals and
teleport containers
* Responsive banner/header gradient handling with tunable CSS variables
and fallbacks

* **Tests**
* Expanded tests for theme flows, dark-mode detection, banner gradients
and manifest robustness

<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-12-15 12:52:47 -05:00
committed by GitHub
parent 317e0fa307
commit d6e29395c8
31 changed files with 1458 additions and 250 deletions

View File

@@ -63,15 +63,6 @@
*/
.unapi {
--color-alpha: #1c1b1b;
--color-beta: #f2f2f2;
--color-gamma: #999999;
--color-gamma-opaque: rgba(153, 153, 153, 0.5);
--color-customgradient-start: rgba(242, 242, 242, 0);
--color-customgradient-end: rgba(242, 242, 242, 0.85);
--shadow-beta: 0 25px 50px -12px rgba(242, 242, 242, 0.15);
--ring-offset-shadow: 0 0 var(--color-beta);
--ring-shadow: 0 0 var(--color-beta);
}
.unapi button:not(:disabled),

View File

@@ -11,6 +11,11 @@
--color-beta: #1c1b1b;
--color-gamma: #ffffff;
--color-gamma-opaque: rgba(255, 255, 255, 0.3);
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
--ring-offset-shadow: 0 0 var(--color-beta);
--ring-shadow: 0 0 var(--color-beta);
}
/* Black Theme */
@@ -21,15 +26,26 @@
--color-beta: #f2f2f2;
--color-gamma: #1c1b1b;
--color-gamma-opaque: rgba(28, 27, 27, 0.3);
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
--ring-offset-shadow: 0 0 var(--color-beta);
--ring-shadow: 0 0 var(--color-beta);
}
/* Gray Theme */
.Theme--gray {
.Theme--gray,
.Theme--gray.dark {
--color-border: #383735;
--color-alpha: #ff8c2f;
--color-beta: #383735;
--color-gamma: #ffffff;
--color-gamma-opaque: rgba(255, 255, 255, 0.3);
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
--ring-offset-shadow: 0 0 var(--color-beta);
--ring-shadow: 0 0 var(--color-beta);
}
/* Azure Theme */
@@ -39,6 +55,11 @@
--color-beta: #e7f2f8;
--color-gamma: #336699;
--color-gamma-opaque: rgba(51, 102, 153, 0.3);
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
--ring-offset-shadow: 0 0 var(--color-beta);
--ring-shadow: 0 0 var(--color-beta);
}
/* Dark Mode Overrides */

View File

@@ -944,6 +944,23 @@ input UpdateApiKeyInput {
permissions: [AddPermissionInput!]
}
"""Customization related mutations"""
type CustomizationMutations {
"""Update the UI theme (writes dynamix.cfg)"""
setTheme(
"""Theme to apply"""
theme: ThemeName!
): Theme!
}
"""The theme name"""
enum ThemeName {
azure
black
gray
white
}
"""
Parity check related mutations, WIP, response types and functionaliy will change
"""
@@ -1042,14 +1059,6 @@ type Theme {
headerSecondaryTextColor: String
}
"""The theme name"""
enum ThemeName {
azure
black
gray
white
}
type ExplicitStatusItem {
name: String!
updateStatus: UpdateStatus!
@@ -2449,6 +2458,7 @@ type Mutation {
vm: VmMutations!
parityCheck: ParityCheckMutations!
apiKey: ApiKeyMutations!
customization: CustomizationMutations!
rclone: RCloneMutations!
createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1!
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { CustomizationMutationsResolver } from '@app/unraid-api/graph/resolvers/customization/customization.mutations.resolver.js';
import { CustomizationResolver } from '@app/unraid-api/graph/resolvers/customization/customization.resolver.js';
import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js';
@Module({
providers: [CustomizationService, CustomizationResolver],
providers: [CustomizationService, CustomizationResolver, CustomizationMutationsResolver],
exports: [CustomizationService],
})
export class CustomizationModule {}

View File

@@ -0,0 +1,25 @@
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js';
import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js';
import { CustomizationMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
@Resolver(() => CustomizationMutations)
export class CustomizationMutationsResolver {
constructor(private readonly customizationService: CustomizationService) {}
@ResolveField(() => Theme, { description: 'Update the UI theme (writes dynamix.cfg)' })
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.CUSTOMIZATIONS,
})
async setTheme(
@Args('theme', { type: () => ThemeName, description: 'Theme to apply' })
theme: ThemeName
): Promise<Theme> {
return this.customizationService.setTheme(theme);
}
}

View File

@@ -9,7 +9,9 @@ import * as ini from 'ini';
import { emcmd } from '@app/core/utils/clients/emcmd.js';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js';
import { getters, store } from '@app/store/index.js';
import { updateDynamixConfig } from '@app/store/modules/dynamix.js';
import {
ActivationCode,
PublicPartnerInfo,
@@ -466,4 +468,16 @@ export class CustomizationService implements OnModuleInit {
showHeaderDescription: descriptionShow === 'yes',
};
}
public async setTheme(theme: ThemeName): Promise<Theme> {
this.logger.log(`Updating theme to ${theme}`);
await this.updateCfgFile(this.configFile, 'display', { theme });
// Refresh in-memory store so subsequent reads get the new theme without a restart
const paths = getters.paths();
const updatedConfig = loadDynamixConfigFromDiskSync(paths['dynamix-config']);
store.dispatch(updateDynamixConfig(updatedConfig));
return this.getTheme();
}
}

View File

@@ -24,6 +24,11 @@ export class VmMutations {}
})
export class ApiKeyMutations {}
@ObjectType({
description: 'Customization related mutations',
})
export class CustomizationMutations {}
@ObjectType({
description: 'Parity check related mutations, WIP, response types and functionaliy will change',
})
@@ -54,6 +59,9 @@ export class RootMutations {
@Field(() => ApiKeyMutations, { description: 'API Key related mutations' })
apiKey: ApiKeyMutations = new ApiKeyMutations();
@Field(() => CustomizationMutations, { description: 'Customization related mutations' })
customization: CustomizationMutations = new CustomizationMutations();
@Field(() => ParityCheckMutations, { description: 'Parity check related mutations' })
parityCheck: ParityCheckMutations = new ParityCheckMutations();

View File

@@ -3,6 +3,7 @@ import { Mutation, Resolver } from '@nestjs/graphql';
import {
ApiKeyMutations,
ArrayMutations,
CustomizationMutations,
DockerMutations,
ParityCheckMutations,
RCloneMutations,
@@ -37,6 +38,11 @@ export class RootMutationsResolver {
return new ApiKeyMutations();
}
@Mutation(() => CustomizationMutations, { name: 'customization' })
customization(): CustomizationMutations {
return new CustomizationMutations();
}
@Mutation(() => RCloneMutations, { name: 'rclone' })
rclone(): RCloneMutations {
return new RCloneMutations();

View File

@@ -44,7 +44,12 @@ class WebComponentsExtractor
public function getManifestContents(string $manifestPath): array
{
$contents = @file_get_contents($manifestPath);
return $contents ? json_decode($contents, true) : [];
if (!$contents) {
return [];
}
$decoded = json_decode($contents, true);
return is_array($decoded) ? $decoded : [];
}
private function processManifestFiles(): string
@@ -209,6 +214,11 @@ class WebComponentsExtractor
}
$theme = strtolower(trim($display['theme'] ?? ''));
$darkThemes = ['gray', 'black'];
$isDarkMode = in_array($theme, $darkThemes, true);
$vars['--theme-dark-mode'] = $isDarkMode ? '1' : '0';
$vars['--theme-name'] = $theme ?: 'white';
if ($theme === 'white') {
if (!$textPrimary) {
$vars['--header-text-primary'] = 'var(--inverse-text-color, #ffffff)';
@@ -218,19 +228,35 @@ class WebComponentsExtractor
}
}
// Unraid WebGUI stores banner enablement as a non-empty `display['banner']` value
// (typically the banner file name/path).
$shouldShowBanner = !empty($display['banner']);
$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);
// Only set gradient variables if banner image is enabled
if ($shouldShowBanner) {
$vars['--header-gradient-start'] = $this->hexToRgba($bgColor, 0);
$vars['--header-gradient-end'] = $this->hexToRgba($bgColor, 1);
}
}
$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)';
if ($shouldShowBanner && $shouldShowBannerGradient) {
// If the user didn't set a custom background color, prefer existing theme defaults instead of falling back to black.
if (!isset($vars['--header-gradient-start'])) {
$vars['--header-gradient-start'] = 'var(--color-header-gradient-start, rgba(242, 242, 242, 0))';
}
if (!isset($vars['--header-gradient-end'])) {
$vars['--header-gradient-end'] = 'var(--color-header-gradient-end, rgba(242, 242, 242, 1))';
}
$start = $vars['--header-gradient-start'];
$end = $vars['--header-gradient-end'];
// Keep compatibility with older CSS that expects these names.
$vars['--color-header-gradient-start'] = $start;
$vars['--color-header-gradient-end'] = $end;
$vars['--banner-gradient'] = sprintf(
'linear-gradient(90deg, %s 0, %s 90%%)',
'linear-gradient(90deg, %s 0, %s var(--banner-gradient-stop, 30%%))',
$start,
$end
);

View File

@@ -101,6 +101,29 @@ class ExtractorTest {
'file' => 'special\'file".css'
]
], JSON_PRETTY_PRINT));
// Create an invalid JSON manifest to ensure it is safely ignored
file_put_contents($this->componentDir . '/other/invalid.manifest.json', '{ invalid json ');
// Create an empty manifest file
file_put_contents($this->componentDir . '/other/empty.manifest.json', '');
// Create a manifest with unsupported file types to ensure they are ignored
file_put_contents($this->componentDir . '/other/unsupported.manifest.json', json_encode([
'image-entry' => [
'file' => 'logo.svg'
],
'font-entry' => [
'file' => 'font.woff2'
]
], JSON_PRETTY_PRINT));
// Create a manifest with invalid CSS list entries (only strings should be emitted)
file_put_contents($this->componentDir . '/other/css-list-invalid.manifest.json', json_encode([
'css-list-test' => [
'file' => 'css-list-test.js',
'css' => ['ok.css', '', null, 0, false]
]
], JSON_PRETTY_PRINT));
// Copy and modify the extractor for testing
$this->prepareExtractor();
@@ -124,14 +147,24 @@ class ExtractorTest {
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
require_once $this->testDir . '/extractor.php';
if ($resetStatic && class_exists('WebComponentsExtractor')) {
WebComponentsExtractor::resetScriptsOutput();
if ($resetStatic) {
$this->resetExtractor();
}
$extractor = WebComponentsExtractor::getInstance();
return $extractor->getScriptTagHtml();
}
private function getExtractorOutputWithDisplay(?array $display): string
{
if ($display === null) {
unset($GLOBALS['display']);
} else {
$GLOBALS['display'] = $display;
}
return $this->getExtractorOutput(true);
}
private function runTests() {
echo "\n";
echo "========================================\n";
@@ -302,11 +335,32 @@ class ExtractorTest {
"CSS from manifest has data-unraid attribute",
preg_match('/<link[^>]+id="unraid-[^"]*-css-[^"]+"[^>]+data-unraid="1"/', $output) > 0
);
$this->test(
"Ignores non-string/empty entries in css array",
preg_match_all('/id="unraid-other-css-list-test-css-[^"]+"/', $output, $matches) === 1 &&
isset($matches[0][0]) &&
strpos($matches[0][0], 'id="unraid-other-css-list-test-css-ok-css"') !== false
);
// Test: Manifest Format Robustness
echo "\nTest: Manifest Format Robustness\n";
echo "---------------------------------\n";
$this->testManifestContentsRobustness();
$this->test(
"Does not generate tags for unsupported file extensions",
strpos($output, 'logo.svg') === false &&
strpos($output, 'font.woff2') === false
);
// Test: CSS Variable Validation
echo "\nTest: CSS Variable Validation\n";
echo "------------------------------\n";
$this->testCssVariableValidation();
// Test: Display Variations / Theme CSS Vars
echo "\nTest: Display Variations\n";
echo "-------------------------\n";
$this->testDisplayVariations();
// Test: Duplicate Prevention
echo "\nTest: Duplicate Prevention\n";
@@ -433,6 +487,174 @@ class ExtractorTest {
strpos($output, '123') === false
);
}
private function testDisplayVariations(): void
{
// No $display => no theme CSS vars injected
$output = $this->getExtractorOutputWithDisplay(null);
$this->test(
"No display data produces no theme CSS var style tag",
strpos($output, 'id="unraid-theme-css-vars"') === false
);
// Banner empty + gradient yes => gradient should be ignored (no banner image)
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => '',
'showBannerGradient' => 'yes',
'background' => '112233',
]);
$this->test(
"Banner disabled suppresses --banner-gradient",
strpos($output, '--banner-gradient:') === false
);
$this->test(
"Banner disabled suppresses header gradient start/end",
strpos($output, '--header-gradient-start:') === false &&
strpos($output, '--header-gradient-end:') === false
);
// Banner enabled + gradient yes + valid background => gradient vars and banner gradient
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => 'image',
'showBannerGradient' => 'yes',
'background' => '112233',
]);
$this->test(
"Injects theme vars style tag",
strpos($output, 'id="unraid-theme-css-vars"') !== false &&
strpos($output, ':root {') !== false
);
$this->test(
"Sets --theme-name from display theme",
strpos($output, '--theme-name: azure;') !== false
);
$this->test(
"Sets --theme-dark-mode for non-dark themes",
strpos($output, '--theme-dark-mode: 0;') !== false
);
$this->test(
"Normalizes and sets background color",
strpos($output, '--header-background-color: #112233;') !== false
);
$this->test(
"Derives header gradient start/end from background",
strpos($output, '--header-gradient-start: rgba(17, 34, 51, 0.000);') !== false &&
strpos($output, '--header-gradient-end: rgba(17, 34, 51, 1.000);') !== false
);
$this->test(
"Emits --banner-gradient with banner stop variable",
strpos($output, '--banner-gradient: linear-gradient(90deg,') !== false &&
strpos($output, 'var(--banner-gradient-stop, 30%)') !== false
);
// Banner enabled + gradient yes but no custom background => should use theme defaults (not black fallbacks)
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => 'image',
'showBannerGradient' => 'yes',
]);
$this->test(
"No custom background uses theme defaults for gradient vars",
strpos($output, '--header-gradient-start: var(--color-header-gradient-start') !== false &&
strpos($output, '--header-gradient-end: var(--color-header-gradient-end') !== false
);
$this->test(
"No custom background still emits --banner-gradient",
strpos($output, '--banner-gradient: linear-gradient(90deg,') !== false
);
// Banner enabled + gradient no => no --banner-gradient, but does set start/end for other CSS usage
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => 'image',
'showBannerGradient' => 'no',
'background' => '112233',
]);
$this->test(
"Gradient disabled suppresses --banner-gradient",
strpos($output, '--banner-gradient:') === false
);
$this->test(
"Banner enabled still emits header gradient start/end",
strpos($output, '--header-gradient-start:') !== false &&
strpos($output, '--header-gradient-end:') !== false
);
// Dark themes set --theme-dark-mode = 1
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'black',
'banner' => 'image',
'showBannerGradient' => 'yes',
'background' => '112233',
]);
$this->test(
"Dark theme sets --theme-dark-mode to 1",
strpos($output, '--theme-dark-mode: 1;') !== false &&
strpos($output, '--theme-name: black;') !== false
);
// Hex normalization: 3-digit values expand and lower-case
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => 'image',
'showBannerGradient' => 'yes',
'background' => 'aBc',
'header' => 'FfF',
'headermetacolor' => '#0F0',
]);
$this->test(
"Normalizes 3-digit hex values",
strpos($output, '--header-background-color: #aabbcc;') !== false &&
strpos($output, '--header-text-primary: #ffffff;') !== false &&
strpos($output, '--header-text-secondary: #00ff00;') !== false
);
// Invalid background => should not emit background var
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => 'image',
'showBannerGradient' => 'yes',
'background' => 'not-a-hex',
]);
$this->test(
"Rejects invalid background color",
strpos($output, '--header-background-color:') === false
);
}
private function testManifestContentsRobustness(): void
{
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
require_once $this->testDir . '/extractor.php';
$extractor = WebComponentsExtractor::getInstance();
$missing = $extractor->getManifestContents($this->componentDir . '/other/does-not-exist.manifest.json');
$this->test(
"Missing manifest returns an empty array",
is_array($missing) && $missing === []
);
$empty = $extractor->getManifestContents($this->componentDir . '/other/empty.manifest.json');
$this->test(
"Empty manifest returns an empty array",
is_array($empty) && $empty === []
);
$invalid = $extractor->getManifestContents($this->componentDir . '/other/invalid.manifest.json');
$this->test(
"Invalid JSON manifest returns an empty array",
is_array($invalid) && $invalid === []
);
$valid = $extractor->getManifestContents($this->componentDir . '/other/manifest.json');
$this->test(
"Valid manifest decodes to an array",
is_array($valid) && isset($valid['app-entry']) && isset($valid['app-styles'])
);
}
private function test($name, $condition) {
if ($condition) {

View File

@@ -1,12 +1,16 @@
import useTeleport from '@/composables/useTeleport';
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent } from 'vue';
import { defineComponent, nextTick } from 'vue';
describe('useTeleport', () => {
beforeEach(() => {
// Reset modules before each test to ensure fresh state
vi.resetModules();
// Clear the DOM before each test
document.body.innerHTML = '';
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
document.documentElement.style.removeProperty('--theme-dark-mode');
vi.clearAllMocks();
});
@@ -16,16 +20,19 @@ describe('useTeleport', () => {
if (virtualContainer) {
virtualContainer.remove();
}
// Reset the module to clear the virtualModalContainer variable
vi.resetModules();
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
document.documentElement.style.removeProperty('--theme-dark-mode');
});
it('should return teleportTarget ref with correct value', () => {
it('should return teleportTarget ref with correct value', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#unraid-api-modals-virtual');
});
it('should create virtual container element on mount with correct properties', () => {
it('should create virtual container element on mount with correct properties', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
const TestComponent = defineComponent({
setup() {
const { teleportTarget } = useTeleport();
@@ -39,6 +46,7 @@ describe('useTeleport', () => {
// Mount the component
mount(TestComponent);
await nextTick();
// After mount, virtual container should be created with correct properties
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
@@ -49,7 +57,8 @@ describe('useTeleport', () => {
expect(virtualContainer?.parentElement).toBe(document.body);
});
it('should reuse existing virtual container within same test', () => {
it('should reuse existing virtual container within same test', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
// Manually create the container first
const manualContainer = document.createElement('div');
manualContainer.id = 'unraid-api-modals-virtual';
@@ -68,10 +77,128 @@ describe('useTeleport', () => {
// Mount component - should not create a new container
mount(TestComponent);
await nextTick();
// Should still have only one container
const containers = document.querySelectorAll('#unraid-api-modals-virtual');
expect(containers.length).toBe(1);
expect(containers[0]).toBe(manualContainer);
});
it('should apply dark class when dark mode is active via CSS variable', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
const originalGetComputedStyle = window.getComputedStyle;
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '1';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
const TestComponent = defineComponent({
setup() {
const { teleportTarget } = useTeleport();
return { teleportTarget };
},
template: '<div>{{ teleportTarget }}</div>',
});
const wrapper = mount(TestComponent);
await nextTick();
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
expect(virtualContainer).toBeTruthy();
expect(virtualContainer?.classList.contains('dark')).toBe(true);
wrapper.unmount();
getComputedStyleSpy.mockRestore();
});
it('should not apply dark class when dark mode is inactive via CSS variable', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
const originalGetComputedStyle = window.getComputedStyle;
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '0';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
const TestComponent = defineComponent({
setup() {
const { teleportTarget } = useTeleport();
return { teleportTarget };
},
template: '<div>{{ teleportTarget }}</div>',
});
const wrapper = mount(TestComponent);
await nextTick();
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
expect(virtualContainer).toBeTruthy();
expect(virtualContainer?.classList.contains('dark')).toBe(false);
wrapper.unmount();
getComputedStyleSpy.mockRestore();
});
it('should apply dark class when dark mode is active via documentElement class', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
document.documentElement.classList.add('dark');
const originalGetComputedStyle = window.getComputedStyle;
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
const TestComponent = defineComponent({
setup() {
const { teleportTarget } = useTeleport();
return { teleportTarget };
},
template: '<div>{{ teleportTarget }}</div>',
});
const wrapper = mount(TestComponent);
await nextTick();
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
expect(virtualContainer).toBeTruthy();
expect(virtualContainer?.classList.contains('dark')).toBe(true);
wrapper.unmount();
getComputedStyleSpy.mockRestore();
document.documentElement.classList.remove('dark');
});
});

View File

@@ -1,15 +1,24 @@
import { isDarkModeActive } from '@/lib/utils';
import { onMounted, ref } from 'vue';
let virtualModalContainer: HTMLDivElement | null = null;
const ensureVirtualContainer = () => {
if (!virtualModalContainer) {
virtualModalContainer = document.createElement('div');
virtualModalContainer.id = 'unraid-api-modals-virtual';
virtualModalContainer.className = 'unapi';
virtualModalContainer.style.position = 'relative';
virtualModalContainer.style.zIndex = '999999';
document.body.appendChild(virtualModalContainer);
const existing = document.getElementById('unraid-api-modals-virtual');
if (existing) {
virtualModalContainer = existing as HTMLDivElement;
} else {
virtualModalContainer = document.createElement('div');
virtualModalContainer.id = 'unraid-api-modals-virtual';
virtualModalContainer.className = 'unapi';
virtualModalContainer.style.position = 'relative';
virtualModalContainer.style.zIndex = '999999';
if (isDarkModeActive()) {
virtualModalContainer.classList.add('dark');
}
document.body.appendChild(virtualModalContainer);
}
}
return virtualModalContainer;
};

View File

@@ -0,0 +1,193 @@
import { isDarkModeActive } from '@/lib/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('isDarkModeActive', () => {
const originalGetComputedStyle = window.getComputedStyle;
const originalDocumentElement = document.documentElement;
const originalBody = document.body;
beforeEach(() => {
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
document.documentElement.style.removeProperty('--theme-dark-mode');
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
document.documentElement.style.removeProperty('--theme-dark-mode');
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
});
describe('CSS variable detection', () => {
it('should return true when CSS variable is set to "1"', () => {
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '1';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(true);
});
it('should return false when CSS variable is set to "0"', () => {
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '0';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(false);
});
it('should return false when CSS variable is explicitly "0" even if dark class exists', () => {
document.documentElement.classList.add('dark');
document.body.classList.add('dark');
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '0';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(false);
});
});
describe('ClassList detection fallback', () => {
it('should return true when documentElement has dark class and CSS variable is not set', () => {
document.documentElement.classList.add('dark');
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(true);
});
it('should return true when body has dark class and CSS variable is not set', () => {
document.body.classList.add('dark');
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(true);
});
it('should return true when .unapi.dark element exists and CSS variable is not set', () => {
const unapiElement = document.createElement('div');
unapiElement.className = 'unapi dark';
document.body.appendChild(unapiElement);
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(true);
unapiElement.remove();
});
it('should return false when no dark indicators are present', () => {
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(false);
});
});
describe('SSR/Node environment', () => {
it('should return false when document is undefined', () => {
const originalDocument = global.document;
// @ts-expect-error - intentionally removing document for SSR test
global.document = undefined;
expect(isDarkModeActive()).toBe(false);
global.document = originalDocument;
});
});
});

View File

@@ -54,3 +54,17 @@ export class Markdown {
return Markdown.instance.parse(markdownContent);
}
}
export const isDarkModeActive = (): boolean => {
if (typeof document === 'undefined') return false;
const cssVar = getComputedStyle(document.documentElement).getPropertyValue('--theme-dark-mode').trim();
if (cssVar === '1') return true;
if (cssVar === '0') return false;
if (document.documentElement.classList.contains('dark')) return true;
if (document.body?.classList.contains('dark')) return true;
if (document.querySelector('.unapi.dark')) return true;
return false;
};

View File

@@ -2,3 +2,4 @@ auto-imports.d.ts
components.d.ts
composables/gql/
src/composables/gql/
dist/

View File

@@ -22,6 +22,13 @@ vi.mock('@vue/apollo-composable', () => ({
onResult: vi.fn(),
onError: vi.fn(),
}),
useLazyQuery: () => ({
load: vi.fn(),
result: ref(null),
loading: ref(false),
onResult: vi.fn(),
onError: vi.fn(),
}),
}));
// Explicitly mock @unraid/ui to ensure we use the actual components
@@ -54,6 +61,11 @@ describe('ColorSwitcher', () => {
beforeEach(() => {
vi.useFakeTimers();
// Set CSS variables for theme store
document.documentElement.style.setProperty('--theme-dark-mode', '0');
document.documentElement.style.setProperty('--banner-gradient', '');
const pinia = createTestingPinia({ createSpy: vi.fn });
setActivePinia(pinia);
themeStore = useThemeStore();
@@ -69,8 +81,12 @@ describe('ColorSwitcher', () => {
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
document.body.removeChild(modalDiv);
consoleWarnSpy.mockRestore();
if (modalDiv && modalDiv.parentNode) {
modalDiv.parentNode.removeChild(modalDiv);
}
if (consoleWarnSpy) {
consoleWarnSpy.mockRestore();
}
});
it('renders all form elements correctly', () => {

View File

@@ -53,6 +53,18 @@ vi.mock('@unraid/ui', () => ({
props: ['variant', 'size'],
},
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
isDarkModeActive: vi.fn(() => {
if (typeof document === 'undefined') return false;
const cssVar = getComputedStyle(document.documentElement)
.getPropertyValue('--theme-dark-mode')
.trim();
if (cssVar === '1') return true;
if (cssVar === '0') return false;
if (document.documentElement.classList.contains('dark')) return true;
if (document.body?.classList.contains('dark')) return true;
if (document.querySelector('.unapi.dark')) return true;
return false;
}),
}));
const mockWatcher = vi.fn();
@@ -182,26 +194,33 @@ describe('UserProfile.standalone.vue', () => {
createSpy: vi.fn,
initialState: {
server: { ...initialServerData },
theme: {
theme: {
name: 'default',
banner: true,
bannerGradient: true,
descriptionShow: true,
textColor: '',
metaColor: '',
bgColor: '',
},
bannerGradient: 'linear-gradient(to right, #ff0000, #0000ff)',
},
},
stubActions: false,
});
setActivePinia(pinia);
serverStore = useServerStore();
// Set CSS variables directly on document element for theme store
document.documentElement.style.setProperty('--theme-dark-mode', '0');
document.documentElement.style.setProperty(
'--banner-gradient',
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) var(--banner-gradient-stop, 30%))'
);
themeStore = useThemeStore();
// Set the theme using setTheme method
themeStore.setTheme({
name: 'white',
banner: true,
bannerGradient: true,
descriptionShow: true,
textColor: '',
metaColor: '',
bgColor: '',
});
// Override the setServer method to prevent console logging
vi.spyOn(serverStore, 'setServer').mockImplementation((server) => {
Object.assign(serverStore, server);
@@ -326,7 +345,7 @@ describe('UserProfile.standalone.vue', () => {
expect(themeStore.theme?.descriptionShow).toBe(true);
serverStore.description = initialServerData.description!;
themeStore.theme!.descriptionShow = true;
themeStore.setTheme({ ...themeStore.theme, descriptionShow: true });
await wrapper.vm.$nextTick();
// Look for the description in a span element with v-html directive
@@ -334,14 +353,14 @@ describe('UserProfile.standalone.vue', () => {
expect(descriptionElement.exists()).toBe(true);
expect(descriptionElement.html()).toContain(initialServerData.description);
themeStore.theme!.descriptionShow = false;
themeStore.setTheme({ ...themeStore.theme, descriptionShow: false });
await wrapper.vm.$nextTick();
// When descriptionShow is false, the element should not exist
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
expect(descriptionElement.exists()).toBe(false);
themeStore.theme!.descriptionShow = true;
themeStore.setTheme({ ...themeStore.theme, descriptionShow: true });
await wrapper.vm.$nextTick();
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
@@ -359,28 +378,34 @@ describe('UserProfile.standalone.vue', () => {
});
it('conditionally renders banner based on theme store', async () => {
const bannerSelector = 'div.absolute.z-0';
const bannerSelector = '.unraid-banner-gradient-layer';
themeStore.theme = {
...themeStore.theme!,
themeStore.setTheme({
...themeStore.theme,
banner: true,
bannerGradient: true,
};
});
await wrapper.vm.$nextTick();
expect(themeStore.bannerGradient).toContain('background-image: linear-gradient');
expect(themeStore.bannerGradient).toBe(true);
expect(wrapper.find(bannerSelector).exists()).toBe(true);
themeStore.theme!.bannerGradient = false;
themeStore.setTheme({
...themeStore.theme,
bannerGradient: false,
});
await wrapper.vm.$nextTick();
expect(themeStore.bannerGradient).toBeUndefined();
expect(themeStore.bannerGradient).toBe(false);
expect(wrapper.find(bannerSelector).exists()).toBe(false);
themeStore.theme!.bannerGradient = true;
themeStore.setTheme({
...themeStore.theme,
bannerGradient: true,
});
await wrapper.vm.$nextTick();
expect(themeStore.bannerGradient).toContain('background-image: linear-gradient');
expect(themeStore.bannerGradient).toBe(true);
expect(wrapper.find(bannerSelector).exists()).toBe(true);
});
});

View File

@@ -21,6 +21,21 @@ vi.mock('@nuxt/ui/vue-plugin', () => ({
},
}));
vi.mock('@unraid/ui', () => ({
isDarkModeActive: vi.fn(() => {
if (typeof document === 'undefined') return false;
const cssVar = getComputedStyle(document.documentElement)
.getPropertyValue('--theme-dark-mode')
.trim();
if (cssVar === '1') return true;
if (cssVar === '0') return false;
if (document.documentElement.classList.contains('dark')) return true;
if (document.body?.classList.contains('dark')) return true;
if (document.querySelector('.unapi.dark')) return true;
return false;
}),
}));
// Mock component registry
const mockComponentMappings: ComponentMapping[] = [];
vi.mock('~/components/Wrapper/component-registry', () => ({

View File

@@ -94,5 +94,17 @@ vi.mock('@unraid/ui', () => ({
name: 'ResponsiveModalTitle',
template: '<div><slot /></div>',
},
isDarkModeActive: vi.fn(() => {
if (typeof document === 'undefined') return false;
const cssVar = getComputedStyle(document.documentElement)
.getPropertyValue('--theme-dark-mode')
.trim();
if (cssVar === '1') return true;
if (cssVar === '0') return false;
if (document.documentElement.classList.contains('dark')) return true;
if (document.body?.classList.contains('dark')) return true;
if (document.querySelector('.unapi.dark')) return true;
return false;
}),
// Add other UI components as needed
}));

View File

@@ -18,6 +18,28 @@ vi.mock('@vue/apollo-composable', () => ({
onResult: vi.fn(),
onError: vi.fn(),
}),
useLazyQuery: () => ({
load: vi.fn(),
result: ref(null),
loading: ref(false),
onResult: vi.fn(),
onError: vi.fn(),
}),
}));
vi.mock('@unraid/ui', () => ({
isDarkModeActive: vi.fn(() => {
if (typeof document === 'undefined') return false;
const cssVar = getComputedStyle(document.documentElement)
.getPropertyValue('--theme-dark-mode')
.trim();
if (cssVar === '1') return true;
if (cssVar === '0') return false;
if (document.documentElement.classList.contains('dark')) return true;
if (document.body?.classList.contains('dark')) return true;
if (document.querySelector('.unapi.dark')) return true;
return false;
}),
}));
describe('Theme Store', () => {
@@ -43,6 +65,11 @@ describe('Theme Store', () => {
document.body.style.cssText = '';
document.documentElement.classList.add = vi.fn();
document.documentElement.classList.remove = vi.fn();
document.documentElement.style.removeProperty('--theme-dark-mode');
document.documentElement.style.removeProperty('--theme-name');
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
@@ -55,7 +82,13 @@ describe('Theme Store', () => {
afterEach(() => {
store?.$dispose();
store = undefined;
app?.unmount();
if (app) {
try {
app.unmount();
} catch {
// App was not mounted, ignore
}
}
app = undefined;
document.body.classList.add = originalAddClassFn;
@@ -90,44 +123,39 @@ describe('Theme Store', () => {
expect(store.activeColorVariables).toEqual(defaultColors.white);
});
it('should compute darkMode correctly', () => {
it('should compute darkMode from CSS variable when set to 1', () => {
document.documentElement.style.setProperty('--theme-dark-mode', '1');
const store = createStore();
expect(store.darkMode).toBe(false);
store.setTheme({ ...store.theme, name: 'black' });
expect(store.darkMode).toBe(true);
});
store.setTheme({ ...store.theme, name: 'gray' });
expect(store.darkMode).toBe(true);
store.setTheme({ ...store.theme, name: 'white' });
it('should compute darkMode from CSS variable when set to 0', () => {
document.documentElement.style.setProperty('--theme-dark-mode', '0');
const store = createStore();
expect(store.darkMode).toBe(false);
});
it('should compute bannerGradient correctly', () => {
it('should compute bannerGradient from CSS variable when set', async () => {
document.documentElement.style.setProperty('--theme-dark-mode', '0');
// Set the gradient with the resolved value (not nested var()) since getComputedStyle resolves it
document.documentElement.style.setProperty(
'--banner-gradient',
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) 30%)'
);
const store = createStore();
store.setTheme({ banner: true, bannerGradient: true });
await nextTick();
expect(store.theme.banner).toBe(true);
expect(store.theme.bannerGradient).toBe(true);
expect(store.darkMode).toBe(false);
expect(store.bannerGradient).toBe(true);
});
expect(store.bannerGradient).toBeUndefined();
store.setTheme({
...store.theme,
banner: true,
bannerGradient: true,
});
expect(store.bannerGradient).toMatchInlineSnapshot(
`"background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, var(--header-background-color) 90%);"`
);
store.setTheme({
...store.theme,
banner: true,
bannerGradient: true,
bgColor: '#123456',
});
expect(store.bannerGradient).toMatchInlineSnapshot(
`"background-image: linear-gradient(90deg, var(--header-gradient-start) 0, var(--header-gradient-end) 90%);"`
);
it('should return false when bannerGradient CSS variable is not set', () => {
document.documentElement.style.removeProperty('--banner-gradient');
const store = createStore();
expect(store.bannerGradient).toBe(false);
});
});
@@ -157,12 +185,16 @@ describe('Theme Store', () => {
await nextTick();
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
expect(store.darkMode).toBe(true);
store.setTheme({ ...store.theme, name: 'white' });
await nextTick();
expect(document.body.classList.remove).toHaveBeenCalledWith('dark');
expect(document.documentElement.classList.remove).toHaveBeenCalledWith('dark');
expect(store.darkMode).toBe(false);
});
it('should update activeColorVariables when theme changes', async () => {
@@ -195,33 +227,22 @@ describe('Theme Store', () => {
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
expect(store.darkMode).toBe(true);
});
it('should apply dark mode classes to all .unapi elements', async () => {
it('should update darkMode reactively when theme changes', 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');
expect(store.darkMode).toBe(false);
store.setTheme({
...store.theme,
name: 'black',
name: 'gray',
});
await nextTick();
expect(addSpy1).toHaveBeenCalledWith('dark');
expect(addSpy2).toHaveBeenCalledWith('dark');
expect(store.darkMode).toBe(true);
store.setTheme({
...store.theme,
@@ -230,11 +251,40 @@ describe('Theme Store', () => {
await nextTick();
expect(removeSpy1).toHaveBeenCalledWith('dark');
expect(removeSpy2).toHaveBeenCalledWith('dark');
expect(store.darkMode).toBe(false);
});
document.body.removeChild(unapiElement1);
document.body.removeChild(unapiElement2);
it('should initialize dark mode from CSS variable on store creation', () => {
// Mock getComputedStyle to return dark mode
const originalGetComputedStyle = window.getComputedStyle;
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '1';
}
if (prop === '--theme-name') {
return 'black';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
document.documentElement.style.setProperty('--theme-dark-mode', '1');
const store = createStore();
// Should have added dark class to documentElement and body
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
expect(store.darkMode).toBe(true);
vi.restoreAllMocks();
});
});
});

View File

@@ -157,6 +157,21 @@ iframe#progressFrame {
color-scheme: light;
}
/* Banner gradient tuning */
:root {
--banner-gradient-stop: 30%;
}
.unraid-banner-gradient-layer {
background-image: var(--banner-gradient);
}
@media (max-width: 768px) {
:root {
--banner-gradient-stop: 60%;
}
}
/* Header banner compatibility tweaks */
#header.image {
background-position: center center;
@@ -178,16 +193,8 @@ iframe#progressFrame {
background-position: left center, right center;
background-size: min(30%, 320px) 100%, min(30%, 320px) 100%;
background-image:
linear-gradient(
90deg,
var(--color-header-gradient-end, rgba(0, 0, 0, 0.7)) 0%,
var(--color-header-gradient-start, rgba(0, 0, 0, 0)) 100%
),
linear-gradient(
270deg,
var(--color-header-gradient-end, rgba(0, 0, 0, 0.7)) 0%,
var(--color-header-gradient-start, rgba(0, 0, 0, 0)) 100%
);
var(--banner-gradient),
linear-gradient(270deg, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 0%, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 10%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 90%, transparent) 25%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 60%, transparent) 40%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 30%, transparent) 55%, var(--header-gradient-start, var(--color-header-gradient-start, rgba(0, 0, 0, 0))) 70%, var(--header-gradient-start, var(--color-header-gradient-start, rgba(0, 0, 0, 0))) var(--banner-gradient-stop, 30%));
z-index: 0;
}

View File

@@ -0,0 +1,17 @@
import { graphql } from '~/composables/gql/gql';
export const SET_THEME_MUTATION = graphql(/* GraphQL */ `
mutation setTheme($theme: ThemeName!) {
customization {
setTheme(theme: $theme) {
name
showBannerImage
showBannerGradient
headerBackgroundColor
showHeaderDescription
headerPrimaryTextColor
headerSecondaryTextColor
}
}
}
`);

View File

@@ -1,66 +1,139 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { useThemeStore } from '~/store/theme';
import type { GetThemeQuery } from '~/composables/gql/graphql';
import { SET_THEME_MUTATION } from '~/components/DevThemeSwitcher.mutation';
import { ThemeName } from '~/composables/gql/graphql';
import { DARK_UI_THEMES, GET_THEME_QUERY, useThemeStore } from '~/store/theme';
const themeStore = useThemeStore();
const themeOptions = [
{ value: 'white', label: 'White' },
{ value: 'black', label: 'Black' },
{ value: 'gray', label: 'Gray' },
{ value: 'azure', label: 'Azure' },
] as const;
const themeOptions: Array<{ value: ThemeName; label: string }> = [
{ value: ThemeName.WHITE, label: 'White' },
{ value: ThemeName.BLACK, label: 'Black' },
{ value: ThemeName.GRAY, label: 'Gray' },
{ value: ThemeName.AZURE, label: 'Azure' },
];
const STORAGE_KEY_THEME = 'unraid:test:theme';
const THEME_COOKIE_KEY = 'unraid_dev_theme';
const { theme } = storeToRefs(themeStore);
const currentTheme = ref<string>(theme.value.name);
const themeValues = new Set<ThemeName>(themeOptions.map((option) => option.value));
const getCurrentTheme = (): string => {
const urlParams = new URLSearchParams(window.location.search);
const urlTheme = urlParams.get('theme');
const normalizeTheme = (value?: string | ThemeName | null): ThemeName | null => {
const normalized = (value ?? '').toString().toLowerCase();
return themeValues.has(normalized as ThemeName) ? (normalized as ThemeName) : null;
};
if (urlTheme && themeOptions.some((t) => t.value === urlTheme)) {
return urlTheme;
const readCookieTheme = (): string | null => {
if (typeof document === 'undefined') {
return null;
}
if (theme.value?.name) {
return theme.value.name;
const cookies = document.cookie?.split(';') ?? [];
for (const cookie of cookies) {
const [name, ...rest] = cookie.split('=');
if (name?.trim() === THEME_COOKIE_KEY) {
return decodeURIComponent(rest.join('=').trim());
}
}
return null;
};
const readLocalStorageTheme = (): string | null => {
try {
return window.localStorage?.getItem(STORAGE_KEY_THEME) || 'white';
return window.localStorage?.getItem(STORAGE_KEY_THEME) ?? null;
} catch {
return 'white';
return null;
}
};
const updateTheme = (themeName: string, skipUrlUpdate = false) => {
if (!skipUrlUpdate) {
const url = new URL(window.location.href);
url.searchParams.set('theme', themeName);
window.history.replaceState({}, '', url);
const readCssTheme = (): string | null => {
if (typeof window === 'undefined') {
return null;
}
return getComputedStyle(document.documentElement).getPropertyValue('--theme-name').trim() || null;
};
const resolveInitialTheme = async (): Promise<ThemeName> => {
const candidates = [readCssTheme(), readCookieTheme(), readLocalStorageTheme(), theme.value?.name];
for (const candidate of candidates) {
const normalized = normalizeTheme(candidate);
if (normalized) {
return normalized;
}
}
return ThemeName.WHITE;
};
const currentTheme = ref<ThemeName>(normalizeTheme(theme.value.name) ?? ThemeName.WHITE);
const isSaving = ref(false);
const isQueryLoading = ref(false);
const { onResult: onThemeResult, loading: queryLoading } = useQuery<GetThemeQuery>(
GET_THEME_QUERY,
null,
{ fetchPolicy: 'network-only' }
);
onThemeResult(({ data }) => {
const serverTheme = normalizeTheme(data?.publicTheme?.name);
if (serverTheme) {
void applyThemeSelection(serverTheme, { skipStore: false });
}
});
watch(
() => queryLoading.value,
(loading) => {
isQueryLoading.value = loading;
},
{ immediate: true }
);
const { mutate: setThemeMutation } = useMutation(SET_THEME_MUTATION);
const persistThemePreference = (themeName: ThemeName) => {
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `${THEME_COOKIE_KEY}=${encodeURIComponent(themeName)}; path=/; SameSite=Lax; expires=${expires}`;
try {
window.localStorage?.setItem(STORAGE_KEY_THEME, themeName);
} catch {
// ignore
}
};
themeStore.setTheme({ name: themeName });
const syncDomForTheme = (themeName: ThemeName) => {
const root = document.documentElement;
const isDark = DARK_UI_THEMES.includes(themeName as (typeof DARK_UI_THEMES)[number]);
const method: 'add' | 'remove' = isDark ? 'add' : 'remove';
root.style.setProperty('--theme-name', themeName);
root.style.setProperty('--theme-dark-mode', isDark ? '1' : '0');
root.setAttribute('data-theme', themeName);
root.classList[method]('dark');
document.body?.classList[method]('dark');
document.querySelectorAll('.unapi').forEach((el) => el.classList[method]('dark'));
};
const updateThemeCssLink = (themeName: ThemeName) => {
const linkId = 'dev-theme-css-link';
let themeLink = document.getElementById(linkId) as HTMLLinkElement | null;
const themeCssMap: Record<string, string> = {
azure: '/test-pages/unraid-assets/themes/azure.css',
black: '/test-pages/unraid-assets/themes/black.css',
gray: '/test-pages/unraid-assets/themes/gray.css',
white: '/test-pages/unraid-assets/themes/white.css',
const themeCssMap: Record<ThemeName, string> = {
[ThemeName.AZURE]: '/test-pages/unraid-assets/themes/azure.css',
[ThemeName.BLACK]: '/test-pages/unraid-assets/themes/black.css',
[ThemeName.GRAY]: '/test-pages/unraid-assets/themes/gray.css',
[ThemeName.WHITE]: '/test-pages/unraid-assets/themes/white.css',
};
const cssUrl = themeCssMap[themeName];
@@ -73,51 +146,74 @@ const updateTheme = (themeName: string, skipUrlUpdate = false) => {
document.head.appendChild(themeLink);
}
themeLink.href = cssUrl;
} else {
if (themeLink) {
themeLink.remove();
} else if (themeLink) {
themeLink.remove();
}
};
const applyThemeSelection = async (
themeName: string | null | undefined,
{ persist = false, skipStore = false }: { persist?: boolean; skipStore?: boolean } = {}
) => {
const normalized = normalizeTheme(themeName) ?? ThemeName.WHITE;
currentTheme.value = normalized;
persistThemePreference(normalized);
syncDomForTheme(normalized);
updateThemeCssLink(normalized);
if (!skipStore) {
themeStore.setTheme({ name: normalized });
}
if (persist) {
isSaving.value = true;
try {
await setThemeMutation({ theme: normalized });
} catch (error) {
console.warn('[DevThemeSwitcher] Failed to persist theme via GraphQL', error);
} finally {
isSaving.value = false;
}
}
};
const handleThemeChange = (event: Event) => {
const newTheme = (event.target as HTMLSelectElement).value;
if (newTheme === currentTheme.value) {
const newTheme = normalizeTheme((event.target as HTMLSelectElement).value);
if (!newTheme || newTheme === currentTheme.value) {
return;
}
currentTheme.value = newTheme;
updateTheme(newTheme);
void applyThemeSelection(newTheme, { persist: true });
};
onMounted(() => {
onMounted(async () => {
themeStore.setDevOverride(true);
const initialTheme = getCurrentTheme();
currentTheme.value = initialTheme;
const existingLink = document.getElementById('dev-theme-css-link') as HTMLLinkElement | null;
if (!existingLink || !existingLink.href) {
updateTheme(initialTheme, true);
} else {
themeStore.setTheme({ name: initialTheme });
}
const initialTheme = await resolveInitialTheme();
await applyThemeSelection(initialTheme);
});
watch(
() => theme.value.name,
(newName) => {
if (newName && newName !== currentTheme.value) {
currentTheme.value = newName;
const url = new URL(window.location.href);
url.searchParams.set('theme', newName);
window.history.replaceState({}, '', url);
const normalized = normalizeTheme(newName);
if (!normalized || normalized === currentTheme.value) {
return;
}
void applyThemeSelection(normalized, { skipStore: true });
}
);
</script>
<template>
<select :value="currentTheme" class="dev-theme-select" @change="handleThemeChange">
<select
:value="currentTheme"
class="dev-theme-select"
:disabled="isSaving || isQueryLoading"
@change="handleThemeChange"
>
<option v-for="option in themeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
@@ -145,4 +241,9 @@ watch(
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
.dev-theme-select:disabled {
opacity: 0.7;
cursor: not-allowed;
}
</style>

View File

@@ -94,12 +94,11 @@ onMounted(() => {
<template>
<div
id="UserProfile"
class="text-foreground absolute top-0 right-0 z-20 flex h-full max-w-full flex-col items-end gap-y-1 pt-2 pr-2"
class="text-foreground absolute top-0 right-0 z-20 flex h-full max-w-full flex-col items-end gap-y-1 pt-2 pr-2 pl-[30%] md:pl-[160px]"
>
<div
v-if="bannerGradient"
class="pointer-events-none absolute inset-y-0 right-0 left-0 z-0 w-full"
:style="bannerGradient"
class="unraid-banner-gradient-layer pointer-events-none absolute inset-y-0 right-0 left-0 z-0 w-full"
/>
<UpcServerStatus class="relative z-10" />

View File

@@ -3,6 +3,7 @@ import { DefaultApolloClient } from '@vue/apollo-composable';
import UApp from '@nuxt/ui/components/App.vue';
import ui from '@nuxt/ui/vue-plugin';
import { isDarkModeActive } from '@unraid/ui';
// Import component registry (only imported here to avoid ordering issues)
import { componentMappings } from '@/components/Wrapper/component-registry';
import { client } from '~/helpers/create-apollo-client';
@@ -179,6 +180,10 @@ export async function mountUnifiedApp() {
element.setAttribute('data-vue-mounted', 'true');
element.classList.add('unapi');
if (isDarkModeActive()) {
element.classList.add('dark');
}
// Store for cleanup
mountedComponents.push({
element,

View File

@@ -28,6 +28,7 @@ type Documents = {
"\n query GetPermissionsForRoles($roles: [Role!]!) {\n getPermissionsForRoles(roles: $roles) {\n resource\n actions\n }\n }\n": typeof types.GetPermissionsForRolesDocument,
"\n query Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": typeof types.UnifiedDocument,
"\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": typeof types.UpdateConnectSettingsDocument,
"\n mutation setTheme($theme: ThemeName!) {\n customization {\n setTheme(theme: $theme) {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\n }\n }\n }\n": typeof types.SetThemeDocument,
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": typeof types.LogFilesDocument,
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": typeof types.LogFileContentDocument,
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": typeof types.LogFileSubscriptionDocument,
@@ -73,6 +74,7 @@ const documents: Documents = {
"\n query GetPermissionsForRoles($roles: [Role!]!) {\n getPermissionsForRoles(roles: $roles) {\n resource\n actions\n }\n }\n": types.GetPermissionsForRolesDocument,
"\n query Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": types.UnifiedDocument,
"\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": types.UpdateConnectSettingsDocument,
"\n mutation setTheme($theme: ThemeName!) {\n customization {\n setTheme(theme: $theme) {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\n }\n }\n }\n": types.SetThemeDocument,
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument,
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": types.LogFileContentDocument,
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": types.LogFileSubscriptionDocument,
@@ -174,6 +176,10 @@ export function graphql(source: "\n query Unified {\n settings {\n unif
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n"): (typeof documents)["\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation setTheme($theme: ThemeName!) {\n customization {\n setTheme(theme: $theme) {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\n }\n }\n }\n"): (typeof documents)["\n mutation setTheme($theme: ThemeName!) {\n customization {\n setTheme(theme: $theme) {\n name\n showBannerImage\n showBannerGradient\n headerBackgroundColor\n showHeaderDescription\n headerPrimaryTextColor\n headerSecondaryTextColor\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@@ -559,6 +559,17 @@ export type CpuLoad = {
percentUser: Scalars['Float']['output'];
};
export type CpuPackages = Node & {
__typename?: 'CpuPackages';
id: Scalars['PrefixedID']['output'];
/** Power draw per package (W) */
power: Array<Scalars['Float']['output']>;
/** Temperature per package (°C) */
temp: Array<Scalars['Float']['output']>;
/** Total CPU package power draw (W) */
totalPower: Scalars['Float']['output'];
};
export type CpuUtilization = Node & {
__typename?: 'CpuUtilization';
/** CPU load for each core */
@@ -590,6 +601,19 @@ export type Customization = {
theme: Theme;
};
/** Customization related mutations */
export type CustomizationMutations = {
__typename?: 'CustomizationMutations';
/** Update the UI theme (writes dynamix.cfg) */
setTheme: Theme;
};
/** Customization related mutations */
export type CustomizationMutationsSetThemeArgs = {
theme: ThemeName;
};
export type DeleteApiKeyInput = {
ids: Array<Scalars['PrefixedID']['input']>;
};
@@ -869,6 +893,7 @@ export type InfoCpu = Node & {
manufacturer?: Maybe<Scalars['String']['output']>;
/** CPU model */
model?: Maybe<Scalars['String']['output']>;
packages: CpuPackages;
/** Number of physical processors */
processors?: Maybe<Scalars['Int']['output']>;
/** CPU revision */
@@ -885,6 +910,8 @@ export type InfoCpu = Node & {
stepping?: Maybe<Scalars['Int']['output']>;
/** Number of CPU threads */
threads?: Maybe<Scalars['Int']['output']>;
/** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */
topology: Array<Array<Array<Scalars['Int']['output']>>>;
/** CPU vendor */
vendor?: Maybe<Scalars['String']['output']>;
/** CPU voltage */
@@ -1225,6 +1252,7 @@ export type Mutation = {
createDockerFolder: ResolvedOrganizerV1;
/** Creates a new notification record */
createNotification: Notification;
customization: CustomizationMutations;
/** Deletes all archived notifications on server. */
deleteArchivedNotifications: NotificationOverview;
deleteDockerEntries: ResolvedOrganizerV1;
@@ -2053,6 +2081,7 @@ export type Subscription = {
parityHistorySubscription: ParityCheck;
serversSubscription: Server;
systemMetricsCpu: CpuUtilization;
systemMetricsCpuTelemetry: CpuPackages;
systemMetricsMemory: MemoryUtilization;
upsUpdates: UpsDevice;
};
@@ -2662,6 +2691,13 @@ export type UpdateConnectSettingsMutationVariables = Exact<{
export type UpdateConnectSettingsMutation = { __typename?: 'Mutation', updateSettings: { __typename?: 'UpdateSettingsResponse', restartRequired: boolean, values: any } };
export type SetThemeMutationVariables = Exact<{
theme: ThemeName;
}>;
export type SetThemeMutation = { __typename?: 'Mutation', customization: { __typename?: 'CustomizationMutations', setTheme: { __typename?: 'Theme', name: ThemeName, showBannerImage: boolean, showBannerGradient: boolean, headerBackgroundColor?: string | null, showHeaderDescription: boolean, headerPrimaryTextColor?: string | null, headerSecondaryTextColor?: string | null } } };
export type LogFilesQueryVariables = Exact<{ [key: string]: never; }>;
@@ -2860,6 +2896,7 @@ export const PreviewEffectivePermissionsDocument = {"kind":"Document","definitio
export const GetPermissionsForRolesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPermissionsForRoles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roles"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getPermissionsForRoles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"roles"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roles"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<GetPermissionsForRolesQuery, GetPermissionsForRolesQueryVariables>;
export const UnifiedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]}}]} as unknown as DocumentNode<UnifiedQuery, UnifiedQueryVariables>;
export const UpdateConnectSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateConnectSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"restartRequired"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]} as unknown as DocumentNode<UpdateConnectSettingsMutation, UpdateConnectSettingsMutationVariables>;
export const SetThemeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"setTheme"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"theme"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ThemeName"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setTheme"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"theme"},"value":{"kind":"Variable","name":{"kind":"Name","value":"theme"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"headerBackgroundColor"}},{"kind":"Field","name":{"kind":"Name","value":"showHeaderDescription"}},{"kind":"Field","name":{"kind":"Name","value":"headerPrimaryTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"headerSecondaryTextColor"}}]}}]}}]}}]} as unknown as DocumentNode<SetThemeMutation, SetThemeMutationVariables>;
export const LogFilesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"modifiedAt"}}]}}]}}]} as unknown as DocumentNode<LogFilesQuery, LogFilesQueryVariables>;
export const LogFileContentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFileContent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lines"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}},{"kind":"Argument","name":{"kind":"Name","value":"lines"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lines"}}},{"kind":"Argument","name":{"kind":"Name","value":"startLine"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}},{"kind":"Field","name":{"kind":"Name","value":"startLine"}}]}}]}}]} as unknown as DocumentNode<LogFileContentQuery, LogFileContentQueryVariables>;
export const LogFileSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"LogFileSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}}]}}]}}]} as unknown as DocumentNode<LogFileSubscriptionSubscription, LogFileSubscriptionSubscriptionVariables>;

View File

@@ -1,2 +1,2 @@
export * from './fragment-masking';
export * from './gql';
export * from "./fragment-masking";
export * from "./gql";

View File

@@ -1,7 +1,8 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useQuery } from '@vue/apollo-composable';
import { useLazyQuery } from '@vue/apollo-composable';
import { isDarkModeActive } from '@unraid/ui';
import { defaultColors } from '~/themes/default';
import type { GetThemeQuery } from '~/composables/gql/graphql';
@@ -38,53 +39,36 @@ const DEFAULT_THEME: Theme = {
type ThemeSource = 'local' | 'server';
let pendingDarkModeHandler: ((event: Event) => void) | null = null;
const isDomAvailable = () => typeof document !== 'undefined';
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 getCssVar = (name: string): string => {
if (!isDomAvailable()) return '';
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
};
const applyDarkClass = (isDark: boolean) => {
if (typeof document === 'undefined') return;
const readDomThemeName = () => getCssVar('--theme-name');
const method: 'add' | 'remove' = isDark ? 'add' : 'remove';
const syncDarkClass = (method: 'add' | 'remove') => {
if (!isDomAvailable()) return;
document.documentElement.classList[method]('dark');
document.body?.classList[method]('dark');
document.querySelectorAll('.unapi').forEach((el) => el.classList[method]('dark'));
};
const unapiElements = document.querySelectorAll('.unapi');
unapiElements.forEach((element) => {
element.classList[method]('dark');
});
if (pendingDarkModeHandler) {
document.removeEventListener('DOMContentLoaded', pendingDarkModeHandler);
pendingDarkModeHandler = null;
const applyDarkClass = (isDark: boolean, darkModeRef?: { value: boolean }) => {
if (!isDomAvailable()) return;
const method: 'add' | 'remove' = isDark ? 'add' : 'remove';
syncDarkClass(method);
document.documentElement.style.setProperty('--theme-dark-mode', isDark ? '1' : '0');
if (darkModeRef) {
darkModeRef.value = isDark;
}
};
if (syncBodyDarkClass(method)) {
return;
const bootstrapDarkClass = (darkModeRef?: { value: boolean }) => {
if (isDarkModeActive()) {
applyDarkClass(true, darkModeRef);
}
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 => {
@@ -112,8 +96,16 @@ export const useThemeStore = defineStore('theme', () => {
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
const hasServerTheme = ref(false);
const devOverride = ref(false);
const darkMode = ref<boolean>(false);
const { result, onResult, onError } = useQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
// Initialize dark mode from CSS variable set by PHP or any pre-applied .dark class
if (isDomAvailable()) {
darkMode.value = isDarkModeActive();
bootstrapDarkClass(darkMode);
}
// Lazy query - only executes when explicitly called
const { load, onResult, onError } = useLazyQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
});
@@ -149,27 +141,34 @@ export const useThemeStore = defineStore('theme', () => {
}
});
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])
);
// Getters - read from DOM CSS variables set by PHP
const themeName = computed<string>(() => {
if (!isDomAvailable()) return DEFAULT_THEME.name;
const name = readDomThemeName() || theme.value.name;
return name || DEFAULT_THEME.name;
});
const bannerGradient = computed(() => {
if (!theme.value?.banner || !theme.value?.bannerGradient) {
return undefined;
const readBannerGradientVar = (): string => {
const raw = getCssVar('--banner-gradient');
if (!raw) return '';
const normalized = raw.trim().toLowerCase();
if (!normalized || normalized === 'null' || normalized === 'none' || normalized === 'undefined') {
return '';
}
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%);`;
return raw;
};
const bannerGradient = computed<boolean>(() => {
const { banner, bannerGradient } = theme.value;
if (!banner || !bannerGradient) {
return false;
}
const gradient = readBannerGradientVar();
return Boolean(gradient);
});
// Actions
@@ -202,27 +201,39 @@ export const useThemeStore = defineStore('theme', () => {
devOverride.value = enabled;
};
const setCssVars = () => {
applyDarkClass(darkMode.value);
const fetchTheme = () => {
load();
};
// Only apply dark class when theme changes (for dev tools that don't refresh)
// In production, PHP sets the dark class and page refreshes on theme change
watch(
theme,
() => {
setCssVars();
() => theme.value.name,
(themeName) => {
const isDark = DARK_UI_THEMES.includes(themeName as (typeof DARK_UI_THEMES)[number]);
applyDarkClass(isDark, darkMode);
},
{ immediate: true }
{ immediate: false }
);
// Initialize theme from DOM on store creation
const domThemeName = themeName.value;
if (domThemeName && domThemeName !== DEFAULT_THEME.name) {
theme.value.name = domThemeName;
}
return {
// state
activeColorVariables,
bannerGradient,
darkMode,
theme,
darkMode: computed(() => darkMode.value),
theme: computed(() => ({
...theme.value,
name: themeName.value,
})),
// actions
setTheme,
setCssVars,
setDevOverride,
fetchTheme,
};
});

View File

@@ -1,5 +1,8 @@
{% if mode === 'development' %}
{% set activeTheme = query.theme or 'white' %}
{% set activeTheme = resolvedTheme or query.theme or 'white' %}
{% if activeTheme != 'white' and activeTheme != 'black' and activeTheme != 'gray' and activeTheme != 'azure' %}
{% set activeTheme = 'white' %}
{% endif %}
<link rel="stylesheet" href="/test-pages/unraid-assets/default-fonts.css">
<link rel="stylesheet" href="/test-pages/unraid-assets/default-base.css">
<link rel="stylesheet" href="/test-pages/unraid-assets/default-cases.css">
@@ -20,6 +23,47 @@
{% elif activeTheme === 'white' %}
<link rel="stylesheet" href="/test-pages/unraid-assets/themes/white.css" id="dev-theme-css-link">
{% endif %}
<script>
(function() {
var theme = {{ activeTheme | default('white') | dump }};
var themeCssMap = {
azure: '/test-pages/unraid-assets/themes/azure.css',
black: '/test-pages/unraid-assets/themes/black.css',
gray: '/test-pages/unraid-assets/themes/gray.css',
white: '/test-pages/unraid-assets/themes/white.css',
};
var themeLink = document.getElementById('dev-theme-css-link');
var desiredHref = themeCssMap[theme];
if (themeLink && desiredHref && themeLink.getAttribute('href') !== desiredHref) {
themeLink.href = desiredHref;
}
var root = document.documentElement;
var isDark = theme === 'black' || theme === 'gray';
root.style.setProperty('--theme-name', theme);
root.style.setProperty('--theme-dark-mode', isDark ? '1' : '0');
root.setAttribute('data-theme', theme);
var syncDarkClass = function() {
var method = isDark ? 'add' : 'remove';
root.classList[method]('dark');
if (document.body) {
document.body.classList[method]('dark');
}
document.querySelectorAll('.unapi').forEach(function(el) {
el.classList[method]('dark');
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', syncDarkClass, { once: true });
} else {
syncDarkClass();
}
})();
</script>
{% else %}
<link rel="stylesheet" href="/test-pages/unraid-assets/default-fonts.css">
<link rel="stylesheet" href="/test-pages/unraid-assets/default-base.css">

View File

@@ -7,6 +7,8 @@ import nunjucks from 'nunjucks';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Plugin } from 'vite';
type ThemeName = 'white' | 'black' | 'gray' | 'azure';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.join(__dirname, 'public');
const templatesDir = path.join(__dirname, 'test-pages');
@@ -21,6 +23,144 @@ const env = nunjucks.configure(templatesDir, {
const GITHUB_RAW_BASE =
'https://raw.githubusercontent.com/unraid/webgui/189edb1a690cfaef3358db9d6bef281a5e1231bc/emhttp/plugins/dynamix/styles';
const ALLOWED_THEMES: ThemeName[] = ['white', 'black', 'gray', 'azure'];
const normalizeTheme = (theme?: string | null): ThemeName => {
const normalized = (theme ?? '').toLowerCase() as ThemeName;
return ALLOWED_THEMES.includes(normalized) ? normalized : 'white';
};
const repoRoot = path.resolve(__dirname, '..');
const toAbsolute = (maybePath?: string | null) => {
if (!maybePath) return null;
return path.isAbsolute(maybePath) ? maybePath : path.resolve(repoRoot, maybePath);
};
const parseCookies = (cookieHeader?: string | string[]): Record<string, string> => {
const header = Array.isArray(cookieHeader) ? cookieHeader.join(';') : cookieHeader;
if (!header) {
return {};
}
return header.split(';').reduce<Record<string, string>>((acc, cookie) => {
const [name, ...rest] = cookie.split('=');
if (!name) {
return acc;
}
const trimmedName = name.trim();
if (!trimmedName) {
return acc;
}
acc[trimmedName] = decodeURIComponent(rest.join('=').trim());
return acc;
}, {});
};
const dynamixCandidates = [
toAbsolute(process.env.DEV_DYNAMIX_CFG),
toAbsolute(process.env.PATHS_DYNAMIX_CONFIG),
path.join(repoRoot, 'api/dev/dynamix/dynamix.cfg'),
path.join(__dirname, 'dev/dynamix/dynamix.cfg'),
].filter(Boolean) as string[];
const findDynamixConfigPath = (): string | null =>
dynamixCandidates.find((candidate) => fs.existsSync(candidate)) ?? null;
const parseIniSection = (content: string, section: string): Record<string, string> => {
const lines = content.split(/\r?\n/);
const sectionName = section.trim().toLowerCase();
const data: Record<string, string> = {};
let inSection = false;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith(';') || line.startsWith('#')) {
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
inSection = line.slice(1, -1).trim().toLowerCase() === sectionName;
continue;
}
if (!inSection) {
continue;
}
const [key, ...rest] = line.split('=');
if (!key) continue;
data[key.trim()] = rest.join('=').trim();
}
return data;
};
const readThemeFromConfig = (): ThemeName | null => {
const configPath = findDynamixConfigPath();
if (!configPath) {
return null;
}
try {
const content = fs.readFileSync(configPath, 'utf-8');
const displaySection = parseIniSection(content, 'display');
return normalizeTheme(displaySection.theme);
} catch {
return null;
}
};
const writeThemeToConfig = (theme: ThemeName): { success: boolean; path?: string; error?: string } => {
const configPath = findDynamixConfigPath() ?? dynamixCandidates[0];
if (!configPath) {
return { success: false, error: 'Config path not found' };
}
try {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
const content = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : '[display]\n';
const lines = content.split(/\r?\n/);
let inDisplay = false;
let updated = false;
const nextLines = lines.map((line) => {
const trimmed = line.trim();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
inDisplay = trimmed.slice(1, -1).trim().toLowerCase() === 'display';
return line;
}
if (inDisplay && trimmed.toLowerCase().startsWith('theme=')) {
updated = true;
return `theme=${theme}`;
}
return line;
});
if (!updated) {
const displayIndex = nextLines.findIndex((line) => line.trim().toLowerCase() === '[display]');
if (displayIndex >= 0) {
nextLines.splice(displayIndex + 1, 0, `theme=${theme}`);
} else {
nextLines.push('[display]', `theme=${theme}`);
}
}
fs.writeFileSync(configPath, nextLines.join('\n'), 'utf-8');
return { success: true, path: configPath };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
};
const readRequestBody = async (req: IncomingMessage): Promise<string> => {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => resolve(body));
req.on('error', reject);
});
};
export function serveStaticHtml(): Plugin {
return {
name: 'serve-static-html',
@@ -87,6 +227,53 @@ export function serveStaticHtml(): Plugin {
await handleUnraidAsset(res, assetPath);
});
server.middlewares.use('/dev/theme', async (req: IncomingMessage, res: ServerResponse, next) => {
if (req.method === 'GET') {
const theme = readThemeFromConfig();
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ theme }));
return;
}
if (req.method === 'POST') {
try {
const raw = await readRequestBody(req);
const parsed = raw ? JSON.parse(raw) : {};
if (!parsed || typeof parsed.theme !== 'string') {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: false, error: 'theme is required' }));
return;
}
const normalized = normalizeTheme(parsed.theme);
const result = writeThemeToConfig(normalized);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ ...result, theme: normalized }));
return;
} catch (error) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
success: false,
error: error instanceof Error ? error.message : 'Invalid request body',
})
);
return;
}
}
if (req.method && !['GET', 'POST'].includes(req.method)) {
res.statusCode = 405;
res.setHeader('Allow', 'GET, POST');
res.end('Method Not Allowed');
return;
}
next();
});
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith('/test-pages')) {
next();
@@ -117,10 +304,17 @@ export function serveStaticHtml(): Plugin {
'/'
);
const cookies = parseCookies(req.headers.cookie);
const cookieTheme = cookies['unraid_dev_theme'];
const queryTheme = requestUrl.searchParams.get('theme');
const cfgTheme = readThemeFromConfig();
const resolvedTheme = normalizeTheme(cfgTheme || queryTheme || cookieTheme);
const html = env.render(templateName, {
url: requestUrl.pathname,
query: Object.fromEntries(requestUrl.searchParams.entries()),
mode: server.config.mode,
resolvedTheme,
});
res.setHeader('Content-Type', 'text/html');