mirror of
https://github.com/unraid/api.git
synced 2026-01-01 22:20:05 -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 {
|
.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),
|
.unapi button:not(:disabled),
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
--color-beta: #1c1b1b;
|
--color-beta: #1c1b1b;
|
||||||
--color-gamma: #ffffff;
|
--color-gamma: #ffffff;
|
||||||
--color-gamma-opaque: rgba(255, 255, 255, 0.3);
|
--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 */
|
/* Black Theme */
|
||||||
@@ -21,15 +26,26 @@
|
|||||||
--color-beta: #f2f2f2;
|
--color-beta: #f2f2f2;
|
||||||
--color-gamma: #1c1b1b;
|
--color-gamma: #1c1b1b;
|
||||||
--color-gamma-opaque: rgba(28, 27, 27, 0.3);
|
--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 */
|
/* Gray Theme */
|
||||||
.Theme--gray {
|
.Theme--gray,
|
||||||
|
.Theme--gray.dark {
|
||||||
--color-border: #383735;
|
--color-border: #383735;
|
||||||
--color-alpha: #ff8c2f;
|
--color-alpha: #ff8c2f;
|
||||||
--color-beta: #383735;
|
--color-beta: #383735;
|
||||||
--color-gamma: #ffffff;
|
--color-gamma: #ffffff;
|
||||||
--color-gamma-opaque: rgba(255, 255, 255, 0.3);
|
--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 */
|
/* Azure Theme */
|
||||||
@@ -39,6 +55,11 @@
|
|||||||
--color-beta: #e7f2f8;
|
--color-beta: #e7f2f8;
|
||||||
--color-gamma: #336699;
|
--color-gamma: #336699;
|
||||||
--color-gamma-opaque: rgba(51, 102, 153, 0.3);
|
--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 */
|
/* Dark Mode Overrides */
|
||||||
|
|||||||
@@ -944,6 +944,23 @@ input UpdateApiKeyInput {
|
|||||||
permissions: [AddPermissionInput!]
|
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
|
Parity check related mutations, WIP, response types and functionaliy will change
|
||||||
"""
|
"""
|
||||||
@@ -1042,14 +1059,6 @@ type Theme {
|
|||||||
headerSecondaryTextColor: String
|
headerSecondaryTextColor: String
|
||||||
}
|
}
|
||||||
|
|
||||||
"""The theme name"""
|
|
||||||
enum ThemeName {
|
|
||||||
azure
|
|
||||||
black
|
|
||||||
gray
|
|
||||||
white
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExplicitStatusItem {
|
type ExplicitStatusItem {
|
||||||
name: String!
|
name: String!
|
||||||
updateStatus: UpdateStatus!
|
updateStatus: UpdateStatus!
|
||||||
@@ -2449,6 +2458,7 @@ type Mutation {
|
|||||||
vm: VmMutations!
|
vm: VmMutations!
|
||||||
parityCheck: ParityCheckMutations!
|
parityCheck: ParityCheckMutations!
|
||||||
apiKey: ApiKeyMutations!
|
apiKey: ApiKeyMutations!
|
||||||
|
customization: CustomizationMutations!
|
||||||
rclone: RCloneMutations!
|
rclone: RCloneMutations!
|
||||||
createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1!
|
createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1!
|
||||||
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!
|
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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 { CustomizationResolver } from '@app/unraid-api/graph/resolvers/customization/customization.resolver.js';
|
||||||
import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js';
|
import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [CustomizationService, CustomizationResolver],
|
providers: [CustomizationService, CustomizationResolver, CustomizationMutationsResolver],
|
||||||
|
exports: [CustomizationService],
|
||||||
})
|
})
|
||||||
export class CustomizationModule {}
|
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 { emcmd } from '@app/core/utils/clients/emcmd.js';
|
||||||
import { fileExists } from '@app/core/utils/files/file-exists.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 { getters, store } from '@app/store/index.js';
|
||||||
|
import { updateDynamixConfig } from '@app/store/modules/dynamix.js';
|
||||||
import {
|
import {
|
||||||
ActivationCode,
|
ActivationCode,
|
||||||
PublicPartnerInfo,
|
PublicPartnerInfo,
|
||||||
@@ -466,4 +468,16 @@ export class CustomizationService implements OnModuleInit {
|
|||||||
showHeaderDescription: descriptionShow === 'yes',
|
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 {}
|
export class ApiKeyMutations {}
|
||||||
|
|
||||||
|
@ObjectType({
|
||||||
|
description: 'Customization related mutations',
|
||||||
|
})
|
||||||
|
export class CustomizationMutations {}
|
||||||
|
|
||||||
@ObjectType({
|
@ObjectType({
|
||||||
description: 'Parity check related mutations, WIP, response types and functionaliy will change',
|
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' })
|
@Field(() => ApiKeyMutations, { description: 'API Key related mutations' })
|
||||||
apiKey: ApiKeyMutations = new ApiKeyMutations();
|
apiKey: ApiKeyMutations = new ApiKeyMutations();
|
||||||
|
|
||||||
|
@Field(() => CustomizationMutations, { description: 'Customization related mutations' })
|
||||||
|
customization: CustomizationMutations = new CustomizationMutations();
|
||||||
|
|
||||||
@Field(() => ParityCheckMutations, { description: 'Parity check related mutations' })
|
@Field(() => ParityCheckMutations, { description: 'Parity check related mutations' })
|
||||||
parityCheck: ParityCheckMutations = new ParityCheckMutations();
|
parityCheck: ParityCheckMutations = new ParityCheckMutations();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Mutation, Resolver } from '@nestjs/graphql';
|
|||||||
import {
|
import {
|
||||||
ApiKeyMutations,
|
ApiKeyMutations,
|
||||||
ArrayMutations,
|
ArrayMutations,
|
||||||
|
CustomizationMutations,
|
||||||
DockerMutations,
|
DockerMutations,
|
||||||
ParityCheckMutations,
|
ParityCheckMutations,
|
||||||
RCloneMutations,
|
RCloneMutations,
|
||||||
@@ -37,6 +38,11 @@ export class RootMutationsResolver {
|
|||||||
return new ApiKeyMutations();
|
return new ApiKeyMutations();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CustomizationMutations, { name: 'customization' })
|
||||||
|
customization(): CustomizationMutations {
|
||||||
|
return new CustomizationMutations();
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => RCloneMutations, { name: 'rclone' })
|
@Mutation(() => RCloneMutations, { name: 'rclone' })
|
||||||
rclone(): RCloneMutations {
|
rclone(): RCloneMutations {
|
||||||
return new RCloneMutations();
|
return new RCloneMutations();
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ class WebComponentsExtractor
|
|||||||
public function getManifestContents(string $manifestPath): array
|
public function getManifestContents(string $manifestPath): array
|
||||||
{
|
{
|
||||||
$contents = @file_get_contents($manifestPath);
|
$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
|
private function processManifestFiles(): string
|
||||||
@@ -209,6 +214,11 @@ class WebComponentsExtractor
|
|||||||
}
|
}
|
||||||
|
|
||||||
$theme = strtolower(trim($display['theme'] ?? ''));
|
$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 ($theme === 'white') {
|
||||||
if (!$textPrimary) {
|
if (!$textPrimary) {
|
||||||
$vars['--header-text-primary'] = 'var(--inverse-text-color, #ffffff)';
|
$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);
|
$bgColor = $this->normalizeHex($display['background'] ?? null);
|
||||||
if ($bgColor) {
|
if ($bgColor) {
|
||||||
$vars['--header-background-color'] = $bgColor;
|
$vars['--header-background-color'] = $bgColor;
|
||||||
$vars['--header-gradient-start'] = $this->hexToRgba($bgColor, 0);
|
// Only set gradient variables if banner image is enabled
|
||||||
$vars['--header-gradient-end'] = $this->hexToRgba($bgColor, 0.7);
|
if ($shouldShowBanner) {
|
||||||
|
$vars['--header-gradient-start'] = $this->hexToRgba($bgColor, 0);
|
||||||
|
$vars['--header-gradient-end'] = $this->hexToRgba($bgColor, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$shouldShowBannerGradient = ($display['showBannerGradient'] ?? '') === 'yes';
|
$shouldShowBannerGradient = ($display['showBannerGradient'] ?? '') === 'yes';
|
||||||
if ($shouldShowBannerGradient) {
|
if ($shouldShowBanner && $shouldShowBannerGradient) {
|
||||||
$start = $vars['--header-gradient-start'] ?? 'rgba(0, 0, 0, 0)';
|
// If the user didn't set a custom background color, prefer existing theme defaults instead of falling back to black.
|
||||||
$end = $vars['--header-gradient-end'] ?? 'rgba(0, 0, 0, 0.7)';
|
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(
|
$vars['--banner-gradient'] = sprintf(
|
||||||
'linear-gradient(90deg, %s 0, %s 90%%)',
|
'linear-gradient(90deg, %s 0, %s var(--banner-gradient-stop, 30%%))',
|
||||||
$start,
|
$start,
|
||||||
$end
|
$end
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -102,6 +102,29 @@ class ExtractorTest {
|
|||||||
]
|
]
|
||||||
], JSON_PRETTY_PRINT));
|
], 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
|
// Copy and modify the extractor for testing
|
||||||
$this->prepareExtractor();
|
$this->prepareExtractor();
|
||||||
}
|
}
|
||||||
@@ -124,14 +147,24 @@ class ExtractorTest {
|
|||||||
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
|
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
|
||||||
require_once $this->testDir . '/extractor.php';
|
require_once $this->testDir . '/extractor.php';
|
||||||
|
|
||||||
if ($resetStatic && class_exists('WebComponentsExtractor')) {
|
if ($resetStatic) {
|
||||||
WebComponentsExtractor::resetScriptsOutput();
|
$this->resetExtractor();
|
||||||
}
|
}
|
||||||
|
|
||||||
$extractor = WebComponentsExtractor::getInstance();
|
$extractor = WebComponentsExtractor::getInstance();
|
||||||
return $extractor->getScriptTagHtml();
|
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() {
|
private function runTests() {
|
||||||
echo "\n";
|
echo "\n";
|
||||||
echo "========================================\n";
|
echo "========================================\n";
|
||||||
@@ -302,12 +335,33 @@ class ExtractorTest {
|
|||||||
"CSS from manifest has data-unraid attribute",
|
"CSS from manifest has data-unraid attribute",
|
||||||
preg_match('/<link[^>]+id="unraid-[^"]*-css-[^"]+"[^>]+data-unraid="1"/', $output) > 0
|
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
|
// Test: CSS Variable Validation
|
||||||
echo "\nTest: CSS Variable Validation\n";
|
echo "\nTest: CSS Variable Validation\n";
|
||||||
echo "------------------------------\n";
|
echo "------------------------------\n";
|
||||||
$this->testCssVariableValidation();
|
$this->testCssVariableValidation();
|
||||||
|
|
||||||
|
// Test: Display Variations / Theme CSS Vars
|
||||||
|
echo "\nTest: Display Variations\n";
|
||||||
|
echo "-------------------------\n";
|
||||||
|
$this->testDisplayVariations();
|
||||||
|
|
||||||
// Test: Duplicate Prevention
|
// Test: Duplicate Prevention
|
||||||
echo "\nTest: Duplicate Prevention\n";
|
echo "\nTest: Duplicate Prevention\n";
|
||||||
echo "---------------------------\n";
|
echo "---------------------------\n";
|
||||||
@@ -434,6 +488,174 @@ class ExtractorTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private function test($name, $condition) {
|
||||||
if ($condition) {
|
if ($condition) {
|
||||||
echo " " . self::GREEN . "✓" . self::NC . " " . $name . "\n";
|
echo " " . self::GREEN . "✓" . self::NC . " " . $name . "\n";
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import useTeleport from '@/composables/useTeleport';
|
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent, nextTick } from 'vue';
|
||||||
|
|
||||||
describe('useTeleport', () => {
|
describe('useTeleport', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// Reset modules before each test to ensure fresh state
|
||||||
|
vi.resetModules();
|
||||||
// Clear the DOM before each test
|
// Clear the DOM before each test
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.body.classList.remove('dark');
|
||||||
|
document.documentElement.style.removeProperty('--theme-dark-mode');
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -16,16 +20,19 @@ describe('useTeleport', () => {
|
|||||||
if (virtualContainer) {
|
if (virtualContainer) {
|
||||||
virtualContainer.remove();
|
virtualContainer.remove();
|
||||||
}
|
}
|
||||||
// Reset the module to clear the virtualModalContainer variable
|
document.documentElement.classList.remove('dark');
|
||||||
vi.resetModules();
|
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();
|
const { teleportTarget } = useTeleport();
|
||||||
expect(teleportTarget.value).toBe('#unraid-api-modals-virtual');
|
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({
|
const TestComponent = defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
const { teleportTarget } = useTeleport();
|
const { teleportTarget } = useTeleport();
|
||||||
@@ -39,6 +46,7 @@ describe('useTeleport', () => {
|
|||||||
|
|
||||||
// Mount the component
|
// Mount the component
|
||||||
mount(TestComponent);
|
mount(TestComponent);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
// After mount, virtual container should be created with correct properties
|
// After mount, virtual container should be created with correct properties
|
||||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||||
@@ -49,7 +57,8 @@ describe('useTeleport', () => {
|
|||||||
expect(virtualContainer?.parentElement).toBe(document.body);
|
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
|
// Manually create the container first
|
||||||
const manualContainer = document.createElement('div');
|
const manualContainer = document.createElement('div');
|
||||||
manualContainer.id = 'unraid-api-modals-virtual';
|
manualContainer.id = 'unraid-api-modals-virtual';
|
||||||
@@ -68,10 +77,128 @@ describe('useTeleport', () => {
|
|||||||
|
|
||||||
// Mount component - should not create a new container
|
// Mount component - should not create a new container
|
||||||
mount(TestComponent);
|
mount(TestComponent);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
// Should still have only one container
|
// Should still have only one container
|
||||||
const containers = document.querySelectorAll('#unraid-api-modals-virtual');
|
const containers = document.querySelectorAll('#unraid-api-modals-virtual');
|
||||||
expect(containers.length).toBe(1);
|
expect(containers.length).toBe(1);
|
||||||
expect(containers[0]).toBe(manualContainer);
|
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';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
let virtualModalContainer: HTMLDivElement | null = null;
|
let virtualModalContainer: HTMLDivElement | null = null;
|
||||||
|
|
||||||
const ensureVirtualContainer = () => {
|
const ensureVirtualContainer = () => {
|
||||||
if (!virtualModalContainer) {
|
if (!virtualModalContainer) {
|
||||||
virtualModalContainer = document.createElement('div');
|
const existing = document.getElementById('unraid-api-modals-virtual');
|
||||||
virtualModalContainer.id = 'unraid-api-modals-virtual';
|
if (existing) {
|
||||||
virtualModalContainer.className = 'unapi';
|
virtualModalContainer = existing as HTMLDivElement;
|
||||||
virtualModalContainer.style.position = 'relative';
|
} else {
|
||||||
virtualModalContainer.style.zIndex = '999999';
|
virtualModalContainer = document.createElement('div');
|
||||||
document.body.appendChild(virtualModalContainer);
|
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;
|
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);
|
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
|
components.d.ts
|
||||||
composables/gql/
|
composables/gql/
|
||||||
src/composables/gql/
|
src/composables/gql/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ vi.mock('@vue/apollo-composable', () => ({
|
|||||||
onResult: vi.fn(),
|
onResult: vi.fn(),
|
||||||
onError: 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
|
// Explicitly mock @unraid/ui to ensure we use the actual components
|
||||||
@@ -54,6 +61,11 @@ describe('ColorSwitcher', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
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 });
|
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
themeStore = useThemeStore();
|
themeStore = useThemeStore();
|
||||||
@@ -69,8 +81,12 @@ describe('ColorSwitcher', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
document.body.removeChild(modalDiv);
|
if (modalDiv && modalDiv.parentNode) {
|
||||||
consoleWarnSpy.mockRestore();
|
modalDiv.parentNode.removeChild(modalDiv);
|
||||||
|
}
|
||||||
|
if (consoleWarnSpy) {
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders all form elements correctly', () => {
|
it('renders all form elements correctly', () => {
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ vi.mock('@unraid/ui', () => ({
|
|||||||
props: ['variant', 'size'],
|
props: ['variant', 'size'],
|
||||||
},
|
},
|
||||||
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
|
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();
|
const mockWatcher = vi.fn();
|
||||||
@@ -182,26 +194,33 @@ describe('UserProfile.standalone.vue', () => {
|
|||||||
createSpy: vi.fn,
|
createSpy: vi.fn,
|
||||||
initialState: {
|
initialState: {
|
||||||
server: { ...initialServerData },
|
server: { ...initialServerData },
|
||||||
theme: {
|
|
||||||
theme: {
|
|
||||||
name: 'default',
|
|
||||||
banner: true,
|
|
||||||
bannerGradient: true,
|
|
||||||
descriptionShow: true,
|
|
||||||
textColor: '',
|
|
||||||
metaColor: '',
|
|
||||||
bgColor: '',
|
|
||||||
},
|
|
||||||
bannerGradient: 'linear-gradient(to right, #ff0000, #0000ff)',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
stubActions: false,
|
stubActions: false,
|
||||||
});
|
});
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
|
|
||||||
serverStore = useServerStore();
|
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();
|
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
|
// Override the setServer method to prevent console logging
|
||||||
vi.spyOn(serverStore, 'setServer').mockImplementation((server) => {
|
vi.spyOn(serverStore, 'setServer').mockImplementation((server) => {
|
||||||
Object.assign(serverStore, server);
|
Object.assign(serverStore, server);
|
||||||
@@ -326,7 +345,7 @@ describe('UserProfile.standalone.vue', () => {
|
|||||||
expect(themeStore.theme?.descriptionShow).toBe(true);
|
expect(themeStore.theme?.descriptionShow).toBe(true);
|
||||||
|
|
||||||
serverStore.description = initialServerData.description!;
|
serverStore.description = initialServerData.description!;
|
||||||
themeStore.theme!.descriptionShow = true;
|
themeStore.setTheme({ ...themeStore.theme, descriptionShow: true });
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
// Look for the description in a span element with v-html directive
|
// 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.exists()).toBe(true);
|
||||||
expect(descriptionElement.html()).toContain(initialServerData.description);
|
expect(descriptionElement.html()).toContain(initialServerData.description);
|
||||||
|
|
||||||
themeStore.theme!.descriptionShow = false;
|
themeStore.setTheme({ ...themeStore.theme, descriptionShow: false });
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
// When descriptionShow is false, the element should not exist
|
// When descriptionShow is false, the element should not exist
|
||||||
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
|
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
|
||||||
expect(descriptionElement.exists()).toBe(false);
|
expect(descriptionElement.exists()).toBe(false);
|
||||||
|
|
||||||
themeStore.theme!.descriptionShow = true;
|
themeStore.setTheme({ ...themeStore.theme, descriptionShow: true });
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
|
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 () => {
|
it('conditionally renders banner based on theme store', async () => {
|
||||||
const bannerSelector = 'div.absolute.z-0';
|
const bannerSelector = '.unraid-banner-gradient-layer';
|
||||||
|
|
||||||
themeStore.theme = {
|
themeStore.setTheme({
|
||||||
...themeStore.theme!,
|
...themeStore.theme,
|
||||||
banner: true,
|
banner: true,
|
||||||
bannerGradient: true,
|
bannerGradient: true,
|
||||||
};
|
});
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
expect(themeStore.bannerGradient).toContain('background-image: linear-gradient');
|
expect(themeStore.bannerGradient).toBe(true);
|
||||||
expect(wrapper.find(bannerSelector).exists()).toBe(true);
|
expect(wrapper.find(bannerSelector).exists()).toBe(true);
|
||||||
|
|
||||||
themeStore.theme!.bannerGradient = false;
|
themeStore.setTheme({
|
||||||
|
...themeStore.theme,
|
||||||
|
bannerGradient: false,
|
||||||
|
});
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
expect(themeStore.bannerGradient).toBeUndefined();
|
expect(themeStore.bannerGradient).toBe(false);
|
||||||
expect(wrapper.find(bannerSelector).exists()).toBe(false);
|
expect(wrapper.find(bannerSelector).exists()).toBe(false);
|
||||||
|
|
||||||
themeStore.theme!.bannerGradient = true;
|
themeStore.setTheme({
|
||||||
|
...themeStore.theme,
|
||||||
|
bannerGradient: true,
|
||||||
|
});
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
expect(themeStore.bannerGradient).toContain('background-image: linear-gradient');
|
expect(themeStore.bannerGradient).toBe(true);
|
||||||
expect(wrapper.find(bannerSelector).exists()).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
|
// Mock component registry
|
||||||
const mockComponentMappings: ComponentMapping[] = [];
|
const mockComponentMappings: ComponentMapping[] = [];
|
||||||
vi.mock('~/components/Wrapper/component-registry', () => ({
|
vi.mock('~/components/Wrapper/component-registry', () => ({
|
||||||
|
|||||||
@@ -94,5 +94,17 @@ vi.mock('@unraid/ui', () => ({
|
|||||||
name: 'ResponsiveModalTitle',
|
name: 'ResponsiveModalTitle',
|
||||||
template: '<div><slot /></div>',
|
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
|
// Add other UI components as needed
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ vi.mock('@vue/apollo-composable', () => ({
|
|||||||
onResult: vi.fn(),
|
onResult: vi.fn(),
|
||||||
onError: 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', () => {
|
describe('Theme Store', () => {
|
||||||
@@ -43,6 +65,11 @@ describe('Theme Store', () => {
|
|||||||
document.body.style.cssText = '';
|
document.body.style.cssText = '';
|
||||||
document.documentElement.classList.add = vi.fn();
|
document.documentElement.classList.add = vi.fn();
|
||||||
document.documentElement.classList.remove = 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) => {
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||||
cb(0);
|
cb(0);
|
||||||
@@ -55,7 +82,13 @@ describe('Theme Store', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
store?.$dispose();
|
store?.$dispose();
|
||||||
store = undefined;
|
store = undefined;
|
||||||
app?.unmount();
|
if (app) {
|
||||||
|
try {
|
||||||
|
app.unmount();
|
||||||
|
} catch {
|
||||||
|
// App was not mounted, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
app = undefined;
|
app = undefined;
|
||||||
|
|
||||||
document.body.classList.add = originalAddClassFn;
|
document.body.classList.add = originalAddClassFn;
|
||||||
@@ -90,44 +123,39 @@ describe('Theme Store', () => {
|
|||||||
expect(store.activeColorVariables).toEqual(defaultColors.white);
|
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();
|
const store = createStore();
|
||||||
|
|
||||||
expect(store.darkMode).toBe(false);
|
|
||||||
|
|
||||||
store.setTheme({ ...store.theme, name: 'black' });
|
|
||||||
expect(store.darkMode).toBe(true);
|
expect(store.darkMode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
store.setTheme({ ...store.theme, name: 'gray' });
|
it('should compute darkMode from CSS variable when set to 0', () => {
|
||||||
expect(store.darkMode).toBe(true);
|
document.documentElement.style.setProperty('--theme-dark-mode', '0');
|
||||||
|
const store = createStore();
|
||||||
store.setTheme({ ...store.theme, name: 'white' });
|
|
||||||
expect(store.darkMode).toBe(false);
|
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();
|
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();
|
it('should return false when bannerGradient CSS variable is not set', () => {
|
||||||
|
document.documentElement.style.removeProperty('--banner-gradient');
|
||||||
store.setTheme({
|
const store = createStore();
|
||||||
...store.theme,
|
expect(store.bannerGradient).toBe(false);
|
||||||
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%);"`
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,12 +185,16 @@ describe('Theme Store', () => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
|
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' });
|
store.setTheme({ ...store.theme, name: 'white' });
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(document.body.classList.remove).toHaveBeenCalledWith('dark');
|
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 () => {
|
it('should update activeColorVariables when theme changes', async () => {
|
||||||
@@ -195,33 +227,22 @@ describe('Theme Store', () => {
|
|||||||
|
|
||||||
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
|
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
|
||||||
expect(document.body.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 store = createStore();
|
||||||
|
|
||||||
const unapiElement1 = document.createElement('div');
|
expect(store.darkMode).toBe(false);
|
||||||
unapiElement1.classList.add('unapi');
|
|
||||||
document.body.appendChild(unapiElement1);
|
|
||||||
|
|
||||||
const unapiElement2 = document.createElement('div');
|
|
||||||
unapiElement2.classList.add('unapi');
|
|
||||||
document.body.appendChild(unapiElement2);
|
|
||||||
|
|
||||||
const addSpy1 = vi.spyOn(unapiElement1.classList, 'add');
|
|
||||||
const addSpy2 = vi.spyOn(unapiElement2.classList, 'add');
|
|
||||||
const removeSpy1 = vi.spyOn(unapiElement1.classList, 'remove');
|
|
||||||
const removeSpy2 = vi.spyOn(unapiElement2.classList, 'remove');
|
|
||||||
|
|
||||||
store.setTheme({
|
store.setTheme({
|
||||||
...store.theme,
|
...store.theme,
|
||||||
name: 'black',
|
name: 'gray',
|
||||||
});
|
});
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(addSpy1).toHaveBeenCalledWith('dark');
|
expect(store.darkMode).toBe(true);
|
||||||
expect(addSpy2).toHaveBeenCalledWith('dark');
|
|
||||||
|
|
||||||
store.setTheme({
|
store.setTheme({
|
||||||
...store.theme,
|
...store.theme,
|
||||||
@@ -230,11 +251,40 @@ describe('Theme Store', () => {
|
|||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(removeSpy1).toHaveBeenCalledWith('dark');
|
expect(store.darkMode).toBe(false);
|
||||||
expect(removeSpy2).toHaveBeenCalledWith('dark');
|
});
|
||||||
|
|
||||||
document.body.removeChild(unapiElement1);
|
it('should initialize dark mode from CSS variable on store creation', () => {
|
||||||
document.body.removeChild(unapiElement2);
|
// 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;
|
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 banner compatibility tweaks */
|
||||||
#header.image {
|
#header.image {
|
||||||
background-position: center center;
|
background-position: center center;
|
||||||
@@ -178,16 +193,8 @@ iframe#progressFrame {
|
|||||||
background-position: left center, right center;
|
background-position: left center, right center;
|
||||||
background-size: min(30%, 320px) 100%, min(30%, 320px) 100%;
|
background-size: min(30%, 320px) 100%, min(30%, 320px) 100%;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(
|
var(--banner-gradient),
|
||||||
90deg,
|
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%));
|
||||||
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%
|
|
||||||
);
|
|
||||||
z-index: 0;
|
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>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
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 themeStore = useThemeStore();
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions: Array<{ value: ThemeName; label: string }> = [
|
||||||
{ value: 'white', label: 'White' },
|
{ value: ThemeName.WHITE, label: 'White' },
|
||||||
{ value: 'black', label: 'Black' },
|
{ value: ThemeName.BLACK, label: 'Black' },
|
||||||
{ value: 'gray', label: 'Gray' },
|
{ value: ThemeName.GRAY, label: 'Gray' },
|
||||||
{ value: 'azure', label: 'Azure' },
|
{ value: ThemeName.AZURE, label: 'Azure' },
|
||||||
] as const;
|
];
|
||||||
|
|
||||||
const STORAGE_KEY_THEME = 'unraid:test:theme';
|
const STORAGE_KEY_THEME = 'unraid:test:theme';
|
||||||
|
const THEME_COOKIE_KEY = 'unraid_dev_theme';
|
||||||
|
|
||||||
const { theme } = storeToRefs(themeStore);
|
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 normalizeTheme = (value?: string | ThemeName | null): ThemeName | null => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const normalized = (value ?? '').toString().toLowerCase();
|
||||||
const urlTheme = urlParams.get('theme');
|
return themeValues.has(normalized as ThemeName) ? (normalized as ThemeName) : null;
|
||||||
|
};
|
||||||
|
|
||||||
if (urlTheme && themeOptions.some((t) => t.value === urlTheme)) {
|
const readCookieTheme = (): string | null => {
|
||||||
return urlTheme;
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (theme.value?.name) {
|
const cookies = document.cookie?.split(';') ?? [];
|
||||||
return theme.value.name;
|
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 {
|
try {
|
||||||
return window.localStorage?.getItem(STORAGE_KEY_THEME) || 'white';
|
return window.localStorage?.getItem(STORAGE_KEY_THEME) ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return 'white';
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTheme = (themeName: string, skipUrlUpdate = false) => {
|
const readCssTheme = (): string | null => {
|
||||||
if (!skipUrlUpdate) {
|
if (typeof window === 'undefined') {
|
||||||
const url = new URL(window.location.href);
|
return null;
|
||||||
url.searchParams.set('theme', themeName);
|
|
||||||
window.history.replaceState({}, '', url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
window.localStorage?.setItem(STORAGE_KEY_THEME, themeName);
|
window.localStorage?.setItem(STORAGE_KEY_THEME, themeName);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 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';
|
const linkId = 'dev-theme-css-link';
|
||||||
let themeLink = document.getElementById(linkId) as HTMLLinkElement | null;
|
let themeLink = document.getElementById(linkId) as HTMLLinkElement | null;
|
||||||
|
|
||||||
const themeCssMap: Record<string, string> = {
|
const themeCssMap: Record<ThemeName, string> = {
|
||||||
azure: '/test-pages/unraid-assets/themes/azure.css',
|
[ThemeName.AZURE]: '/test-pages/unraid-assets/themes/azure.css',
|
||||||
black: '/test-pages/unraid-assets/themes/black.css',
|
[ThemeName.BLACK]: '/test-pages/unraid-assets/themes/black.css',
|
||||||
gray: '/test-pages/unraid-assets/themes/gray.css',
|
[ThemeName.GRAY]: '/test-pages/unraid-assets/themes/gray.css',
|
||||||
white: '/test-pages/unraid-assets/themes/white.css',
|
[ThemeName.WHITE]: '/test-pages/unraid-assets/themes/white.css',
|
||||||
};
|
};
|
||||||
|
|
||||||
const cssUrl = themeCssMap[themeName];
|
const cssUrl = themeCssMap[themeName];
|
||||||
@@ -73,51 +146,74 @@ const updateTheme = (themeName: string, skipUrlUpdate = false) => {
|
|||||||
document.head.appendChild(themeLink);
|
document.head.appendChild(themeLink);
|
||||||
}
|
}
|
||||||
themeLink.href = cssUrl;
|
themeLink.href = cssUrl;
|
||||||
} else {
|
} else if (themeLink) {
|
||||||
if (themeLink) {
|
themeLink.remove();
|
||||||
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 handleThemeChange = (event: Event) => {
|
||||||
const newTheme = (event.target as HTMLSelectElement).value;
|
const newTheme = normalizeTheme((event.target as HTMLSelectElement).value);
|
||||||
if (newTheme === currentTheme.value) {
|
if (!newTheme || newTheme === currentTheme.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentTheme.value = newTheme;
|
|
||||||
updateTheme(newTheme);
|
void applyThemeSelection(newTheme, { persist: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
themeStore.setDevOverride(true);
|
themeStore.setDevOverride(true);
|
||||||
|
|
||||||
const initialTheme = getCurrentTheme();
|
const initialTheme = await resolveInitialTheme();
|
||||||
currentTheme.value = initialTheme;
|
await applyThemeSelection(initialTheme);
|
||||||
|
|
||||||
const existingLink = document.getElementById('dev-theme-css-link') as HTMLLinkElement | null;
|
|
||||||
if (!existingLink || !existingLink.href) {
|
|
||||||
updateTheme(initialTheme, true);
|
|
||||||
} else {
|
|
||||||
themeStore.setTheme({ name: initialTheme });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => theme.value.name,
|
() => theme.value.name,
|
||||||
(newName) => {
|
(newName) => {
|
||||||
if (newName && newName !== currentTheme.value) {
|
const normalized = normalizeTheme(newName);
|
||||||
currentTheme.value = newName;
|
if (!normalized || normalized === currentTheme.value) {
|
||||||
const url = new URL(window.location.href);
|
return;
|
||||||
url.searchParams.set('theme', newName);
|
|
||||||
window.history.replaceState({}, '', url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void applyThemeSelection(normalized, { skipStore: true });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 v-for="option in themeOptions" :key="option.value" :value="option.value">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</option>
|
</option>
|
||||||
@@ -145,4 +241,9 @@ watch(
|
|||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dev-theme-select:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -94,12 +94,11 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
id="UserProfile"
|
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
|
<div
|
||||||
v-if="bannerGradient"
|
v-if="bannerGradient"
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 left-0 z-0 w-full"
|
class="unraid-banner-gradient-layer pointer-events-none absolute inset-y-0 right-0 left-0 z-0 w-full"
|
||||||
:style="bannerGradient"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UpcServerStatus class="relative z-10" />
|
<UpcServerStatus class="relative z-10" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DefaultApolloClient } from '@vue/apollo-composable';
|
|||||||
import UApp from '@nuxt/ui/components/App.vue';
|
import UApp from '@nuxt/ui/components/App.vue';
|
||||||
import ui from '@nuxt/ui/vue-plugin';
|
import ui from '@nuxt/ui/vue-plugin';
|
||||||
|
|
||||||
|
import { isDarkModeActive } from '@unraid/ui';
|
||||||
// Import component registry (only imported here to avoid ordering issues)
|
// Import component registry (only imported here to avoid ordering issues)
|
||||||
import { componentMappings } from '@/components/Wrapper/component-registry';
|
import { componentMappings } from '@/components/Wrapper/component-registry';
|
||||||
import { client } from '~/helpers/create-apollo-client';
|
import { client } from '~/helpers/create-apollo-client';
|
||||||
@@ -179,6 +180,10 @@ export async function mountUnifiedApp() {
|
|||||||
element.setAttribute('data-vue-mounted', 'true');
|
element.setAttribute('data-vue-mounted', 'true');
|
||||||
element.classList.add('unapi');
|
element.classList.add('unapi');
|
||||||
|
|
||||||
|
if (isDarkModeActive()) {
|
||||||
|
element.classList.add('dark');
|
||||||
|
}
|
||||||
|
|
||||||
// Store for cleanup
|
// Store for cleanup
|
||||||
mountedComponents.push({
|
mountedComponents.push({
|
||||||
element,
|
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 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 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 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 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 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,
|
"\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 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 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 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 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 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,
|
"\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.
|
* 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"];
|
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.
|
* 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'];
|
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 & {
|
export type CpuUtilization = Node & {
|
||||||
__typename?: 'CpuUtilization';
|
__typename?: 'CpuUtilization';
|
||||||
/** CPU load for each core */
|
/** CPU load for each core */
|
||||||
@@ -590,6 +601,19 @@ export type Customization = {
|
|||||||
theme: Theme;
|
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 = {
|
export type DeleteApiKeyInput = {
|
||||||
ids: Array<Scalars['PrefixedID']['input']>;
|
ids: Array<Scalars['PrefixedID']['input']>;
|
||||||
};
|
};
|
||||||
@@ -869,6 +893,7 @@ export type InfoCpu = Node & {
|
|||||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||||
/** CPU model */
|
/** CPU model */
|
||||||
model?: Maybe<Scalars['String']['output']>;
|
model?: Maybe<Scalars['String']['output']>;
|
||||||
|
packages: CpuPackages;
|
||||||
/** Number of physical processors */
|
/** Number of physical processors */
|
||||||
processors?: Maybe<Scalars['Int']['output']>;
|
processors?: Maybe<Scalars['Int']['output']>;
|
||||||
/** CPU revision */
|
/** CPU revision */
|
||||||
@@ -885,6 +910,8 @@ export type InfoCpu = Node & {
|
|||||||
stepping?: Maybe<Scalars['Int']['output']>;
|
stepping?: Maybe<Scalars['Int']['output']>;
|
||||||
/** Number of CPU threads */
|
/** Number of CPU threads */
|
||||||
threads?: Maybe<Scalars['Int']['output']>;
|
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 */
|
/** CPU vendor */
|
||||||
vendor?: Maybe<Scalars['String']['output']>;
|
vendor?: Maybe<Scalars['String']['output']>;
|
||||||
/** CPU voltage */
|
/** CPU voltage */
|
||||||
@@ -1225,6 +1252,7 @@ export type Mutation = {
|
|||||||
createDockerFolder: ResolvedOrganizerV1;
|
createDockerFolder: ResolvedOrganizerV1;
|
||||||
/** Creates a new notification record */
|
/** Creates a new notification record */
|
||||||
createNotification: Notification;
|
createNotification: Notification;
|
||||||
|
customization: CustomizationMutations;
|
||||||
/** Deletes all archived notifications on server. */
|
/** Deletes all archived notifications on server. */
|
||||||
deleteArchivedNotifications: NotificationOverview;
|
deleteArchivedNotifications: NotificationOverview;
|
||||||
deleteDockerEntries: ResolvedOrganizerV1;
|
deleteDockerEntries: ResolvedOrganizerV1;
|
||||||
@@ -2053,6 +2081,7 @@ export type Subscription = {
|
|||||||
parityHistorySubscription: ParityCheck;
|
parityHistorySubscription: ParityCheck;
|
||||||
serversSubscription: Server;
|
serversSubscription: Server;
|
||||||
systemMetricsCpu: CpuUtilization;
|
systemMetricsCpu: CpuUtilization;
|
||||||
|
systemMetricsCpuTelemetry: CpuPackages;
|
||||||
systemMetricsMemory: MemoryUtilization;
|
systemMetricsMemory: MemoryUtilization;
|
||||||
upsUpdates: UpsDevice;
|
upsUpdates: UpsDevice;
|
||||||
};
|
};
|
||||||
@@ -2662,6 +2691,13 @@ export type UpdateConnectSettingsMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type UpdateConnectSettingsMutation = { __typename?: 'Mutation', updateSettings: { __typename?: 'UpdateSettingsResponse', restartRequired: boolean, values: any } };
|
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; }>;
|
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 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 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 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 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 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>;
|
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 "./fragment-masking";
|
||||||
export * from './gql';
|
export * from "./gql";
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
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 { defaultColors } from '~/themes/default';
|
||||||
|
|
||||||
import type { GetThemeQuery } from '~/composables/gql/graphql';
|
import type { GetThemeQuery } from '~/composables/gql/graphql';
|
||||||
@@ -38,53 +39,36 @@ const DEFAULT_THEME: Theme = {
|
|||||||
|
|
||||||
type ThemeSource = 'local' | 'server';
|
type ThemeSource = 'local' | 'server';
|
||||||
|
|
||||||
let pendingDarkModeHandler: ((event: Event) => void) | null = null;
|
const isDomAvailable = () => typeof document !== 'undefined';
|
||||||
|
|
||||||
const syncBodyDarkClass = (method: 'add' | 'remove'): boolean => {
|
const getCssVar = (name: string): string => {
|
||||||
const body = typeof document !== 'undefined' ? document.body : null;
|
if (!isDomAvailable()) return '';
|
||||||
if (!body) {
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.classList[method]('dark');
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyDarkClass = (isDark: boolean) => {
|
const readDomThemeName = () => getCssVar('--theme-name');
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
|
|
||||||
const method: 'add' | 'remove' = isDark ? 'add' : 'remove';
|
const syncDarkClass = (method: 'add' | 'remove') => {
|
||||||
|
if (!isDomAvailable()) return;
|
||||||
document.documentElement.classList[method]('dark');
|
document.documentElement.classList[method]('dark');
|
||||||
|
document.body?.classList[method]('dark');
|
||||||
|
document.querySelectorAll('.unapi').forEach((el) => el.classList[method]('dark'));
|
||||||
|
};
|
||||||
|
|
||||||
const unapiElements = document.querySelectorAll('.unapi');
|
const applyDarkClass = (isDark: boolean, darkModeRef?: { value: boolean }) => {
|
||||||
unapiElements.forEach((element) => {
|
if (!isDomAvailable()) return;
|
||||||
element.classList[method]('dark');
|
const method: 'add' | 'remove' = isDark ? 'add' : 'remove';
|
||||||
});
|
syncDarkClass(method);
|
||||||
|
document.documentElement.style.setProperty('--theme-dark-mode', isDark ? '1' : '0');
|
||||||
if (pendingDarkModeHandler) {
|
if (darkModeRef) {
|
||||||
document.removeEventListener('DOMContentLoaded', pendingDarkModeHandler);
|
darkModeRef.value = isDark;
|
||||||
pendingDarkModeHandler = null;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (syncBodyDarkClass(method)) {
|
const bootstrapDarkClass = (darkModeRef?: { value: boolean }) => {
|
||||||
return;
|
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 => {
|
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 activeColorVariables = ref<ThemeVariables>(defaultColors.white);
|
||||||
const hasServerTheme = ref(false);
|
const hasServerTheme = ref(false);
|
||||||
const devOverride = 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',
|
fetchPolicy: 'cache-and-network',
|
||||||
nextFetchPolicy: 'cache-first',
|
nextFetchPolicy: 'cache-first',
|
||||||
});
|
});
|
||||||
@@ -149,27 +141,34 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.value?.publicTheme) {
|
|
||||||
applyThemeFromQuery(result.value.publicTheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
onError((err) => {
|
onError((err) => {
|
||||||
console.warn('Failed to load theme from server, keeping existing theme:', err);
|
console.warn('Failed to load theme from server, keeping existing theme:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Getters
|
// Getters - read from DOM CSS variables set by PHP
|
||||||
// Apply dark mode for gray and black themes
|
const themeName = computed<string>(() => {
|
||||||
const darkMode = computed<boolean>(() =>
|
if (!isDomAvailable()) return DEFAULT_THEME.name;
|
||||||
DARK_UI_THEMES.includes(theme.value?.name as (typeof DARK_UI_THEMES)[number])
|
const name = readDomThemeName() || theme.value.name;
|
||||||
);
|
return name || DEFAULT_THEME.name;
|
||||||
|
});
|
||||||
|
|
||||||
const bannerGradient = computed(() => {
|
const readBannerGradientVar = (): string => {
|
||||||
if (!theme.value?.banner || !theme.value?.bannerGradient) {
|
const raw = getCssVar('--banner-gradient');
|
||||||
return undefined;
|
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)';
|
return raw;
|
||||||
const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)';
|
};
|
||||||
return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`;
|
|
||||||
|
const bannerGradient = computed<boolean>(() => {
|
||||||
|
const { banner, bannerGradient } = theme.value;
|
||||||
|
if (!banner || !bannerGradient) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const gradient = readBannerGradientVar();
|
||||||
|
return Boolean(gradient);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -202,27 +201,39 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
devOverride.value = enabled;
|
devOverride.value = enabled;
|
||||||
};
|
};
|
||||||
|
|
||||||
const setCssVars = () => {
|
const fetchTheme = () => {
|
||||||
applyDarkClass(darkMode.value);
|
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(
|
watch(
|
||||||
theme,
|
() => theme.value.name,
|
||||||
() => {
|
(themeName) => {
|
||||||
setCssVars();
|
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 {
|
return {
|
||||||
// state
|
// state
|
||||||
activeColorVariables,
|
activeColorVariables,
|
||||||
bannerGradient,
|
bannerGradient,
|
||||||
darkMode,
|
darkMode: computed(() => darkMode.value),
|
||||||
theme,
|
theme: computed(() => ({
|
||||||
|
...theme.value,
|
||||||
|
name: themeName.value,
|
||||||
|
})),
|
||||||
// actions
|
// actions
|
||||||
setTheme,
|
setTheme,
|
||||||
setCssVars,
|
|
||||||
setDevOverride,
|
setDevOverride,
|
||||||
|
fetchTheme,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{% if mode === 'development' %}
|
{% 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-fonts.css">
|
||||||
<link rel="stylesheet" href="/test-pages/unraid-assets/default-base.css">
|
<link rel="stylesheet" href="/test-pages/unraid-assets/default-base.css">
|
||||||
<link rel="stylesheet" href="/test-pages/unraid-assets/default-cases.css">
|
<link rel="stylesheet" href="/test-pages/unraid-assets/default-cases.css">
|
||||||
@@ -20,6 +23,47 @@
|
|||||||
{% elif activeTheme === 'white' %}
|
{% elif activeTheme === 'white' %}
|
||||||
<link rel="stylesheet" href="/test-pages/unraid-assets/themes/white.css" id="dev-theme-css-link">
|
<link rel="stylesheet" href="/test-pages/unraid-assets/themes/white.css" id="dev-theme-css-link">
|
||||||
{% endif %}
|
{% 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 %}
|
{% else %}
|
||||||
<link rel="stylesheet" href="/test-pages/unraid-assets/default-fonts.css">
|
<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-base.css">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import nunjucks from 'nunjucks';
|
|||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
import type { Plugin } from 'vite';
|
import type { Plugin } from 'vite';
|
||||||
|
|
||||||
|
type ThemeName = 'white' | 'black' | 'gray' | 'azure';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const publicDir = path.join(__dirname, 'public');
|
const publicDir = path.join(__dirname, 'public');
|
||||||
const templatesDir = path.join(__dirname, 'test-pages');
|
const templatesDir = path.join(__dirname, 'test-pages');
|
||||||
@@ -21,6 +23,144 @@ const env = nunjucks.configure(templatesDir, {
|
|||||||
const GITHUB_RAW_BASE =
|
const GITHUB_RAW_BASE =
|
||||||
'https://raw.githubusercontent.com/unraid/webgui/189edb1a690cfaef3358db9d6bef281a5e1231bc/emhttp/plugins/dynamix/styles';
|
'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 {
|
export function serveStaticHtml(): Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'serve-static-html',
|
name: 'serve-static-html',
|
||||||
@@ -87,6 +227,53 @@ export function serveStaticHtml(): Plugin {
|
|||||||
await handleUnraidAsset(res, assetPath);
|
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) => {
|
server.middlewares.use((req, res, next) => {
|
||||||
if (!req.url?.startsWith('/test-pages')) {
|
if (!req.url?.startsWith('/test-pages')) {
|
||||||
next();
|
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, {
|
const html = env.render(templateName, {
|
||||||
url: requestUrl.pathname,
|
url: requestUrl.pathname,
|
||||||
query: Object.fromEntries(requestUrl.searchParams.entries()),
|
query: Object.fromEntries(requestUrl.searchParams.entries()),
|
||||||
mode: server.config.mode,
|
mode: server.config.mode,
|
||||||
|
resolvedTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/html');
|
res.setHeader('Content-Type', 'text/html');
|
||||||
|
|||||||
Reference in New Issue
Block a user