Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -1042,14 +1059,6 @@ type Theme {
headerSecondaryTextColor: String
}

"""The theme name"""
enum ThemeName {
azure
black
gray
white
}

type ExplicitStatusItem {
name: String!
updateStatus: UpdateStatus!
Expand Down Expand Up @@ -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!
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
}
8 changes: 8 additions & 0 deletions api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
Expand Down Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Mutation, Resolver } from '@nestjs/graphql';
import {
ApiKeyMutations,
ArrayMutations,
CustomizationMutations,
DockerMutations,
ParityCheckMutations,
RCloneMutations,
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ private function getDisplayThemeVars(): ?array
}

$theme = strtolower(trim($display['theme'] ?? ''));
$darkThemes = ['gray', 'black'];
$isDarkMode = in_array($theme, $darkThemes, true);
$vars['--theme-dark-mode'] = $isDarkMode ? '1' : '0';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i get antipattern/red flag vibes from this (ie multiple ways to set dark mode, multiple places you have to consider to debug themes--which could step on each others' toes)

i suspect this is in the spirit of a css implementation of the dark mode recognition we were doing in js, though.

for my own context, what are the limitations of purely setting a css var palette based on theme? is it the use of dark: styles in our component code & component dependencies?

$vars['--theme-name'] = $theme ?: 'white';

if ($theme === 'white') {
if (!$textPrimary) {
$vars['--header-text-primary'] = 'var(--inverse-text-color, #ffffff)';
Expand All @@ -218,19 +223,23 @@ private function getDisplayThemeVars(): ?array
}
}

$shouldShowBanner = ($display['showBannerImage'] ?? '') === 'yes';
$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, 0.7);
}
}

$shouldShowBannerGradient = ($display['showBannerGradient'] ?? '') === 'yes';
if ($shouldShowBannerGradient) {
if ($shouldShowBanner && $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%%)',
'linear-gradient(90deg, %s 0, %s var(--banner-gradient-stop, 30%%))',
$start,
$end
);
Expand Down
141 changes: 134 additions & 7 deletions unraid-ui/src/composables/useTeleport.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});

Expand All @@ -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();
Expand All @@ -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');
Expand All @@ -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';
Expand All @@ -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');
});
});
Loading
Loading