Compare commits

...

26 Commits

Author SHA1 Message Date
Eli Bosley
71a5214a03 Merge 2cb6eaeb12 into 4e945f5f56 2025-09-02 12:50:33 -04:00
Eli Bosley
2cb6eaeb12 refactor: enhance clipboard functionality and UI responsiveness in HeaderOsVersion component
- Integrated clipboard support check to ensure OS and API version copying only occurs when supported.
- Updated button states to reflect clipboard capability, improving user experience.
- Adjusted icon styles for better visual consistency and responsiveness in the component layout.
2025-09-02 12:50:19 -04:00
Eli Bosley
b1590ee609 refactor: improve button click handling and class management in components
- Updated button click handlers in Notifications and RClone components to pass parameters directly, enhancing clarity and functionality.
- Introduced a computed property for button classes in UserProfile component, streamlining class management based on item properties.
- Refactored navigation logic in UpdateOs component for better readability and maintainability.
2025-09-02 10:24:33 -04:00
Eli Bosley
51ebe77d09 refactor: update component structure and improve tooltip content
- Removed unnecessary margin from the ConnectSettings component for cleaner layout.
- Enhanced the Sidebar component's tooltip to include a more descriptive label for editing notification settings, improving user clarity.
2025-09-01 20:10:48 -04:00
Eli Bosley
e719780ee8 refactor: enhance component styles and introduce responsive modal
- Updated CSS variables and utility classes for improved theme integration and style consistency across components.
- Introduced a new responsive modal component to enhance user experience on various screen sizes.
- Refined button and badge styles to ensure better visual hierarchy and interaction feedback.
- Adjusted component imports and structure for better modularity and maintainability.
- Removed deprecated styles and streamlined CSS for improved performance and clarity.
2025-09-01 20:06:48 -04:00
Eli Bosley
67a6a2e7c8 refactor: enhance CSS variable management and layer structure for improved theme integration
- Introduced overrides for Tailwind v4 global styles to utilize webgui variables, ensuring better compatibility and theming.
- Scoped border colors and other styles to specific components, preventing unintended style leakage.
- Updated layer definitions in main.css to prioritize webgui styles effectively, enhancing overall style management.
- Added new Tailwind v4 color variables for utility classes in the theme store, improving customization options.
2025-08-31 15:23:17 -04:00
Eli Bosley
3faa637d97 refactor: streamline CSS patching and layer management for improved style isolation
- Simplified the CSS patching function to wrap styles in a single `@layer`, enhancing control over cascade order and ensuring Tailwind styles can override as needed.
- Removed the previous exclusion selectors logic, focusing on a more efficient layer-based approach to prevent style conflicts with webgui elements.
- Updated the Nuxt configuration to eliminate the postcssPrefixPlugin, reflecting the shift towards layer management for CSS class handling.
- Enhanced the main.css file to define layer order explicitly, ensuring that webgui styles are overridden by Tailwind utilities effectively.
2025-08-31 13:06:00 -04:00
Eli Bosley
83107a743c feat: implement CSS class prefixing and exclusion logic for improved style management
- Introduced a new Vite plugin to prefix CSS classes, ensuring that styles from the webgui do not interfere with our components.
- Enhanced the CSS patching script to apply exclusion selectors, preventing style leakage from `.unapi` containers.
- Updated the Nuxt configuration to integrate the new postcssPrefixPlugin, allowing for better control over CSS class names.
- Modified the Vue app mounting logic to add the `.unapi` class for improved style isolation and backward compatibility with `.unraid-reset`.
2025-08-31 12:37:53 -04:00
Eli Bosley
aa9648105f refactor: update CSS patching for improved compatibility and specificity
- Modified the CSS patching script to enhance compatibility by updating echo statements and adjusting the patching logic.
- Removed unnecessary layer wrapping in the CSS content, simplifying the structure while maintaining style specificity.
- Updated comments for clarity on the purpose of the compatibility patch and its impact on CSS management.
2025-08-31 10:00:07 -04:00
Eli Bosley
fdacc21d0e refactor: improve CSS layer management for better style precedence
- Updated the CSS layer definitions in both the plugin and main.css to ensure a clear hierarchy, preventing style conflicts and enhancing specificity.
- Revised comments to clarify the intended layer order and its impact on style application, ensuring better maintainability and understanding of the CSS structure.
2025-08-31 09:43:54 -04:00
Eli Bosley
7ec4874680 refactor: enhance CSS backup and restoration logic in deployment script
- Added functionality to restore existing CSS backups before creating new ones, ensuring a clean state during deployment.
- Updated comments for clarity on the backup process and the creation of the backup directory.
- Improved the handling of CSS imports in `main.css` to prevent global resets and enhance style specificity.
2025-08-31 09:29:27 -04:00
Eli Bosley
771dcef4f7 fix: update PHP path in plugin and enhance deployment script for web components
- Changed the PHP executable path in the plugin from `/bin/php` to `/usr/bin/php` for better compatibility.
- Improved the `deploy-dev.sh` script by ensuring proper quoting in the rsync command and adding a check to create the remote directory for standalone apps, enhancing deployment reliability.
2025-08-31 09:10:41 -04:00
Eli Bosley
2594df7e9c refactor: enhance CSS layering and specificity for improved style management
- Updated the CSS patching script to wrap styles in a new `@layer` structure, ensuring better isolation and priority management for webgui and unraid-api styles.
- Refined the `.unraid-reset` class in `main.css` to utilize CSS layers, enhancing specificity and preventing style conflicts with webgui elements.
- Improved the handling of CSS content during patching to ensure all styles are correctly wrapped and prioritized.
2025-08-31 09:03:40 -04:00
Eli Bosley
9dcd05748e refactor: enhance CSS patching and restoration logic in deployment script
- Added a new installation script to patch webgui CSS files, ensuring that styles for specific elements are wrapped with `:not(.unraid-reset)` to prevent style leakage.
- Implemented a backup and restoration mechanism for original CSS files, allowing for easy recovery after patching.
- Improved the handling of CSS directories and added warnings for missing directories to enhance robustness during deployment.
2025-08-31 09:00:40 -04:00
Eli Bosley
3cd5c0e8fd refactor: improve CSS reset strategy and deployment script logic
- Refined the `.unraid-reset` class in `main.css` to create a CSS layer for resets, enhancing style isolation and preventing leakage from webgui styles.
- Updated the deployment script `deploy-dev.sh` to improve checks for the existence of web components and standalone apps, ensuring accurate deployment and error handling.
2025-08-30 22:40:30 -04:00
Eli Bosley
c60f7b7204 refactor: enhance CSS isolation and z-index management for modals
- Updated the `.unraid-reset` class to apply isolation to non-modal components, preventing style leakage.
- Added z-index rules to ensure modals and their backdrops appear above all other content, improving UI layering.
- Refined button styles within the `.unraid-reset` class to reset inherited properties for better consistency.
2025-08-30 22:28:35 -04:00
Eli Bosley
ce67257526 refactor: enhance CSS reset and improve Vue app mounting logic
- Updated the `.unraid-reset` class in `main.css` to include additional properties for better styling consistency across Unraid components.
- Refined the `autoMountComponent` function in `vue-mount-app.ts` to check for element existence before mounting, improving robustness and preventing errors during the mounting process.
2025-08-30 22:19:17 -04:00
Eli Bosley
4a39cd9862 refactor: enhance manifest validation and Vue app mounting logic
- Improved validation in `WebComponentsExtractor` to log errors for missing standalone apps entries and file keys, ensuring better error handling during manifest processing.
- Updated CSS content retrieval in `vite.standalone.config.ts` to include a fallback mechanism for missing Nuxt CSS files, enhancing robustness.
- Simplified modal component mounting in `standalone-mount.ts` by utilizing a dedicated function for better readability and maintainability.
- Refined `mountVueApp` logic in `vue-mount-app.ts` to differentiate between the main app and clones, optimizing the mounting process for multiple targets.
2025-08-30 22:06:25 -04:00
Eli Bosley
f7ad582436 refactor: streamline standalone app deployment and manifest generation
- Removed redundant modal div from `test-standalone.html`, simplifying the structure for Vue component mounting.
- Added a check in `add-timestamp-standalone-manifest.js` to ensure the existence of the standalone apps directory before manifest generation, improving error handling.
- Updated `deploy-dev.sh` to enhance the rsync command for standalone apps, ensuring proper synchronization and cleanup of old files during deployment.
2025-08-30 22:02:02 -04:00
Eli Bosley
7c59c03786 refactor: improve standalone app manifest handling and Vue app mounting
- Updated `WebComponentsExtractor` to iterate over all manifest files, ensuring valid standalone apps entries are processed and preventing duplicate script loading.
- Enhanced `mountVueApp` to manage multiple clones and their respective shadow-root containers, improving cleanup and organization of mounted Vue apps.
- Modified deployment script to capture exit codes from standalone app synchronization, ensuring accurate error reporting during deployment.
2025-08-30 21:58:24 -04:00
Eli Bosley
eeed20215f feat: add prop parsing from HTML attributes in Vue app mounting
- Introduced a helper function `parsePropsFromElement` to extract props from HTML attributes, enhancing the flexibility of prop handling.
- Updated `mountVueApp` to utilize parsed props for both the main app and additional targets, allowing for dynamic prop assignment based on the HTML structure.
- Improved overall integration of props with Vue components, ensuring a more seamless mounting process.
2025-08-30 21:52:43 -04:00
Eli Bosley
41f11b0f8d refactor: enhance CSS content retrieval in Vite config
- Updated the CSS content retrieval logic in `vite.standalone.config.ts` to dynamically find and read entry CSS files from specified directories, improving flexibility and maintainability.
- Removed hardcoded CSS file paths in favor of a directory-based approach, allowing for easier updates and better organization of CSS assets.
2025-08-30 21:28:09 -04:00
Eli Bosley
32bc79b93d fix: update CSS validation patterns for Tailwind classes
- Enhanced regex patterns in `validate-custom-elements-css.js` to accommodate minified CSS formats, ensuring accurate detection of Tailwind utility classes and other CSS properties.
- Adjusted patterns for flex, margin, padding, color, background utilities, CSS custom properties, and responsive breakpoints to support both spaced and non-spaced formats.
2025-08-30 21:26:49 -04:00
Eli Bosley
b9632b9774 style: add unraid-reset class and CSS rules for component styling
- Introduced a new CSS class `.unraid-reset` to reset inherited styles for Unraid components, ensuring consistent styling across the application.
- Updated `vue-mount-app.ts` to apply the `.unraid-reset` class to mount targets, preventing webgui styles from leaking into Unraid components.
2025-08-30 21:18:49 -04:00
Eli Bosley
a1d91a0b4d fix: update artifact path and manifest validation logic
- Changed the artifact path in the GitHub Actions workflow to point to the new standalone apps directory.
- Enhanced the manifest file validation to include support for standalone.manifest.json, allowing for more flexible manifest file requirements.
2025-08-30 21:03:15 -04:00
Eli Bosley
85b250eb80 feat: mount vue apps, not web components 2025-08-30 20:49:31 -04:00
100 changed files with 2482 additions and 872 deletions

View File

@@ -359,7 +359,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: unraid-wc-rich
path: web/.nuxt/nuxt-custom-elements/dist/unraid-components
path: web/.nuxt/standalone-apps
build-plugin-staging-pr:
name: Build and Deploy Plugin

View File

