mirror of
https://github.com/unraid/api.git
synced 2025-12-31 21:49:57 -06:00
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:
@@ -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),
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
193
unraid-ui/src/lib/utils.test.ts
Normal file
193
unraid-ui/src/lib/utils.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -2,3 +2,4 @@ auto-imports.d.ts
|
||||
components.d.ts
|
||||
composables/gql/
|
||||
src/composables/gql/
|
||||
dist/
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
17
web/src/components/DevThemeSwitcher.mutation.ts
Normal file
17
web/src/components/DevThemeSwitcher.mutation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './fragment-masking';
|
||||
export * from './gql';
|
||||
export * from "./fragment-masking";
|
||||
export * from "./gql";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user