@@ -1,7 +1,7 @@
@custom-variant dark (&:where(.dark, .dark *));
@layer utilities {
:host {
/* Utility defaults for web components (when we were using shadow DOM) */
:host {
--tw-divide-y-reverse: 0;
--tw-border-style: solid;
--tw-font-weight: initial;
@@ -48,21 +48,20 @@
--tw-drop-shadow: initial;
--tw-duration: initial;
--tw-ease: initial;
}
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: hsl(var(--border));
}
/* Global border color - this is what's causing the issue! */
/* Commenting out since it affects all elements globally
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: hsl(var(--border));
}
*/
body {
body {
--color-alpha: #1c1b1b;
--color-beta: #f2f2f2;
--color-gamma: #999999;
@@ -74,8 +73,7 @@
--ring-shadow: 0 0 var(--color-beta);
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}

View File

@@ -1,7 +1,11 @@
/* Hybrid theme system: Native CSS + Theme Store fallback */
@layer base {
/* Light mode defaults */
:root {
/* Light mode defaults */
:root {
/* Override Tailwind v4 global styles to use webgui variables */
--ui-bg: var(--background-color) !important;
--ui-text: var(--text-color) !important;
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--muted: 0 0% 96.1%;
@@ -30,6 +34,10 @@
/* Dark mode */
.dark {
/* Override Tailwind v4 global styles to use webgui variables */
--ui-bg: var(--background-color) !important;
--ui-text: var(--text-color) !important;
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
@@ -62,69 +70,4 @@
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--border: 0 0% 14.9%;
}
/* For web components: inherit CSS variables from the host */
:host {
--background: inherit;
--foreground: inherit;
--muted: inherit;
--muted-foreground: inherit;
--popover: inherit;
--popover-foreground: inherit;
--card: inherit;
--card-foreground: inherit;
--border: inherit;
--input: inherit;
--primary: inherit;
--primary-foreground: inherit;
--secondary: inherit;
--secondary-foreground: inherit;
--accent: inherit;
--accent-foreground: inherit;
--destructive: inherit;
--destructive-foreground: inherit;
--ring: inherit;
--chart-1: inherit;
--chart-2: inherit;
--chart-3: inherit;
--chart-4: inherit;
--chart-5: inherit;
}
/* Class-based dark mode support for web components using :host-context */
:host-context(.dark) {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
/* Alternative class-based dark mode support for specific Unraid themes */
:host-context(.dark[data-theme='black']),
:host-context(.dark[data-theme='gray']) {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--border: 0 0% 14.9%;
}
}
}

View File

@@ -2,4 +2,5 @@
@import './css-variables.css';
@import './unraid-theme.css';
@import './base-utilities.css';
@import './sonner.css';
@import './sonner.css';
@import './reka-resets.css';

View File

@@ -0,0 +1,21 @@
/*
* Minimal resets for reka-ui components
* Only override the problematic webgui button styles
*/
/* Target all reka-ui buttons by their common attributes */
button[id^="reka-accordion-trigger"],
button[role="combobox"],
button[aria-haspopup="menu"],
[role="dialog"] button[type="button"] {
/* Only override the truly problematic styles */
font-family: inherit !important; /* Don't force clear-sans */
font-size: inherit !important; /* Don't force 1.1rem */
font-weight: normal !important; /* Don't force bold */
letter-spacing: normal !important; /* Don't force 1.8px spacing */
text-transform: none !important; /* Don't force uppercase */
min-width: auto !important; /* Don't force 86px minimum */
margin: 0 !important; /* Don't add 10px margins */
border: none !important; /* Remove forced border */
/* Let components handle their own padding through Tailwind classes */
}

View File

@@ -29,7 +29,9 @@ const findManifestFiles = async (dir: string): Promise<string[]> => {
}
} else if (
entry.isFile() &&
(entry.name === "manifest.json" || entry.name === "ui.manifest.json")
(entry.name === "manifest.json" ||
entry.name === "ui.manifest.json" ||
entry.name === "standalone.manifest.json")
) {
files.push(entry.name);
}
@@ -124,19 +126,21 @@ const validateSourceDir = async (validatedEnv: TxzEnv) => {
const manifestFiles = await findManifestFiles(webcomponentDir);
const hasManifest = manifestFiles.includes("manifest.json");
const hasStandaloneManifest = manifestFiles.includes("standalone.manifest.json");
const hasUiManifest = manifestFiles.includes("ui.manifest.json");
if (!hasManifest || !hasUiManifest) {
// Accept either manifest.json (old web components) or standalone.manifest.json (new standalone apps)
if ((!hasManifest && !hasStandaloneManifest) || !hasUiManifest) {
console.log("Existing Manifest Files:", manifestFiles);
const missingFiles: string[] = [];
if (!hasManifest) missingFiles.push("manifest.json");
if (!hasManifest && !hasStandaloneManifest) missingFiles.push("manifest.json or standalone.manifest.json");
if (!hasUiManifest) missingFiles.push("ui.manifest.json");
throw new Error(
`Webcomponents missing required file(s): ${missingFiles.join(", ")} - ` +
`${!hasUiManifest ? "run 'pnpm build:wc' in unraid-ui for ui.manifest.json" : ""}` +
`${!hasManifest && !hasUiManifest ? " and " : ""}` +
`${!hasManifest ? "run 'pnpm build' in web for manifest.json" : ""}`
`${(!hasManifest && !hasStandaloneManifest) && !hasUiManifest ? " and " : ""}` +
`${(!hasManifest && !hasStandaloneManifest) ? "run 'pnpm build' in web for standalone.manifest.json" : ""}`
);
}

View File

@@ -161,7 +161,41 @@ exit 0
sed -i 's|<body>|<body>\n<unraid-modals></unraid-modals>|' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"
fi
fi
]]>
</INLINE>
</FILE>
<FILE Run="/usr/bin/php" Method="install">
<INLINE>
<![CDATA[
<?php
echo "Patching webgui CSS files for compatibility...\n";
$cssDir = "/usr/local/emhttp/plugins/dynamix/styles";
$backupDir = "$cssDir/.unraid-api-backup";
if (!is_dir($cssDir)) {
echo "Warning: CSS directory not found at $cssDir\n";
exit(0);
}
// First restore any existing backups to ensure clean state
if (is_dir($backupDir)) {
echo "Restoring original CSS files from backup...\n";
foreach (glob("$backupDir/*.css") as $backupFile) {
$filename = basename($backupFile);
$originalFile = "$cssDir/$filename";
echo " Restoring $filename...\n";
copy($backupFile, $originalFile);
}
}
// Create backup directory if it doesn't exist
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
?>
]]>
</INLINE>
</FILE>
@@ -272,6 +306,27 @@ exit 0
[ -f "$FILE-" ] && mv -f "$FILE-" "$FILE"
done
# Restore CSS files from backup
echo "Restoring original CSS files..."
CSS_DIR="/usr/local/emhttp/plugins/dynamix/styles"
BACKUP_DIR="$CSS_DIR/.unraid-api-backup"
if [ -d "$BACKUP_DIR" ]; then
for backup_file in "$BACKUP_DIR"/*.css; do
if [ -f "$backup_file" ]; then
filename=$(basename "$backup_file")
original_file="$CSS_DIR/$filename"
echo " Restoring $filename..."
cp "$backup_file" "$original_file"
fi
done
# Remove backup directory after restoration
rm -rf "$BACKUP_DIR"
echo "CSS restoration complete."
else
echo "No CSS backup found, skipping restoration."
fi
# Handle the unraid-components directory
DIR=/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components
# Remove the archive's contents before restoring

View File

@@ -8,6 +8,7 @@ class WebComponentsExtractor
private const RICH_COMPONENTS_ENTRY_JS = 'unraid-components.client.js';
private const UI_ENTRY = 'src/register.ts';
private const UI_STYLES_ENTRY = 'style.css';
private const STANDALONE_APPS_ENTRY = 'standalone-apps.js';
private static ?WebComponentsExtractor $instance = null;
@@ -98,6 +99,57 @@ class WebComponentsExtractor
</script>';
}
private function getStandaloneAppsScript(): string
{
$manifestFiles = $this->findManifestFiles('standalone.manifest.json');
if (empty($manifestFiles)) {
// No standalone apps, return empty
return '';
}
// Iterate over all manifest files to find valid standalone apps entry
foreach ($manifestFiles as $manifestPath) {
$manifest = $this->getManifestContents($manifestPath);
$subfolder = $this->getRelativePath($manifestPath);
// Check if STANDALONE_APPS_ENTRY exists
if (!isset($manifest[self::STANDALONE_APPS_ENTRY])) {
error_log("Standalone apps manifest at '{$manifestPath}' is missing the '" . self::STANDALONE_APPS_ENTRY . "' entry key");
return '';
}
$entry = $manifest[self::STANDALONE_APPS_ENTRY];
// Check if 'file' key exists
if (!isset($entry['file']) || empty($entry['file'])) {
error_log("Standalone apps manifest at '{$manifestPath}' has entry '" . self::STANDALONE_APPS_ENTRY . "' but is missing the 'file' field");
return '';
}
// Build the JS file path
$jsFile = ($subfolder ? $subfolder . '/' : '') . $entry['file'];
// Use a unique identifier to prevent duplicate script loading
$scriptId = 'unraid-standalone-apps-script';
return '<script id="' . $scriptId . '" type="module" src="' . $this->getAssetPath($jsFile) . '"></script>
<script>
// Remove duplicate script tags to prevent multiple loads
(function() {
var scripts = document.querySelectorAll(\'script[id="' . $scriptId . '"]\');
if (scripts.length > 1) {
for (var i = 1; i < scripts.length; i++) {
scripts[i].remove();
}
}
})();
</script>';
}
// Return empty string if no valid standalone apps entry found
return '';
}
private function getUnraidUiScriptHtml(): string
{
$manifestFiles = $this->findManifestFiles('ui.manifest.json');
@@ -173,7 +225,9 @@ class WebComponentsExtractor
try {
$scriptsOutput = true;
return $this->getRichComponentsScript() . $this->getUnraidUiScriptHtml();
return $this->getRichComponentsScript() .
$this->getUnraidUiScriptHtml() .
$this->getStandaloneAppsScript();
} catch (\Exception $e) {
error_log("Error in WebComponentsExtractor::getScriptTagHtml: " . $e->getMessage());
$scriptsOutput = false; // Reset on error

View File

@@ -87,6 +87,13 @@ const commonGlobals = {
HTMLElement: 'readonly',
HTMLInputElement: 'readonly',
CustomEvent: 'readonly',
MouseEvent: 'readonly',
KeyboardEvent: 'readonly',
FocusEvent: 'readonly',
PointerEvent: 'readonly',
TouchEvent: 'readonly',
WheelEvent: 'readonly',
DragEvent: 'readonly',
};
export default [// Base config from recommended configs

View File

@@ -17,6 +17,7 @@ export * from '@/components/common/tabs';
export * from '@/components/common/tooltip';
export * from '@/components/common/toast';
export * from '@/components/common/popover';
export * from '@/components/common/responsive-modal';
export * from '@/components/modals';
export * from '@/components/common/accordion';
export * from '@/components/common/dialog';

View File

@@ -7,7 +7,6 @@ export interface BrandButtonProps {
variant?: BrandButtonVariants['variant'];
size?: BrandButtonVariants['size'];
padding?: BrandButtonVariants['padding'];
btnType?: 'button' | 'submit' | 'reset';
class?: string;
click?: () => void;
disabled?: boolean;
@@ -26,7 +25,6 @@ const props = withDefaults(defineProps<BrandButtonProps>(), {
variant: 'fill',
size: '16px',
padding: 'default',
btnType: 'button',
class: undefined,
click: undefined,
disabled: false,
@@ -58,15 +56,26 @@ const needsBrandGradientBackground = computed(() => {
<template>
<component
:is="href ? 'a' : 'button'"
:disabled="disabled"
:is="href ? 'a' : 'span'"
:role="!href ? 'button' : undefined"
:tabindex="!href && !disabled ? 0 : undefined"
:aria-disabled="!href && disabled ? true : undefined"
:href="href"
:rel="external ? 'noopener noreferrer' : ''"
:target="external ? '_blank' : ''"
:type="!href ? btnType : ''"
:class="classes.button"
:title="title"
@click="click ?? $emit('click')"
@click="!disabled && (click ?? $emit('click'))"
@keydown="
!href &&
!disabled &&
((e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(click ?? $emit('click'))();
}
})
"
>
<div
v-if="variant === 'fill'"

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
export const badgeVariants = cva(
'inline-flex items-center rounded-full font-semibold leading-none transition-all duration-200 ease-in-out unraid-ui-badge-test',
'inline-flex items-center rounded-full font-semibold leading-tight transition-all duration-200 ease-in-out h-fit unraid-ui-badge-test',
{
variants: {
variant: {

View File

@@ -7,20 +7,57 @@ export interface ButtonProps {
variant?: ButtonVariants['variant'];
size?: ButtonVariants['size'];
class?: string;
disabled?: boolean;
}
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'md',
disabled: false,
});
const emit = defineEmits<{
click: [event: MouseEvent];
}>();
const buttonClass = computed(() => {
return cn(buttonVariants({ variant: props.variant, size: props.size }), props.class);
return cn(
buttonVariants({ variant: props.variant, size: props.size }),
'cursor-pointer select-none',
props.disabled && 'pointer-events-none opacity-50',
props.class
);
});
const handleClick = (event: MouseEvent) => {
if (!props.disabled) {
emit('click', event);
}
};
const handleKeydown = (event: KeyboardEvent) => {
if (!props.disabled && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
// Create a synthetic click event
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
});
emit('click', clickEvent);
}
};
</script>
<template>
<button :class="buttonClass">
<span
:class="buttonClass"
role="button"
:tabindex="disabled ? -1 : 0"
:aria-disabled="disabled"
@click="handleClick"
@keydown="handleKeydown"
>
<slot />
</button>
</span>
</template>

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
export const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-base font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center rounded-md text-base font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none',
{
variants: {
variant: {
@@ -12,12 +12,16 @@ export const buttonVariants = cva(
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
header:
'group relative flex flex-row items-center text-base border-0 text-header-text-primary bg-transparent hover:bg-accent/20 focus-visible:bg-accent/20 focus-visible:ring-1 focus-visible:ring-accent/20 focus-visible:ring-offset-0 rounded-lg min-h-[36px]',
},
size: {
sm: 'rounded-md px-3 py-1',
md: 'h-10 px-4 py-2',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
header: 'h-10 px-2 py-6',
'header-icon': 'h-9 w-9 p-2',
},
},
defaultVariants: {

View File

@@ -6,6 +6,7 @@ export * from './dialog/index.js';
export * from './dropdown-menu/index.js';
export * from './loading/index.js';
export * from './popover/index.js';
export * from './responsive-modal/index.js';
export * from './scroll-area/index.js';
export * from './sheet/index.js';
export * from './stepper/index.js';

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { SheetVariants } from '@/components/common/sheet/sheet.variants';
import Sheet from '@/components/common/sheet/Sheet.vue';
import SheetContent from '@/components/common/sheet/SheetContent.vue';
import DialogContent from '@/components/ui/dialog/DialogContent.vue';
import DialogRoot from '@/components/ui/dialog/DialogRoot.vue';
import { useMediaQuery } from '@vueuse/core';
import { computed, type HTMLAttributes } from 'vue';
export interface ResponsiveModalProps {
open: boolean;
showCloseButton?: boolean;
// Sheet-specific props
sheetSide?: SheetVariants['side'];
sheetPadding?: SheetVariants['padding'];
sheetClass?: HTMLAttributes['class'];
// Dialog-specific props
dialogClass?: HTMLAttributes['class'];
// Breakpoint for switching between mobile/desktop
breakpoint?: string;
}
const props = withDefaults(defineProps<ResponsiveModalProps>(), {
open: false,
showCloseButton: true,
sheetSide: 'bottom',
sheetPadding: 'none',
breakpoint: '(max-width: 639px)', // sm breakpoint
});
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const isMobile = useMediaQuery(props.breakpoint);
const handleOpenChange = (value: boolean) => {
emit('update:open', value);
};
// Compute final classes for sheet and dialog
const finalSheetClass = computed(() => {
const baseClass = 'h-screen flex flex-col';
return props.sheetClass ? `${baseClass} ${props.sheetClass}` : baseClass;
});
const finalDialogClass = computed(() => {
const baseClass = 'flex flex-col';
return props.dialogClass ? `${baseClass} ${props.dialogClass}` : baseClass;
});
</script>
<template>
<!-- Mobile: Use Sheet -->
<Sheet v-if="isMobile" :open="open" @update:open="handleOpenChange">
<SheetContent :side="sheetSide" :padding="sheetPadding" :class="finalSheetClass">
<slot />
</SheetContent>
</Sheet>
<!-- Desktop: Use Dialog -->
<DialogRoot v-else :open="open" @update:open="handleOpenChange">
<DialogContent :class="finalDialogClass" :show-close-button="showCloseButton">
<slot />
</DialogContent>
</DialogRoot>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import SheetFooter from '@/components/common/sheet/SheetFooter.vue';
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue';
import { cn } from '@/lib/utils';
import { useMediaQuery } from '@vueuse/core';
import { computed, type HTMLAttributes } from 'vue';
export interface ResponsiveModalFooterProps {
class?: HTMLAttributes['class'];
breakpoint?: string;
}
const props = withDefaults(defineProps<ResponsiveModalFooterProps>(), {
breakpoint: '(max-width: 639px)', // sm breakpoint
});
const isMobile = useMediaQuery(props.breakpoint);
const footerClass = computed(() => {
return cn('px-3 pb-3 flex-shrink-0', props.class);
});
</script>
<template>
<SheetFooter v-if="isMobile" :class="footerClass">
<slot />
</SheetFooter>
<DialogFooter v-else :class="footerClass">
<slot />
</DialogFooter>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import SheetHeader from '@/components/common/sheet/SheetHeader.vue';
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue';
import { cn } from '@/lib/utils';
import { useMediaQuery } from '@vueuse/core';
import { computed, type HTMLAttributes } from 'vue';
export interface ResponsiveModalHeaderProps {
class?: HTMLAttributes['class'];
breakpoint?: string;
}
const props = withDefaults(defineProps<ResponsiveModalHeaderProps>(), {
breakpoint: '(max-width: 639px)', // sm breakpoint
});
const isMobile = useMediaQuery(props.breakpoint);
const headerClass = computed(() => {
return cn('px-6 pt-6 flex-shrink-0', props.class);
});
</script>
<template>
<SheetHeader v-if="isMobile" :class="headerClass">
<slot />
</SheetHeader>
<DialogHeader v-else :class="headerClass">
<slot />
</DialogHeader>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import SheetTitle from '@/components/common/sheet/SheetTitle.vue';
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue';
import { useMediaQuery } from '@vueuse/core';
import { type HTMLAttributes } from 'vue';
export interface ResponsiveModalTitleProps {
class?: HTMLAttributes['class'];
breakpoint?: string;
}
const props = withDefaults(defineProps<ResponsiveModalTitleProps>(), {
breakpoint: '(max-width: 639px)', // sm breakpoint
});
const isMobile = useMediaQuery(props.breakpoint);
</script>
<template>
<SheetTitle v-if="isMobile" :class="props.class">
<slot />
</SheetTitle>
<DialogTitle v-else :class="props.class">
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,8 @@
export { default as ResponsiveModal } from './ResponsiveModal.vue';
export { default as ResponsiveModalHeader } from './ResponsiveModalHeader.vue';
export { default as ResponsiveModalTitle } from './ResponsiveModalTitle.vue';
export { default as ResponsiveModalFooter } from './ResponsiveModalFooter.vue';
// Type exports
export type { ResponsiveModalProps } from './ResponsiveModal.vue';
export type { ResponsiveModalHeaderProps } from './ResponsiveModalHeader.vue';

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import Button from '@/components/common/button/Button.vue';
import { sheetVariants, type SheetVariants } from '@/components/common/sheet/sheet.variants';
import SheetClose from '@/components/common/sheet/SheetClose.vue';
import useTeleport from '@/composables/useTeleport';
import { cn } from '@/lib/utils';
import { X } from 'lucide-vue-next';
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
@@ -50,11 +51,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
/>
<DialogContent :class="sheetClass" v-bind="forwarded">
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="text-muted-foreground h-4 w-4" />
</DialogClose>
<SheetClose as="span" class="absolute top-4 right-4">
<Button variant="ghost" size="sm" class="h-auto w-auto p-1">
<X class="h-4 w-4" />
</Button>
</SheetClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -15,7 +15,9 @@ export const sheetVariants = cva(
},
padding: {
none: '',
md: 'p-6',
sm: 'p-2',
md: 'p-4',
lg: 'p-6',
},
},
defaultVariants: {

View File

@@ -17,15 +17,17 @@ const forwardedProps = useForwardProps(delegatedProps);
<template>
<TabsTrigger
v-bind="forwardedProps"
as="span"
tabindex="0"
:class="
cn(
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded px-4.5 py-2.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-xs',
'ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors select-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
'hover:bg-accent hover:text-accent-foreground',
'data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:hover:bg-primary/90',
props.class
)
"
>
<span class="truncate">
<slot />
</span>
<slot />
</TabsTrigger>
</template>

View File

@@ -38,7 +38,7 @@ const { teleportTarget } = useTeleport();
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
props.class
)
"

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { TooltipTrigger, type TooltipTriggerProps } from 'reka-ui';
const props = defineProps<TooltipTriggerProps>();
const props = withDefaults(defineProps<TooltipTriggerProps>(), {
asChild: true,
});
</script>
<template>

View File

@@ -33,7 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-[200px] rounded-md border shadow-md outline-hidden',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-50 w-[200px] rounded-md border shadow-md outline-hidden',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class

View File

@@ -25,6 +25,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template>
<SwitchRoot
v-bind="forwarded"
as="span"
:tabindex="props.disabled ? -1 : 0"
:class="
cn(
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',

View File

@@ -14,9 +14,11 @@ const delegatedProps = reactiveOmit(props, 'class');
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
as="span"
tabindex="0"
:class="
cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
'flex flex-1 cursor-pointer items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class
)
"

View File

@@ -9,3 +9,28 @@ const props = defineProps<DialogCloseProps>();
<slot />
</DialogClose>
</template>
<style>
/* Reset webgui button styles for dialog close buttons */
[role='dialog'] button[type='button'],
button[aria-label*='close' i],
button[aria-label*='dismiss' i] {
/* Reset ALL webgui button styles using !important where needed */
all: unset !important;
/* Re-apply necessary styles after reset */
display: inline-flex !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
cursor: pointer !important;
box-sizing: border-box !important;
/* Reset any webgui CSS variables */
--button-border: none !important;
--button-text-color: inherit !important;
--button-background: transparent !important;
--button-background-size: auto !important;
}
</style>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import Button from '@/components/common/button/Button.vue';
import useTeleport from '@/composables/useTeleport';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
@@ -39,7 +40,7 @@ const { teleportTarget } = useTeleport();
v-bind="forwarded"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed top-1/2 left-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg duration-200',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 border-muted fixed top-1/2 left-1/2 z-50 flex w-full max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-lg border p-6 shadow-lg duration-200',
// Only apply zoom and slide animations if not fullscreen
!props.class?.includes('min-h-screen') &&
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
@@ -52,12 +53,15 @@ const { teleportTarget } = useTeleport();
>
<slot />
<DialogClose
v-if="showCloseButton !== false"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
<DialogClose v-if="showCloseButton !== false" as-child class="absolute top-4 right-4">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-sm opacity-70 transition-opacity hover:opacity-100"
aria-label="Close"
>
<X class="h-4 w-4" />
</Button>
</DialogClose>
</DialogContent>
</DialogPortal>

View File

@@ -5,7 +5,7 @@ const props = defineProps<DialogTriggerProps>();
</script>
<template>
<DialogTrigger v-bind="props">
<DialogTrigger v-bind="props" as="span" tabindex="0">
<slot />
</DialogTrigger>
</template>

View File

@@ -32,7 +32,7 @@ const { teleportTarget } = useTeleport();
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-lg border p-1 shadow-md',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-50 min-w-32 overflow-hidden rounded-lg border p-1 shadow-md',
props.class
)
"

View File

@@ -15,9 +15,11 @@ const forwardedProps = useForwardProps(delegatedProps);
<template>
<DropdownMenuSubTrigger
v-bind="forwardedProps"
as="span"
tabindex="0"
:class="
cn(
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none',
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none',
props.class
)
"

View File

@@ -10,6 +10,8 @@ const forwardedProps = useForwardProps(props);
<DropdownMenuTrigger
class="cursor-pointer outline-hidden data-[state=open]:cursor-pointer"
v-bind="forwardedProps"
as="span"
tabindex="0"
>
<slot />
</DropdownMenuTrigger>

View File

@@ -35,7 +35,7 @@ const { teleportTarget } = useTeleport();
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border shadow-md',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class

View File

@@ -15,9 +15,11 @@ const forwardedProps = useForwardProps(delegatedProps);
<template>
<SelectTrigger
v-bind="forwardedProps"
as="span"
tabindex="0"
:class="
cn(
'border-input bg-background ring-offset-background data-placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-start text-sm focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate',
'border-input bg-background ring-offset-background data-placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full cursor-pointer items-center justify-between rounded-md border px-3 py-2 text-start text-sm focus:ring-2 focus:ring-offset-2 focus:outline-hidden data-disabled:cursor-not-allowed data-disabled:opacity-50 [&>span]:truncate',
props.class
)
"

View File

@@ -5,7 +5,7 @@
v-for="(element, index) in elements"
:key="`${layout.path || ''}-${index}`"
:value="`item-${index}`"
class="bg-background rounded-lg border"
class="bg-background border-muted rounded-lg border"
>
<AccordionTrigger class="hover:bg-muted/50 px-4 py-3 [&[data-state=open]>svg]:rotate-180">
<div class="flex flex-col items-start space-y-1 text-left">

View File

@@ -196,7 +196,7 @@ const updateItem = (index: number, newValue: unknown) => {
:value="String(index)"
class="mt-0 w-full"
>
<div class="w-full rounded-lg border p-1 sm:p-6">
<div class="border-muted w-full rounded-lg border p-1 sm:p-6">
<div class="mb-4 flex justify-end">
<Button
v-if="!isItemProtected(item)"
@@ -214,7 +214,7 @@ const updateItem = (index: number, newValue: unknown) => {
<!-- Show warning if item matches protected condition -->
<div
v-if="getItemWarning(item)"
class="bg-warning/10 border-warning/20 mb-4 rounded-lg border p-3"
class="bg-warning/10 border-warning/20 border-muted mb-4 rounded-lg border p-3"
>
<div class="flex items-start gap-2">
<span class="text-warning"></span>
@@ -240,7 +240,7 @@ const updateItem = (index: number, newValue: unknown) => {
</TabsContent>
</Tabs>
<div v-else class="rounded-lg border-2 border-dashed py-8 text-center">
<div v-else class="border-muted rounded-lg border-2 border-dashed py-8 text-center">
<p class="text-muted-foreground mb-4">No {{ itemTypeName.toLowerCase() }}s configured</p>
<Button variant="outline" size="md" :disabled="!control.enabled" @click="addItem">
<Plus class="mr-2 h-4 w-4" />

View File

@@ -155,7 +155,7 @@ const getStepState = (stepIndex: number): StepState => {
<!-- Render elements for the current step -->
<!-- Added key to force re-render on step change, ensuring correct elements display -->
<div
class="current-step-content rounded-md border p-4 shadow-sm"
class="current-step-content border-muted rounded-md border p-4 shadow-sm"
:key="`step-content-${currentStep}`"
>
<DispatchRenderer

View File

@@ -33,7 +33,7 @@ const elements = computed(() => {
</script>
<template>
<SettingsGrid v-if="isVisible">
<SettingsGrid v-if="isVisible" class="[&_.grow]:max-w-3xl">
<template v-for="(element, _i) in elements" :key="_i">
<DispatchRenderer
:schema="layout.layout.value.schema"

View File

@@ -27,6 +27,12 @@ vi.mock('@unraid/ui', async (importOriginal) => {
</div>
`,
},
BrandButton: {
name: 'BrandButton',
props: ['text', 'disabled'],
emits: ['click'],
template: '<button :disabled="disabled" @click="$emit(\'click\')">{{ text }}</button>',
},
};
});
@@ -168,17 +174,16 @@ describe('Activation/WelcomeModal.ce.vue', () => {
expect(button.exists()).toBe(true);
// Initially dialog should be visible
let dialog = wrapper.findComponent({ name: 'Dialog' });
const dialog = wrapper.findComponent({ name: 'Dialog' });
expect(dialog.exists()).toBe(true);
expect(dialog.props('modelValue')).toBe(true);
await button.trigger('click');
await wrapper.vm.$nextTick();
// After click, the dialog should be hidden (modelValue should be false)
dialog = wrapper.findComponent({ name: 'Dialog' });
expect(dialog.exists()).toBe(true);
expect(dialog.props('modelValue')).toBe(false);
// After click, the dialog should be hidden - check if the dialog div is no longer rendered
const dialogDiv = wrapper.find('[role="dialog"]');
expect(dialogDiv.exists()).toBe(false);
});
it('disables the Create a password button when loading', async () => {
@@ -188,7 +193,7 @@ describe('Activation/WelcomeModal.ce.vue', () => {
const button = wrapper.find('button');
expect(button.exists()).toBe(true);
expect(button.attributes('disabled')).toBe('');
expect(button.attributes('disabled')).toBeDefined();
});
it('renders activation steps with correct active step', async () => {

View File

@@ -23,36 +23,6 @@ vi.mock('@unraid/shared-callbacks', () => ({
useCallback: vi.fn(() => ({ send: vi.fn(), watcher: vi.fn() })),
}));
vi.mock('@unraid/ui', () => ({
Badge: {
name: 'Badge',
template: '<div><slot /></div>',
},
DropdownMenuRoot: {
name: 'DropdownMenuRoot',
template: '<div><slot /></div>',
},
DropdownMenuTrigger: {
name: 'DropdownMenuTrigger',
template: '<div><slot /></div>',
},
DropdownMenuContent: {
name: 'DropdownMenuContent',
template: '<div><slot /></div>',
},
DropdownMenuItem: {
name: 'DropdownMenuItem',
template: '<div><slot /></div>',
},
DropdownMenuLabel: {
name: 'DropdownMenuLabel',
template: '<div><slot /></div>',
},
DropdownMenuSeparator: {
name: 'DropdownMenuSeparator',
template: '<div />',
},
}));
vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({

View File

@@ -12,9 +12,6 @@ import type { Props as ModalProps } from '~/components/Modal.vue';
import Modal from '~/components/Modal.vue';
vi.mock('@unraid/ui', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
const mockSetProperty = vi.fn();
const mockRemoveProperty = vi.fn();

View File

@@ -2,7 +2,6 @@
* Registration Component Test Coverage
*/
import { defineComponent } from 'vue';
import { setActivePinia } from 'pinia';
import { mount } from '@vue/test-utils';
@@ -14,7 +13,6 @@ import type { ServerconnectPluginInstalled } from '~/types/server';
import type { Pinia } from 'pinia';
import Registration from '~/components/Registration.ce.vue';
import MockedRegistrationItem from '~/components/Registration/Item.vue';
import { usePurchaseStore } from '~/store/purchase';
import { useReplaceRenewStore } from '~/store/replaceRenew';
import { useServerStore } from '~/store/server';
@@ -57,6 +55,7 @@ vi.mock('@unraid/ui', async (importOriginal) => {
BrandButton: { template: '<button><slot /></button>', props: ['text', 'title', 'icon', 'disabled'] },
CardWrapper: { template: '<div><slot /></div>' },
PageContainer: { template: '<div><slot /></div>' },
SettingsGrid: { template: '<div class="settings-grid"><slot /></div>' },
};
});
@@ -83,26 +82,6 @@ vi.mock('~/components/UserProfile/UptimeExpire.vue', () => ({
},
}));
vi.mock('~/components/Registration/Item.vue', () => ({
default: defineComponent({
props: ['label', 'text', 'component', 'componentProps', 'error', 'warning', 'componentOpacity'],
name: 'RegistrationItem',
template: `
<div class="registration-item">
<dt v-if="label">{{ label }}</dt>
<dd>
<span v-if="text">{{ text }}</span>
<template v-if="component">
<component :is="component" v-bind="componentProps" :class="[componentOpacity && !error ? 'opacity-75' : '']" />
</template>
</dd>
</div>
`,
setup(props) {
return { ...props };
},
}),
}));
// Define initial state for the server store for testing
const initialServerState = {
@@ -146,9 +125,22 @@ describe('Registration.ce.vue', () => {
let purchaseStore: ReturnType<typeof usePurchaseStore>;
const findItemByLabel = (labelKey: string) => {
const items = wrapper.findAllComponents({ name: 'RegistrationItem' });
return items.find((item) => item.props('label') === t(labelKey));
const allLabels = wrapper.findAll('.font-semibold');
const label = allLabels.find((el) => el.html().includes(t(labelKey)));
if (!label) return undefined;
const nextSibling = label.element.nextElementSibling;
return {
exists: () => true,
props: (prop: string) => {
if (prop === 'text' && nextSibling) {
return nextSibling.textContent?.trim();
}
return undefined;
},
};
};
beforeEach(() => {
@@ -175,8 +167,9 @@ describe('Registration.ce.vue', () => {
wrapper = mount(Registration, {
global: {
plugins: [pinia],
components: {
RegistrationItem: MockedRegistrationItem,
stubs: {
ShieldCheckIcon: { template: '<div class="shield-check-icon"/>' },
ShieldExclamationIcon: { template: '<div class="shield-exclamation-icon"/>' },
},
},
});
@@ -205,21 +198,12 @@ describe('Registration.ce.vue', () => {
await wrapper.vm.$nextTick();
const items = wrapper.findAllComponents({ name: 'RegistrationItem' });
const keyActionsItem = items.find((item) => {
const componentProp = item.props('component');
const keyActionsElement = wrapper.find('[data-testid="key-actions"]');
expect(keyActionsElement.exists(), 'KeyActions element not found').toBe(true);
return componentProp?.template?.includes('data-testid="key-actions"');
});
expect(keyActionsItem, 'RegistrationItem for KeyActions not found').toBeDefined();
const componentProps = keyActionsItem!.props('componentProps') as {
filterOut?: string[];
t: unknown;
};
const expectedActions = serverStore.keyActions?.filter(
(action) => !componentProps?.filterOut?.includes(action.name)
(action) => !['renew'].includes(action.name)
);
expect(expectedActions, 'No expected actions found in store for TRIAL state').toBeDefined();

View File

@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { VueWrapper } from '@vue/test-utils';
import type { Server, ServerconnectPluginInstalled, ServerState } from '~/types/server';
import type { Pinia } from 'pinia';
import type { MaybeRef } from '@vueuse/core';
import UserProfile from '~/components/UserProfile.ce.vue';
import { useServerStore } from '~/store/server';
@@ -21,7 +20,7 @@ const mockCopied = ref(false);
const mockIsSupported = ref(true);
vi.mock('@vueuse/core', () => ({
useClipboard: ({ _source }: { _source: MaybeRef<string> }) => {
useClipboard: () => {
const actualCopy = (text: string) => {
if (mockIsSupported.value) {
mockCopy(text);
@@ -38,6 +37,17 @@ vi.mock('@vueuse/core', () => ({
},
}));
vi.mock('@unraid/ui', () => ({
DropdownMenu: {
template: '<div data-testid="dropdown-menu"><slot name="trigger" /><slot /></div>',
},
Button: {
template: '<button><slot /></button>',
props: ['variant', 'size'],
},
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
}));
const mockWatcher = vi.fn();
vi.mock('~/store/callbackActions', () => ({
@@ -79,9 +89,13 @@ const initialServerData: Server = {
const stubs = {
UpcUptimeExpire: { template: '<div data-testid="uptime-expire"></div>', props: ['t'] },
UpcServerState: { template: '<div data-testid="server-state"></div>', props: ['t'] },
UpcServerStatus: {
template: '<div><div data-testid="uptime-expire"></div><div data-testid="server-state"></div></div>',
props: ['t', 'class']
},
NotificationsSidebar: { template: '<div data-testid="notifications-sidebar"></div>' },
DropdownMenu: {
template: '<div data-testid="dropdown-menu"><slot name="trigger" /><slot /></div>',
template: '<div data-testid="dropdown-menu"><slot name="trigger" /><slot name="content" /></div>',
},
UpcDropdownContent: { template: '<div data-testid="dropdown-content"></div>', props: ['t'] },
UpcDropdownTrigger: { template: '<button data-testid="dropdown-trigger"></button>', props: ['t'] },
@@ -201,9 +215,9 @@ describe('UserProfile.ce.vue', () => {
await wrapper.vm.$nextTick();
const heading = wrapper.find('h1');
const nameButton = wrapper.find('button');
expect(heading.text()).toContain(initialServerData.name);
expect(nameButton.text()).toContain(initialServerData.name);
expect(wrapper.find('[data-testid="uptime-expire"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="server-state"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="notifications-sidebar"]').exists()).toBe(true);
@@ -230,7 +244,7 @@ describe('UserProfile.ce.vue', () => {
expect(serverStore.setServer).toHaveBeenCalledTimes(2);
expect(serverStore.setServer).toHaveBeenLastCalledWith(initialServerData);
expect(wrapperObjectProp.find('h1').text()).toContain(initialServerData.name);
expect(wrapperObjectProp.find('button').text()).toContain(initialServerData.name);
wrapperObjectProp.unmount();
});
@@ -254,7 +268,7 @@ describe('UserProfile.ce.vue', () => {
const copyLanIpSpy = vi.spyOn(wrapper.vm as unknown as { copyLanIp: () => void }, 'copyLanIp');
mockIsSupported.value = true;
const serverNameButton = wrapper.find('h1 > button');
const serverNameButton = wrapper.find('button');
await serverNameButton.trigger('click');
await wrapper.vm.$nextTick();
@@ -263,10 +277,8 @@ describe('UserProfile.ce.vue', () => {
expect(mockCopy).toHaveBeenCalledTimes(1);
expect(mockCopy).toHaveBeenCalledWith(initialServerData.lanIp);
const copiedMessage = wrapper.find('.text-white.text-xs');
expect(copiedMessage.exists()).toBe(true);
expect(copiedMessage.text()).toContain(t('LAN IP Copied'));
// We're not testing the toast message, just that the copy function was called
expect(mockCopied.value).toBe(true);
copyLanIpSpy.mockRestore();
});
@@ -275,7 +287,7 @@ describe('UserProfile.ce.vue', () => {
const copyLanIpSpy = vi.spyOn(wrapper.vm as unknown as { copyLanIp: () => void }, 'copyLanIp');
mockIsSupported.value = false;
const serverNameButton = wrapper.find('h1 > button');
const serverNameButton = wrapper.find('button');
await serverNameButton.trigger('click');
await wrapper.vm.$nextTick();
@@ -283,10 +295,8 @@ describe('UserProfile.ce.vue', () => {
expect(copyLanIpSpy).toHaveBeenCalledTimes(1);
expect(mockCopy).not.toHaveBeenCalled();
const notSupportedMessage = wrapper.find('.text-white.text-xs');
expect(notSupportedMessage.exists()).toBe(true);
expect(notSupportedMessage.text()).toContain(t('LAN IP {0}', [initialServerData.lanIp]));
// When clipboard is not supported, the copy function should not be called
expect(mockCopied.value).toBe(false);
copyLanIpSpy.mockRestore();
});
@@ -299,18 +309,24 @@ describe('UserProfile.ce.vue', () => {
themeStore.theme!.descriptionShow = true;
await wrapper.vm.$nextTick();
const heading = wrapper.find('h1');
expect(heading.html()).toContain(initialServerData.description);
// Look for the description in a span element
let descriptionElement = wrapper.find('span.text-center.md\\:text-right');
expect(descriptionElement.exists()).toBe(true);
expect(descriptionElement.html()).toContain(initialServerData.description);
themeStore.theme!.descriptionShow = false;
await wrapper.vm.$nextTick();
expect(heading.html()).not.toContain(initialServerData.description);
// When descriptionShow is false, the element should not exist
descriptionElement = wrapper.find('span.text-center.md\\:text-right');
expect(descriptionElement.exists()).toBe(false);
themeStore.theme!.descriptionShow = true;
await wrapper.vm.$nextTick();
expect(heading.html()).toContain(initialServerData.description);
descriptionElement = wrapper.find('span.text-center.md\\:text-right');
expect(descriptionElement.exists()).toBe(true);
expect(descriptionElement.html()).toContain(initialServerData.description);
});
it('always renders notifications sidebar, regardless of connectPluginInstalled', async () => {

View File

@@ -44,5 +44,55 @@ vi.mock('@unraid/ui', () => ({
name: 'DropdownMenu',
template: '<div><slot name="trigger" /><slot /></div>',
},
Badge: {
name: 'Badge',
template: '<div><slot /></div>',
},
Button: {
name: 'Button',
template: '<button><slot /></button>',
props: ['variant', 'size'],
},
DropdownMenuRoot: {
name: 'DropdownMenuRoot',
template: '<div><slot /></div>',
},
DropdownMenuTrigger: {
name: 'DropdownMenuTrigger',
template: '<div><slot /></div>',
},
DropdownMenuContent: {
name: 'DropdownMenuContent',
template: '<div><slot /></div>',
},
DropdownMenuItem: {
name: 'DropdownMenuItem',
template: '<div><slot /></div>',
},
DropdownMenuLabel: {
name: 'DropdownMenuLabel',
template: '<div><slot /></div>',
},
DropdownMenuSeparator: {
name: 'DropdownMenuSeparator',
template: '<div />',
},
ResponsiveModal: {
name: 'ResponsiveModal',
template: '<div><slot /></div>',
props: ['open'],
},
ResponsiveModalHeader: {
name: 'ResponsiveModalHeader',
template: '<div><slot /></div>',
},
ResponsiveModalFooter: {
name: 'ResponsiveModalFooter',
template: '<div><slot /></div>',
},
ResponsiveModalTitle: {
name: 'ResponsiveModalTitle',
template: '<div><slot /></div>',
},
// Add other UI components as needed
}));

View File

@@ -1,4 +1,11 @@
@import 'tailwindcss';
/*
* Tailwind v4 configuration without global preflight
* This prevents Tailwind from applying global resets that affect webgui
*/
/* Import only the parts of Tailwind we need - NO PREFLIGHT */
@import 'tailwindcss/theme.css';
@import 'tailwindcss/utilities.css';
@import 'tw-animate-css';
@import '../../@tailwind-shared/index.css';
@import '@nuxt/ui';
@@ -7,3 +14,68 @@
@source "../../unraid-ui/dist/**/*.{js,mjs}";
@source "../../unraid-ui/src/**/*.{vue,ts}";
@source "../**/*.{vue,ts,js}";
/*
* Minimal styles for our components
* Only essential styles to ensure components work properly
*/
/* Box-sizing for proper layout */
.unapi *,
.unapi *::before,
.unapi *::after {
box-sizing: border-box;
}
/* Reset figure element for logo */
.unapi figure {
margin: 0;
padding: 0;
}
/* Reset heading elements - only margin/padding */
.unapi h1,
.unapi h2,
.unapi h3,
.unapi h4,
.unapi h5 {
margin: 0;
padding: 0;
}
/* Reset paragraph element */
.unapi p {
margin: 0;
padding: 0;
}
/* Reset toggle/switch button backgrounds */
button[role="switch"],
button[role="switch"][data-state="checked"],
button[role="switch"][data-state="unchecked"] {
background-color: transparent !important;
background: transparent !important;
border: 1px solid #ccc !important;
}
/* Style for checked state */
button[role="switch"][data-state="checked"] {
background-color: #ff8c2f !important; /* Unraid orange */
}
/* Style for unchecked state */
button[role="switch"][data-state="unchecked"] {
background-color: #e5e5e5 !important;
}
/* Dark mode toggle styles */
.dark button[role="switch"][data-state="unchecked"] {
background-color: #333 !important;
border-color: #555 !important;
}
/* Toggle thumb/handle */
button[role="switch"] span {
background-color: white !important;
}

View File

@@ -11,7 +11,10 @@ import {
AccordionItem,
AccordionTrigger,
Button,
Dialog,
ResponsiveModal,
ResponsiveModalHeader,
ResponsiveModalTitle,
ResponsiveModalFooter,
jsonFormsAjv,
jsonFormsRenderers
} from '@unraid/ui';
@@ -374,37 +377,36 @@ const copyApiKey = async () => {
<template>
<!-- Modal mode (handles both regular creation and authorization) -->
<Dialog
<ResponsiveModal
v-if="modalVisible"
v-model="modalVisible"
size="xl"
:title="
isAuthorizationMode
? 'Authorize API Key Access'
: editingKey
? t
? t('Edit API Key')
: 'Edit API Key'
: t
? t('Create API Key')
: 'Create API Key'
"
:scrollable="true"
close-button-text="Cancel"
:primary-button-text="isAuthorizationMode ? 'Authorize' : editingKey ? 'Save' : 'Create'"
:primary-button-loading="loading || postCreateLoading"
:primary-button-loading-text="
isAuthorizationMode ? 'Authorizing...' : editingKey ? 'Saving...' : 'Creating...'
"
:primary-button-disabled="isButtonDisabled"
@update:model-value="
:open="modalVisible"
sheet-side="bottom"
:sheet-class="'h-[100vh] flex flex-col'"
:dialog-class="'max-w-4xl max-h-[90vh] overflow-hidden'"
:show-close-button="true"
@update:open="
(v) => {
if (!v) close();
}
"
@primary-click="upsertKey"
>
<div class="w-full">
<ResponsiveModalHeader>
<ResponsiveModalTitle>
{{
isAuthorizationMode
? 'Authorize API Key Access'
: editingKey
? t
? t('Edit API Key')
: 'Edit API Key'
: t
? t('Create API Key')
: 'Create API Key'
}}
</ResponsiveModalTitle>
</ResponsiveModalHeader>
<div class="flex-1 overflow-y-auto p-6 w-full">
<!-- Show authorization description if in authorization mode -->
<div
v-if="isAuthorizationMode && formSchema?.dataSchema?.description"
@@ -462,7 +464,7 @@ const copyApiKey = async () => {
<!-- Show selected roles for context -->
<div
v-if="formData.roles && formData.roles.length > 0"
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
class="mt-3 pt-3 border-t border-muted border-gray-200 dark:border-gray-700"
>
<div class="text-xs text-gray-600 dark:text-gray-400 mb-1">Selected Roles:</div>
<div class="flex flex-wrap gap-1">
@@ -516,5 +518,25 @@ const copyApiKey = async () => {
<p class="text-xs text-muted-foreground mt-2">Save this key securely for your application.</p>
</div>
</div>
</Dialog>
<ResponsiveModalFooter>
<div class="flex justify-end gap-2 w-full">
<Button variant="secondary" @click="close()">
Cancel
</Button>
<Button
variant="primary"
:disabled="isButtonDisabled || loading || postCreateLoading"
@click="upsertKey"
>
<span v-if="loading || postCreateLoading">
{{ isAuthorizationMode ? 'Authorizing...' : editingKey ? 'Saving...' : 'Creating...' }}
</span>
<span v-else>
{{ isAuthorizationMode ? 'Authorize' : editingKey ? 'Save' : 'Create' }}
</span>
</Button>
</div>
</ResponsiveModalFooter>
</ResponsiveModal>
</template>

View File

@@ -14,6 +14,12 @@ import {
Badge,
Button,
CardWrapper,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DropdownMenuRoot,
DropdownMenuContent,
DropdownMenuItem,
@@ -237,14 +243,14 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
class="w-full font-mono text-xs px-2 py-1 rounded pr-10"
readonly
/>
<button
type="button"
class="absolute inset-y-0 right-2 flex items-center px-1 text-gray-500 hover:text-gray-700"
tabindex="-1"
<Button
variant="ghost"
size="icon"
class="absolute inset-y-0 right-2 h-auto w-auto px-1 text-gray-500 hover:text-gray-700"
@click="toggleShowKey(key.id)"
>
<component :is="showKey[key.id] ? EyeSlashIcon : EyeIcon" class="w-5 h-5" />
</button>
</Button>
</div>
<TooltipProvider>
<Tooltip :delay-duration="0">
@@ -260,7 +266,7 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
</TooltipProvider>
</div>
</div>
<div v-if="key.permissions?.length || key.roles?.length" class="mt-4 pt-4 border-t">
<div v-if="key.permissions?.length || key.roles?.length" class="mt-4 pt-4 border-t border-muted">
<Accordion
type="single"
collapsible
@@ -285,11 +291,11 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
</AccordionItem>
</Accordion>
</div>
<div class="mt-4 pt-4 border-t flex flex-wrap gap-2">
<div class="mt-4 pt-4 border-t border-muted flex flex-wrap gap-2">
<Button variant="secondary" size="sm" @click="openCreateModal(key)">Edit</Button>
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger>
<TooltipTrigger as-child>
<Button variant="outline" size="sm" @click="copyKeyTemplate(key)">
<LinkIcon class="w-4 h-4 mr-1" />
Copy Template
@@ -311,27 +317,30 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
</div>
<!-- Template Input Dialog -->
<div v-if="showTemplateInput" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-background rounded-lg p-6 max-w-lg w-full mx-4">
<h3 class="text-lg font-semibold mb-4">Create from Template</h3>
<p class="text-sm text-muted-foreground mb-4">
Paste a template URL or query string to pre-fill the API key creation form with permissions.
</p>
<Input
v-model="templateUrl"
placeholder="Paste template URL or query string (e.g., ?name=MyApp&scopes=role:admin)"
class="mb-4"
@keydown.enter="applyTemplate"
/>
<div v-if="templateError" class="mb-4 p-3 rounded border border-destructive bg-destructive/10 text-destructive text-sm">
{{ templateError }}
<Dialog v-model:open="showTemplateInput">
<DialogContent class="max-w-lg">
<DialogHeader>
<DialogTitle>Create from Template</DialogTitle>
<DialogDescription>
Paste a template URL or query string to pre-fill the API key creation form with permissions.
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<Input
v-model="templateUrl"
placeholder="Paste template URL or query string (e.g., ?name=MyApp&scopes=role:admin)"
@keydown.enter="applyTemplate"
/>
<div v-if="templateError" class="p-3 rounded border border-destructive bg-destructive/10 text-destructive text-sm">
{{ templateError }}
</div>
</div>
<div class="flex gap-3 justify-end">
<DialogFooter>
<Button variant="outline" @click="cancelTemplateInput">Cancel</Button>
<Button variant="primary" @click="applyTemplate">Apply Template</Button>
</div>
</div>
</div>
<Button @click="applyTemplate">Apply Template</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</PageContainer>
</template>

View File

@@ -286,7 +286,7 @@ const returnToApp = () => {
class="flex-1"
@click="openAuthorizationModal"
>
{{ hasValidRedirectUri ? 'Review Permissions & Authorize' : 'Review Permissions' }}
{{ hasValidRedirectUri ? 'Authorize' : 'Continue' }}
</Button>
</div>
</div>

View File

@@ -14,9 +14,9 @@ const { authAction, stateData } = storeToRefs(serverStore);
<template>
<div class="whitespace-normal flex flex-col gap-y-4 max-w-3xl">
<span v-if="stateData.error" class="text-unraid-red font-semibold">
<h3 class="text-base mb-2">{{ t(stateData.heading) }}</h3>
<span class="text-sm" v-html="t(stateData.message)" />
<span v-if="stateData?.error" class="text-unraid-red font-semibold">
<h3 class="text-base mb-2">{{ stateData?.heading ? t(stateData.heading) : '' }}</h3>
<span class="text-sm" v-html="stateData?.message ? t(stateData.message) : ''" />
</span>
<span v-if="authAction">
<BrandButton

View File

@@ -19,15 +19,15 @@ const { avatar, connectPluginInstalled, registered, username } = storeToRefs(ser
</script>
<template>
<figure class="group relative z-0 flex items-center justify-center w-9 h-9 rounded-full bg-linear-to-r from-unraid-red to-orange">
<figure class="group relative z-0 flex items-center justify-center min-w-9 w-9 h-9 rounded-full bg-linear-to-r from-unraid-red to-orange flex-shrink-0">
<img
v-if="avatar && connectPluginInstalled && registered"
:src="avatar"
:alt="username"
class="absolute z-10 inset-0 w-9 h-9 rounded-full overflow-hidden"
class="absolute z-10 inset-0 w-9 h-9 rounded-full overflow-hidden object-cover"
>
<template v-else>
<BrandMark gradient-start="#fff" gradient-stop="#fff" class="opacity-100 absolute z-10 w-9 px-[4px]" />
<BrandMark gradient-start="#fff" gradient-stop="#fff" class="opacity-100 absolute z-10 w-9 h-9 p-[6px]" />
</template>
</figure>
</template>

View File

@@ -53,7 +53,7 @@ const items = [
<AccordionItem value="color-theme-customization">
<AccordionTrigger>Color Theme Customization</AccordionTrigger>
<AccordionContent>
<div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2">
<div class="flex flex-col gap-2 border-solid border-2 border-muted p-2">
<h1 class="text-lg">Color Theme Customization</h1>
<Label for="theme-select">Theme</Label>

View File

@@ -12,6 +12,8 @@ import { BrandButton, jsonFormsRenderers, jsonFormsAjv, Label, SettingsGrid } fr
import { JsonForms } from '@jsonforms/vue';
import { useServerStore } from '~/store/server';
import Auth from '~/components/Auth.ce.vue';
import DownloadApiLogs from '~/components/DownloadApiLogs.ce.vue';
// unified settings values are returned as JSON, so use a generic record type
// import type { ConnectSettingsValues } from '~/composables/gql/graphql';
@@ -99,14 +101,10 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
<SettingsGrid>
<template v-if="connectPluginInstalled">
<Label>Account Status:</Label>
<div v-html="'<unraid-auth></unraid-auth>'"/>
<Auth />
</template>
<Label>Download Unraid API Logs:</Label>
<div
v-html="
'<unraid-download-api-logs></unraid-download-api-logs>'
"
/>
<DownloadApiLogs />
</SettingsGrid>
<!-- auto-generated settings form -->
<div class="mt-6 pl-3 [&_.vertical-layout]:space-y-6">
@@ -129,7 +127,7 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
<div class="text-sm text-end">
<p v-if="isUpdating">Applying Settings...</p>
</div>
<div class="col-start-2 ml-10 space-y-4">
<div class="col-start-2 space-y-4 max-w-3xl">
<BrandButton
padding="lean"
size="12px"

View File

@@ -17,7 +17,7 @@ const downloadUrl = computed(() => {
<template>
<div class="whitespace-normal flex flex-col gap-y-4 max-w-3xl">
<span>
<p class="text-sm text-start">
{{ t('The primary method of support for Unraid Connect is through our forums and Discord.') }}
{{
t(
@@ -25,7 +25,7 @@ const downloadUrl = computed(() => {
)
}}
{{ t('The logs may contain sensitive information so do not post them publicly.') }}
</span>
</p>
<span class="flex flex-col gap-y-4">
<div class="flex">
<BrandButton

View File

@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useQuery } from '@vue/apollo-composable';
import { BellAlertIcon, ExclamationTriangleIcon, InformationCircleIcon, DocumentTextIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { Badge, DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@unraid/ui';
import { BellAlertIcon, ExclamationTriangleIcon, InformationCircleIcon, DocumentTextIcon, ArrowTopRightOnSquareIcon, ClipboardDocumentIcon } from '@heroicons/vue/24/solid';
import { Badge, Button, DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@unraid/ui';
import { WEBGUI_TOOLS_DOWNGRADE, WEBGUI_TOOLS_UPDATE, getReleaseNotesUrl } from '~/helpers/urls';
import { useActivationCodeDataStore } from '~/components/Activation/store/activationCodeData';
@@ -14,8 +14,10 @@ import { useUpdateOsStore } from '~/store/updateOs';
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
import { INFO_VERSIONS_QUERY } from './UserProfile/versions.query';
import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
const { t } = useI18n();
const { copyWithNotification, isSupported: clipboardSupported } = useClipboardWithToast();
const serverStore = useServerStore();
const updateOsStore = useUpdateOsStore();
@@ -57,6 +59,18 @@ const openApiChangelog = () => {
window.open('https://github.com/unraid/api/releases', '_blank');
};
const copyOsVersion = () => {
if (displayOsVersion.value && clipboardSupported.value) {
copyWithNotification(displayOsVersion.value, t('OS version copied to clipboard'));
}
};
const copyApiVersion = () => {
if (apiVersion.value && clipboardSupported.value) {
copyWithNotification(apiVersion.value, t('API version copied to clipboard'));
}
};
const unraidLogoHeaderLink = computed<{ href: string; title: string }>(() => {
if (partnerInfo.value?.partnerUrl) {
return {
@@ -122,7 +136,7 @@ const updateOsStatus = computed(() => {
>
<img
:src="'/webGui/images/UN-logotype-gradient.svg'"
class="w-[160px] h-auto max-h-[30px] object-contain"
class="w-[14rem] xs:w-[16rem] h-auto max-h-[3rem] object-contain"
alt="Unraid Logo"
>
</a>
@@ -130,13 +144,17 @@ const updateOsStatus = computed(() => {
<div class="flex flex-wrap justify-start gap-2">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
class="text-xs xs:text-sm flex flex-row items-center gap-x-1 font-semibold text-header-text-secondary hover:text-orange-dark focus:text-orange-dark hover:underline focus:underline leading-none"
<Button
variant="link"
class="text-xs xs:text-sm flex flex-row items-center gap-x-1 font-semibold text-header-text-secondary hover:text-orange-dark focus:text-orange-dark hover:underline focus:underline leading-none h-auto p-0"
:title="t('Version Information')"
>
<InformationCircleIcon class="fill-current w-3 h-3 xs:w-4 xs:h-4 shrink-0" />
<InformationCircleIcon
class="fill-current w-3 h-3 xs:w-4 xs:h-4 shrink-0"
style="width: 12px !important; height: 12px !important; margin: 0 !important; display: inline-block !important;"
/>
{{ displayOsVersion }}
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[200px]" align="start" :side-offset="4">
@@ -144,16 +162,30 @@ const updateOsStatus = computed(() => {
{{ t('Version Information') }}
</DropdownMenuLabel>
<DropdownMenuItem disabled class="text-xs opacity-100">
<span class="flex justify-between w-full">
<span>{{ t('Unraid OS') }}</span>
<DropdownMenuItem
:disabled="!displayOsVersion || !clipboardSupported"
class="text-xs cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
@click="copyOsVersion"
>
<span class="flex justify-between items-center w-full">
<span class="flex items-center gap-x-2">
<span>{{ t('Unraid OS') }}</span>
<ClipboardDocumentIcon v-if="clipboardSupported" class="w-3 h-3 opacity-60" />
</span>
<span class="font-semibold">{{ displayOsVersion || t('Unknown') }}</span>
</span>
</DropdownMenuItem>
<DropdownMenuItem disabled class="text-xs opacity-100">
<span class="flex justify-between w-full">
<span>{{ t('Unraid API') }}</span>
<DropdownMenuItem
:disabled="!apiVersion || !clipboardSupported"
class="text-xs cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
@click="copyApiVersion"
>
<span class="flex justify-between items-center w-full">
<span class="flex items-center gap-x-2">
<span>{{ t('Unraid API') }}</span>
<ClipboardDocumentIcon v-if="clipboardSupported" class="w-3 h-3 opacity-60" />
</span>
<span class="font-semibold">{{ apiVersion || t('Unknown') }}</span>
</span>
</DropdownMenuItem>

View File

@@ -2,7 +2,7 @@
import { computed, watchEffect } from 'vue';
import { XMarkIcon } from '@heroicons/vue/24/outline';
import { cn } from '@unraid/ui';
import { Button, cn } from '@unraid/ui';
import { TransitionChild, TransitionRoot } from '@headlessui/vue';
import type { ComposerTranslation } from 'vue-i18n';
@@ -117,19 +117,20 @@ const computedVerticalCenter = computed<string>(() => {
disableShadow ? 'shadow-none border-none' : 'shadow-xl',
error ? 'shadow-unraid-red/30 border-unraid-red/10' : '',
success ? 'shadow-green-600/30 border-green-600/10' : '',
!error && !success && !disableShadow ? 'shadow-orange/10 border-white/10' : '',
!error && !success && !disableShadow ? 'shadow-orange/10' : '',
]"
class="text-base text-foreground bg-background text-left relative z-10 mx-auto flex flex-col justify-around border-2 border-solid transform overflow-hidden rounded-lg transition-all"
class="text-base text-foreground bg-background text-left relative z-10 mx-auto flex flex-col justify-around border-2 border-solid border-muted transform overflow-hidden rounded-lg transition-all"
>
<div v-if="showCloseX" class="absolute z-20 right-0 top-0 pt-1 pr-1 sm:block">
<button
class="rounded-md text-foreground bg-transparent p-2 hover:text-white focus:text-white hover:bg-unraid-red focus:bg-unraid-red focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
type="button"
<Button
variant="ghost"
size="icon"
class="rounded-md text-foreground hover:text-white focus:text-white hover:bg-unraid-red focus:bg-unraid-red"
:aria-label="t('Close')"
@click="closeModal"
>
<span class="sr-only">{{ t('Close') }}</span>
<XMarkIcon class="h-6 w-6" aria-hidden="true" />
</button>
</Button>
</div>
<header

View File

@@ -44,11 +44,11 @@ const icon = computed<{ component: Component; color: string } | null>(() => {
</script>
<template>
<div class="relative">
<div class="relative flex items-center justify-center">
<BellIcon class="w-6 h-6 text-header-text-primary" />
<div
v-if="!seen && indicatorLevel === 'UNREAD'"
class="absolute top-0 right-0 size-2.5 rounded-full border border-neutral-800 bg-unraid-green"
class="absolute top-0 right-0 size-2.5 rounded-full border border-muted bg-unraid-green"
/>
<component
:is="icon.component"

View File

@@ -85,7 +85,7 @@ const reformattedTimestamp = computed<string>(() => {
</script>
<template>
<div class="group/item relative py-5 flex flex-col gap-2 text-base">
<div class="group/item relative py-3 flex flex-col gap-2 text-base">
<header class="flex flex-row items-baseline justify-between gap-2 -translate-y-1">
<h3
class="tracking-normal flex flex-row items-baseline gap-2 uppercase font-bold overflow-x-hidden"
@@ -128,7 +128,7 @@ const reformattedTimestamp = computed<string>(() => {
<Button
v-if="type === NotificationType.UNREAD"
:disabled="archive.loading"
@click="archive.mutate"
@click="() => archive.mutate({ id: props.id })"
>
<ArchiveBoxIcon class="size-4 mr-2" />
<span class="text-sm">Archive</span>
@@ -136,7 +136,7 @@ const reformattedTimestamp = computed<string>(() => {
<Button
v-if="type === NotificationType.ARCHIVE"
:disabled="deleteNotification.loading"
@click="deleteNotification.mutate"
@click="() => deleteNotification.mutate({ id: props.id, type: props.type })"
>
<TrashIcon class="size-4 mr-2" />
<span class="text-sm">Delete</span>

View File

@@ -93,17 +93,28 @@ async function onLoadMore() {
<div
v-if="notifications?.length > 0"
v-infinite-scroll="[onLoadMore, { canLoadMore: () => canLoadMore }]"
class="divide-y px-7 flex flex-col overflow-y-scroll flex-1 min-h-0"
class="px-3 flex flex-col overflow-y-scroll flex-1 min-h-0"
>
<NotificationsItem
v-for="notification in notifications"
:key="notification.id"
v-bind="notification"
/>
<div v-if="loading" class="py-5 grid place-content-center">
<TransitionGroup
name="notification-list"
tag="div"
class="divide-y"
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-in absolute right-0 left-0"
enter-from-class="opacity-0 -translate-x-4"
leave-to-class="opacity-0 translate-x-4"
move-class="transition-transform duration-300"
>
<NotificationsItem
v-for="notification in notifications"
:key="notification.id"
v-bind="notification"
/>
</TransitionGroup>
<div v-if="loading" class="py-3 grid place-content-center">
<LoadingSpinner />
</div>
<div v-if="!canLoadMore" class="py-5 grid place-content-center text-secondary-foreground">
<div v-if="!canLoadMore" class="py-3 grid place-content-center text-secondary-foreground">
You've reached the end...
</div>
</div>

View File

@@ -14,7 +14,12 @@ import {
TabsContent,
TabsList,
TabsTrigger,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@unraid/ui';
import { Settings } from 'lucide-vue-next';
import { useTrackLatestSeenNotification } from '~/composables/api/use-notifications';
import { useFragment } from '~/composables/gql';
@@ -125,34 +130,42 @@ const prepareToViewNotifications = () => {
<template>
<Sheet>
<SheetTrigger @click="prepareToViewNotifications">
<span class="sr-only">Notifications</span>
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
<SheetTrigger as-child>
<Button
variant="header"
size="header"
@click="prepareToViewNotifications"
>
<span class="sr-only">Notifications</span>
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
</Button>
</SheetTrigger>
<SheetContent
side="right"
class="w-full max-w-screen sm:max-w-[540px] max-h-screen h-screen min-h-screen px-0 flex flex-col gap-5 pb-0"
>
<div class="relative flex flex-col h-full w-full">
<SheetHeader class="ml-1 px-6 items-baseline gap-1 pb-2">
<SheetHeader class="ml-1 px-3 items-baseline gap-1 pb-2">
<SheetTitle class="text-2xl">Notifications</SheetTitle>
<a href="/Settings/Notifications">
<Button variant="link" size="sm" class="p-0 h-auto">Edit Settings</Button>
</a>
</SheetHeader>
<Tabs
default-value="unread"
class="flex flex-1 flex-col min-h-0"
aria-label="Notification filters"
>
<div class="flex flex-row justify-between items-center flex-wrap gap-5 px-6">
<div class="flex flex-row justify-between items-center flex-wrap gap-3 px-3">
<TabsList class="flex" aria-label="Filter notifications by status">
<TabsTrigger value="unread">
Unread <span v-if="overview">({{ overview.unread.total }})</span>
<TabsTrigger value="unread" as-child>
<Button variant="ghost" size="sm" class="inline-flex items-center gap-1 px-3 py-1">
<span>Unread</span>
<span v-if="overview" class="font-normal">({{ overview.unread.total }})</span>
</Button>
</TabsTrigger>
<TabsTrigger value="archived">
Archived
<span v-if="overview">({{ readArchivedCount }})</span>
<TabsTrigger value="archived" as-child>
<Button variant="ghost" size="sm" class="inline-flex items-center gap-1 px-3 py-1">
<span>Archived</span>
<span v-if="overview" class="font-normal">({{ readArchivedCount }})</span>
</Button>
</TabsTrigger>
</TabsList>
<TabsContent value="unread" class="flex-col items-end">
@@ -177,11 +190,13 @@ const prepareToViewNotifications = () => {
Delete All
</Button>
</TabsContent>
</div>
<div class="flex justify-between items-center px-3 gap-2 mt-2">
<Select
:items="filterItems"
placeholder="Filter By"
class="h-auto"
class="h-8 px-3 text-sm"
@update:model-value="
(val: unknown) => {
const strVal = String(val);
@@ -189,6 +204,20 @@ const prepareToViewNotifications = () => {
}
"
/>
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger as-child>
<a href="/Settings/Notifications">
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Settings class="h-4 w-4" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p>Edit Notification Settings</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<TabsContent value="unread" class="flex-col flex-1 min-h-0">

View File

@@ -116,7 +116,7 @@ declare global {
<div v-else-if="error" class="py-8 text-center text-red-500">
<p class="mb-4">Failed to load remotes</p>
<Button @click="refetchRemotes">Retry</Button>
<Button @click="() => refetchRemotes()">Retry</Button>
</div>
<div v-else class="py-8 text-center">

View File

@@ -20,7 +20,7 @@ import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { ShieldCheckIcon, ShieldExclamationIcon } from '@heroicons/vue/24/solid';
import { BrandButton, CardWrapper, PageContainer } from '@unraid/ui';
import { BrandButton, CardWrapper, PageContainer, SettingsGrid } from '@unraid/ui';
import type { RegistrationItemProps } from '~/types/registration';
import type { ServerStateDataAction } from '~/types/server';
@@ -30,7 +30,6 @@ import RegistrationKeyLinkedStatus from '~/components/Registration/KeyLinkedStat
import RegistrationReplaceCheck from '~/components/Registration/ReplaceCheck.vue';
import RegistrationUpdateExpirationAction from '~/components/Registration/UpdateExpirationAction.vue';
import UserProfileUptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
import RegistrationItem from '~/components/Registration/Item.vue';
import useDateTimeHelper from '~/composables/dateTime';
import { useReplaceRenewStore } from '~/store/replaceRenew';
import { useServerStore } from '~/store/server';
@@ -125,75 +124,9 @@ const showFilteredKeyActions = computed(
)
);
const items = computed((): RegistrationItemProps[] => {
// Organize items into three sections
const flashDriveItems = computed((): RegistrationItemProps[] => {
return [
...(computedArray.value
? [
{
label: t('Array status'),
text: computedArray.value,
warning: arrayWarning.value,
},
]
: []),
...(regTy.value
? [
{
label: t('License key type'),
text: regTy.value,
},
]
: []),
...(showTrialExpiration.value
? [
{
error: state.value === 'EEXPIRED',
label: t('Trial expiration'),
component: UserProfileUptimeExpire,
componentProps: {
forExpire: true,
shortText: true,
t,
},
componentOpacity: true,
},
]
: []),
...(regTo.value
? [
{
label: t('Registered to'),
text: regTo.value,
},
]
: []),
...(regTo.value && regTm.value && formattedRegTm.value
? [
{
label: t('Registered on'),
text: formattedRegTm.value,
},
]
: []),
...(showUpdateEligibility.value
? [
{
label: t('OS Update Eligibility'),
warning: regUpdatesExpired.value,
component: RegistrationUpdateExpirationAction,
componentProps: { t },
componentOpacity: !regUpdatesExpired.value,
},
]
: []),
...(state.value === 'EGUID'
? [
{
label: t('Registered GUID'),
text: regGuid.value,
},
]
: []),
...(guid.value
? [
{
@@ -218,6 +151,78 @@ const items = computed((): RegistrationItemProps[] => {
},
]
: []),
...(state.value === 'EGUID'
? [
{
label: t('Registered GUID'),
text: regGuid.value,
},
]
: []),
];
});
const licenseItems = computed((): RegistrationItemProps[] => {
return [
...(computedArray.value
? [
{
label: t('Array status'),
text: computedArray.value,
warning: arrayWarning.value,
},
]
: []),
...(regTy.value
? [
{
label: t('License key type'),
text: regTy.value,
},
]
: []),
...(regTo.value
? [
{
label: t('Registered to'),
text: regTo.value,
},
]
: []),
...(regTo.value && regTm.value && formattedRegTm.value
? [
{
label: t('Registered on'),
text: formattedRegTm.value,
},
]
: []),
...(showTrialExpiration.value
? [
{
error: state.value === 'EEXPIRED',
label: t('Trial expiration'),
component: UserProfileUptimeExpire,
componentProps: {
forExpire: true,
shortText: true,
t,
},
componentOpacity: true,
},
]
: []),
...(showUpdateEligibility.value
? [
{
label: t('OS Update Eligibility'),
warning: regUpdatesExpired.value,
component: RegistrationUpdateExpirationAction,
componentProps: { t },
componentOpacity: !regUpdatesExpired.value,
},
]
: []),
...(keyInstalled.value
? [
{
@@ -235,6 +240,11 @@ const items = computed((): RegistrationItemProps[] => {
},
]
: []),
];
});
const actionItems = computed((): RegistrationItemProps[] => {
return [
...(showLinkedAndTransferStatus.value
? [
{
@@ -253,7 +263,6 @@ const items = computed((): RegistrationItemProps[] => {
},
]
: []),
...(showFilteredKeyActions.value
? [
{
@@ -299,26 +308,84 @@ const items = computed((): RegistrationItemProps[] => {
/>
</span>
</header>
<dl>
<RegistrationItem
v-for="item in items"
:key="item.label"
:component="item?.component"
:component-props="item?.componentProps"
:error="item.error ?? false"
:warning="item.warning ?? false"
:label="item.label"
:text="item.text"
>
<template v-if="item.component" #right>
<component
:is="item.component"
v-bind="item.componentProps"
:class="[item.componentOpacity && !item.error ? 'opacity-75' : '']"
/>
<!-- Flash Drive Section -->
<div v-if="flashDriveItems.length > 0" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="text-lg font-semibold mb-3">{{ t('Flash Drive') }}</h4>
<SettingsGrid>
<template v-for="item in flashDriveItems" :key="item.label">
<div class="font-semibold flex items-center gap-x-2">
<ShieldExclamationIcon v-if="item.error" class="w-4 h-4 text-unraid-red" />
<span v-html="item.label" />
</div>
<div class="select-all" :class="[item.error ? 'text-unraid-red' : 'opacity-75']">
{{ item.text }}
</div>
</template>
</RegistrationItem>
</dl>
</SettingsGrid>
</div>
<!-- License Section -->
<div v-if="licenseItems.length > 0" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="text-lg font-semibold mb-3">{{ t('License') }}</h4>
<SettingsGrid>
<template v-for="item in licenseItems" :key="item.label">
<div class="font-semibold flex items-center gap-x-2">
<ShieldExclamationIcon v-if="item.error" class="w-4 h-4 text-unraid-red" />
<span v-html="item.label" />
</div>
<div
:class="[
item.error ? 'text-unraid-red' : item.warning ? 'text-yellow-600' : '',
item.text && !item.error && !item.warning ? 'opacity-75' : ''
]">
<span v-if="item.text" class="select-all">
{{ item.text }}
</span>
<component
:is="item.component"
v-if="item.component"
v-bind="item.componentProps"
:class="[item.componentOpacity && !item.error ? 'opacity-75' : '']"
/>
</div>
</template>
</SettingsGrid>
</div>
<!-- Actions Section -->
<div v-if="actionItems.length > 0" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="text-lg font-semibold mb-3">{{ t('Actions') }}</h4>
<SettingsGrid>
<template v-for="item in actionItems" :key="item.label || 'action-' + actionItems.indexOf(item)">
<template v-if="item.label">
<div class="font-semibold flex items-center gap-x-2">
<ShieldExclamationIcon v-if="item.error" class="w-4 h-4 text-unraid-red" />
<span v-html="item.label" />
</div>
<div :class="[item.error ? 'text-unraid-red' : '']">
<span v-if="item.text" class="select-all opacity-75">
{{ item.text }}
</span>
<component
:is="item.component"
v-if="item.component"
v-bind="item.componentProps"
:class="[item.componentOpacity && !item.error ? 'opacity-75' : '']"
/>
</div>
</template>
<template v-else>
<div class="md:col-span-2">
<component
:is="item.component"
v-bind="item.componentProps"
/>
</div>
</template>
</template>
</SettingsGrid>
</div>
</div>
</CardWrapper>
</PageContainer>

View File

@@ -1,53 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ShieldExclamationIcon } from '@heroicons/vue/24/solid';
import { storeToRefs } from 'pinia';
import { useThemeStore } from '~/store/theme';
import type { RegistrationItemProps } from '~/types/registration';
withDefaults(defineProps<RegistrationItemProps>(), {
error: false,
text: '',
warning: false,
});
const { darkMode } = storeToRefs(useThemeStore());
const evenBgColor = computed(() => {
return darkMode.value ? 'even:bg-grey-darkest' : 'even:bg-black/5';
});
</script>
<template>
<div
:class="[
!error && !warning && evenBgColor,
error && 'text-white bg-unraid-red',
warning && 'text-black bg-yellow-100',
]"
class="text-base p-3 grid grid-cols-1 gap-1 sm:px-5 sm:grid-cols-5 sm:gap-4 items-baseline rounded"
>
<dt v-if="label" class="font-semibold leading-normal sm:col-span-2 flex flex-row sm:justify-end sm:text-right items-center gap-x-2">
<ShieldExclamationIcon v-if="error" class="w-4 h-4 fill-current" />
<span v-html="label" />
</dt>
<dd
class="leading-normal sm:col-span-3"
:class="!label && 'sm:col-start-2'"
>
<span
v-if="text"
class="select-all"
:class="{
'opacity-75': !error,
}"
>
{{ text }}
</span>
<template v-if="$slots['right']">
<slot name="right" />
</template>
</dd>
</div>
</template>

View File

@@ -9,7 +9,15 @@ import {
KeyIcon,
ServerStackIcon,
} from '@heroicons/vue/24/solid';
import { BrandButton, BrandLoading, cn } from '@unraid/ui';
import {
BrandButton,
BrandLoading,
cn,
ResponsiveModal,
ResponsiveModalHeader,
ResponsiveModalTitle,
ResponsiveModalFooter,
} from '@unraid/ui';
import { allowedDocsOriginRegex, allowedDocsUrlRegex } from '~/helpers/urls';
import type { ComposerTranslation } from 'vue-i18n';
@@ -18,7 +26,6 @@ import RawChangelogRenderer from '~/components/UpdateOs/RawChangelogRenderer.vue
import { usePurchaseStore } from '~/store/purchase';
import { useThemeStore } from '~/store/theme';
import { useUpdateOsStore } from '~/store/updateOs';
import Modal from '~/components/Modal.vue';
export interface Props {
open?: boolean;
@@ -53,6 +60,7 @@ const isDarkMode = computed(() => {
}
return darkMode.value;
});
const { availableWithRenewal, releaseForUpdate, changelogModalVisible } = storeToRefs(updateOsStore);
const { setReleaseForUpdate, fetchAndConfirmInstall } = updateOsStore;
@@ -166,22 +174,25 @@ watch(isDarkMode, () => {
</script>
<template>
<Modal
<ResponsiveModal
v-if="currentRelease?.version"
:center-content="false"
max-width="max-w-[800px]"
:open="modalVisible"
:show-close-x="true"
:t="t"
:tall-content="true"
:title="t('Unraid OS {0} Changelog', [currentRelease.version])"
:disable-overlay-close="false"
@close="handleClose"
sheet-side="bottom"
sheet-padding="none"
:dialog-class="'max-w-[80rem] p-0'"
:show-close-button="true"
@update:open="(value: boolean) => !value && handleClose()"
>
<template #main>
<div class="flex flex-col gap-4 min-w-[280px] sm:min-w-[400px]">
<ResponsiveModalHeader>
<ResponsiveModalTitle>
{{ t('Unraid OS {0} Changelog', [currentRelease.version]) }}
</ResponsiveModalTitle>
</ResponsiveModalHeader>
<div class="px-3 flex-1">
<div class="flex flex-col gap-4 sm:min-w-[40rem]">
<!-- iframe for changelog if available -->
<div v-if="docsChangelogUrl" class="w-[calc(100%+3rem)] h-[475px] -mx-6 -my-6">
<div v-if="docsChangelogUrl" class="w-full h-[calc(100vh-15rem)] sm:h-[45rem] overflow-hidden">
<iframe
v-if="actualIframeSrc"
ref="iframeRef"
@@ -205,17 +216,17 @@ watch(isDarkMode, () => {
<!-- Loading state -->
<div
v-else
class="text-center flex flex-col justify-center w-full min-h-[250px] min-w-[280px] sm:min-w-[400px]"
class="text-center flex flex-col justify-center w-full min-h-[25rem] sm:min-w-[40rem]"
>
<BrandLoading class="w-[150px] mx-auto mt-6" />
<BrandLoading class="w-[15rem] mx-auto mt-6" />
<p>{{ props.t('Loading changelog…') }}</p>
</div>
</div>
</template>
</div>
<template #footer>
<div :class="cn('flex flex-col-reverse xs:!flex-row justify-between gap-3 md:gap-4')">
<div :class="cn('flex flex-col-reverse xs:!flex-row xs:justify-start gap-3 md:gap-4')">
<ResponsiveModalFooter>
<div :class="cn('flex flex-wrap justify-between gap-3 md:gap-4 w-full')">
<div :class="cn('flex flex-wrap justify-start gap-3 md:gap-4')">
<!-- Back to changelog button (when navigated away) -->
<BrandButton
v-if="hasNavigated && docsChangelogUrl"
@@ -256,6 +267,6 @@ watch(isDarkMode, () => {
</BrandButton>
</template>
</div>
</template>
</Modal>
</ResponsiveModalFooter>
</ResponsiveModal>
</template>

View File

@@ -10,8 +10,19 @@ import {
KeyIcon,
XMarkIcon,
} from '@heroicons/vue/24/solid';
import { BrandButton, BrandLoading, cn } from '@unraid/ui';
import { Switch, SwitchGroup, SwitchLabel } from '@headlessui/vue';
import {
BrandButton,
BrandLoading,
cn,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
Switch,
Label,
} from '@unraid/ui';
import type { BrandButtonProps } from '@unraid/ui';
import type { ComposerTranslation } from 'vue-i18n';
@@ -21,7 +32,6 @@ import { useAccountStore } from '~/store/account';
import { usePurchaseStore } from '~/store/purchase';
import { useServerStore } from '~/store/server';
import { useUpdateOsStore } from '~/store/updateOs';
import Modal from '~/components/Modal.vue';
import UpdateOsIgnoredRelease from './IgnoredRelease.vue';
export interface Props {
@@ -264,110 +274,107 @@ const modalWidth = computed(() => {
</script>
<template>
<Modal
:t="t"
<DialogRoot
:open="open"
:title="modalCopy?.title"
:description="modalCopy?.description"
:show-close-x="!checkForUpdatesLoading"
:max-width="modalWidth"
@close="close"
@update:open="(value) => !value && close()"
>
<template v-if="renderMainSlot" #main>
<BrandLoading v-if="checkForUpdatesLoading" class="w-[150px] mx-auto" />
<div v-else class="flex flex-col gap-y-4">
<div v-if="extraLinks.length > 0" :class="cn('flex flex-col xs:!flex-row justify-center gap-2')">
<BrandButton
v-for="item in extraLinks"
:key="item.text"
:btn-style="item.variant ?? undefined"
:href="item.href ?? undefined"
:icon="item.icon"
:icon-right="item.iconRight"
:icon-right-hover-display="item.iconRightHoverDisplay"
:text="t(item.text ?? '')"
:title="item.title ? t(item.title) : undefined"
@click="item.click?.()"
/>
</div>
<DialogContent
:class="modalWidth"
:show-close-button="!checkForUpdatesLoading"
>
<DialogHeader v-if="modalCopy?.title">
<DialogTitle>
{{ modalCopy.title }}
</DialogTitle>
<DialogDescription v-if="modalCopy?.description">
<span v-html="modalCopy.description" />
</DialogDescription>
</DialogHeader>
<div v-if="available || availableWithRenewal" class="mx-auto">
<SwitchGroup>
<div v-if="renderMainSlot" class="flex flex-col gap-4">
<BrandLoading v-if="checkForUpdatesLoading" class="w-[150px] mx-auto" />
<div v-else class="flex flex-col gap-y-4">
<div v-if="extraLinks.length > 0" :class="cn('flex flex-col xs:!flex-row justify-center gap-2')">
<BrandButton
v-for="item in extraLinks"
:key="item.text"
:btn-style="item.variant ?? undefined"
:href="item.href ?? undefined"
:icon="item.icon"
:icon-right="item.iconRight"
:icon-right-hover-display="item.iconRightHoverDisplay"
:text="t(item.text ?? '')"
:title="item.title ? t(item.title) : undefined"
@click="item.click?.()"
/>
</div>
<div v-if="available || availableWithRenewal" class="mx-auto">
<div class="flex justify-center items-center gap-2 p-2 rounded">
<Switch
v-model="ignoreThisRelease"
:class="
ignoreThisRelease ? 'bg-linear-to-r from-unraid-red to-orange' : 'bg-transparent'
ignoreThisRelease ? 'bg-linear-to-r from-unraid-red to-orange' : 'data-[state=unchecked]:bg-transparent data-[state=unchecked]:bg-opacity-10 data-[state=unchecked]:bg-foreground'
"
class="relative inline-flex h-6 w-12 items-center rounded-full overflow-hidden"
>
<span
v-show="!ignoreThisRelease"
class="absolute z-0 inset-0 opacity-10 bg-foreground"
/>
<span
:class="ignoreThisRelease ? 'translate-x-[26px]' : 'translate-x-[2px]'"
class="inline-block h-5 w-5 transform rounded-full bg-white transition"
/>
</Switch>
<SwitchLabel class="text-base">
/>
<Label class="text-base">
{{ t('Ignore this release until next reboot') }}
</SwitchLabel>
</Label>
</div>
</SwitchGroup>
</div>
<div
v-else-if="updateOsIgnoredReleases.length > 0"
class="w-full max-w-[640px] mx-auto flex flex-col gap-2"
>
<h3 class="text-left text-base font-semibold italic">
{{ t('Ignored Releases') }}
</h3>
<UpdateOsIgnoredRelease
v-for="ignoredRelease in updateOsIgnoredReleases"
:key="ignoredRelease"
:label="ignoredRelease"
:t="t"
/>
</div>
<div
v-else-if="updateOsIgnoredReleases.length > 0"
class="w-full max-w-[640px] mx-auto flex flex-col gap-2"
>
<h3 class="text-left text-base font-semibold italic">
{{ t('Ignored Releases') }}
</h3>
<UpdateOsIgnoredRelease
v-for="ignoredRelease in updateOsIgnoredReleases"
:key="ignoredRelease"
:label="ignoredRelease"
:t="t"
/>
</div>
</div>
</div>
</template>
<template #footer>
<div
:class="cn(
'w-full flex gap-2 mx-auto',
actionButtons ? 'flex-col-reverse xs:!flex-row justify-between' : 'justify-center'
)"
>
<div :class="cn('flex flex-col-reverse xs:!flex-row justify-start gap-2')">
<BrandButton
variant="underline-hover-red"
:icon="XMarkIcon"
:text="t('Close')"
@click="close"
/>
<BrandButton
variant="underline"
:icon="ArrowTopRightOnSquareIcon"
:text="t('More options')"
@click="accountStore.updateOs()"
/>
<DialogFooter>
<div
:class="cn(
'w-full flex gap-2 mx-auto',
actionButtons ? 'flex-col-reverse xs:!flex-row justify-between' : 'justify-center'
)"
>
<div :class="cn('flex flex-col-reverse xs:!flex-row justify-start gap-2')">
<BrandButton
variant="underline-hover-red"
:icon="XMarkIcon"
:text="t('Close')"
@click="close"
/>
<BrandButton
variant="underline"
:icon="ArrowTopRightOnSquareIcon"
:text="t('More options')"
@click="accountStore.updateOs()"
/>
</div>
<div v-if="actionButtons" :class="cn('flex flex-col xs:!flex-row justify-end gap-2')">
<BrandButton
v-for="item in actionButtons"
:key="item.text"
:btn-style="item.variant ?? undefined"
:icon="item.icon"
:icon-right="item.iconRight"
:icon-right-hover-display="item.iconRightHoverDisplay"
:text="t(item.text ?? '')"
:title="item.title ? t(item.title) : undefined"
@click="item.click?.()"
/>
</div>
</div>
<div v-if="actionButtons" :class="cn('flex flex-col xs:!flex-row justify-end gap-2')">
<BrandButton
v-for="item in actionButtons"
:key="item.text"
:btn-style="item.variant ?? undefined"
:icon="item.icon"
:icon-right="item.iconRight"
:icon-right-hover-display="item.iconRightHoverDisplay"
:text="t(item.text ?? '')"
:title="item.title ? t(item.title) : undefined"
@click="item.click?.()"
/>
</div>
</div>
</template>
</Modal>
</DialogFooter>
</DialogContent>
</DialogRoot>
</template>

View File

@@ -105,7 +105,7 @@ const mutatedParsedChangelog = computed(() => {
</script>
<template>
<div class="prose prose-sm dark:prose-invert max-w-none markdown-body p-4 overflow-auto">
<div class="prose prose-sm dark:prose-invert max-w-none p-4 overflow-auto [&_.grid]:!flex [&_.grid]:!flex-wrap [&_.grid]:!gap-8 [&_.grid>*]:!flex-1 [&_.grid>*]:!basis-full md:[&_.grid>*]:!basis-[calc(50%-1rem)]">
<div v-if="parseChangelogFailed" class="text-center flex flex-col gap-4 prose">
<h2 class="text-lg text-unraid-red italic font-semibold">
{{ props.t(`Error Parsing Changelog • {0}`, [parseChangelogFailed]) }}
@@ -138,4 +138,4 @@ const mutatedParsedChangelog = computed(() => {
<p>{{ props.t('No changelog content available') }}</p>
</div>
</div>
</template>
</template>

View File

@@ -11,7 +11,7 @@ import {
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/vue/24/solid';
import { Badge, BrandButton, BrandLoading } from '@unraid/ui';
import { Badge, BrandButton, BrandLoading, Button } from '@unraid/ui';
import { WEBGUI_TOOLS_REGISTRATION } from '~/helpers/urls';
import type { BrandButtonProps } from '@unraid/ui';
@@ -110,6 +110,12 @@ const checkButton = computed((): BrandButtonProps => {
: props.t('Unraid OS {0} Update Available', [available.value]),
};
});
const navigateToRegistration = () => {
if (typeof window !== 'undefined') {
window.location.href = WEBGUI_TOOLS_REGISTRATION.toString();
}
};
</script>
<template>
@@ -123,22 +129,24 @@ const checkButton = computed((): BrandButtonProps => {
</h2>
</header>
<div class="flex flex-col md:flex-row gap-4 justify-start md:items-start md:justify-between">
<div class="inline-flex flex-wrap justify-start gap-2">
<button
class="group"
<div class="inline-flex flex-wrap justify-start items-center gap-2">
<Button
variant="ghost"
class="p-0 h-auto hover:bg-transparent"
:title="t('View release notes')"
@click="updateOsActionsStore.viewReleaseNotes(t('{0} Release Notes', [osVersion]))"
>
<Badge :icon="InformationCircleIcon" variant="gray" size="md">
{{ t('Current Version {0}', [osVersion]) }}
</Badge>
</button>
</Button>
<a
<Button
v-if="ineligibleText && !availableWithRenewal"
:href="WEBGUI_TOOLS_REGISTRATION.toString()"
class="group"
variant="ghost"
class="p-0 h-auto hover:bg-transparent"
:title="t('Learn more and fix')"
@click="navigateToRegistration"
>
<Badge
variant="yellow"
@@ -148,7 +156,7 @@ const checkButton = computed((): BrandButtonProps => {
>
{{ t('Key ineligible for future releases') }}
</Badge>
</a>
</Button>
<Badge
v-else-if="ineligibleText && availableWithRenewal"
variant="yellow"

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup>
import { onBeforeMount, onMounted, ref, watch } from 'vue';
import { onBeforeMount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useClipboard } from '@vueuse/core';
import { cn, DropdownMenu } from '@unraid/ui';
import { DropdownMenu, Button } from '@unraid/ui';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
import { devConfig } from '~/helpers/env';
import type { Server } from '~/types/server';
@@ -12,9 +12,7 @@ import type { Server } from '~/types/server';
import NotificationsSidebar from '~/components/Notifications/Sidebar.vue';
import UpcDropdownContent from '~/components/UserProfile/DropdownContent.vue';
import UpcDropdownTrigger from '~/components/UserProfile/DropdownTrigger.vue';
import UpcServerState from '~/components/UserProfile/ServerState.vue';
// Auto-imported components - now manually imported
import UpcUptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
import UpcServerStatus from '~/components/UserProfile/ServerStatus.vue';
import { useCallbackActionsStore } from '~/store/callbackActions';
import { useServerStore } from '~/store/server';
import { useThemeStore } from '~/store/theme';
@@ -33,28 +31,18 @@ const { callbackData } = storeToRefs(callbackStore);
const { name, description, guid, keyfile, lanIp } = storeToRefs(serverStore);
const { bannerGradient, theme } = storeToRefs(useThemeStore());
// Control dropdown open state
const dropdownOpen = ref(false);
/**
* Copy LAN IP on server name click
*/
let copyIpInterval: string | number | NodeJS.Timeout | undefined;
const { copy, copied, isSupported } = useClipboard({ source: lanIp.value ?? '' });
const showCopyNotSupported = ref<boolean>(false);
const copyLanIp = () => {
// if http then clipboard is not supported
if (!isSupported || window.location.protocol === 'http:') {
showCopyNotSupported.value = true;
return;
const { copyWithNotification } = useClipboardWithToast();
const copyLanIp = async () => {
if (lanIp.value) {
await copyWithNotification(lanIp.value, t('LAN IP Copied'));
}
copy(lanIp.value ?? '');
};
watch(showCopyNotSupported, (newVal, oldVal) => {
if (newVal && oldVal === false) {
clearTimeout(copyIpInterval);
copyIpInterval = setTimeout(() => {
showCopyNotSupported.value = false;
}, 5000);
}
});
/**
* Sets the server store and locale messages then listen for callbacks
@@ -104,57 +92,47 @@ onMounted(() => {
:style="bannerGradient"
/>
<div
:class="
cn(
'text-xs text-header-text-secondary text-right font-semibold leading-normal relative z-10 flex flex-wrap xs:flex-row items-baseline justify-end gap-x-1 xs:gap-x-4'
)
"
>
<UpcUptimeExpire :as="'span'" :t="t" class="text-xs" />
<span class="hidden xs:block">&bull;</span>
<UpcServerState :t="t" class="text-xs" />
</div>
<UpcServerStatus :t="t" class="relative z-10" />
<div class="relative z-10 flex flex-row items-center justify-end gap-x-4 h-full">
<h1
class="text-md sm:text-lg relative flex flex-col-reverse items-end md:flex-row border-0 text-header-text-primary"
<div class="relative z-10 flex flex-row items-center justify-end gap-x-2 h-full">
<div
class="text-base relative flex flex-col-reverse items-center md:items-center md:flex-row border-0 text-header-text-primary"
>
<template v-if="description && theme?.descriptionShow">
<span class="text-right text-xs sm:text-lg hidden md:inline-block" v-html="description" />
<span class="text-header-text-secondary hidden md:inline-block px-2">&bull;</span>
<span class="text-center md:text-right text-base hidden md:inline-flex md:items-center" v-html="description" />
<span class="text-header-text-secondary hidden md:inline-flex md:items-center px-2">&bull;</span>
</template>
<button
<Button
v-if="lanIp"
variant="ghost"
:title="t('Click to Copy LAN IP {0}', [lanIp])"
class="text-header-text-primary opacity-100 hover:opacity-75 focus:opacity-75 transition-opacity"
class="text-header-text-primary text-base p-0 h-auto opacity-100 hover:opacity-75 focus:opacity-75 transition-opacity flex items-center"
@click="copyLanIp()"
>
{{ name }}
</button>
<span v-else class="text-header-text-primary">
</Button>
<span v-else class="text-header-text-primary text-sm xs:text-base flex items-center">
{{ name }}
</span>
<span
v-show="copied || showCopyNotSupported"
class="text-white text-xs leading-none py-1 px-2 absolute top-full right-0 bg-linear-to-r from-unraid-red to-orange text-center block rounded"
>
<template v-if="copied">{{ t('LAN IP Copied') }}</template>
<template v-else>{{ t('LAN IP {0}', [lanIp]) }}</template>
</span>
</h1>
<div class="block w-[2px] h-6 bg-header-text-secondary" />
</div>
<NotificationsSidebar />
<DropdownMenu align="end" side="bottom" :side-offset="4">
<DropdownMenu
v-model:open="dropdownOpen"
align="end"
side="bottom"
:side-offset="4"
>
<template #trigger>
<UpcDropdownTrigger :t="t" />
</template>
<template #content>
<div class="max-w-[350px] sm:min-w-[350px]">
<UpcDropdownContent :t="t" />
<UpcDropdownContent
:t="t"
@close-dropdown="dropdownOpen = false"
/>
</div>
</template>
</DropdownMenu>

View File

@@ -4,13 +4,13 @@ export interface Props {
}
withDefaults(defineProps<Props>(), {
colorClasses: 'text-grey-mid border-grey-mid',
colorClasses: 'text-grey-mid border-muted',
});
</script>
<template>
<span
class="text-xs uppercase py-1 px-1.5 border-2 rounded-full"
class="text-xs uppercase py-1 px-1.5 border-2 border-muted rounded-full"
:class="colorClasses"
>
{{ 'Beta' }}

View File

@@ -36,6 +36,10 @@ import Keyline from './Keyline.vue';
const props = defineProps<{ t: ComposerTranslation }>();
const emit = defineEmits<{
'close-dropdown': []
}>();
const accountStore = useAccountStore();
const errorsStore = useErrorsStore();
const updateOsStore = useUpdateOsStore();
@@ -72,6 +76,7 @@ const manageUnraidNetAccount = computed((): UserProfileLink => {
external: true,
click: () => {
accountStore.manage();
emit('close-dropdown');
},
icon: UserIcon,
text: props.t('Manage Unraid.net Account'),
@@ -83,6 +88,7 @@ const updateOsCheckForUpdatesButton = computed((): UserProfileLink => {
return {
click: () => {
updateOsStore.localCheckForUpdate();
emit('close-dropdown');
},
icon: ArrowPathIcon,
text: props.t('Check for Update'),
@@ -92,6 +98,7 @@ const updateOsResponseModalOpenButton = computed((): UserProfileLink => {
return {
click: () => {
updateOsStore.setModalOpen(true);
emit('close-dropdown');
},
emphasize: true,
icon: BellAlertIcon,
@@ -199,7 +206,7 @@ const unraidConnectWelcome = computed(() => {
<div class="flex flex-col grow gap-y-2">
<header
v-if="connectPluginInstalled"
class="flex flex-col items-start justify-between mt-2 mx-2"
class="flex flex-col items-start justify-between mt-2 mx-2 gap-2"
>
<h2 class="text-lg leading-none flex flex-row gap-x-1 items-center justify-between">
<BrandLogoConnect

View File

@@ -14,7 +14,7 @@ const { errors } = storeToRefs(errorsStore);
</script>
<template>
<ul v-if="errors.length" class="list-reset flex flex-col gap-y-2 mb-1 border-2 border-solid border-unraid-red/90 rounded-md">
<ul v-if="errors.length" class="list-reset flex flex-col gap-y-2 mb-1 border-2 border-solid border-muted rounded-md">
<li v-for="(error, index) in errors" :key="index" class="flex flex-col gap-2">
<h3 class="text-lg py-1 px-3 text-white bg-unraid-red/90 font-semibold">
<span>{{ t(error.heading) }}</span>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { Button } from '@unraid/ui';
import type { ComposerTranslation } from 'vue-i18n';
import type { ServerStateDataAction } from '~/types/server';
@@ -17,23 +18,36 @@ const props = withDefaults(defineProps<Props>(), {
});
const showExternalIconOnHover = computed(() => props.item?.external && props.item.icon !== ArrowTopRightOnSquareIcon);
const buttonClass = computed(() => {
const classes = ['text-left', 'text-sm', 'w-full', 'flex', 'flex-row', 'items-center', 'justify-between', 'gap-x-2', 'px-2', 'py-2', 'h-auto'];
if (!props.item?.emphasize) {
classes.push('dropdown-item-hover');
}
if (props.item?.emphasize) {
classes.push('dropdown-item-emphasized');
}
if (showExternalIconOnHover.value) {
classes.push('group');
}
if (props.rounded) {
classes.push('rounded-md');
}
return classes.join(' ');
});
</script>
<template>
<component
:is="item?.click ? 'button' : 'a'"
<Button
:as="item?.click ? 'button' : 'a'"
:disabled="item?.disabled"
:href="item?.href ?? null"
:target="item?.external ? '_blank' : null"
:rel="item?.external ? 'noopener noreferrer' : null"
class="text-left text-sm w-full flex flex-row items-center justify-between gap-x-2 px-2 py-2 cursor-pointer"
:class="{
'text-foreground bg-transparent hover:text-white focus:text-white focus:outline-hidden dropdown-item-hover': !item?.emphasize,
'text-white bg-linear-to-r from-unraid-red to-orange dropdown-item-emphasized': item?.emphasize,
'group': showExternalIconOnHover,
'rounded-md': rounded,
'disabled:opacity-50 disabled:hover:opacity-50 disabled:focus:opacity-50 disabled:cursor-not-allowed': item?.disabled,
}"
variant="ghost"
:class="buttonClass"
@click.stop="item?.click ? item?.click(item?.clickParams ?? []) : null"
>
<span class="leading-snug inline-flex flex-row items-center gap-x-2">
@@ -44,7 +58,7 @@ const showExternalIconOnHover = computed(() => props.item?.external && props.ite
v-if="showExternalIconOnHover"
class="text-white fill-current shrink-0 w-4 h-4 ml-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-in-out"
/>
</component>
</Button>
</template>
<style>

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { Button } from '@unraid/ui';
import {
Bars3Icon,
BellAlertIcon,
@@ -47,8 +48,10 @@ const title = computed((): string => {
</script>
<template>
<button
class="group text-lg border-0 relative flex flex-row justify-end items-center h-full gap-x-2 opacity-100 hover:opacity-75 transition-opacity text-header-text-primary"
<Button
variant="header"
size="header"
class="justify-center gap-x-1.5 pl-0"
:title="title"
>
<template v-if="errors.length && errors[0].level">
@@ -80,5 +83,5 @@ const title = computed((): string => {
<Bars3Icon class="w-5" />
<BrandAvatar v-if="connectPluginInstalled" />
</button>
</Button>
</template>

View File

@@ -27,12 +27,12 @@ const upgradeAction = computed((): ServerStateDataAction | undefined => {
:title="t('Upgrade Key')"
@click="upgradeAction.click?.()"
>
<h5>Unraid OS <em><strong>{{ t(stateData.humanReadable) }}</strong></em></h5>
<span class="font-semibold">Unraid OS <em><strong>{{ t(stateData.humanReadable) }}</strong></em></span>
</UpcServerStateBuy>
</template>
<h5 v-else>
<span v-else class="font-semibold">
Unraid OS <em :class="{ 'text-unraid-red': stateData.error || state === 'EEXPIRED' }"><strong>{{ t(stateData.humanReadable) }}</strong></em>
</h5>
</span>
<template v-if="purchaseAction">
<UpcServerStateBuy

View File

@@ -1,5 +1,12 @@
<script setup lang="ts">
import { Button } from '@unraid/ui';
</script>
<template>
<button class="text-xs font-semibold transition-colors duration-150 ease-in-out border-t-0 border-l-0 border-r-0 border-b-2 border-transparent hover:border-orange-dark focus:border-orange-dark focus:outline-hidden">
<Button
variant="ghost"
class="text-xs font-semibold transition-colors duration-150 ease-in-out border-t-0 border-l-0 border-r-0 border-b-2 border-transparent hover:border-orange-dark focus:border-orange-dark focus:outline-hidden h-auto p-0"
>
<slot />
</button>
</Button>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { ComposerTranslation } from 'vue-i18n';
import { cn } from '@unraid/ui';
import UpcUptimeExpire from './UptimeExpire.vue';
import UpcServerState from './ServerState.vue';
interface Props {
t: ComposerTranslation;
class?: string;
}
defineProps<Props>();
</script>
<template>
<div
:class="
cn(
'text-header-text-secondary text-right font-semibold leading-tight',
'flex flex-col items-end gap-y-0.5 justify-end',
'xs:flex-row xs:items-baseline xs:gap-x-2 xs:gap-y-0',
'text-xs',
$props.class
)
"
>
<UpcUptimeExpire :as="'span'" :t="t" :short-text="true" class="text-xs" />
<span class="hidden xs:inline">&bull;</span>
<UpcServerState :t="t" class="text-xs" />
</div>
</template>

View File

@@ -2,7 +2,7 @@
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { BrandLoading } from '@unraid/ui';
import { BrandLoading, Button } from '@unraid/ui';
import type { ComposerTranslation } from 'vue-i18n';
@@ -78,13 +78,14 @@ const close = () => {
<template v-if="!trialModalLoading" #footer>
<div class="w-full max-w-xs flex flex-col items-center gap-y-4 mx-auto">
<div>
<button
class="text-xs tracking-wide inline-block mx-2 opacity-60 hover:opacity-100 focus:opacity-100 underline transition"
<Button
variant="link"
class="text-xs tracking-wide inline-block mx-2 opacity-60 hover:opacity-100 focus:opacity-100 underline transition h-auto p-0"
:title="t('Close Modal')"
@click="close"
>
{{ t('Close') }}
</button>
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,277 @@
import { createApp } from 'vue';
import type { App as VueApp, Component } from 'vue';
import { createI18n } from 'vue-i18n';
import { DefaultApolloClient } from '@vue/apollo-composable';
// Import Tailwind CSS for injection
import tailwindStyles from '~/assets/main.css?inline';
import en_US from '~/locales/en_US.json';
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
import { globalPinia } from '~/store/globalPinia';
import { client } from '~/helpers/create-apollo-client';
// Ensure Apollo client is singleton
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
// Global store for mounted apps
const mountedApps = new Map<string, VueApp>();
const mountedAppClones = new Map<string, VueApp[]>();
const mountedAppContainers = new Map<string, HTMLElement[]>(); // shadow-root containers for cleanup
// Shared style injection tracking
const styleInjected = new WeakSet<Document | ShadowRoot>();
// Expose globally for debugging
declare global {
interface Window {
mountedApps: Map<string, VueApp>;
globalPinia: typeof globalPinia;
}
}
if (typeof window !== 'undefined') {
window.mountedApps = mountedApps;
window.globalPinia = globalPinia;
}
function injectStyles(root: Document | ShadowRoot) {
// Always inject to document for teleported elements
if (!styleInjected.has(document)) {
const globalStyleElement = document.createElement('style');
globalStyleElement.setAttribute('data-tailwind-global', 'true');
globalStyleElement.textContent = tailwindStyles;
document.head.appendChild(globalStyleElement);
styleInjected.add(document);
}
// Also inject to shadow root if needed
if (root !== document && !styleInjected.has(root)) {
const styleElement = document.createElement('style');
styleElement.setAttribute('data-tailwind', 'true');
styleElement.textContent = tailwindStyles;
root.appendChild(styleElement);
styleInjected.add(root);
}
}
function setupI18n() {
const defaultLocale = 'en_US';
let parsedLocale = '';
let parsedMessages = {};
let nonDefaultLocale = false;
// Check for window locale data
if (typeof window !== 'undefined') {
const windowLocaleData = (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA || null;
if (windowLocaleData) {
try {
parsedMessages = JSON.parse(decodeURIComponent(windowLocaleData));
parsedLocale = Object.keys(parsedMessages)[0];
nonDefaultLocale = parsedLocale !== defaultLocale;
} catch (error) {
console.error('[VueMountApp] error parsing messages', error);
}
}
}
return createI18n({
legacy: false,
locale: nonDefaultLocale ? parsedLocale : defaultLocale,
fallbackLocale: defaultLocale,
messages: {
en_US,
...(nonDefaultLocale ? parsedMessages : {}),
},
postTranslation: createHtmlEntityDecoder(),
});
}
export interface MountOptions {
component: Component;
selector: string;
appId?: string;
useShadowRoot?: boolean;
props?: Record<string, unknown>;
}
// Helper function to parse props from HTML attributes
function parsePropsFromElement(element: Element): Record<string, unknown> {
const props: Record<string, unknown> = {};
for (const attr of element.attributes) {
const name = attr.name;
const value = attr.value;
// Skip Vue internal attributes and common HTML attributes
if (name.startsWith('data-v-') || name === 'class' || name === 'id' || name === 'style') {
continue;
}
// Try to parse JSON values (handles HTML-encoded JSON)
if (value.startsWith('{') || value.startsWith('[')) {
try {
// Decode HTML entities first
const decoded = value
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#39;/g, "'");
props[name] = JSON.parse(decoded);
} catch (_e) {
// If JSON parsing fails, use as string
props[name] = value;
}
} else {
props[name] = value;
}
}
return props;
}
export function mountVueApp(options: MountOptions): VueApp | null {
const { component, selector, appId = selector, useShadowRoot = false, props = {} } = options;
// Check if app is already mounted
if (mountedApps.has(appId)) {
console.warn(`[VueMountApp] App ${appId} is already mounted`);
return mountedApps.get(appId)!;
}
// Find all mount targets
const targets = document.querySelectorAll(selector);
if (targets.length === 0) {
console.warn(`[VueMountApp] No elements found for selector: ${selector}`);
return null;
}
// For the first target, parse props from HTML attributes
const firstTarget = targets[0];
const parsedProps = { ...parsePropsFromElement(firstTarget), ...props };
// Create the Vue app with parsed props
const app = createApp(component, parsedProps);
// Setup i18n
const i18n = setupI18n();
app.use(i18n);
// Use the shared Pinia instance
app.use(globalPinia);
// Provide Apollo client
app.provide(DefaultApolloClient, apolloClient);
// Mount to all targets
const clones: VueApp[] = [];
const containers: HTMLElement[] = [];
targets.forEach((target, index) => {
const mountTarget = target as HTMLElement;
// Add unapi class for minimal styling
mountTarget.classList.add('unapi');
if (useShadowRoot) {
// Create shadow root if needed
if (!mountTarget.shadowRoot) {
mountTarget.attachShadow({ mode: 'open' });
}
// Create mount container in shadow root
const container = document.createElement('div');
container.id = 'app';
container.setAttribute('data-app-id', appId);
mountTarget.shadowRoot!.appendChild(container);
containers.push(container);
// Inject styles into shadow root
injectStyles(mountTarget.shadowRoot!);
// For the first target, use the main app, otherwise create clones
if (index === 0) {
app.mount(container);
} else {
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
const clonedApp = createApp(component, targetProps);
clonedApp.use(i18n);
clonedApp.use(globalPinia);
clonedApp.provide(DefaultApolloClient, apolloClient);
clonedApp.mount(container);
clones.push(clonedApp);
}
} else {
// Direct mount without shadow root
injectStyles(document);
// For multiple targets, we need to create separate app instances
// but they'll share the same Pinia store
if (index === 0) {
// First target, use the main app
app.mount(mountTarget);
} else {
// Additional targets, create cloned apps with their own props
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
const clonedApp = createApp(component, targetProps);
clonedApp.use(i18n);
clonedApp.use(globalPinia); // Shared Pinia instance
clonedApp.provide(DefaultApolloClient, apolloClient);
clonedApp.mount(mountTarget);
clones.push(clonedApp);
}
}
});
// Store the app reference
mountedApps.set(appId, app);
if (clones.length) mountedAppClones.set(appId, clones);
if (containers.length) mountedAppContainers.set(appId, containers);
return app;
}
export function unmountVueApp(appId: string): boolean {
const app = mountedApps.get(appId);
if (!app) {
console.warn(`[VueMountApp] No app found with id: ${appId}`);
return false;
}
// Unmount clones first
const clones = mountedAppClones.get(appId) ?? [];
for (const c of clones) c.unmount();
mountedAppClones.delete(appId);
// Remove shadow containers
const containers = mountedAppContainers.get(appId) ?? [];
for (const el of containers) el.remove();
mountedAppContainers.delete(appId);
app.unmount();
mountedApps.delete(appId);
return true;
}
export function getMountedApp(appId: string): VueApp | undefined {
return mountedApps.get(appId);
}
// Auto-mount function for script tags
export function autoMountComponent(component: Component, selector: string, options?: Partial<MountOptions>) {
const tryMount = () => {
// Check if elements exist before attempting to mount
if (document.querySelector(selector)) {
mountVueApp({ component, selector, ...options });
}
// Silently skip if no elements found - this is expected for most components
};
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryMount);
} else {
// DOM is already ready
tryMount();
}
}

View File

@@ -0,0 +1,134 @@
// Import all components
import type { Component } from 'vue';
import Auth from './Auth.ce.vue';
import ConnectSettings from './ConnectSettings/ConnectSettings.ce.vue';
import DownloadApiLogs from './DownloadApiLogs.ce.vue';
import HeaderOsVersion from './HeaderOsVersion.ce.vue';
import Modals from './Modals.ce.vue';
import UserProfile from './UserProfile.ce.vue';
import UpdateOs from './UpdateOs.ce.vue';
import DowngradeOs from './DowngradeOs.ce.vue';
import Registration from './Registration.ce.vue';
import WanIpCheck from './WanIpCheck.ce.vue';
import WelcomeModal from './Activation/WelcomeModal.ce.vue';
import SsoButton from './SsoButton.ce.vue';
import LogViewer from './Logs/LogViewer.ce.vue';
import ThemeSwitcher from './ThemeSwitcher.ce.vue';
import ApiKeyPage from './ApiKeyPage.ce.vue';
import DevModalTest from './DevModalTest.ce.vue';
import ApiKeyAuthorize from './ApiKeyAuthorize.ce.vue';
// Import utilities
import { autoMountComponent, mountVueApp, getMountedApp } from './Wrapper/vue-mount-app';
import { useThemeStore } from '~/store/theme';
import { globalPinia } from '~/store/globalPinia';
import { client as apolloClient } from '~/helpers/create-apollo-client';
import { provideApolloClient } from '@vue/apollo-composable';
// Extend window interface for Apollo client
declare global {
interface Window {
apolloClient: typeof apolloClient;
}
}
// Initialize global Apollo client context
if (typeof window !== 'undefined') {
// Make Apollo client globally available
window.apolloClient = apolloClient;
// Provide Apollo client globally for all components
provideApolloClient(apolloClient);
// Initialize theme store and set CSS variables - this is needed by all components
const themeStore = useThemeStore(globalPinia);
themeStore.setTheme();
themeStore.setCssVars();
}
// Define component mappings
const componentMappings = [
{ component: Auth, selector: 'unraid-auth', appId: 'auth' },
{ component: ConnectSettings, selector: 'unraid-connect-settings', appId: 'connect-settings' },
{ component: DownloadApiLogs, selector: 'unraid-download-api-logs', appId: 'download-api-logs' },
{ component: HeaderOsVersion, selector: 'unraid-header-os-version', appId: 'header-os-version' },
{ component: Modals, selector: 'unraid-modals', appId: 'modals' },
{ component: UserProfile, selector: 'unraid-user-profile', appId: 'user-profile' },
{ component: UpdateOs, selector: 'unraid-update-os', appId: 'update-os' },
{ component: DowngradeOs, selector: 'unraid-downgrade-os', appId: 'downgrade-os' },
{ component: Registration, selector: 'unraid-registration', appId: 'registration' },
{ component: WanIpCheck, selector: 'unraid-wan-ip-check', appId: 'wan-ip-check' },
{ component: WelcomeModal, selector: 'unraid-welcome-modal', appId: 'welcome-modal' },
{ component: SsoButton, selector: 'unraid-sso-button', appId: 'sso-button' },
{ component: LogViewer, selector: 'unraid-log-viewer', appId: 'log-viewer' },
{ component: ThemeSwitcher, selector: 'unraid-theme-switcher', appId: 'theme-switcher' },
{ component: ApiKeyPage, selector: 'unraid-api-key-manager', appId: 'api-key-manager' },
{ component: DevModalTest, selector: 'unraid-dev-modal-test', appId: 'dev-modal-test' },
{ component: ApiKeyAuthorize, selector: 'unraid-api-key-authorize', appId: 'api-key-authorize' },
];
// Auto-mount all components
componentMappings.forEach(({ component, selector, appId }) => {
autoMountComponent(component, selector, {
appId,
useShadowRoot: false, // Mount directly to avoid shadow DOM issues
});
});
// Special handling for Modals - also mount to #modals
autoMountComponent(Modals, '#modals', {
appId: 'modals-direct',
useShadowRoot: false,
});
// Expose functions globally for testing and dynamic mounting
declare global {
interface Window {
UnraidComponents: Record<string, Component>;
mountVueApp: typeof mountVueApp;
getMountedApp: typeof getMountedApp;
}
}
if (typeof window !== 'undefined') {
// Expose all components
window.UnraidComponents = {
Auth,
ConnectSettings,
DownloadApiLogs,
HeaderOsVersion,
Modals,
UserProfile,
UpdateOs,
DowngradeOs,
Registration,
WanIpCheck,
WelcomeModal,
SsoButton,
LogViewer,
ThemeSwitcher,
ApiKeyPage,
DevModalTest,
ApiKeyAuthorize,
};
// Expose utility functions
window.mountVueApp = mountVueApp;
window.getMountedApp = getMountedApp;
// Create dynamic mount functions for each component
componentMappings.forEach(({ component, selector, appId }) => {
const componentName = appId.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join('');
(window as unknown as Record<string, unknown>)[`mount${componentName}`] = (customSelector?: string) => {
return mountVueApp({
component,
selector: customSelector || selector,
appId: `${appId}-${Date.now()}`,
useShadowRoot: false,
});
};
});
}

View File

@@ -1,12 +1,10 @@
import { useClipboard } from '@vueuse/core';
import { useToast } from '@unraid/ui';
/**
* Composable for clipboard operations with toast notifications
*/
export function useClipboardWithToast() {
const { copy, copied, isSupported } = useClipboard();
const toast = useToast();
/**
* Copy text and show toast
@@ -19,17 +17,26 @@ export function useClipboardWithToast() {
): Promise<boolean> => {
if (!isSupported.value) {
console.warn('Clipboard API is not supported');
toast.error('Clipboard not supported');
// Use global toast if available
if (globalThis.toast) {
globalThis.toast.error('Clipboard not supported');
}
return false;
}
try {
await copy(text);
toast.success(successMessage);
// Use global toast if available
if (globalThis.toast) {
globalThis.toast.success(successMessage);
}
return true;
} catch (error) {
console.error('Failed to copy to clipboard:', error);
toast.error('Failed to copy to clipboard');
// Use global toast if available
if (globalThis.toast) {
globalThis.toast.error('Failed to copy to clipboard');
}
return false;
}
};

View File

@@ -4,11 +4,30 @@ import { RetryLink } from '@apollo/client/link/retry/index.js';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
import { getMainDefinition } from '@apollo/client/utilities/index.js';
import { createClient } from 'graphql-ws';
import type { ErrorResponse } from '@apollo/client/link/error/index.js';
import type { GraphQLFormattedError } from 'graphql';
import { createApolloCache } from './apollo-cache';
import { WEBGUI_GRAPHQL } from './urls';
const httpEndpoint = WEBGUI_GRAPHQL;
const wsEndpoint = new URL(WEBGUI_GRAPHQL.toString().replace('http', 'ws'));
// Allow overriding the GraphQL endpoint for development/testing
declare global {
interface Window {
GRAPHQL_ENDPOINT?: string;
}
}
const getGraphQLEndpoint = () => {
if (typeof window !== 'undefined' && window.GRAPHQL_ENDPOINT) {
return new URL(window.GRAPHQL_ENDPOINT);
}
return WEBGUI_GRAPHQL;
};
const httpEndpoint = getGraphQLEndpoint();
const wsEndpoint = new URL(httpEndpoint.toString());
wsEndpoint.protocol = httpEndpoint.protocol === 'https:' ? 'wss:' : 'ws:';
const DEV_MODE = (globalThis as unknown as { __DEV__: boolean }).__DEV__ ?? false;
const headers = {
@@ -28,17 +47,15 @@ const wsLink = new GraphQLWsLink(
})
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorLink = onError(({ graphQLErrors, networkError }: any) => {
const errorLink = onError(({ graphQLErrors, networkError }: ErrorResponse) => {
if (graphQLErrors) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
graphQLErrors.map((error: any) => {
graphQLErrors.forEach((error: GraphQLFormattedError) => {
console.error('[GraphQL error]', error);
const errorMsg = error.error?.message ?? error.message;
const errorMsg =
(error as GraphQLFormattedError & { error?: { message?: string } }).error?.message ?? error.message;
if (errorMsg?.includes('offline')) {
// @todo restart the api, but make sure not to trigger infinite loop
}
return error.message;
});
}
@@ -46,9 +63,8 @@ const errorLink = onError(({ graphQLErrors, networkError }: any) => {
console.error(`[Network error]: ${networkError}`);
const msg = networkError.message ? networkError.message : networkError;
if (typeof msg === 'string' && msg.includes('Unexpected token < in JSON at position 0')) {
return 'Unraid API • CORS Error';
console.error('Unraid API • CORS Error');
}
return msg;
}
});
@@ -69,7 +85,7 @@ const retryLink = new RetryLink({
// Disable Apollo Client if not in DEV Mode and server state says unraid-api is not running
const disableQueryLink = new ApolloLink((operation, forward) => {
if (!DEV_MODE && operation.getContext().serverState?.unraidApi?.status === 'offline') {
return null;
return null;
}
return forward(operation);
});

View File

@@ -34,7 +34,7 @@ function formatRouteName(name: string | symbol | undefined) {
<template>
<div class="text-black bg-white dark:text-white dark:bg-black">
<ClientOnly>
<div class="bg-white dark:bg-zinc-800 border-b border-gray-200 dark:border-gray-700">
<div class="bg-white dark:bg-zinc-800 border-b border-muted">
<div class="flex flex-wrap items-center justify-between gap-2 p-3 md:p-4">
<nav class="flex flex-wrap items-center gap-2">
<template v-for="route in routes" :key="route.path">
@@ -42,7 +42,7 @@ function formatRouteName(name: string | symbol | undefined) {
<Badge
:variant="router.currentRoute.value.path === route.path ? 'orange' : 'gray'"
size="xs"
class="cursor-pointer"
class="cursor-pointer header-nav-badge hover:brightness-90 hover:bg-transparent [&.bg-gray-200]:hover:bg-gray-200 [&.bg-orange]:hover:bg-orange"
>
{{ formatRouteName(route.name) }}
</Badge>
@@ -52,7 +52,7 @@ function formatRouteName(name: string | symbol | undefined) {
<ModalsCe />
</div>
</div>
<div class="flex flex-col md:flex-row items-center justify-center gap-3 p-3 md:p-4 bg-gray-50 dark:bg-zinc-900 border-b border-gray-200 dark:border-gray-700">
<div class="flex flex-col md:flex-row items-center justify-center gap-3 p-3 md:p-4 bg-gray-50 dark:bg-zinc-900 border-b border-muted">
<DummyServerSwitcher />
<ColorSwitcherCe />
</div>

View File

@@ -3,7 +3,7 @@ import path from 'path';
import tailwindcss from '@tailwindcss/vite';
import removeConsole from 'vite-plugin-remove-console';
import type { PluginOption, UserConfig } from 'vite';
import type { PluginOption } from 'vite';
/**
* Used to avoid redeclaring variables in the webgui codebase.
@@ -34,15 +34,13 @@ console.log(dropConsole ? 'WARN: Console logs are disabled' : 'INFO: Console log
const assetsDir = path.join(__dirname, '../api/dev/webGui/');
/**
* Create a tag configuration
*/
const createWebComponentTag = (name: string, path: string, appContext: string) => ({
async: false,
name,
path,
appContext
});
// REMOVED: No longer needed with standalone mount approach
// const createWebComponentTag = (name: string, path: string, appContext: string) => ({
// async: false,
// name,
// path,
// appContext
// });
/**
* Shared terser options for consistent minification
@@ -118,26 +116,24 @@ const sharedDefine = {
__VUE_PROD_DEVTOOLS__: false,
};
/**
* Apply shared Vite configuration to a config object
*/
const applySharedViteConfig = (config: UserConfig, includeJQueryIsolation = false) => {
if (!config.plugins) config.plugins = [];
if (!config.define) config.define = {};
if (!config.build) config.build = {};
// Add shared plugins
config.plugins.push(...getSharedPlugins(includeJQueryIsolation));
// Merge define values
Object.assign(config.define, sharedDefine);
// Apply build configuration
config.build.minify = 'terser';
config.build.terserOptions = sharedTerserOptions;
return config;
};
// REMOVED: No longer needed with standalone mount approach
// const applySharedViteConfig = (config: UserConfig, includeJQueryIsolation = false) => {
// if (!config.plugins) config.plugins = [];
// if (!config.define) config.define = {};
// if (!config.build) config.build = {};
//
// // Add shared plugins
// config.plugins.push(...getSharedPlugins(includeJQueryIsolation));
//
// // Merge define values
// Object.assign(config.define, sharedDefine);
//
// // Apply build configuration
// config.build.minify = 'terser';
// config.build.terserOptions = sharedTerserOptions;
//
// return config;
// };
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
@@ -205,45 +201,11 @@ export default defineNuxtConfig({
},
},
customElements: {
analyzer: process.env.NODE_ENV !== 'test',
entries: [
// @ts-expect-error The nuxt-custom-elements module types don't perfectly match our configuration object structure.
// The custom elements configuration requires specific properties and methods that may not align with the
// module's TypeScript definitions, particularly around the viteExtend function and tag configuration format.
{
name: 'UnraidComponents',
viteExtend(config: UserConfig) {
const sharedConfig = applySharedViteConfig(config, true);
// Optimize CSS while keeping it inlined for functionality
if (!sharedConfig.css) sharedConfig.css = {};
sharedConfig.css.devSourcemap = process.env.NODE_ENV === 'development';
return sharedConfig;
},
tags: [
createWebComponentTag('UnraidAuth', '@/components/Auth.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidConnectSettings', '@/components/ConnectSettings/ConnectSettings.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidDownloadApiLogs', '@/components/DownloadApiLogs.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidHeaderOsVersion', '@/components/HeaderOsVersion.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidModals', '@/components/Modals.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidUserProfile', '@/components/UserProfile.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidUpdateOs', '@/components/UpdateOs.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidDowngradeOs', '@/components/DowngradeOs.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidRegistration', '@/components/Registration.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidWanIpCheck', '@/components/WanIpCheck.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidWelcomeModal', '@/components/Activation/WelcomeModal.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidSsoButton', '@/components/SsoButton.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidLogViewer', '@/components/Logs/LogViewer.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidThemeSwitcher', '@/components/ThemeSwitcher.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidApiKeyManager', '@/components/ApiKeyPage.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidDevModalTest', '@/components/DevModalTest.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidApiKeyAuthorize', '@/components/ApiKeyAuthorize.ce', '@/components/Wrapper/web-component-plugins'),
],
},
],
},
// DISABLED: Using standalone mount approach instead
// customElements: {
// analyzer: process.env.NODE_ENV !== 'test',
// entries: [],
// },
compatibilityDate: '2024-12-05',

View File

@@ -11,13 +11,15 @@
"serve": "NODE_ENV=production PORT=${PORT:-4321} node .output/server/index.mjs",
"// Build": "",
"prebuild:dev": "pnpm predev",
"build:dev": "nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run deploy-to-unraid:dev",
"build:dev": "pnpm run build && pnpm run deploy-to-unraid:dev",
"build:webgui": "pnpm run type-check && nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run copy-to-webgui-repo",
"build": "NODE_ENV=production nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run validate:css",
"build": "NODE_ENV=production nuxi build --dotenv .env.production && pnpm run build:standalone && pnpm run manifest-ts && pnpm run validate:css",
"build:standalone": "vite build --config vite.standalone.config.ts && pnpm run manifest-standalone",
"prebuild:watch": "pnpm predev",
"build:watch": "nuxi build --dotenv .env.production --watch && pnpm run manifest-ts",
"generate": "nuxt generate",
"manifest-ts": "node ./scripts/add-timestamp-webcomponent-manifest.js",
"manifest-standalone": "node ./scripts/add-timestamp-standalone-manifest.js",
"validate:css": "node ./scripts/validate-custom-elements-css.js",
"// Deployment": "",
"unraid:deploy": "pnpm build:dev",
@@ -35,6 +37,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:ci": "vitest run",
"test:standalone": "pnpm run build:standalone && vite --config vite.test.config.ts",
"// Nuxt": "",
"postinstall": "nuxt prepare"
},

View File

@@ -122,7 +122,7 @@ watch(
<h3 class="text-lg font-semibold font-mono">ConnectSettingsCe</h3>
<ConnectSettingsCe />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<!-- <h3 class="text-lg font-semibold font-mono">
DownloadApiLogsCe
@@ -141,19 +141,19 @@ watch(
<hr class="border-black dark:border-white"> -->
<h3 class="text-lg font-semibold font-mono">UpdateOsCe</h3>
<UpdateOsCe />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">DowngraadeOsCe</h3>
<DowngradeOsCe :restore-release-date="'2022-10-10'" :restore-version="'6.11.2'" />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">RegistrationCe</h3>
<RegistrationCe />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">ModalsCe</h3>
<ModalsCe />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">WelcomeModalCe</h3>
<WelcomeModalCe />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">Test Callback Builder</h3>
<div class="flex flex-col justify-end gap-2">
<p>
@@ -172,7 +172,7 @@ watch(
</code>
</div>
<div>
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h2 class="text-xl font-semibold font-mono">Nuxt UI Button - Primary Color Test</h2>
<div class="flex gap-4 items-center">
<UButton color="primary" variant="solid">Primary Solid</UButton>
@@ -183,7 +183,7 @@ watch(
</div>
</div>
<div class="bg-background">
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h2 class="text-xl font-semibold font-mono">Brand Button Component</h2>
<template v-for="variant in variants" :key="variant">
<BrandButton
@@ -196,12 +196,12 @@ watch(
</template>
</div>
<div class="bg-background">
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h2 class="text-xl font-semibold font-mono">SSO Button Component</h2>
<SsoButtonCe />
</div>
<div class="bg-background">
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h2 class="text-xl font-semibold font-mono">Log Viewer Component</h2>
<LogViewerCe />
</div>

View File

@@ -146,7 +146,7 @@ const executeCliCommand = async () => {
<pre class="bg-muted p-3 rounded text-xs overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">{{ JSON.stringify(debugData, null, 2) }}</pre>
</div>
<div class="border-t pt-4">
<div class="border-t border-muted pt-4">
<h3 class="font-semibold mb-2 text-sm">JWT/OIDC Token Validation Tool:</h3>
<p class="text-xs text-muted-foreground mb-3">Enter a JWT or OIDC session token to validate it using the CLI command</p>

View File

@@ -27,38 +27,38 @@ const { serverState } = storeToRefs(serverStore);
</div>
<unraid-user-profile :server="JSON.stringify(serverState)" />
</header>
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">ConnectSettingsCe</h3>
<ConnectSettingsCe />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">DownloadApiLogsCe</h3>
<unraid-download-api-logs />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">AuthCe</h3>
<unraid-auth />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">WanIpCheckCe</h3>
<unraid-wan-ip-check php-wan-ip="47.184.85.45" />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">HeaderOsVersion</h3>
<unraid-header-os-version />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">UpdateOsCe</h3>
<unraid-update-os />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">DowngradeOsCe</h3>
<unraid-downgrade-os restore-release-date="2022-10-10" restore-version="6.11.2" />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">RegistrationCe</h3>
<unraid-registration />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">ModalsCe</h3>
<!-- uncomment to test modals <unraid-modals />-->
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">SSOSignInButtonCe</h3>
<unraid-sso-button />
<hr class="border-black dark:border-white" >
<hr class="border-muted" >
<h3 class="text-lg font-semibold font-mono">ApiKeyManagerCe</h3>
<unraid-api-key-manager />
</div>

View File

@@ -38,7 +38,7 @@ const showWelcomeModal = () => {
<div class="flex flex-col gap-6 p-6">
<WelcomeModalCe ref="welcomeModalRef" />
<ModalsCe />
<div class="mt-4 p-4 border rounded bg-gray-100 dark:bg-gray-800">
<div class="mt-4 p-4 border border-muted rounded bg-gray-100 dark:bg-gray-800">
<h3 class="text-lg font-semibold mb-2">Activation Modal Debug Info:</h3>
<p>Should Show Modal (`showActivationModal`): {{ isVisible }}</p>
<ul class="list-disc list-inside ml-4">

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 222.36 39.04"><defs><linearGradient id="header-logo" x1="47.53" y1="79.1" x2="170.71" y2="-44.08" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e32929"/><stop offset="1" stop-color="#ff8d30"/></linearGradient></defs><title>unraid.net</title><path d="M146.7,29.47H135l-3,9h-6.49L138.93,0h8l13.41,38.49h-7.09L142.62,6.93l-5.83,16.88h8ZM29.69,0V25.4c0,8.91-5.77,13.64-14.9,13.64S0,34.31,0,25.4V0H6.54V25.4c0,5.17,3.19,7.92,8.25,7.92s8.36-2.75,8.36-7.92V0ZM50.86,12v26.5H44.31V0h6.11l17,26.5V0H74V38.49H67.9ZM171.29,0h6.54V38.49h-6.54Zm51.07,24.69c0,9-5.88,13.8-15.17,13.8H192.67V0H207.3c9.18,0,15.06,4.78,15.06,13.8ZM215.82,13.8c0-5.28-3.3-8.14-8.52-8.14h-8.08V32.77h8c5.33,0,8.63-2.8,8.63-8.08ZM108.31,23.92c4.34-1.6,6.93-5.28,6.93-11.55C115.24,3.68,110.18,0,102.48,0H88.84V38.49h6.55V5.66h6.87c3.8,0,6.21,1.82,6.21,6.71s-2.41,6.76-6.21,6.76H98.88l9.21,19.36h7.53Z" fill="url(#header-logo)"/></svg>

After

Width:  |  Height:  |  Size: 1008 B

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const distPath = '.nuxt/standalone-apps';
const manifestPath = path.join(distPath, 'standalone.manifest.json');
// Check if directory exists
if (!fs.existsSync(distPath)) {
console.warn(`Directory ${distPath} does not exist. Skipping manifest generation.`);
process.exit(0);
}
// Get all JS files in the dist directory
const files = fs.readdirSync(distPath);
const manifest = {};
files.forEach(file => {
if (file.endsWith('.js') || file.endsWith('.css')) {
const key = file.replace(/\.(js|css)$/, '.$1');
manifest[key] = {
file: file,
src: file,
};
}
});
// Add timestamp
manifest.ts = Date.now();
// Write manifest
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log('Standalone apps manifest created:', manifestPath);

View File

@@ -2,6 +2,13 @@ const fs = require('fs');
// Read the JSON file
const filePath = '../web/.nuxt/nuxt-custom-elements/dist/unraid-components/manifest.json';
// Check if file exists (web components are now disabled in favor of standalone)
if (!fs.existsSync(filePath)) {
console.log('Web components manifest not found (using standalone mount instead)');
process.exit(0);
}
const jsonData = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// Add timestamp (ts) to the JSON data

View File

@@ -10,77 +10,116 @@ fi
# Set server name from command-line argument
server_name="$1"
# Source directory path
source_directory=".nuxt/nuxt-custom-elements/dist/unraid-components/"
# Source directory paths
webcomponents_directory=".nuxt/nuxt-custom-elements/dist/unraid-components/"
standalone_directory=".nuxt/standalone-apps/"
if [ ! -d "$source_directory" ]; then
echo "The web components directory does not exist."
# Check what we have to deploy
has_webcomponents=false
has_standalone=false
if [ -d "$webcomponents_directory" ]; then
has_webcomponents=true
fi
if [ -d "$standalone_directory" ]; then
has_standalone=true
fi
# Exit if neither exists
if [ "$has_webcomponents" = false ] && [ "$has_standalone" = false ]; then
echo "Error: Neither web components nor standalone apps directory exists."
echo "Please run 'pnpm build' or 'pnpm build:standalone' first."
exit 1
fi
# Replace the value inside the rsync command with the user's input
# Delete existing web components in the target directory
ssh "root@${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt/*"
exit_code=0
rsync_command="rsync -avz -e ssh $source_directory root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt"
# Deploy web components if they exist
if [ "$has_webcomponents" = true ]; then
echo "Deploying web components..."
# Run rsync with proper quoting and --delete for safe mirroring
rsync -avz --delete -e "ssh" "$webcomponents_directory" "root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt/"
exit_code=$?
fi
echo "Executing the following command:"
echo "$rsync_command"
# Execute the rsync command and capture the exit code
eval "$rsync_command"
exit_code=$?
# Deploy standalone apps if they exist
if [ "$has_standalone" = true ]; then
echo "Deploying standalone apps..."
# Ensure remote directory exists
ssh root@"${server_name}" "mkdir -p /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
# Run rsync with proper quoting
rsync -avz --delete -e "ssh" "$standalone_directory" "root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
standalone_exit_code=$?
# If standalone rsync failed, update exit_code
if [ $standalone_exit_code -ne 0 ]; then
exit_code=$standalone_exit_code
fi
fi
# Update the auth-request.php file to include the new web component JS
update_auth_request() {
local server_name="$1"
# SSH into server and update auth-request.php
ssh "root@${server_name}" "
ssh "root@${server_name}" bash -s << 'EOF'
AUTH_REQUEST_FILE='/usr/local/emhttp/auth-request.php'
WEB_COMPS_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt/_nuxt/'
STANDALONE_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/'
# Find JS files and modify paths
mapfile -t JS_FILES < <(find \"\$WEB_COMPS_DIR\" -type f -name \"*.js\" | sed 's|/usr/local/emhttp||' | sort -u)
mapfile -t JS_FILES < <(find "$WEB_COMPS_DIR" -type f -name "*.js" | sed 's|/usr/local/emhttp||' | sort -u)
# Find standalone JS files if directory exists
if [ -d "$STANDALONE_DIR" ]; then
mapfile -t STANDALONE_JS < <(find "$STANDALONE_DIR" -type f -name "*.js" | sed 's|/usr/local/emhttp||' | sort -u)
FILES_TO_ADD+=("${STANDALONE_JS[@]}")
fi
FILES_TO_ADD+=(\"\${JS_FILES[@]}\")
FILES_TO_ADD+=("${JS_FILES[@]}")
if grep -q '\$arrWhitelist' \"\$AUTH_REQUEST_FILE\"; then
if grep -q '\$arrWhitelist' "$AUTH_REQUEST_FILE"; then
awk '
BEGIN { in_array = 0 }
/\\\$arrWhitelist\s*=\s*\[/ {
/\$arrWhitelist\s*=\s*\[/ {
in_array = 1
print \$0
print $0
next
}
in_array && /^\s*\]/ {
in_array = 0
print \$0
print $0
next
}
!in_array || !/\/plugins\/dynamix\.my\.servers\/unraid-components\/nuxt\/_nuxt\/unraid-components\.client-/ {
print \$0
!in_array || !/\/plugins\/dynamix\.my\.servers\/unraid-components\/(nuxt\/_nuxt|standalone)\/.*\.m?js/ {
print $0
}
' \"\$AUTH_REQUEST_FILE\" > \"\${AUTH_REQUEST_FILE}.tmp\"
' "$AUTH_REQUEST_FILE" > "${AUTH_REQUEST_FILE}.tmp"
# Now add new entries right after the opening bracket
awk -v files_to_add=\"\$(printf '%s\n' \"\${FILES_TO_ADD[@]}\" | sort -u | awk '{printf \" \\\x27%s\\\x27,\n\", \$0}')\" '
/\\\$arrWhitelist\s*=\s*\[/ {
print \$0
awk -v files_to_add="$(printf '%s\n' "${FILES_TO_ADD[@]}" | sort -u | awk '{printf " \047%s\047,\n", $0}')" '
/\$arrWhitelist\s*=\s*\[/ {
print $0
print files_to_add
next
}
{ print }
' \"\${AUTH_REQUEST_FILE}.tmp\" > \"\${AUTH_REQUEST_FILE}\"
' "${AUTH_REQUEST_FILE}.tmp" > "${AUTH_REQUEST_FILE}"
rm \"\${AUTH_REQUEST_FILE}.tmp\"
echo \"Updated \$AUTH_REQUEST_FILE with new web component JS files\"
rm "${AUTH_REQUEST_FILE}.tmp"
echo "Updated $AUTH_REQUEST_FILE with new web component JS files"
else
echo \"\\\$arrWhitelist array not found in \$AUTH_REQUEST_FILE\"
echo "\$arrWhitelist array not found in $AUTH_REQUEST_FILE"
fi
"
EOF
}
update_auth_request "$server_name"
auth_request_exit_code=$?
# If auth request update failed, update exit_code
if [ $auth_request_exit_code -ne 0 ]; then
exit_code=$auth_request_exit_code
fi
# Play built-in sound based on the operating system
if [[ "$OSTYPE" == "darwin"* ]]; then
@@ -94,5 +133,5 @@ elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then
powershell.exe -c "(New-Object Media.SoundPlayer 'C:\Windows\Media\Windows Default.wav').PlaySync()"
fi
# Exit with the rsync command's exit code
# Exit with the final exit code (non-zero if any command failed)
exit $exit_code

View File

@@ -29,16 +29,26 @@ function findJSFiles(dir, jsFiles = []) {
* Validates that Tailwind CSS styles are properly inlined in the JavaScript bundle
*/
function validateCustomElementsCSS() {
console.log('🔍 Validating custom elements JS bundle includes inlined Tailwind styles...');
console.log('🔍 Validating JS bundle includes inlined Tailwind styles...');
try {
// Find the custom elements JS files
const customElementsDir = '.nuxt/nuxt-custom-elements/dist';
const jsFiles = findJSFiles(customElementsDir);
// Check standalone apps first (new approach)
const standaloneDir = '.nuxt/standalone-apps';
let jsFiles = findJSFiles(standaloneDir);
let usingStandalone = true;
// Fallback to custom elements if standalone doesn't exist
if (jsFiles.length === 0) {
throw new Error('No custom elements JS files found in ' + customElementsDir);
const customElementsDir = '.nuxt/nuxt-custom-elements/dist';
jsFiles = findJSFiles(customElementsDir);
usingStandalone = false;
if (jsFiles.length === 0) {
throw new Error('No JS files found in standalone apps or custom elements dist');
}
}
console.log(`📦 Using ${usingStandalone ? 'standalone apps' : 'custom elements'} bundle`);
// Find the largest JS file (likely the main bundle with inlined CSS)
const jsFile = jsFiles.reduce((largest, current) => {
@@ -52,40 +62,41 @@ function validateCustomElementsCSS() {
const jsContent = fs.readFileSync(jsFile, 'utf8');
// Define required Tailwind indicators (looking for inlined CSS in JS)
// Updated patterns to work with minified CSS (no spaces)
const requiredIndicators = [
{
name: 'Tailwind utility classes (inline)',
pattern: /\.flex\s*\{[^}]*display:\s*flex/,
pattern: /\.flex\s*\{[^}]*display:\s*flex|\.flex{display:flex/,
description: 'Basic Tailwind utility classes inlined'
},
{
name: 'Tailwind margin utilities (inline)',
pattern: /\.m-\d+\s*\{[^}]*margin:/,
pattern: /\.m-\d+\s*\{[^}]*margin:|\.m-\d+{[^}]*margin:/,
description: 'Tailwind margin utilities inlined'
},
{
name: 'Tailwind padding utilities (inline)',
pattern: /\.p-\d+\s*\{[^}]*padding:/,
pattern: /\.p-\d+\s*\{[^}]*padding:|\.p-\d+{[^}]*padding:/,
description: 'Tailwind padding utilities inlined'
},
{
name: 'Tailwind color utilities (inline)',
pattern: /\.text-\w+\s*\{[^}]*color:/,
pattern: /\.text-\w+\s*\{[^}]*color:|\.text-\w+{[^}]*color:/,
description: 'Tailwind text color utilities inlined'
},
{
name: 'Tailwind background utilities (inline)',
pattern: /\.bg-\w+\s*\{[^}]*background/,
pattern: /\.bg-\w+\s*\{[^}]*background|\.bg-\w+{[^}]*background/,
description: 'Tailwind background utilities inlined'
},
{
name: 'CSS custom properties',
pattern: /--[\w-]+:\s*[^;]+;/,
pattern: /--[\w-]+:\s*[^;]+;|--[\w-]+:[^;]+;/,
description: 'CSS custom properties (variables)'
},
{
name: 'Responsive breakpoints',
pattern: /@media\s*\([^)]*min-width/,
pattern: /@media\s*\([^)]*min-width|@media\([^)]*min-width/,
description: 'Responsive media queries'
},
{

View File

@@ -117,15 +117,29 @@ export const useThemeStore = defineStore('theme', () => {
// overwrite with hex colors set in webGUI @ /Settings/DisplaySettings
if (theme.value.textColor) {
customTheme['--header-text-primary'] = theme.value.textColor;
// Also set the Tailwind v4 color variable for utility classes
customTheme['--color-header-text-primary'] = theme.value.textColor;
}
if (theme.value.metaColor) {
customTheme['--header-text-secondary'] = theme.value.metaColor;
// Also set the Tailwind v4 color variable for utility classes
customTheme['--color-header-text-secondary'] = theme.value.metaColor;
}
if (theme.value.bgColor) {
customTheme['--header-background-color'] = theme.value.bgColor;
// Also set the Tailwind v4 color variable for utility classes
customTheme['--color-header-background'] = theme.value.bgColor;
customTheme['--header-gradient-start'] = hexToRgba(theme.value.bgColor, 0);
customTheme['--header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7);
// Also set the Tailwind v4 color variables for gradient utility classes
customTheme['--color-header-gradient-start'] = hexToRgba(theme.value.bgColor, 0);
customTheme['--color-header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7);
}
// Set ui-border-muted if it exists in the theme
if (customTheme['--ui-border-muted']) {
// The value is already set from the defaultColors theme
}
requestAnimationFrame(() => {

327
web/test-standalone.html Normal file
View File

@@ -0,0 +1,327 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Standalone Vue Apps Test Page</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.test-section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 10px;
}
h2 {
color: #666;
margin-top: 0;
margin-bottom: 15px;
font-size: 18px;
}
.status {
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
font-family: monospace;
font-size: 14px;
}
.status.loading {
background: #fff3cd;
color: #856404;
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.mount-target {
padding: 20px;
background: #fafafa;
border: 2px dashed #ddd;
border-radius: 4px;
min-height: 100px;
position: relative;
}
.mount-target::before {
content: attr(data-label);
position: absolute;
top: -10px;
left: 10px;
background: white;
padding: 0 5px;
color: #999;
font-size: 12px;
}
.debug-info {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
}
.multiple-mounts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.test-button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
.test-button:hover {
background: #0056b3;
}
.test-button:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
</head>
<body>
<!-- Teleport target for dropdowns and modals -->
<div id="teleports"></div>
<!-- Mount point for Modals component -->
<unraid-modals></unraid-modals>
<div class="container">
<h1>🧪 Standalone Vue Apps Test Page</h1>
<div id="status" class="status loading">Loading...</div>
<!-- Test Section 1: Single Mount -->
<div class="test-section">
<h2>Test 1: Single Component Mount</h2>
<p>Testing single instance of HeaderOsVersion component</p>
<div class="mount-target" data-label="HeaderOsVersion Mount">
<unraid-header-os-version></unraid-header-os-version>
</div>
</div>
<!-- Test Section 2: Multiple Mounts -->
<div class="test-section">
<h2>Test 2: Multiple Component Mounts (Shared Pinia Store)</h2>
<p>Testing that multiple instances share the same Pinia store</p>
<div class="multiple-mounts">
<div class="mount-target" data-label="Instance 1">
<unraid-header-os-version></unraid-header-os-version>
</div>
<div class="mount-target" data-label="Instance 2">
<unraid-header-os-version></unraid-header-os-version>
</div>
<div class="mount-target" data-label="Instance 3">
<unraid-header-os-version></unraid-header-os-version>
</div>
</div>
</div>
<!-- Test Section 3: Dynamic Mount -->
<div class="test-section">
<h2>Test 3: Dynamic Component Creation</h2>
<p>Test dynamically adding components after page load</p>
<button class="test-button" id="addComponent">Add New Component</button>
<button class="test-button" id="removeComponent">Remove Last Component</button>
<button class="test-button" id="remountAll">Remount All</button>
<div id="dynamicContainer" style="margin-top: 20px;">
<!-- Dynamic components will be added here -->
</div>
</div>
<!-- Test Section 4: Modal Testing -->
<div class="test-section">
<h2>Test 4: Modal Components</h2>
<p>Test modal functionality</p>
<button class="test-button" onclick="testTrialModal()">Open Trial Modal</button>
<button class="test-button" onclick="testUpdateModal()">Open Update Modal</button>
<button class="test-button" onclick="testApiKeyModal()">Open API Key Modal</button>
<div style="margin-top: 10px;">
<small>Note: Modals require proper store state to display</small>
</div>
</div>
<!-- Debug Info -->
<div class="test-section">
<h2>Debug Information</h2>
<div class="debug-info" id="debugInfo">
Waiting for initialization...
</div>
</div>
</div>
<!-- Mock configurations for local testing -->
<script>
// Set GraphQL endpoint directly to API server
// Change this to match your API server port
window.GRAPHQL_ENDPOINT = 'http://localhost:3001/graphql';
// Mock webGui path for images
window.__WEBGUI_PATH__ = '';
// Add some debug logging
window.addEventListener('DOMContentLoaded', () => {
const status = document.getElementById('status');
const debugInfo = document.getElementById('debugInfo');
// Log when scripts are loaded
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeName === 'SCRIPT') {
console.log('Script loaded:', node.src || 'inline');
}
});
}
});
});
observer.observe(document.head, { childList: true });
observer.observe(document.body, { childList: true });
// Check for Vue app mounting
let checkInterval = setInterval(() => {
const mountedElements = document.querySelectorAll('unraid-header-os-version');
let mountedCount = 0;
mountedElements.forEach(el => {
if (el.innerHTML.trim() !== '') {
mountedCount++;
}
});
if (mountedCount > 0) {
status.className = 'status success';
status.textContent = `✅ Successfully mounted ${mountedCount} component(s)`;
// Update debug info
debugInfo.textContent = `
Components Found: ${mountedElements.length}
Components Mounted: ${mountedCount}
Vue Apps: ${window.mountedApps ? Object.keys(window.mountedApps).length : 0}
Pinia Store: ${window.globalPinia ? 'Initialized' : 'Not found'}
GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
`.trim();
clearInterval(checkInterval);
}
}, 500);
// Timeout after 10 seconds
setTimeout(() => {
if (checkInterval) {
clearInterval(checkInterval);
if (status.className === 'status loading') {
status.className = 'status error';
status.textContent = '❌ Failed to mount components (timeout)';
}
}
}, 10000);
});
// Dynamic component controls
document.addEventListener('DOMContentLoaded', () => {
let dynamicCount = 0;
const dynamicContainer = document.getElementById('dynamicContainer');
document.getElementById('addComponent').addEventListener('click', () => {
dynamicCount++;
const wrapper = document.createElement('div');
wrapper.className = 'mount-target';
wrapper.setAttribute('data-label', `Dynamic Instance ${dynamicCount}`);
wrapper.style.marginBottom = '10px';
wrapper.innerHTML = '<unraid-header-os-version></unraid-header-os-version>';
dynamicContainer.appendChild(wrapper);
// Trigger mount if app is already loaded
if (window.mountVueApp) {
window.mountVueApp({
component: window.HeaderOsVersion,
selector: 'unraid-header-os-version',
appId: `dynamic-${dynamicCount}`,
});
}
});
document.getElementById('removeComponent').addEventListener('click', () => {
const lastChild = dynamicContainer.lastElementChild;
if (lastChild) {
dynamicContainer.removeChild(lastChild);
dynamicCount = Math.max(0, dynamicCount - 1);
}
});
document.getElementById('remountAll').addEventListener('click', () => {
// This would require the mount function to be exposed globally
console.log('Remounting all components...');
location.reload();
});
});
// Modal test functions
window.testTrialModal = function() {
console.log('Testing trial modal...');
if (window.globalPinia) {
const trialStore = window.globalPinia._s.get('trial');
if (trialStore) {
trialStore.trialModalVisible = true;
console.log('Trial modal triggered');
} else {
console.error('Trial store not found');
}
}
};
window.testUpdateModal = function() {
console.log('Testing update modal...');
if (window.globalPinia) {
const updateStore = window.globalPinia._s.get('updateOs');
if (updateStore) {
updateStore.updateOsModalVisible = true;
console.log('Update modal triggered');
} else {
console.error('Update store not found');
}
}
};
window.testApiKeyModal = function() {
console.log('Testing API key modal...');
if (window.globalPinia) {
const apiKeyStore = window.globalPinia._s.get('apiKey');
if (apiKeyStore) {
apiKeyStore.showCreateModal = true;
console.log('API key modal triggered');
} else {
console.error('API key store not found');
}
}
};
</script>
<!-- Load the standalone app -->
<script type="module" src=".nuxt/standalone-apps/standalone-apps.js"></script>
</body>
</html>

View File

@@ -36,12 +36,19 @@ export const defaultDark: ThemeVariables = {
'--destructive-foreground': '0 0% 98%',
'--ring': '0 0% 83.1%',
'--radius': '0.5rem',
'--custom-font-size': '10px',
'--ui-border-muted': 'hsl(0 0% 20%)',
'--header-text-primary': '#1c1c1c',
'--header-text-secondary': '#999999',
'--header-background-color': '#f2f2f2',
'--header-gradient-start': 'rgba(0, 0, 0, 0)',
'--header-gradient-end': 'var(--header-background-color)',
'--banner-gradient': null,
'--color-header-text-primary': '#1c1c1c',
'--color-header-text-secondary': '#999999',
'--color-header-background': '#f2f2f2',
'--color-header-gradient-start': 'rgba(0, 0, 0, 0)',
'--color-header-gradient-end': '#f2f2f2',
...legacyColors,
} as const satisfies ThemeVariables;
@@ -66,12 +73,19 @@ export const defaultLight: ThemeVariables = {
'--destructive-foreground': '0 0% 98%',
'--ring': '0 0% 3.9%',
'--radius': '0.5rem',
'--custom-font-size': '10px',
'--ui-border-muted': 'hsl(0 0% 89.8%)',
'--header-text-primary': '#f2f2f2',
'--header-text-secondary': '#999999',
'--header-background-color': '#1c1b1b',
'--header-gradient-start': 'rgba(0, 0, 0, 0)',
'--header-gradient-end': 'var(--header-background-color)',
'--banner-gradient': null,
'--color-header-text-primary': '#f2f2f2',
'--color-header-text-secondary': '#999999',
'--color-header-background': '#1c1b1b',
'--color-header-gradient-start': 'rgba(0, 0, 0, 0)',
'--color-header-gradient-end': '#1c1b1b',
...legacyColors,
} as const satisfies ThemeVariables;
@@ -94,11 +108,21 @@ export const defaultColors: Record<string, ThemeVariables> = {
'--header-text-primary': '#39587f',
'--header-text-secondary': '#606e7f',
'--header-background-color': '#1c1b1b',
'--color-header-text-primary': '#39587f',
'--color-header-text-secondary': '#606e7f',
'--color-header-background': '#1c1b1b',
'--color-header-gradient-start': 'rgba(28, 27, 27, 0)',
'--color-header-gradient-end': '#1c1b1b',
},
azure: {
...defaultDark,
'--header-text-primary': '#39587f',
'--header-text-secondary': '#606e7f',
'--header-background-color': '#f2f2f2',
'--color-header-text-primary': '#39587f',
'--color-header-text-secondary': '#606e7f',
'--color-header-background': '#f2f2f2',
'--color-header-gradient-start': 'rgba(242, 242, 242, 0)',
'--color-header-gradient-end': '#f2f2f2',
},
} as const satisfies Record<string, ThemeVariables>;

View File

@@ -35,6 +35,14 @@ type BaseThemeVariables = {
'--header-gradient-start': string;
'--header-gradient-end': string;
'--banner-gradient': string | null;
// Tailwind v4 color variables for utility classes
'--color-header-text-primary'?: string;
'--color-header-text-secondary'?: string;
'--color-header-background'?: string;
'--color-header-gradient-start'?: string;
'--color-header-gradient-end'?: string;
'--custom-font-size'?: string;
'--ui-border-muted'?: string;
};
type LegacyThemeVariables = {

View File

@@ -0,0 +1,98 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path, { resolve } from 'path';
import fs from 'fs';
// Read CSS content at build time
const getCssContent = () => {
const directories = [
'.nuxt/dist/client/_nuxt',
'.output/public/_nuxt'
];
for (const dir of directories) {
const fullDir = path.resolve(__dirname, dir);
if (fs.existsSync(fullDir)) {
// Find entry.*.css files dynamically
const files = fs.readdirSync(fullDir);
const entryFile = files.find(f => f.startsWith('entry.') && f.endsWith('.css'));
if (entryFile) {
const fullPath = path.join(fullDir, entryFile);
console.log(`Reading CSS from: ${fullPath}`);
return fs.readFileSync(fullPath, 'utf-8');
}
}
}
// Fallback to source asset
const fallback = path.resolve(__dirname, 'assets/main.css');
if (fs.existsSync(fallback)) {
console.warn('Nuxt CSS not found; falling back to assets/main.css');
return fs.readFileSync(fallback, 'utf-8');
}
console.warn('No CSS file found, using empty string');
return '';
};
export default defineConfig({
plugins: [
vue(),
{
name: 'inject-css-content',
transform(code, id) {
// Replace CSS import with actual content
if (id.includes('vue-mount-app')) {
const cssContent = getCssContent();
const replacement = `const tailwindStyles = ${JSON.stringify(cssContent)};`;
// Replace the import statement
code = code.replace(
/import tailwindStyles from ['"]~\/assets\/main\.css\?inline['"];?/,
replacement
);
return code;
}
return null;
},
},
],
resolve: {
alias: {
'~': resolve(__dirname, './'),
'@': resolve(__dirname, './'),
},
},
build: {
outDir: '.nuxt/standalone-apps',
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'components/standalone-mount.ts'),
name: 'UnraidStandaloneApps',
fileName: 'standalone-apps',
formats: ['es'],
},
rollupOptions: {
external: [],
output: {
format: 'es',
entryFileNames: 'standalone-apps.js',
chunkFileNames: '[name]-[hash].js',
assetFileNames: '[name]-[hash][extname]',
inlineDynamicImports: false,
},
},
cssCodeSplit: false,
minify: 'terser',
terserOptions: {
mangle: {
toplevel: true,
},
},
},
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
},
});

26
web/vite.test.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
root: '.',
server: {
port: 5173,
open: '/test-standalone.html',
cors: true,
fs: {
strict: false,
},
},
resolve: {
alias: {
'~': resolve(__dirname, './'),
'@': resolve(__dirname, './'),
'vue': 'vue/dist/vue.esm-bundler.js',
},
},
optimizeDeps: {
include: ['vue', 'pinia', '@vue/apollo-composable'],
},
});