mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
feat: mount vue apps, not web components (#1639)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Standalone web bundle with auto-mount utilities and a self-contained test page. * New responsive modal components for consistent mobile/desktop dialogs. * Header actions to copy OS/API versions. * **Improvements** * Refreshed UI styles (muted borders), accessibility and animation refinements. * Theming updates and Tailwind v4–aligned, component-scoped styles. * Runtime GraphQL endpoint override and CSRF header support. * **Bug Fixes** * Safer network fetching and improved manifest/asset loading with duplicate protection. * **Tests/Chores** * Parallel plugin tests, new extractor test suite, and updated build/test scripts. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
12
.github/workflows/main.yml
vendored
12
.github/workflows/main.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
|||||||
- name: Cache APT Packages
|
- name: Cache APT Packages
|
||||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||||
with:
|
with:
|
||||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
|
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system php-cli
|
||||||
version: 1.0
|
version: 1.0
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
@@ -147,12 +147,17 @@ jobs:
|
|||||||
(cd ../unraid-ui && pnpm test --coverage 2>/dev/null || pnpm test) > ui-test.log 2>&1 &
|
(cd ../unraid-ui && pnpm test --coverage 2>/dev/null || pnpm test) > ui-test.log 2>&1 &
|
||||||
UI_PID=$!
|
UI_PID=$!
|
||||||
|
|
||||||
|
echo "🚀 Starting Plugin tests..."
|
||||||
|
(cd ../plugin && pnpm test) > plugin-test.log 2>&1 &
|
||||||
|
PLUGIN_PID=$!
|
||||||
|
|
||||||
# Wait for all processes and capture exit codes
|
# Wait for all processes and capture exit codes
|
||||||
wait $API_PID && echo "✅ API tests completed" || { echo "❌ API tests failed"; API_EXIT=1; }
|
wait $API_PID && echo "✅ API tests completed" || { echo "❌ API tests failed"; API_EXIT=1; }
|
||||||
wait $CONNECT_PID && echo "✅ Connect tests completed" || { echo "❌ Connect tests failed"; CONNECT_EXIT=1; }
|
wait $CONNECT_PID && echo "✅ Connect tests completed" || { echo "❌ Connect tests failed"; CONNECT_EXIT=1; }
|
||||||
wait $SHARED_PID && echo "✅ Shared tests completed" || { echo "❌ Shared tests failed"; SHARED_EXIT=1; }
|
wait $SHARED_PID && echo "✅ Shared tests completed" || { echo "❌ Shared tests failed"; SHARED_EXIT=1; }
|
||||||
wait $WEB_PID && echo "✅ Web tests completed" || { echo "❌ Web tests failed"; WEB_EXIT=1; }
|
wait $WEB_PID && echo "✅ Web tests completed" || { echo "❌ Web tests failed"; WEB_EXIT=1; }
|
||||||
wait $UI_PID && echo "✅ UI tests completed" || { echo "❌ UI tests failed"; UI_EXIT=1; }
|
wait $UI_PID && echo "✅ UI tests completed" || { echo "❌ UI tests failed"; UI_EXIT=1; }
|
||||||
|
wait $PLUGIN_PID && echo "✅ Plugin tests completed" || { echo "❌ Plugin tests failed"; PLUGIN_EXIT=1; }
|
||||||
|
|
||||||
# Display all outputs
|
# Display all outputs
|
||||||
echo "📋 API Test Results:" && cat api-test.log
|
echo "📋 API Test Results:" && cat api-test.log
|
||||||
@@ -160,9 +165,10 @@ jobs:
|
|||||||
echo "📋 Shared Package Test Results:" && cat shared-test.log
|
echo "📋 Shared Package Test Results:" && cat shared-test.log
|
||||||
echo "📋 Web Package Test Results:" && cat web-test.log
|
echo "📋 Web Package Test Results:" && cat web-test.log
|
||||||
echo "📋 UI Package Test Results:" && cat ui-test.log
|
echo "📋 UI Package Test Results:" && cat ui-test.log
|
||||||
|
echo "📋 Plugin Test Results:" && cat plugin-test.log
|
||||||
|
|
||||||
# Exit with error if any test failed
|
# Exit with error if any test failed
|
||||||
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 || ${WEB_EXIT:-0} -eq 1 || ${UI_EXIT:-0} -eq 1 ]]; then
|
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 || ${WEB_EXIT:-0} -eq 1 || ${UI_EXIT:-0} -eq 1 || ${PLUGIN_EXIT:-0} -eq 1 ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -379,7 +385,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: unraid-wc-rich
|
name: unraid-wc-rich
|
||||||
path: web/.nuxt/nuxt-custom-elements/dist/unraid-components
|
path: web/.nuxt/standalone-apps
|
||||||
|
|
||||||
build-plugin-staging-pr:
|
build-plugin-staging-pr:
|
||||||
name: Build and Deploy Plugin
|
name: Build and Deploy Plugin
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@layer utilities {
|
/* Utility defaults for web components (when we were using shadow DOM) */
|
||||||
:host {
|
:host {
|
||||||
--tw-divide-y-reverse: 0;
|
--tw-divide-y-reverse: 0;
|
||||||
--tw-border-style: solid;
|
--tw-border-style: solid;
|
||||||
--tw-font-weight: initial;
|
--tw-font-weight: initial;
|
||||||
@@ -48,21 +48,20 @@
|
|||||||
--tw-drop-shadow: initial;
|
--tw-drop-shadow: initial;
|
||||||
--tw-duration: initial;
|
--tw-duration: initial;
|
||||||
--tw-ease: initial;
|
--tw-ease: initial;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
/* Global border color - this is what's causing the issue! */
|
||||||
*,
|
/* Commenting out since it affects all elements globally
|
||||||
::after,
|
*,
|
||||||
::before,
|
::after,
|
||||||
::backdrop,
|
::before,
|
||||||
::file-selector-button {
|
::backdrop,
|
||||||
border-color: hsl(var(--border));
|
::file-selector-button {
|
||||||
}
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
|
||||||
body {
|
|
||||||
--color-alpha: #1c1b1b;
|
--color-alpha: #1c1b1b;
|
||||||
--color-beta: #f2f2f2;
|
--color-beta: #f2f2f2;
|
||||||
--color-gamma: #999999;
|
--color-gamma: #999999;
|
||||||
@@ -74,8 +73,7 @@
|
|||||||
--ring-shadow: 0 0 var(--color-beta);
|
--ring-shadow: 0 0 var(--color-beta);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:not(:disabled),
|
button:not(:disabled),
|
||||||
[role='button']:not(:disabled) {
|
[role='button']:not(:disabled) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
/* Hybrid theme system: Native CSS + Theme Store fallback */
|
/* Hybrid theme system: Native CSS + Theme Store fallback */
|
||||||
@layer base {
|
|
||||||
/* Light mode defaults */
|
/* Light mode defaults */
|
||||||
:root {
|
: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%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 0 0% 3.9%;
|
--foreground: 0 0% 3.9%;
|
||||||
--muted: 0 0% 96.1%;
|
--muted: 0 0% 96.1%;
|
||||||
@@ -30,6 +34,10 @@
|
|||||||
|
|
||||||
/* Dark mode */
|
/* Dark mode */
|
||||||
.dark {
|
.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%;
|
--background: 0 0% 3.9%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--muted: 0 0% 14.9%;
|
--muted: 0 0% 14.9%;
|
||||||
@@ -62,69 +70,4 @@
|
|||||||
--background: 0 0% 3.9%;
|
--background: 0 0% 3.9%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--border: 0 0% 14.9%;
|
--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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
@import './css-variables.css';
|
@import './css-variables.css';
|
||||||
@import './unraid-theme.css';
|
@import './unraid-theme.css';
|
||||||
@import './base-utilities.css';
|
@import './base-utilities.css';
|
||||||
@import './sonner.css';
|
@import './sonner.css';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
setupPluginEnv,
|
setupPluginEnv,
|
||||||
} from "../../cli/setup-plugin-environment";
|
} from "../../cli/setup-plugin-environment";
|
||||||
import { access, readFile } from "node:fs/promises";
|
import { access, readFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
// Mock fs/promises
|
// Mock fs/promises
|
||||||
vi.mock("node:fs/promises", () => ({
|
vi.mock("node:fs/promises", () => ({
|
||||||
@@ -14,8 +15,19 @@ vi.mock("node:fs/promises", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock node:fs
|
||||||
|
vi.mock("node:fs", () => ({
|
||||||
|
existsSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
|
// Mock existsSync to return true for test.txz
|
||||||
|
vi.mocked(existsSync).mockImplementation((path) => {
|
||||||
|
return path.toString().includes("test.txz");
|
||||||
|
});
|
||||||
|
|
||||||
vi.mocked(readFile).mockImplementation((path, encoding) => {
|
vi.mocked(readFile).mockImplementation((path, encoding) => {
|
||||||
console.log("Mock readFile called with:", path, encoding);
|
console.log("Mock readFile called with:", path, encoding);
|
||||||
|
|
||||||
@@ -42,6 +54,7 @@ describe("validatePluginEnv", () => {
|
|||||||
|
|
||||||
it("validates required fields", async () => {
|
it("validates required fields", async () => {
|
||||||
const validEnv = {
|
const validEnv = {
|
||||||
|
apiVersion: "4.17.0",
|
||||||
baseUrl: "https://example.com",
|
baseUrl: "https://example.com",
|
||||||
txzPath: "./test.txz",
|
txzPath: "./test.txz",
|
||||||
pluginVersion: "2024.05.05.1232",
|
pluginVersion: "2024.05.05.1232",
|
||||||
@@ -53,6 +66,7 @@ describe("validatePluginEnv", () => {
|
|||||||
|
|
||||||
it("throws on invalid URL", async () => {
|
it("throws on invalid URL", async () => {
|
||||||
const invalidEnv = {
|
const invalidEnv = {
|
||||||
|
apiVersion: "4.17.0",
|
||||||
baseUrl: "not-a-url",
|
baseUrl: "not-a-url",
|
||||||
txzPath: "./test.txz",
|
txzPath: "./test.txz",
|
||||||
pluginVersion: "2024.05.05.1232",
|
pluginVersion: "2024.05.05.1232",
|
||||||
@@ -63,6 +77,7 @@ describe("validatePluginEnv", () => {
|
|||||||
|
|
||||||
it("handles tag option in non-CI mode", async () => {
|
it("handles tag option in non-CI mode", async () => {
|
||||||
const envWithTag = {
|
const envWithTag = {
|
||||||
|
apiVersion: "4.17.0",
|
||||||
baseUrl: "https://example.com",
|
baseUrl: "https://example.com",
|
||||||
txzPath: "./test.txz",
|
txzPath: "./test.txz",
|
||||||
pluginVersion: "2024.05.05.1232",
|
pluginVersion: "2024.05.05.1232",
|
||||||
@@ -77,6 +92,7 @@ describe("validatePluginEnv", () => {
|
|||||||
|
|
||||||
it("reads release notes when release-notes-path is provided", async () => {
|
it("reads release notes when release-notes-path is provided", async () => {
|
||||||
const envWithNotes = {
|
const envWithNotes = {
|
||||||
|
apiVersion: "4.17.0",
|
||||||
baseUrl: "https://example.com",
|
baseUrl: "https://example.com",
|
||||||
txzPath: "./test.txz",
|
txzPath: "./test.txz",
|
||||||
pluginVersion: "2024.05.05.1232",
|
pluginVersion: "2024.05.05.1232",
|
||||||
@@ -100,6 +116,7 @@ describe("validatePluginEnv", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const envWithEmptyNotes = {
|
const envWithEmptyNotes = {
|
||||||
|
apiVersion: "4.17.0",
|
||||||
baseUrl: "https://example.com",
|
baseUrl: "https://example.com",
|
||||||
txzPath: "./test.txz",
|
txzPath: "./test.txz",
|
||||||
pluginVersion: "2024.05.05.1232",
|
pluginVersion: "2024.05.05.1232",
|
||||||
|
|||||||
@@ -6,8 +6,20 @@ import { deployDir } from "../../utils/paths";
|
|||||||
|
|
||||||
describe("setupTxzEnvironment", () => {
|
describe("setupTxzEnvironment", () => {
|
||||||
it("should return default values when no arguments are provided", async () => {
|
it("should return default values when no arguments are provided", async () => {
|
||||||
const envArgs = {};
|
const envArgs = {
|
||||||
const expected: TxzEnv = { ci: false, skipValidation: "false", compress: "1", txzOutputDir: join(startingDir, deployDir) };
|
apiVersion: "4.17.0",
|
||||||
|
baseUrl: "https://example.com"
|
||||||
|
};
|
||||||
|
const expected: TxzEnv = {
|
||||||
|
apiVersion: "4.17.0",
|
||||||
|
baseUrl: "https://example.com",
|
||||||
|
ci: false,
|
||||||
|
skipValidation: "false",
|
||||||
|
compress: "1",
|
||||||
|
txzOutputDir: join(startingDir, deployDir),
|
||||||
|
tag: "",
|
||||||
|
buildNumber: 1
|
||||||
|
};
|
||||||
|
|
||||||
const result = await validateTxzEnv(envArgs);
|
const result = await validateTxzEnv(envArgs);
|
||||||
|
|
||||||
@@ -15,8 +27,24 @@ describe("setupTxzEnvironment", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should parse and return provided environment arguments", async () => {
|
it("should parse and return provided environment arguments", async () => {
|
||||||
const envArgs = { ci: true, skipValidation: "true", txzOutputDir: join(startingDir, "deploy/release/test"), compress: '8' };
|
const envArgs = {
|
||||||
const expected: TxzEnv = { ci: true, skipValidation: "true", compress: "8", txzOutputDir: join(startingDir, "deploy/release/test") };
|
apiVersion: "4.17.0",
|
||||||
|
baseUrl: "https://example.com",
|
||||||
|
ci: true,
|
||||||
|
skipValidation: "true",
|
||||||
|
txzOutputDir: join(startingDir, "deploy/release/test"),
|
||||||
|
compress: '8'
|
||||||
|
};
|
||||||
|
const expected: TxzEnv = {
|
||||||
|
apiVersion: "4.17.0",
|
||||||
|
baseUrl: "https://example.com",
|
||||||
|
ci: true,
|
||||||
|
skipValidation: "true",
|
||||||
|
compress: "8",
|
||||||
|
txzOutputDir: join(startingDir, "deploy/release/test"),
|
||||||
|
tag: "",
|
||||||
|
buildNumber: 1
|
||||||
|
};
|
||||||
|
|
||||||
const result = await validateTxzEnv(envArgs);
|
const result = await validateTxzEnv(envArgs);
|
||||||
|
|
||||||
@@ -24,7 +52,11 @@ describe("setupTxzEnvironment", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should warn and skip validation when skipValidation is true", async () => {
|
it("should warn and skip validation when skipValidation is true", async () => {
|
||||||
const envArgs = { skipValidation: "true" };
|
const envArgs = {
|
||||||
|
apiVersion: "4.17.0",
|
||||||
|
baseUrl: "https://example.com",
|
||||||
|
skipValidation: "true"
|
||||||
|
};
|
||||||
const consoleWarnSpy = vi
|
const consoleWarnSpy = vi
|
||||||
.spyOn(console, "warn")
|
.spyOn(console, "warn")
|
||||||
.mockImplementation(() => {});
|
.mockImplementation(() => {});
|
||||||
@@ -38,7 +70,11 @@ describe("setupTxzEnvironment", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw an error for invalid SKIP_VALIDATION value", async () => {
|
it("should throw an error for invalid SKIP_VALIDATION value", async () => {
|
||||||
const envArgs = { skipValidation: "invalid" };
|
const envArgs = {
|
||||||
|
apiVersion: "4.17.0",
|
||||||
|
baseUrl: "https://example.com",
|
||||||
|
skipValidation: "invalid"
|
||||||
|
};
|
||||||
|
|
||||||
await expect(validateTxzEnv(envArgs)).rejects.toThrow(
|
await expect(validateTxzEnv(envArgs)).rejects.toThrow(
|
||||||
"Must be true or false"
|
"Must be true or false"
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ const findManifestFiles = async (dir: string): Promise<string[]> => {
|
|||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
entry.isFile() &&
|
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);
|
files.push(entry.name);
|
||||||
}
|
}
|
||||||
@@ -124,19 +126,21 @@ const validateSourceDir = async (validatedEnv: TxzEnv) => {
|
|||||||
|
|
||||||
const manifestFiles = await findManifestFiles(webcomponentDir);
|
const manifestFiles = await findManifestFiles(webcomponentDir);
|
||||||
const hasManifest = manifestFiles.includes("manifest.json");
|
const hasManifest = manifestFiles.includes("manifest.json");
|
||||||
|
const hasStandaloneManifest = manifestFiles.includes("standalone.manifest.json");
|
||||||
const hasUiManifest = manifestFiles.includes("ui.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);
|
console.log("Existing Manifest Files:", manifestFiles);
|
||||||
const missingFiles: string[] = [];
|
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");
|
if (!hasUiManifest) missingFiles.push("ui.manifest.json");
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Webcomponents missing required file(s): ${missingFiles.join(", ")} - ` +
|
`Webcomponents missing required file(s): ${missingFiles.join(", ")} - ` +
|
||||||
`${!hasUiManifest ? "run 'pnpm build:wc' in unraid-ui for ui.manifest.json" : ""}` +
|
`${!hasUiManifest ? "run 'pnpm build:wc' in unraid-ui for ui.manifest.json" : ""}` +
|
||||||
`${!hasManifest && !hasUiManifest ? " and " : ""}` +
|
`${(!hasManifest && !hasStandaloneManifest) && !hasUiManifest ? " and " : ""}` +
|
||||||
`${!hasManifest ? "run 'pnpm build' in web for manifest.json" : ""}`
|
`${(!hasManifest && !hasStandaloneManifest) ? "run 'pnpm build' in web for standalone.manifest.json" : ""}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const validateTxzEnv = async (
|
|||||||
): Promise<TxzEnv> => {
|
): Promise<TxzEnv> => {
|
||||||
const validatedEnv = txzEnvSchema.parse(envArgs);
|
const validatedEnv = txzEnvSchema.parse(envArgs);
|
||||||
|
|
||||||
if ("skipValidation" in validatedEnv) {
|
if (validatedEnv.skipValidation === "true") {
|
||||||
console.warn("skipValidation is true, skipping validation");
|
console.warn("skipValidation is true, skipping validation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"env:validate": "test -f .env || (echo 'Error: .env file missing. Run npm run env:init first' && exit 1)",
|
"env:validate": "test -f .env || (echo 'Error: .env file missing. Run npm run env:init first' && exit 1)",
|
||||||
"env:clean": "rm -f .env",
|
"env:clean": "rm -f .env",
|
||||||
"// Testing": "",
|
"// Testing": "",
|
||||||
"test": "vitest"
|
"test": "vitest && pnpm run test:extractor",
|
||||||
|
"test:extractor": "bash ./tests/test-extractor.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"http-server": "14.1.1",
|
"http-server": "14.1.1",
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ exit 0
|
|||||||
sed -i 's|<body>|<body>\n<unraid-modals></unraid-modals>|' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"
|
sed -i 's|<body>|<body>\n<unraid-modals></unraid-modals>|' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
]]>
|
]]>
|
||||||
</INLINE>
|
</INLINE>
|
||||||
</FILE>
|
</FILE>
|
||||||
@@ -272,6 +272,27 @@ exit 0
|
|||||||
[ -f "$FILE-" ] && mv -f "$FILE-" "$FILE"
|
[ -f "$FILE-" ] && mv -f "$FILE-" "$FILE"
|
||||||
done
|
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
|
# Handle the unraid-components directory
|
||||||
DIR=/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components
|
DIR=/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components
|
||||||
# Remove the archive's contents before restoring
|
# Remove the archive's contents before restoring
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ $docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
|
|||||||
class WebComponentsExtractor
|
class WebComponentsExtractor
|
||||||
{
|
{
|
||||||
private const PREFIXED_PATH = '/plugins/dynamix.my.servers/unraid-components/';
|
private const PREFIXED_PATH = '/plugins/dynamix.my.servers/unraid-components/';
|
||||||
private const RICH_COMPONENTS_ENTRY = 'unraid-components.client.mjs';
|
|
||||||
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 static ?WebComponentsExtractor $instance = null;
|
private static ?WebComponentsExtractor $instance = null;
|
||||||
|
|
||||||
@@ -21,15 +17,6 @@ class WebComponentsExtractor
|
|||||||
return self::$instance;
|
return self::$instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findManifestFiles(string $manifestName): array
|
|
||||||
{
|
|
||||||
$basePath = '/usr/local/emhttp' . self::PREFIXED_PATH;
|
|
||||||
$escapedBasePath = escapeshellarg($basePath);
|
|
||||||
$escapedManifestName = escapeshellarg($manifestName);
|
|
||||||
$command = "find {$escapedBasePath} -name {$escapedManifestName}";
|
|
||||||
exec($command, $files);
|
|
||||||
return $files;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAssetPath(string $asset, string $subfolder = ''): string
|
public function getAssetPath(string $asset, string $subfolder = ''): string
|
||||||
{
|
{
|
||||||
@@ -42,6 +29,11 @@ class WebComponentsExtractor
|
|||||||
$relative = str_replace($basePath, '', $fullPath);
|
$relative = str_replace($basePath, '', $fullPath);
|
||||||
return dirname($relative);
|
return dirname($relative);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function sanitizeForId(string $input): string
|
||||||
|
{
|
||||||
|
return preg_replace('/[^a-zA-Z0-9-]/', '-', $input);
|
||||||
|
}
|
||||||
|
|
||||||
public function getManifestContents(string $manifestPath): array
|
public function getManifestContents(string $manifestPath): array
|
||||||
{
|
{
|
||||||
@@ -49,117 +41,108 @@ class WebComponentsExtractor
|
|||||||
return $contents ? json_decode($contents, true) : [];
|
return $contents ? json_decode($contents, true) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function processManifestFiles(): string
|
||||||
private function getRichComponentsFile(): string
|
|
||||||
{
|
{
|
||||||
$manifestFiles = $this->findManifestFiles('manifest.json');
|
// Find all manifest files (*.manifest.json or manifest.json)
|
||||||
|
$manifestFiles = $this->findAllManifestFiles();
|
||||||
|
|
||||||
|
if (empty($manifestFiles)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$scripts = [];
|
||||||
|
|
||||||
|
// Process each manifest file
|
||||||
foreach ($manifestFiles as $manifestPath) {
|
foreach ($manifestFiles as $manifestPath) {
|
||||||
$manifest = $this->getManifestContents($manifestPath);
|
$manifest = $this->getManifestContents($manifestPath);
|
||||||
|
if (empty($manifest)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$subfolder = $this->getRelativePath($manifestPath);
|
$subfolder = $this->getRelativePath($manifestPath);
|
||||||
|
|
||||||
foreach ($manifest as $key => $value) {
|
// Process each entry in the manifest
|
||||||
// Skip timestamp entries
|
foreach ($manifest as $key => $entry) {
|
||||||
if ($key === 'ts' || !is_array($value)) {
|
// Skip if not an array with a 'file' key
|
||||||
|
if (!is_array($entry) || !isset($entry['file']) || empty($entry['file'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for both old format (direct key match) and new format (path-based key)
|
// Build the file path
|
||||||
$matchesMjs = strpos($key, self::RICH_COMPONENTS_ENTRY) !== false;
|
$filePath = ($subfolder ? $subfolder . '/' : '') . $entry['file'];
|
||||||
$matchesJs = strpos($key, self::RICH_COMPONENTS_ENTRY_JS) !== false;
|
$fullPath = $this->getAssetPath($filePath);
|
||||||
|
|
||||||
if (($matchesMjs || $matchesJs) && isset($value["file"])) {
|
// Determine file type and generate appropriate tag
|
||||||
return ($subfolder ? $subfolder . '/' : '') . $value["file"];
|
$extension = pathinfo($entry['file'], PATHINFO_EXTENSION);
|
||||||
|
|
||||||
|
// Sanitize subfolder and key for ID generation
|
||||||
|
$sanitizedSubfolder = $subfolder ? $this->sanitizeForId($subfolder) . '-' : '';
|
||||||
|
$sanitizedKey = $this->sanitizeForId($key);
|
||||||
|
|
||||||
|
// Escape attributes for HTML safety
|
||||||
|
$safeId = htmlspecialchars('unraid-' . $sanitizedSubfolder . $sanitizedKey, ENT_QUOTES, 'UTF-8');
|
||||||
|
$safePath = htmlspecialchars($fullPath, ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
if ($extension === 'js' || $extension === 'mjs') {
|
||||||
|
// Generate script tag with unique ID based on subfolder and key
|
||||||
|
$scripts[] = '<script id="' . $safeId . '" type="module" src="' . $safePath . '" data-unraid="1"></script>';
|
||||||
|
// Also emit any CSS referenced by this entry (Vite manifest "css": [])
|
||||||
|
if (!empty($entry['css']) && is_array($entry['css'])) {
|
||||||
|
foreach ($entry['css'] as $cssFile) {
|
||||||
|
if (!is_string($cssFile) || $cssFile === '') continue;
|
||||||
|
$cssPath = ($subfolder ? $subfolder . '/' : '') . $cssFile;
|
||||||
|
$cssFull = $this->getAssetPath($cssPath);
|
||||||
|
$cssId = htmlspecialchars(
|
||||||
|
'unraid-' . $sanitizedSubfolder . $sanitizedKey . '-css-' . $this->sanitizeForId(basename($cssFile)),
|
||||||
|
ENT_QUOTES,
|
||||||
|
'UTF-8'
|
||||||
|
);
|
||||||
|
$cssHref = htmlspecialchars($cssFull, ENT_QUOTES, 'UTF-8');
|
||||||
|
$scripts[] = '<link id="' . $cssId . '" rel="stylesheet" href="' . $cssHref . '" data-unraid="1">';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($extension === 'css') {
|
||||||
|
// Generate link tag for CSS files with unique ID
|
||||||
|
$scripts[] = '<link id="' . $safeId . '" rel="stylesheet" href="' . $safePath . '" data-unraid="1">';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '';
|
|
||||||
}
|
if (empty($scripts)) {
|
||||||
|
return '';
|
||||||
private function getRichComponentsScript(): string
|
|
||||||
{
|
|
||||||
$jsFile = $this->getRichComponentsFile();
|
|
||||||
if (empty($jsFile)) {
|
|
||||||
return '<script>console.error("%cNo matching key containing \'' . self::RICH_COMPONENTS_ENTRY . '\' or \'' . self::RICH_COMPONENTS_ENTRY_JS . '\' found.", "font-weight: bold; color: white; background-color: red");</script>';
|
|
||||||
}
|
}
|
||||||
// Add a unique identifier to prevent duplicate script loading
|
|
||||||
$scriptId = 'unraid-rich-components-script';
|
// Add deduplication script
|
||||||
return '<script id="' . $scriptId . '" src="' . $this->getAssetPath($jsFile) . '"></script>
|
$deduplicationScript = '
|
||||||
<script>
|
<script>
|
||||||
// Remove duplicate script tags to prevent multiple loads
|
// Remove duplicate resource tags to prevent multiple loads
|
||||||
(function() {
|
(function() {
|
||||||
var scripts = document.querySelectorAll(\'script[id="' . $scriptId . '"]\');
|
var elements = document.querySelectorAll(\'[data-unraid="1"]\');
|
||||||
if (scripts.length > 1) {
|
var seen = {};
|
||||||
for (var i = 1; i < scripts.length; i++) {
|
for (var i = 0; i < elements.length; i++) {
|
||||||
scripts[i].remove();
|
var el = elements[i];
|
||||||
|
if (seen[el.id]) {
|
||||||
|
el.remove();
|
||||||
|
} else {
|
||||||
|
seen[el.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>';
|
</script>';
|
||||||
|
|
||||||
|
return implode("\n", $scripts) . $deduplicationScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getUnraidUiScriptHtml(): string
|
private function findAllManifestFiles(): array
|
||||||
{
|
{
|
||||||
$manifestFiles = $this->findManifestFiles('ui.manifest.json');
|
$basePath = '/usr/local/emhttp' . self::PREFIXED_PATH;
|
||||||
|
$escapedBasePath = escapeshellarg($basePath);
|
||||||
|
|
||||||
if (empty($manifestFiles)) {
|
// Find all files ending with .manifest.json or exactly named manifest.json
|
||||||
error_log("No ui.manifest.json found");
|
$command = "find {$escapedBasePath} -type f \\( -name '*.manifest.json' -o -name 'manifest.json' \\) 2>/dev/null";
|
||||||
return '';
|
exec($command, $files);
|
||||||
}
|
|
||||||
|
|
||||||
$manifestPath = $manifestFiles[0]; // Use the first found manifest
|
|
||||||
$manifest = $this->getManifestContents($manifestPath);
|
|
||||||
$subfolder = $this->getRelativePath($manifestPath);
|
|
||||||
|
|
||||||
if (!isset($manifest[self::UI_ENTRY]) || !isset($manifest[self::UI_STYLES_ENTRY])) {
|
|
||||||
error_log("Required entries not found in ui.manifest.json");
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$jsFile = ($subfolder ? $subfolder . '/' : '') . $manifest[self::UI_ENTRY]['file'];
|
|
||||||
$cssFile = ($subfolder ? $subfolder . '/' : '') . $manifest[self::UI_STYLES_ENTRY]['file'];
|
|
||||||
|
|
||||||
// Read the CSS file content
|
return $files;
|
||||||
$cssPath = '/usr/local/emhttp' . $this->getAssetPath($cssFile);
|
|
||||||
$cssContent = @file_get_contents($cssPath);
|
|
||||||
|
|
||||||
if ($cssContent === false) {
|
|
||||||
error_log("Failed to read CSS file: " . $cssPath);
|
|
||||||
$cssContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape the CSS content for JavaScript
|
|
||||||
$escapedCssContent = json_encode($cssContent);
|
|
||||||
|
|
||||||
// Use a data attribute to ensure this only runs once per page
|
|
||||||
return '<script data-unraid-ui-register defer type="module">
|
|
||||||
(async function() {
|
|
||||||
// Check if components have already been registered
|
|
||||||
if (window.__unraidUiComponentsRegistered) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as registered immediately to prevent race conditions
|
|
||||||
window.__unraidUiComponentsRegistered = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { registerAllComponents } = await import("' . $this->getAssetPath($jsFile) . '");
|
|
||||||
registerAllComponents({ sharedCssContent: ' . $escapedCssContent . ' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Unraid UI] Failed to register components:", error);
|
|
||||||
// Reset flag on error so it can be retried
|
|
||||||
window.__unraidUiComponentsRegistered = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up duplicate script tags
|
|
||||||
const scripts = document.querySelectorAll(\'script[data-unraid-ui-register]\');
|
|
||||||
if (scripts.length > 1) {
|
|
||||||
for (let i = 1; i < scripts.length; i++) {
|
|
||||||
scripts[i].remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getScriptTagHtml(): string
|
public function getScriptTagHtml(): string
|
||||||
@@ -168,12 +151,12 @@ class WebComponentsExtractor
|
|||||||
static $scriptsOutput = false;
|
static $scriptsOutput = false;
|
||||||
|
|
||||||
if ($scriptsOutput) {
|
if ($scriptsOutput) {
|
||||||
return '<!-- Web components scripts already loaded -->';
|
return '<!-- Resources already loaded -->';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$scriptsOutput = true;
|
$scriptsOutput = true;
|
||||||
return $this->getRichComponentsScript() . $this->getUnraidUiScriptHtml();
|
return $this->processManifestFiles();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log("Error in WebComponentsExtractor::getScriptTagHtml: " . $e->getMessage());
|
error_log("Error in WebComponentsExtractor::getScriptTagHtml: " . $e->getMessage());
|
||||||
$scriptsOutput = false; // Reset on error
|
$scriptsOutput = false; // Reset on error
|
||||||
|
|||||||
@@ -100,6 +100,57 @@ class UnraidOsCheck
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe version of http_get_contents that falls back to system function if defined
|
||||||
|
* @param string $url The URL to fetch
|
||||||
|
* @param array $opts Array of options to pass to curl_setopt()
|
||||||
|
* @param array $getinfo Empty array passed by reference, will contain results of curl_getinfo and curl_error
|
||||||
|
* Note: CURLINFO_HEADER_OUT exposes request headers (not response headers) for curl_getinfo
|
||||||
|
* @return string|false $out The fetched content
|
||||||
|
*/
|
||||||
|
private function safe_http_get_contents(string $url, array $opts = [], array &$getinfo = NULL) {
|
||||||
|
// Use system http_get_contents if it exists
|
||||||
|
if (function_exists('http_get_contents')) {
|
||||||
|
return http_get_contents($url, $opts, $getinfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use our implementation
|
||||||
|
$ch = curl_init();
|
||||||
|
if(isset($getinfo)) {
|
||||||
|
curl_setopt($ch, CURLINFO_HEADER_OUT, TRUE);
|
||||||
|
}
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 45);
|
||||||
|
curl_setopt($ch, CURLOPT_ENCODING, "");
|
||||||
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||||
|
curl_setopt($ch, CURLOPT_REFERER, "");
|
||||||
|
curl_setopt($ch, CURLOPT_FAILONERROR, true);
|
||||||
|
// Explicitly enforce TLS verification
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||||
|
if(is_array($opts) && $opts) {
|
||||||
|
foreach($opts as $key => $val) {
|
||||||
|
curl_setopt($ch, $key, $val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$out = curl_exec($ch);
|
||||||
|
if(isset($getinfo)) {
|
||||||
|
$getinfo = curl_getinfo($ch);
|
||||||
|
}
|
||||||
|
if (curl_errno($ch)) {
|
||||||
|
$msg = curl_error($ch) . " {$url}";
|
||||||
|
if(isset($getinfo)) {
|
||||||
|
$getinfo['error'] = $msg;
|
||||||
|
}
|
||||||
|
my_logger($msg, "safe_http_get_contents");
|
||||||
|
}
|
||||||
|
curl_close($ch);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
/** @todo clean up this method to be more extensible */
|
/** @todo clean up this method to be more extensible */
|
||||||
public function checkForUpdate()
|
public function checkForUpdate()
|
||||||
{
|
{
|
||||||
@@ -136,7 +187,7 @@ class UnraidOsCheck
|
|||||||
$urlbase = $parsedAltUrl ?? $defaultUrl;
|
$urlbase = $parsedAltUrl ?? $defaultUrl;
|
||||||
$url = $urlbase.'?'.http_build_query($params);
|
$url = $urlbase.'?'.http_build_query($params);
|
||||||
$curlinfo = [];
|
$curlinfo = [];
|
||||||
$response = http_get_contents($url,[],$curlinfo);
|
$response = $this->safe_http_get_contents($url,[],$curlinfo);
|
||||||
if (array_key_exists('error', $curlinfo)) {
|
if (array_key_exists('error', $curlinfo)) {
|
||||||
$response = json_encode(array('error' => $curlinfo['error']), JSON_PRETTY_PRINT);
|
$response = json_encode(array('error' => $curlinfo['error']), JSON_PRETTY_PRINT);
|
||||||
}
|
}
|
||||||
|
|||||||
369
plugin/tests/test-extractor.php
Executable file
369
plugin/tests/test-extractor.php
Executable file
@@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* WebComponentsExtractor Test Suite
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 - All tests passed
|
||||||
|
* 1 - One or more tests failed
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ExtractorTest {
|
||||||
|
private $testDir;
|
||||||
|
private $componentDir;
|
||||||
|
private $passed = 0;
|
||||||
|
private $failed = 0;
|
||||||
|
private $verbose = false;
|
||||||
|
|
||||||
|
// Color codes for terminal output
|
||||||
|
const RED = "\033[0;31m";
|
||||||
|
const GREEN = "\033[0;32m";
|
||||||
|
const YELLOW = "\033[1;33m";
|
||||||
|
const NC = "\033[0m"; // No Color
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->verbose = getenv('VERBOSE') === '1' || in_array('--verbose', $_SERVER['argv'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run() {
|
||||||
|
$this->setup();
|
||||||
|
$this->runTests();
|
||||||
|
$this->teardown();
|
||||||
|
return $this->reportResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setup() {
|
||||||
|
echo "Setting up test environment...\n";
|
||||||
|
|
||||||
|
// Create temp directory
|
||||||
|
$this->testDir = sys_get_temp_dir() . '/extractor_test_' . uniqid();
|
||||||
|
$this->componentDir = $this->testDir . '/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components';
|
||||||
|
|
||||||
|
// Create directory structure
|
||||||
|
@mkdir($this->componentDir . '/standalone-apps', 0777, true);
|
||||||
|
@mkdir($this->componentDir . '/ui-components', 0777, true);
|
||||||
|
@mkdir($this->componentDir . '/other', 0777, true);
|
||||||
|
|
||||||
|
// Create test manifest files
|
||||||
|
file_put_contents($this->componentDir . '/standalone-apps/standalone.manifest.json', json_encode([
|
||||||
|
'standalone-apps-RlN0czLV.css' => [
|
||||||
|
'file' => 'standalone-apps-RlN0czLV.css',
|
||||||
|
'src' => 'standalone-apps-RlN0czLV.css'
|
||||||
|
],
|
||||||
|
'standalone-apps.js' => [
|
||||||
|
'file' => 'standalone-apps.js',
|
||||||
|
'src' => 'standalone-apps.js',
|
||||||
|
'css' => ['app-styles.css', 'theme.css']
|
||||||
|
],
|
||||||
|
'ts' => 1234567890
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
file_put_contents($this->componentDir . '/ui-components/ui.manifest.json', json_encode([
|
||||||
|
'ui-styles' => [
|
||||||
|
'file' => 'ui-components.css'
|
||||||
|
],
|
||||||
|
'ui-script' => [
|
||||||
|
'file' => 'components.mjs'
|
||||||
|
],
|
||||||
|
'invalid-entry' => [
|
||||||
|
'notAFile' => 'should be skipped'
|
||||||
|
],
|
||||||
|
'empty-file' => [
|
||||||
|
'file' => ''
|
||||||
|
]
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
file_put_contents($this->componentDir . '/other/manifest.json', json_encode([
|
||||||
|
'app-entry' => [
|
||||||
|
'file' => 'app.js',
|
||||||
|
'css' => ['main.css']
|
||||||
|
],
|
||||||
|
'app-styles' => [
|
||||||
|
'file' => 'app.css'
|
||||||
|
]
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
// Create duplicate key in different subfolder to test collision prevention
|
||||||
|
file_put_contents($this->componentDir . '/standalone-apps/collision.manifest.json', json_encode([
|
||||||
|
'app-entry' => [
|
||||||
|
'file' => 'collision-app.js'
|
||||||
|
]
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
// Create manifest with special characters for HTML escaping test
|
||||||
|
file_put_contents($this->componentDir . '/ui-components/special.manifest.json', json_encode([
|
||||||
|
'test"with>quotes' => [
|
||||||
|
'file' => 'test-file.js'
|
||||||
|
],
|
||||||
|
'test&with<special' => [
|
||||||
|
'file' => 'special\'file".css'
|
||||||
|
]
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
// Copy and modify the extractor for testing
|
||||||
|
$this->prepareExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prepareExtractor() {
|
||||||
|
$extractorPath = dirname(__DIR__) . '/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php';
|
||||||
|
$extractorContent = file_get_contents($extractorPath);
|
||||||
|
|
||||||
|
// Modify paths for test environment
|
||||||
|
$extractorContent = str_replace(
|
||||||
|
"'/usr/local/emhttp' . self::PREFIXED_PATH",
|
||||||
|
"'" . $this->testDir . "/usr/local/emhttp' . self::PREFIXED_PATH",
|
||||||
|
$extractorContent
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($this->testDir . '/extractor.php', $extractorContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getExtractorOutput() {
|
||||||
|
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
|
||||||
|
require_once $this->testDir . '/extractor.php';
|
||||||
|
|
||||||
|
$extractor = WebComponentsExtractor::getInstance();
|
||||||
|
return $extractor->getScriptTagHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runTests() {
|
||||||
|
echo "\n";
|
||||||
|
echo "========================================\n";
|
||||||
|
echo " WebComponentsExtractor Test Suite\n";
|
||||||
|
echo "========================================\n";
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
$output = $this->getExtractorOutput();
|
||||||
|
|
||||||
|
if ($this->verbose) {
|
||||||
|
echo self::YELLOW . "Generated output:" . self::NC . "\n";
|
||||||
|
echo $output . "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Script Tag Generation
|
||||||
|
echo "Test: Script Tag Generation\n";
|
||||||
|
echo "----------------------------\n";
|
||||||
|
$this->test(
|
||||||
|
"Generates script tag for standalone-apps.js",
|
||||||
|
strpos($output, 'script id="unraid-standalone-apps-standalone-apps-js"') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Generates script tag for components.mjs",
|
||||||
|
strpos($output, 'script id="unraid-ui-components-ui-script"') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Generates script tag for app.js",
|
||||||
|
strpos($output, 'script id="unraid-other-app-entry"') !== false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: CSS Link Generation
|
||||||
|
echo "\nTest: CSS Link Generation\n";
|
||||||
|
echo "--------------------------\n";
|
||||||
|
$this->test(
|
||||||
|
"Generates link tag for standalone CSS",
|
||||||
|
strpos($output, 'link id="unraid-standalone-apps-standalone-apps-RlN0czLV-css"') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Generates link tag for UI styles",
|
||||||
|
strpos($output, 'link id="unraid-ui-components-ui-styles"') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Generates link tag for app styles",
|
||||||
|
strpos($output, 'link id="unraid-other-app-styles"') !== false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: Invalid Entries Handling
|
||||||
|
echo "\nTest: Invalid Entries Handling\n";
|
||||||
|
echo "-------------------------------\n";
|
||||||
|
$this->test(
|
||||||
|
"Skips entries without 'file' key",
|
||||||
|
strpos($output, 'notAFile') === false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Skips invalid entry content",
|
||||||
|
strpos($output, 'should be skipped') === false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Skips entries with empty file value",
|
||||||
|
strpos($output, 'empty-file') === false && strpos($output, 'id="unraid-ui-components-empty-file"') === false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: Deduplication Script
|
||||||
|
echo "\nTest: Deduplication Script\n";
|
||||||
|
echo "---------------------------\n";
|
||||||
|
$this->test(
|
||||||
|
"Includes deduplication script",
|
||||||
|
strpos($output, 'Remove duplicate resource tags') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Deduplication targets correct elements",
|
||||||
|
strpos($output, 'document.querySelectorAll') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Deduplication only targets data-unraid elements",
|
||||||
|
strpos($output, 'querySelectorAll(\'[data-unraid="1"]\')') !== false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: Path Construction
|
||||||
|
echo "\nTest: Path Construction\n";
|
||||||
|
echo "------------------------\n";
|
||||||
|
$this->test(
|
||||||
|
"Correctly constructs standalone-apps path",
|
||||||
|
strpos($output, '/plugins/dynamix.my.servers/unraid-components/standalone-apps/standalone-apps.js') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Correctly constructs ui-components path",
|
||||||
|
strpos($output, '/plugins/dynamix.my.servers/unraid-components/ui-components/components.mjs') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Correctly constructs generic manifest path",
|
||||||
|
strpos($output, '/plugins/dynamix.my.servers/unraid-components/other/app.js') !== false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: ID Collision Prevention
|
||||||
|
echo "\nTest: ID Collision Prevention\n";
|
||||||
|
echo "------------------------------\n";
|
||||||
|
$this->test(
|
||||||
|
"Different IDs for same key in different subfolders (standalone-apps)",
|
||||||
|
strpos($output, 'id="unraid-standalone-apps-app-entry"') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Different IDs for same key in different subfolders (other)",
|
||||||
|
strpos($output, 'id="unraid-other-app-entry"') !== false
|
||||||
|
);
|
||||||
|
$appEntryCount = substr_count($output, 'id="unraid-') - substr_count($output, 'id="unraid-ui-');
|
||||||
|
$this->test(
|
||||||
|
"Both app-entry scripts are present with unique IDs",
|
||||||
|
preg_match_all('/id="unraid-[^"]*app-entry"/', $output, $matches) === 2
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: HTML Attribute Escaping
|
||||||
|
echo "\nTest: HTML Attribute Escaping\n";
|
||||||
|
echo "------------------------------\n";
|
||||||
|
$this->test(
|
||||||
|
"Properly escapes quotes in ID attributes",
|
||||||
|
strpos($output, '"test"with>quotes"') === false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Properly escapes special characters in ID",
|
||||||
|
strpos($output, 'unraid-ui-components-test-with-quotes') !== false ||
|
||||||
|
strpos($output, 'unraid-ui-components-test-with-special') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Properly escapes special characters in src/href attributes",
|
||||||
|
strpos($output, "special'file\"") === false &&
|
||||||
|
(strpos($output, 'special'file"') !== false ||
|
||||||
|
strpos($output, "special'file"") !== false ||
|
||||||
|
strpos($output, "special'file"") === false)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: Data-Unraid Attribute
|
||||||
|
echo "\nTest: Data-Unraid Attribute\n";
|
||||||
|
echo "----------------------------\n";
|
||||||
|
$this->test(
|
||||||
|
"Script tags have data-unraid attribute",
|
||||||
|
preg_match('/<script[^>]+data-unraid="1"/', $output) > 0
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Link tags have data-unraid attribute",
|
||||||
|
preg_match('/<link[^>]+data-unraid="1"/', $output) > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: CSS Loading from Manifest
|
||||||
|
echo "\nTest: CSS Loading from Manifest\n";
|
||||||
|
echo "--------------------------------\n";
|
||||||
|
$this->test(
|
||||||
|
"Loads CSS from JS entry css array (app-styles.css)",
|
||||||
|
strpos($output, 'id="unraid-standalone-apps-standalone-apps-js-css-app-styles-css"') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Loads CSS from JS entry css array (theme.css)",
|
||||||
|
strpos($output, 'id="unraid-standalone-apps-standalone-apps-js-css-theme-css"') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"CSS from manifest has correct href path (app-styles.css)",
|
||||||
|
strpos($output, '/plugins/dynamix.my.servers/unraid-components/standalone-apps/app-styles.css') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"CSS from manifest has correct href path (theme.css)",
|
||||||
|
strpos($output, '/plugins/dynamix.my.servers/unraid-components/standalone-apps/theme.css') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"Loads CSS from other JS entry (main.css)",
|
||||||
|
strpos($output, 'id="unraid-other-app-entry-css-main-css"') !== false
|
||||||
|
);
|
||||||
|
$this->test(
|
||||||
|
"CSS from manifest has data-unraid attribute",
|
||||||
|
preg_match('/<link[^>]+id="unraid-[^"]*-css-[^"]+"[^>]+data-unraid="1"/', $output) > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: Duplicate Prevention
|
||||||
|
echo "\nTest: Duplicate Prevention\n";
|
||||||
|
echo "---------------------------\n";
|
||||||
|
// Reset singleton for duplicate test
|
||||||
|
$reflection = new ReflectionClass('WebComponentsExtractor');
|
||||||
|
$instance = $reflection->getProperty('instance');
|
||||||
|
$instance->setAccessible(true);
|
||||||
|
$instance->setValue(null, null);
|
||||||
|
|
||||||
|
$extractor = WebComponentsExtractor::getInstance();
|
||||||
|
$first = $extractor->getScriptTagHtml();
|
||||||
|
$second = $extractor->getScriptTagHtml();
|
||||||
|
$this->test(
|
||||||
|
"Second call returns 'already loaded' message",
|
||||||
|
strpos($second, 'Resources already loaded') !== false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function test($name, $condition) {
|
||||||
|
if ($condition) {
|
||||||
|
echo " " . self::GREEN . "✓" . self::NC . " " . $name . "\n";
|
||||||
|
$this->passed++;
|
||||||
|
} else {
|
||||||
|
echo " " . self::RED . "✗" . self::NC . " " . $name . "\n";
|
||||||
|
$this->failed++;
|
||||||
|
if ($this->verbose) {
|
||||||
|
echo " " . self::YELLOW . "Condition failed" . self::NC . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function teardown() {
|
||||||
|
// Clean up temp directory
|
||||||
|
if ($this->testDir && is_dir($this->testDir)) {
|
||||||
|
$this->removeDirectory($this->testDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDirectory($dir) {
|
||||||
|
if (!is_dir($dir)) return;
|
||||||
|
$files = array_diff(scandir($dir), ['.', '..']);
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$path = $dir . '/' . $file;
|
||||||
|
is_dir($path) ? $this->removeDirectory($path) : unlink($path);
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reportResults() {
|
||||||
|
echo "\n";
|
||||||
|
echo "========================================\n";
|
||||||
|
echo "Test Results:\n";
|
||||||
|
echo " Passed: " . self::GREEN . $this->passed . self::NC . "\n";
|
||||||
|
echo " Failed: " . self::RED . $this->failed . self::NC . "\n";
|
||||||
|
echo "========================================\n";
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
if ($this->failed === 0) {
|
||||||
|
echo self::GREEN . "All tests passed!" . self::NC . "\n";
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
echo self::RED . "Some tests failed." . self::NC . "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
$test = new ExtractorTest();
|
||||||
|
exit($test->run());
|
||||||
16
plugin/tests/test-extractor.sh
Executable file
16
plugin/tests/test-extractor.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# WebComponentsExtractor Integration Test
|
||||||
|
#
|
||||||
|
# This script runs the PHP test suite for WebComponentsExtractor
|
||||||
|
# Exit codes:
|
||||||
|
# 0 - All tests passed
|
||||||
|
# 1 - One or more tests failed
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
# Get the directory of this script
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
|
||||||
|
# Run the PHP test suite
|
||||||
|
exec php "$SCRIPT_DIR/test-extractor.php" "$@"
|
||||||
@@ -3,5 +3,8 @@ import { defineConfig } from "vitest/config";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
|
env: {
|
||||||
|
TEST: "true",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
540
pnpm-lock.yaml
generated
540
pnpm-lock.yaml
generated
@@ -1263,9 +1263,6 @@ importers:
|
|||||||
nuxt:
|
nuxt:
|
||||||
specifier: 3.18.1
|
specifier: 3.18.1
|
||||||
version: 3.18.1(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@22.18.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(eslint@9.34.0(jiti@2.5.1))(ioredis@5.6.1)(lightningcss@1.30.1)(magicast@0.3.5)(meow@13.2.0)(optionator@0.9.4)(rollup@4.46.2)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-tsc@3.0.6(typescript@5.9.2))(xml2js@0.6.2)(yaml@2.8.1)
|
version: 3.18.1(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@22.18.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(eslint@9.34.0(jiti@2.5.1))(ioredis@5.6.1)(lightningcss@1.30.1)(magicast@0.3.5)(meow@13.2.0)(optionator@0.9.4)(rollup@4.46.2)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-tsc@3.0.6(typescript@5.9.2))(xml2js@0.6.2)(yaml@2.8.1)
|
||||||
nuxt-custom-elements:
|
|
||||||
specifier: 2.0.0-beta.32
|
|
||||||
version: 2.0.0-beta.32(webpack@5.98.0(esbuild@0.23.1))
|
|
||||||
prettier:
|
prettier:
|
||||||
specifier: 3.6.2
|
specifier: 3.6.2
|
||||||
version: 3.6.2
|
version: 3.6.2
|
||||||
@@ -1926,10 +1923,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==}
|
resolution: {integrity: sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@discoveryjs/json-ext@0.5.7':
|
|
||||||
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
|
||||||
engines: {node: '>=10.0.0'}
|
|
||||||
|
|
||||||
'@emnapi/core@1.4.3':
|
'@emnapi/core@1.4.3':
|
||||||
resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==}
|
resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==}
|
||||||
|
|
||||||
@@ -5029,9 +5022,6 @@ packages:
|
|||||||
'@types/eslint-config-prettier@6.11.3':
|
'@types/eslint-config-prettier@6.11.3':
|
||||||
resolution: {integrity: sha512-3wXCiM8croUnhg9LdtZUJQwNcQYGWxxdOWDjPe1ykCqJFPVpzAKfs/2dgSoCtAvdPeaponcWPI7mPcGGp9dkKQ==}
|
resolution: {integrity: sha512-3wXCiM8croUnhg9LdtZUJQwNcQYGWxxdOWDjPe1ykCqJFPVpzAKfs/2dgSoCtAvdPeaponcWPI7mPcGGp9dkKQ==}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
|
||||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
|
||||||
|
|
||||||
'@types/eslint@9.6.1':
|
'@types/eslint@9.6.1':
|
||||||
resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==}
|
resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==}
|
||||||
|
|
||||||
@@ -5053,9 +5043,6 @@ packages:
|
|||||||
'@types/graphql-type-uuid@0.2.6':
|
'@types/graphql-type-uuid@0.2.6':
|
||||||
resolution: {integrity: sha512-/R8hDcg/XLkuLblzIfKtx/EFZMzhpDBdYQCk5dwNUwDrGdWohv+3BVWvgNk80kaJD2u+THos1PAqp9lEyxxrsA==}
|
resolution: {integrity: sha512-/R8hDcg/XLkuLblzIfKtx/EFZMzhpDBdYQCk5dwNUwDrGdWohv+3BVWvgNk80kaJD2u+THos1PAqp9lEyxxrsA==}
|
||||||
|
|
||||||
'@types/html-minifier-terser@6.1.0':
|
|
||||||
resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==}
|
|
||||||
|
|
||||||
'@types/http-cache-semantics@4.0.4':
|
'@types/http-cache-semantics@4.0.4':
|
||||||
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
|
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
|
||||||
|
|
||||||
@@ -5908,51 +5895,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.5.0
|
vue: ^3.5.0
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
|
||||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
|
||||||
|
|
||||||
'@webassemblyjs/floating-point-hex-parser@1.13.2':
|
|
||||||
resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==}
|
|
||||||
|
|
||||||
'@webassemblyjs/helper-api-error@1.13.2':
|
|
||||||
resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==}
|
|
||||||
|
|
||||||
'@webassemblyjs/helper-buffer@1.14.1':
|
|
||||||
resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==}
|
|
||||||
|
|
||||||
'@webassemblyjs/helper-numbers@1.13.2':
|
|
||||||
resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==}
|
|
||||||
|
|
||||||
'@webassemblyjs/helper-wasm-bytecode@1.13.2':
|
|
||||||
resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==}
|
|
||||||
|
|
||||||
'@webassemblyjs/helper-wasm-section@1.14.1':
|
|
||||||
resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==}
|
|
||||||
|
|
||||||
'@webassemblyjs/ieee754@1.13.2':
|
|
||||||
resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==}
|
|
||||||
|
|
||||||
'@webassemblyjs/leb128@1.13.2':
|
|
||||||
resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==}
|
|
||||||
|
|
||||||
'@webassemblyjs/utf8@1.13.2':
|
|
||||||
resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==}
|
|
||||||
|
|
||||||
'@webassemblyjs/wasm-edit@1.14.1':
|
|
||||||
resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==}
|
|
||||||
|
|
||||||
'@webassemblyjs/wasm-gen@1.14.1':
|
|
||||||
resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==}
|
|
||||||
|
|
||||||
'@webassemblyjs/wasm-opt@1.14.1':
|
|
||||||
resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==}
|
|
||||||
|
|
||||||
'@webassemblyjs/wasm-parser@1.14.1':
|
|
||||||
resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==}
|
|
||||||
|
|
||||||
'@webassemblyjs/wast-printer@1.14.1':
|
|
||||||
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
|
|
||||||
|
|
||||||
'@whatwg-node/disposablestack@0.0.5':
|
'@whatwg-node/disposablestack@0.0.5':
|
||||||
resolution: {integrity: sha512-9lXugdknoIequO4OYvIjhygvfSEgnO8oASLqLelnDhkRjgBZhc39shC3QSlZuyDO9bgYSIVa2cHAiN+St3ty4w==}
|
resolution: {integrity: sha512-9lXugdknoIequO4OYvIjhygvfSEgnO8oASLqLelnDhkRjgBZhc39shC3QSlZuyDO9bgYSIVa2cHAiN+St3ty4w==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -5993,12 +5935,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==}
|
resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0':
|
|
||||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
|
||||||
|
|
||||||
'@xtuc/long@4.2.2':
|
|
||||||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
|
||||||
|
|
||||||
abbrev@3.0.0:
|
abbrev@3.0.0:
|
||||||
resolution: {integrity: sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==}
|
resolution: {integrity: sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==}
|
||||||
engines: {node: ^18.17.0 || >=20.5.0}
|
engines: {node: ^18.17.0 || >=20.5.0}
|
||||||
@@ -6035,10 +5971,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
|
resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
acorn-walk@8.3.4:
|
|
||||||
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
|
|
||||||
engines: {node: '>=0.4.0'}
|
|
||||||
|
|
||||||
acorn@7.4.1:
|
acorn@7.4.1:
|
||||||
resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
|
resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
@@ -6086,11 +6018,6 @@ packages:
|
|||||||
ajv:
|
ajv:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
ajv-keywords@5.1.0:
|
|
||||||
resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==}
|
|
||||||
peerDependencies:
|
|
||||||
ajv: ^8.8.2
|
|
||||||
|
|
||||||
ajv@6.12.6:
|
ajv@6.12.6:
|
||||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||||
|
|
||||||
@@ -6655,10 +6582,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
chrome-trace-event@1.0.4:
|
|
||||||
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
|
|
||||||
engines: {node: '>=6.0'}
|
|
||||||
|
|
||||||
ci-info@4.3.0:
|
ci-info@4.3.0:
|
||||||
resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==}
|
resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6675,10 +6598,6 @@ packages:
|
|||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
clean-css@5.3.3:
|
|
||||||
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
|
|
||||||
engines: {node: '>= 10.0'}
|
|
||||||
|
|
||||||
clean-regexp@1.0.0:
|
clean-regexp@1.0.0:
|
||||||
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
|
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -6839,14 +6758,6 @@ packages:
|
|||||||
commander@2.20.3:
|
commander@2.20.3:
|
||||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||||
|
|
||||||
commander@7.2.0:
|
|
||||||
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
|
|
||||||
commander@8.3.0:
|
|
||||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
|
||||||
engines: {node: '>= 12'}
|
|
||||||
|
|
||||||
commander@9.5.0:
|
commander@9.5.0:
|
||||||
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
|
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
|
||||||
engines: {node: ^12.20.0 || >=14}
|
engines: {node: ^12.20.0 || >=14}
|
||||||
@@ -7110,9 +7021,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.0.9
|
postcss: ^8.0.9
|
||||||
|
|
||||||
css-select@4.3.0:
|
|
||||||
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
|
|
||||||
|
|
||||||
css-select@5.1.0:
|
css-select@5.1.0:
|
||||||
resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
|
resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
|
||||||
|
|
||||||
@@ -7500,22 +7408,12 @@ packages:
|
|||||||
dom-accessibility-api@0.6.3:
|
dom-accessibility-api@0.6.3:
|
||||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||||
|
|
||||||
dom-converter@0.2.0:
|
|
||||||
resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==}
|
|
||||||
|
|
||||||
dom-serializer@1.4.1:
|
|
||||||
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
|
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
domelementtype@2.3.0:
|
domelementtype@2.3.0:
|
||||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||||
|
|
||||||
domhandler@4.3.1:
|
|
||||||
resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
|
|
||||||
engines: {node: '>= 4'}
|
|
||||||
|
|
||||||
domhandler@5.0.3:
|
domhandler@5.0.3:
|
||||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -7523,9 +7421,6 @@ packages:
|
|||||||
dompurify@3.2.6:
|
dompurify@3.2.6:
|
||||||
resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==}
|
resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==}
|
||||||
|
|
||||||
domutils@2.8.0:
|
|
||||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
|
||||||
|
|
||||||
domutils@3.2.2:
|
domutils@3.2.2:
|
||||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||||
|
|
||||||
@@ -7671,9 +7566,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
|
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
entities@2.2.0:
|
|
||||||
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
|
|
||||||
|
|
||||||
entities@4.5.0:
|
entities@4.5.0:
|
||||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
@@ -8102,10 +7994,6 @@ packages:
|
|||||||
'@vue/compiler-sfc': ^3.3.0
|
'@vue/compiler-sfc': ^3.3.0
|
||||||
eslint: '>=9.0.0'
|
eslint: '>=9.0.0'
|
||||||
|
|
||||||
eslint-scope@5.1.1:
|
|
||||||
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
|
|
||||||
engines: {node: '>=8.0.0'}
|
|
||||||
|
|
||||||
eslint-scope@8.4.0:
|
eslint-scope@8.4.0:
|
||||||
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
|
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -8157,10 +8045,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
|
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
estraverse@4.3.0:
|
|
||||||
resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==}
|
|
||||||
engines: {node: '>=4.0'}
|
|
||||||
|
|
||||||
estraverse@5.3.0:
|
estraverse@5.3.0:
|
||||||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@@ -8816,10 +8700,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==}
|
resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==}
|
||||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||||
|
|
||||||
gzip-size@6.0.0:
|
|
||||||
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
|
|
||||||
engines: {node: '>=10'}
|
|
||||||
|
|
||||||
gzip-size@7.0.0:
|
gzip-size@7.0.0:
|
||||||
resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==}
|
resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -8922,29 +8802,9 @@ packages:
|
|||||||
html-escaper@3.0.3:
|
html-escaper@3.0.3:
|
||||||
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
|
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
|
||||||
|
|
||||||
html-minifier-terser@6.1.0:
|
|
||||||
resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
html-sloppy-escaper@0.1.0:
|
html-sloppy-escaper@0.1.0:
|
||||||
resolution: {integrity: sha512-ONSUC5HwiImkny/29ApddyM+BxpqjgTZ+pOag6y39Q5FQgJuWypPLl7cGDpPYp1RtC5+6Wi5yQld3zAXhlO3xg==}
|
resolution: {integrity: sha512-ONSUC5HwiImkny/29ApddyM+BxpqjgTZ+pOag6y39Q5FQgJuWypPLl7cGDpPYp1RtC5+6Wi5yQld3zAXhlO3xg==}
|
||||||
|
|
||||||
html-webpack-plugin@5.6.3:
|
|
||||||
resolution: {integrity: sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==}
|
|
||||||
engines: {node: '>=10.13.0'}
|
|
||||||
peerDependencies:
|
|
||||||
'@rspack/core': 0.x || 1.x
|
|
||||||
webpack: ^5.20.0
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@rspack/core':
|
|
||||||
optional: true
|
|
||||||
webpack:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
htmlparser2@6.1.0:
|
|
||||||
resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
|
|
||||||
|
|
||||||
http-cache-semantics@4.1.1:
|
http-cache-semantics@4.1.1:
|
||||||
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
|
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
|
||||||
|
|
||||||
@@ -9472,10 +9332,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
|
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
jest-worker@27.5.1:
|
|
||||||
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
|
|
||||||
engines: {node: '>= 10.13.0'}
|
|
||||||
|
|
||||||
jiti@1.21.7:
|
jiti@1.21.7:
|
||||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -9781,10 +9637,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
|
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
loader-runner@4.3.0:
|
|
||||||
resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==}
|
|
||||||
engines: {node: '>=6.11.5'}
|
|
||||||
|
|
||||||
local-pkg@0.5.1:
|
local-pkg@0.5.1:
|
||||||
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
|
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -10434,9 +10286,6 @@ packages:
|
|||||||
nullthrows@1.1.1:
|
nullthrows@1.1.1:
|
||||||
resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==}
|
resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==}
|
||||||
|
|
||||||
nuxt-custom-elements@2.0.0-beta.32:
|
|
||||||
resolution: {integrity: sha512-uT8v+3f6N68/r0wOgcxyb2h9nxiDm8yeEQK8Ura4jr5ifMGsZebFZ272XgeNM2DH39vZ3RLeP1g3MVQQY4+nFg==}
|
|
||||||
|
|
||||||
nuxt@3.18.1:
|
nuxt@3.18.1:
|
||||||
resolution: {integrity: sha512-y2pLKty6R8MCCFlAUsJNJcOuT6M3EovzEpi7/U3WXQsnzf2MzP+5I67ScfmwSqZ3UUMgXvfc9H4+KC4Ifnq5wg==}
|
resolution: {integrity: sha512-y2pLKty6R8MCCFlAUsJNJcOuT6M3EovzEpi7/U3WXQsnzf2MzP+5I67ScfmwSqZ3UUMgXvfc9H4+KC4Ifnq5wg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -11251,9 +11100,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
|
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
|
||||||
engines: {node: ^14.13.1 || >=16.0.0}
|
engines: {node: ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
pretty-error@4.0.0:
|
|
||||||
resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==}
|
|
||||||
|
|
||||||
pretty-format@27.5.1:
|
pretty-format@27.5.1:
|
||||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
@@ -11556,10 +11402,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: '>= 3.2.0'
|
vue: '>= 3.2.0'
|
||||||
|
|
||||||
relateurl@0.2.7:
|
|
||||||
resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==}
|
|
||||||
engines: {node: '>= 0.10'}
|
|
||||||
|
|
||||||
relay-runtime@12.0.0:
|
relay-runtime@12.0.0:
|
||||||
resolution: {integrity: sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug==}
|
resolution: {integrity: sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug==}
|
||||||
|
|
||||||
@@ -11572,9 +11414,6 @@ packages:
|
|||||||
remove-trailing-spaces@1.0.8:
|
remove-trailing-spaces@1.0.8:
|
||||||
resolution: {integrity: sha512-O3vsMYfWighyFbTd8hk8VaSj9UAGENxAtX+//ugIst2RMk5e03h6RoIS+0ylsFxY1gvmPuAY/PO4It+gPEeySA==}
|
resolution: {integrity: sha512-O3vsMYfWighyFbTd8hk8VaSj9UAGENxAtX+//ugIst2RMk5e03h6RoIS+0ylsFxY1gvmPuAY/PO4It+gPEeySA==}
|
||||||
|
|
||||||
renderkid@3.0.0:
|
|
||||||
resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==}
|
|
||||||
|
|
||||||
require-directory@2.1.1:
|
require-directory@2.1.1:
|
||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -11765,10 +11604,6 @@ packages:
|
|||||||
scheduler@0.26.0:
|
scheduler@0.26.0:
|
||||||
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
|
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
|
||||||
|
|
||||||
schema-utils@4.3.2:
|
|
||||||
resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==}
|
|
||||||
engines: {node: '>= 10.13.0'}
|
|
||||||
|
|
||||||
scslre@0.3.0:
|
scslre@0.3.0:
|
||||||
resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==}
|
resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==}
|
||||||
engines: {node: ^14.0.0 || >=16.0.0}
|
engines: {node: ^14.0.0 || >=16.0.0}
|
||||||
@@ -11935,10 +11770,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
sirv@2.0.4:
|
|
||||||
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
|
|
||||||
sirv@3.0.1:
|
sirv@3.0.1:
|
||||||
resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==}
|
resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -12350,22 +12181,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
|
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
terser-webpack-plugin@5.3.14:
|
|
||||||
resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==}
|
|
||||||
engines: {node: '>= 10.13.0'}
|
|
||||||
peerDependencies:
|
|
||||||
'@swc/core': '*'
|
|
||||||
esbuild: '*'
|
|
||||||
uglify-js: '*'
|
|
||||||
webpack: ^5.1.0
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@swc/core':
|
|
||||||
optional: true
|
|
||||||
esbuild:
|
|
||||||
optional: true
|
|
||||||
uglify-js:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
terser@5.43.1:
|
terser@5.43.1:
|
||||||
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
|
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -12916,9 +12731,6 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
utila@0.4.0:
|
|
||||||
resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==}
|
|
||||||
|
|
||||||
utils-merge@1.0.1:
|
utils-merge@1.0.1:
|
||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
@@ -13250,9 +13062,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=5.0.0'
|
typescript: '>=5.0.0'
|
||||||
|
|
||||||
vue-web-component-wrapper@1.6.9:
|
|
||||||
resolution: {integrity: sha512-65Ybkjg2Y+MAMsx62AgEL2n5RCfzSvqbzJLEy6WvPalLATGEUhfT88Lbx5EYC6yzvZArfLKHq7Zl75Dg29ssNQ==}
|
|
||||||
|
|
||||||
vue-web-component-wrapper@1.7.7:
|
vue-web-component-wrapper@1.7.7:
|
||||||
resolution: {integrity: sha512-2uy6VdN8AwSzCeqc9tV4ZK2HKtgZ/NWL1rvdgOsddF1UFtszBZHKyQT9sDBUc4BpyXmP7f8tmI1rI0n/A6Qptw==}
|
resolution: {integrity: sha512-2uy6VdN8AwSzCeqc9tV4ZK2HKtgZ/NWL1rvdgOsddF1UFtszBZHKyQT9sDBUc4BpyXmP7f8tmI1rI0n/A6Qptw==}
|
||||||
|
|
||||||
@@ -13287,10 +13096,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
watchpack@2.4.4:
|
|
||||||
resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==}
|
|
||||||
engines: {node: '>=10.13.0'}
|
|
||||||
|
|
||||||
wcwidth@1.0.1:
|
wcwidth@1.0.1:
|
||||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||||
|
|
||||||
@@ -13305,28 +13110,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
webpack-bundle-analyzer@4.10.2:
|
|
||||||
resolution: {integrity: sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==}
|
|
||||||
engines: {node: '>= 10.13.0'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
webpack-sources@3.3.3:
|
|
||||||
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
|
|
||||||
engines: {node: '>=10.13.0'}
|
|
||||||
|
|
||||||
webpack-virtual-modules@0.6.2:
|
webpack-virtual-modules@0.6.2:
|
||||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||||
|
|
||||||
webpack@5.98.0:
|
|
||||||
resolution: {integrity: sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==}
|
|
||||||
engines: {node: '>=10.13.0'}
|
|
||||||
hasBin: true
|
|
||||||
peerDependencies:
|
|
||||||
webpack-cli: '*'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
webpack-cli:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
whatwg-encoding@2.0.0:
|
whatwg-encoding@2.0.0:
|
||||||
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
|
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -14449,8 +14235,6 @@ snapshots:
|
|||||||
gonzales-pe: 4.3.0
|
gonzales-pe: 4.3.0
|
||||||
node-source-walk: 7.0.1
|
node-source-walk: 7.0.1
|
||||||
|
|
||||||
'@discoveryjs/json-ext@0.5.7': {}
|
|
||||||
|
|
||||||
'@emnapi/core@1.4.3':
|
'@emnapi/core@1.4.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.0.2
|
'@emnapi/wasi-threads': 1.0.2
|
||||||
@@ -17835,12 +17619,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/eslint-config-prettier@6.11.3': {}
|
'@types/eslint-config-prettier@6.11.3': {}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
|
||||||
dependencies:
|
|
||||||
'@types/eslint': 9.6.1
|
|
||||||
'@types/estree': 1.0.8
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@types/eslint@9.6.1':
|
'@types/eslint@9.6.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -17876,8 +17654,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
graphql: 16.11.0
|
graphql: 16.11.0
|
||||||
|
|
||||||
'@types/html-minifier-terser@6.1.0': {}
|
|
||||||
|
|
||||||
'@types/http-cache-semantics@4.0.4': {}
|
'@types/http-cache-semantics@4.0.4': {}
|
||||||
|
|
||||||
'@types/http-errors@2.0.4': {}
|
'@types/http-errors@2.0.4': {}
|
||||||
@@ -18915,97 +18691,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.20(typescript@5.9.2)
|
vue: 3.5.20(typescript@5.9.2)
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
|
||||||
dependencies:
|
|
||||||
'@webassemblyjs/helper-numbers': 1.13.2
|
|
||||||
'@webassemblyjs/helper-wasm-bytecode': 1.13.2
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/floating-point-hex-parser@1.13.2':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/helper-api-error@1.13.2':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/helper-buffer@1.14.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/helper-numbers@1.13.2':
|
|
||||||
dependencies:
|
|
||||||
'@webassemblyjs/floating-point-hex-parser': 1.13.2
|
|
||||||
'@webassemblyjs/helper-api-error': 1.13.2
|
|
||||||
'@xtuc/long': 4.2.2
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/helper-wasm-bytecode@1.13.2':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/helper-wasm-section@1.14.1':
|
|
||||||
dependencies:
|
|
||||||
'@webassemblyjs/ast': 1.14.1
|
|
||||||
'@webassemblyjs/helper-buffer': 1.14.1
|
|
||||||
'@webassemblyjs/helper-wasm-bytecode': 1.13.2
|
|
||||||
'@webassemblyjs/wasm-gen': 1.14.1
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/ieee754@1.13.2':
|
|
||||||
dependencies:
|
|
||||||
'@xtuc/ieee754': 1.2.0
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/leb128@1.13.2':
|
|
||||||
dependencies:
|
|
||||||
'@xtuc/long': 4.2.2
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/utf8@1.13.2':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/wasm-edit@1.14.1':
|
|
||||||
dependencies:
|
|
||||||
'@webassemblyjs/ast': 1.14.1
|
|
||||||
'@webassemblyjs/helper-buffer': 1.14.1
|
|
||||||
'@webassemblyjs/helper-wasm-bytecode': 1.13.2
|
|
||||||
'@webassemblyjs/helper-wasm-section': 1.14.1
|
|
||||||
'@webassemblyjs/wasm-gen': 1.14.1
|
|
||||||
'@webassemblyjs/wasm-opt': 1.14.1
|
|
||||||
'@webassemblyjs/wasm-parser': 1.14.1
|
|
||||||
'@webassemblyjs/wast-printer': 1.14.1
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/wasm-gen@1.14.1':
|
|
||||||
dependencies:
|
|
||||||
'@webassemblyjs/ast': 1.14.1
|
|
||||||
'@webassemblyjs/helper-wasm-bytecode': 1.13.2
|
|
||||||
'@webassemblyjs/ieee754': 1.13.2
|
|
||||||
'@webassemblyjs/leb128': 1.13.2
|
|
||||||
'@webassemblyjs/utf8': 1.13.2
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/wasm-opt@1.14.1':
|
|
||||||
dependencies:
|
|
||||||
'@webassemblyjs/ast': 1.14.1
|
|
||||||
'@webassemblyjs/helper-buffer': 1.14.1
|
|
||||||
'@webassemblyjs/wasm-gen': 1.14.1
|
|
||||||
'@webassemblyjs/wasm-parser': 1.14.1
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/wasm-parser@1.14.1':
|
|
||||||
dependencies:
|
|
||||||
'@webassemblyjs/ast': 1.14.1
|
|
||||||
'@webassemblyjs/helper-api-error': 1.13.2
|
|
||||||
'@webassemblyjs/helper-wasm-bytecode': 1.13.2
|
|
||||||
'@webassemblyjs/ieee754': 1.13.2
|
|
||||||
'@webassemblyjs/leb128': 1.13.2
|
|
||||||
'@webassemblyjs/utf8': 1.13.2
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@webassemblyjs/wast-printer@1.14.1':
|
|
||||||
dependencies:
|
|
||||||
'@webassemblyjs/ast': 1.14.1
|
|
||||||
'@xtuc/long': 4.2.2
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@whatwg-node/disposablestack@0.0.5':
|
'@whatwg-node/disposablestack@0.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -19054,12 +18739,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@xtuc/long@4.2.2':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
abbrev@3.0.0: {}
|
abbrev@3.0.0: {}
|
||||||
|
|
||||||
abind@1.0.5: {}
|
abind@1.0.5: {}
|
||||||
@@ -19089,10 +18768,6 @@ snapshots:
|
|||||||
|
|
||||||
acorn-walk@8.3.2: {}
|
acorn-walk@8.3.2: {}
|
||||||
|
|
||||||
acorn-walk@8.3.4:
|
|
||||||
dependencies:
|
|
||||||
acorn: 8.15.0
|
|
||||||
|
|
||||||
acorn@7.4.1: {}
|
acorn@7.4.1: {}
|
||||||
|
|
||||||
acorn@8.14.0: {}
|
acorn@8.14.0: {}
|
||||||
@@ -19120,12 +18795,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ajv: 8.17.1
|
ajv: 8.17.1
|
||||||
|
|
||||||
ajv-keywords@5.1.0(ajv@8.17.1):
|
|
||||||
dependencies:
|
|
||||||
ajv: 8.17.1
|
|
||||||
fast-deep-equal: 3.1.3
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
ajv@6.12.6:
|
ajv@6.12.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
@@ -19806,9 +19475,6 @@ snapshots:
|
|||||||
|
|
||||||
chownr@3.0.0: {}
|
chownr@3.0.0: {}
|
||||||
|
|
||||||
chrome-trace-event@1.0.4:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
ci-info@4.3.0: {}
|
ci-info@4.3.0: {}
|
||||||
|
|
||||||
citty@0.1.6:
|
citty@0.1.6:
|
||||||
@@ -19827,10 +19493,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
clean-css@5.3.3:
|
|
||||||
dependencies:
|
|
||||||
source-map: 0.6.1
|
|
||||||
|
|
||||||
clean-regexp@1.0.0:
|
clean-regexp@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp: 1.0.5
|
escape-string-regexp: 1.0.5
|
||||||
@@ -19978,10 +19640,6 @@ snapshots:
|
|||||||
|
|
||||||
commander@2.20.3: {}
|
commander@2.20.3: {}
|
||||||
|
|
||||||
commander@7.2.0: {}
|
|
||||||
|
|
||||||
commander@8.3.0: {}
|
|
||||||
|
|
||||||
commander@9.5.0:
|
commander@9.5.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -20263,14 +19921,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
|
|
||||||
css-select@4.3.0:
|
|
||||||
dependencies:
|
|
||||||
boolbase: 1.0.0
|
|
||||||
css-what: 6.1.0
|
|
||||||
domhandler: 4.3.1
|
|
||||||
domutils: 2.8.0
|
|
||||||
nth-check: 2.1.1
|
|
||||||
|
|
||||||
css-select@5.1.0:
|
css-select@5.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
boolbase: 1.0.0
|
boolbase: 1.0.0
|
||||||
@@ -20642,16 +20292,6 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.6.3: {}
|
dom-accessibility-api@0.6.3: {}
|
||||||
|
|
||||||
dom-converter@0.2.0:
|
|
||||||
dependencies:
|
|
||||||
utila: 0.4.0
|
|
||||||
|
|
||||||
dom-serializer@1.4.1:
|
|
||||||
dependencies:
|
|
||||||
domelementtype: 2.3.0
|
|
||||||
domhandler: 4.3.1
|
|
||||||
entities: 2.2.0
|
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
@@ -20660,10 +20300,6 @@ snapshots:
|
|||||||
|
|
||||||
domelementtype@2.3.0: {}
|
domelementtype@2.3.0: {}
|
||||||
|
|
||||||
domhandler@4.3.1:
|
|
||||||
dependencies:
|
|
||||||
domelementtype: 2.3.0
|
|
||||||
|
|
||||||
domhandler@5.0.3:
|
domhandler@5.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
@@ -20672,12 +20308,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
domutils@2.8.0:
|
|
||||||
dependencies:
|
|
||||||
dom-serializer: 1.4.1
|
|
||||||
domelementtype: 2.3.0
|
|
||||||
domhandler: 4.3.1
|
|
||||||
|
|
||||||
domutils@3.2.2:
|
domutils@3.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
dom-serializer: 2.0.0
|
dom-serializer: 2.0.0
|
||||||
@@ -20807,8 +20437,6 @@ snapshots:
|
|||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
entities@2.2.0: {}
|
|
||||||
|
|
||||||
entities@4.5.0: {}
|
entities@4.5.0: {}
|
||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
@@ -21404,12 +21032,6 @@ snapshots:
|
|||||||
'@vue/compiler-sfc': 3.5.20
|
'@vue/compiler-sfc': 3.5.20
|
||||||
eslint: 9.34.0(jiti@2.5.1)
|
eslint: 9.34.0(jiti@2.5.1)
|
||||||
|
|
||||||
eslint-scope@5.1.1:
|
|
||||||
dependencies:
|
|
||||||
esrecurse: 4.3.0
|
|
||||||
estraverse: 4.3.0
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
eslint-scope@8.4.0:
|
eslint-scope@8.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esrecurse: 4.3.0
|
esrecurse: 4.3.0
|
||||||
@@ -21492,9 +21114,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
estraverse: 5.3.0
|
estraverse: 5.3.0
|
||||||
|
|
||||||
estraverse@4.3.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
estraverse@5.3.0: {}
|
estraverse@5.3.0: {}
|
||||||
|
|
||||||
estree-walker@2.0.2: {}
|
estree-walker@2.0.2: {}
|
||||||
@@ -22288,10 +21907,6 @@ snapshots:
|
|||||||
|
|
||||||
graphql@16.11.0: {}
|
graphql@16.11.0: {}
|
||||||
|
|
||||||
gzip-size@6.0.0:
|
|
||||||
dependencies:
|
|
||||||
duplexer: 0.1.2
|
|
||||||
|
|
||||||
gzip-size@7.0.0:
|
gzip-size@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
duplexer: 0.1.2
|
duplexer: 0.1.2
|
||||||
@@ -22402,37 +22017,10 @@ snapshots:
|
|||||||
|
|
||||||
html-escaper@3.0.3: {}
|
html-escaper@3.0.3: {}
|
||||||
|
|
||||||
html-minifier-terser@6.1.0:
|
|
||||||
dependencies:
|
|
||||||
camel-case: 4.1.2
|
|
||||||
clean-css: 5.3.3
|
|
||||||
commander: 8.3.0
|
|
||||||
he: 1.2.0
|
|
||||||
param-case: 3.0.4
|
|
||||||
relateurl: 0.2.7
|
|
||||||
terser: 5.43.1
|
|
||||||
|
|
||||||
html-sloppy-escaper@0.1.0:
|
html-sloppy-escaper@0.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
html-escaper: 3.0.3
|
html-escaper: 3.0.3
|
||||||
|
|
||||||
html-webpack-plugin@5.6.3(webpack@5.98.0(esbuild@0.23.1)):
|
|
||||||
dependencies:
|
|
||||||
'@types/html-minifier-terser': 6.1.0
|
|
||||||
html-minifier-terser: 6.1.0
|
|
||||||
lodash: 4.17.21
|
|
||||||
pretty-error: 4.0.0
|
|
||||||
tapable: 2.2.2
|
|
||||||
optionalDependencies:
|
|
||||||
webpack: 5.98.0(esbuild@0.23.1)
|
|
||||||
|
|
||||||
htmlparser2@6.1.0:
|
|
||||||
dependencies:
|
|
||||||
domelementtype: 2.3.0
|
|
||||||
domhandler: 4.3.1
|
|
||||||
domutils: 2.8.0
|
|
||||||
entities: 2.2.0
|
|
||||||
|
|
||||||
http-cache-semantics@4.1.1: {}
|
http-cache-semantics@4.1.1: {}
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
@@ -22984,13 +22572,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/cliui': 8.0.2
|
'@isaacs/cliui': 8.0.2
|
||||||
|
|
||||||
jest-worker@27.5.1:
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 22.18.0
|
|
||||||
merge-stream: 2.0.0
|
|
||||||
supports-color: 8.1.1
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
jiti@1.21.7: {}
|
jiti@1.21.7: {}
|
||||||
|
|
||||||
jiti@2.0.0-beta.3: {}
|
jiti@2.0.0-beta.3: {}
|
||||||
@@ -23293,9 +22874,6 @@ snapshots:
|
|||||||
|
|
||||||
load-tsconfig@0.2.5: {}
|
load-tsconfig@0.2.5: {}
|
||||||
|
|
||||||
loader-runner@4.3.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
local-pkg@0.5.1:
|
local-pkg@0.5.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mlly: 1.7.4
|
mlly: 1.7.4
|
||||||
@@ -23958,21 +23536,6 @@ snapshots:
|
|||||||
|
|
||||||
nullthrows@1.1.1: {}
|
nullthrows@1.1.1: {}
|
||||||
|
|
||||||
nuxt-custom-elements@2.0.0-beta.32(webpack@5.98.0(esbuild@0.23.1)):
|
|
||||||
dependencies:
|
|
||||||
change-case: 5.4.4
|
|
||||||
clone: 2.1.2
|
|
||||||
defu: 6.1.4
|
|
||||||
html-webpack-plugin: 5.6.3(webpack@5.98.0(esbuild@0.23.1))
|
|
||||||
lodash-es: 4.17.21
|
|
||||||
vue-web-component-wrapper: 1.6.9
|
|
||||||
webpack-bundle-analyzer: 4.10.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@rspack/core'
|
|
||||||
- bufferutil
|
|
||||||
- utf-8-validate
|
|
||||||
- webpack
|
|
||||||
|
|
||||||
nuxt@3.18.1(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@22.18.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(eslint@9.34.0(jiti@2.5.1))(ioredis@5.6.1)(lightningcss@1.30.1)(magicast@0.3.5)(meow@13.2.0)(optionator@0.9.4)(rollup@4.46.2)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-tsc@3.0.6(typescript@5.9.2))(xml2js@0.6.2)(yaml@2.8.1):
|
nuxt@3.18.1(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@22.18.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(eslint@9.34.0(jiti@2.5.1))(ioredis@5.6.1)(lightningcss@1.30.1)(magicast@0.3.5)(meow@13.2.0)(optionator@0.9.4)(rollup@4.46.2)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-tsc@3.0.6(typescript@5.9.2))(xml2js@0.6.2)(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nuxt/cli': 3.27.0(magicast@0.3.5)
|
'@nuxt/cli': 3.27.0(magicast@0.3.5)
|
||||||
@@ -25004,11 +24567,6 @@ snapshots:
|
|||||||
|
|
||||||
pretty-bytes@6.1.1: {}
|
pretty-bytes@6.1.1: {}
|
||||||
|
|
||||||
pretty-error@4.0.0:
|
|
||||||
dependencies:
|
|
||||||
lodash: 4.17.21
|
|
||||||
renderkid: 3.0.0
|
|
||||||
|
|
||||||
pretty-format@27.5.1:
|
pretty-format@27.5.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex: 5.0.1
|
ansi-regex: 5.0.1
|
||||||
@@ -25405,8 +24963,6 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
relateurl@0.2.7: {}
|
|
||||||
|
|
||||||
relay-runtime@12.0.0:
|
relay-runtime@12.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.27.6
|
'@babel/runtime': 7.27.6
|
||||||
@@ -25421,14 +24977,6 @@ snapshots:
|
|||||||
|
|
||||||
remove-trailing-spaces@1.0.8: {}
|
remove-trailing-spaces@1.0.8: {}
|
||||||
|
|
||||||
renderkid@3.0.0:
|
|
||||||
dependencies:
|
|
||||||
css-select: 4.3.0
|
|
||||||
dom-converter: 0.2.0
|
|
||||||
htmlparser2: 6.1.0
|
|
||||||
lodash: 4.17.21
|
|
||||||
strip-ansi: 6.0.1
|
|
||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
@@ -25620,14 +25168,6 @@ snapshots:
|
|||||||
|
|
||||||
scheduler@0.26.0: {}
|
scheduler@0.26.0: {}
|
||||||
|
|
||||||
schema-utils@4.3.2:
|
|
||||||
dependencies:
|
|
||||||
'@types/json-schema': 7.0.15
|
|
||||||
ajv: 8.17.1
|
|
||||||
ajv-formats: 2.1.1(ajv@8.17.1)
|
|
||||||
ajv-keywords: 5.1.0(ajv@8.17.1)
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
scslre@0.3.0:
|
scslre@0.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.1
|
'@eslint-community/regexpp': 4.12.1
|
||||||
@@ -25894,12 +25434,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.2
|
semver: 7.7.2
|
||||||
|
|
||||||
sirv@2.0.4:
|
|
||||||
dependencies:
|
|
||||||
'@polka/url': 1.0.0-next.28
|
|
||||||
mrmime: 2.0.1
|
|
||||||
totalist: 3.0.1
|
|
||||||
|
|
||||||
sirv@3.0.1:
|
sirv@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@polka/url': 1.0.0-next.28
|
'@polka/url': 1.0.0-next.28
|
||||||
@@ -26350,18 +25884,6 @@ snapshots:
|
|||||||
mkdirp: 3.0.1
|
mkdirp: 3.0.1
|
||||||
yallist: 5.0.0
|
yallist: 5.0.0
|
||||||
|
|
||||||
terser-webpack-plugin@5.3.14(esbuild@0.23.1)(webpack@5.98.0(esbuild@0.23.1)):
|
|
||||||
dependencies:
|
|
||||||
'@jridgewell/trace-mapping': 0.3.29
|
|
||||||
jest-worker: 27.5.1
|
|
||||||
schema-utils: 4.3.2
|
|
||||||
serialize-javascript: 6.0.2
|
|
||||||
terser: 5.43.1
|
|
||||||
webpack: 5.98.0(esbuild@0.23.1)
|
|
||||||
optionalDependencies:
|
|
||||||
esbuild: 0.23.1
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
terser@5.43.1:
|
terser@5.43.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/source-map': 0.3.6
|
'@jridgewell/source-map': 0.3.6
|
||||||
@@ -26944,8 +26466,6 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
utila@0.4.0: {}
|
|
||||||
|
|
||||||
utils-merge@1.0.1: {}
|
utils-merge@1.0.1: {}
|
||||||
|
|
||||||
uuid@10.0.0: {}
|
uuid@10.0.0: {}
|
||||||
@@ -27355,8 +26875,6 @@ snapshots:
|
|||||||
'@vue/language-core': 3.0.6(typescript@5.9.2)
|
'@vue/language-core': 3.0.6(typescript@5.9.2)
|
||||||
typescript: 5.9.2
|
typescript: 5.9.2
|
||||||
|
|
||||||
vue-web-component-wrapper@1.6.9: {}
|
|
||||||
|
|
||||||
vue-web-component-wrapper@1.7.7: {}
|
vue-web-component-wrapper@1.7.7: {}
|
||||||
|
|
||||||
vue@3.5.20(typescript@5.9.2):
|
vue@3.5.20(typescript@5.9.2):
|
||||||
@@ -27401,12 +26919,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
watchpack@2.4.4:
|
|
||||||
dependencies:
|
|
||||||
glob-to-regexp: 0.4.1
|
|
||||||
graceful-fs: 4.2.11
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
wcwidth@1.0.1:
|
wcwidth@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
defaults: 1.0.4
|
defaults: 1.0.4
|
||||||
@@ -27417,60 +26929,8 @@ snapshots:
|
|||||||
|
|
||||||
webidl-conversions@7.0.0: {}
|
webidl-conversions@7.0.0: {}
|
||||||
|
|
||||||
webpack-bundle-analyzer@4.10.2:
|
|
||||||
dependencies:
|
|
||||||
'@discoveryjs/json-ext': 0.5.7
|
|
||||||
acorn: 8.15.0
|
|
||||||
acorn-walk: 8.3.4
|
|
||||||
commander: 7.2.0
|
|
||||||
debounce: 1.2.1
|
|
||||||
escape-string-regexp: 4.0.0
|
|
||||||
gzip-size: 6.0.0
|
|
||||||
html-escaper: 2.0.2
|
|
||||||
opener: 1.5.2
|
|
||||||
picocolors: 1.1.1
|
|
||||||
sirv: 2.0.4
|
|
||||||
ws: 7.5.10
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- bufferutil
|
|
||||||
- utf-8-validate
|
|
||||||
|
|
||||||
webpack-sources@3.3.3:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
webpack-virtual-modules@0.6.2: {}
|
webpack-virtual-modules@0.6.2: {}
|
||||||
|
|
||||||
webpack@5.98.0(esbuild@0.23.1):
|
|
||||||
dependencies:
|
|
||||||
'@types/eslint-scope': 3.7.7
|
|
||||||
'@types/estree': 1.0.8
|
|
||||||
'@webassemblyjs/ast': 1.14.1
|
|
||||||
'@webassemblyjs/wasm-edit': 1.14.1
|
|
||||||
'@webassemblyjs/wasm-parser': 1.14.1
|
|
||||||
acorn: 8.15.0
|
|
||||||
browserslist: 4.25.1
|
|
||||||
chrome-trace-event: 1.0.4
|
|
||||||
enhanced-resolve: 5.18.3
|
|
||||||
es-module-lexer: 1.7.0
|
|
||||||
eslint-scope: 5.1.1
|
|
||||||
events: 3.3.0
|
|
||||||
glob-to-regexp: 0.4.1
|
|
||||||
graceful-fs: 4.2.11
|
|
||||||
json-parse-even-better-errors: 2.3.1
|
|
||||||
loader-runner: 4.3.0
|
|
||||||
mime-types: 2.1.35
|
|
||||||
neo-async: 2.6.2
|
|
||||||
schema-utils: 4.3.2
|
|
||||||
tapable: 2.2.2
|
|
||||||
terser-webpack-plugin: 5.3.14(esbuild@0.23.1)(webpack@5.98.0(esbuild@0.23.1))
|
|
||||||
watchpack: 2.4.4
|
|
||||||
webpack-sources: 3.3.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@swc/core'
|
|
||||||
- esbuild
|
|
||||||
- uglify-js
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
whatwg-encoding@2.0.0:
|
whatwg-encoding@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ const commonGlobals = {
|
|||||||
HTMLElement: 'readonly',
|
HTMLElement: 'readonly',
|
||||||
HTMLInputElement: 'readonly',
|
HTMLInputElement: 'readonly',
|
||||||
CustomEvent: 'readonly',
|
CustomEvent: 'readonly',
|
||||||
|
MouseEvent: 'readonly',
|
||||||
|
KeyboardEvent: 'readonly',
|
||||||
|
FocusEvent: 'readonly',
|
||||||
|
PointerEvent: 'readonly',
|
||||||
|
TouchEvent: 'readonly',
|
||||||
|
WheelEvent: 'readonly',
|
||||||
|
DragEvent: 'readonly',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default [// Base config from recommended configs
|
export default [// Base config from recommended configs
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from '@/components/common/tabs';
|
|||||||
export * from '@/components/common/tooltip';
|
export * from '@/components/common/tooltip';
|
||||||
export * from '@/components/common/toast';
|
export * from '@/components/common/toast';
|
||||||
export * from '@/components/common/popover';
|
export * from '@/components/common/popover';
|
||||||
|
export * from '@/components/common/responsive-modal';
|
||||||
export * from '@/components/modals';
|
export * from '@/components/modals';
|
||||||
export * from '@/components/common/accordion';
|
export * from '@/components/common/accordion';
|
||||||
export * from '@/components/common/dialog';
|
export * from '@/components/common/dialog';
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export interface BrandButtonProps {
|
|||||||
variant?: BrandButtonVariants['variant'];
|
variant?: BrandButtonVariants['variant'];
|
||||||
size?: BrandButtonVariants['size'];
|
size?: BrandButtonVariants['size'];
|
||||||
padding?: BrandButtonVariants['padding'];
|
padding?: BrandButtonVariants['padding'];
|
||||||
btnType?: 'button' | 'submit' | 'reset';
|
|
||||||
class?: string;
|
class?: string;
|
||||||
click?: () => void;
|
click?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -26,7 +25,6 @@ const props = withDefaults(defineProps<BrandButtonProps>(), {
|
|||||||
variant: 'fill',
|
variant: 'fill',
|
||||||
size: '16px',
|
size: '16px',
|
||||||
padding: 'default',
|
padding: 'default',
|
||||||
btnType: 'button',
|
|
||||||
class: undefined,
|
class: undefined,
|
||||||
click: undefined,
|
click: undefined,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -51,22 +49,37 @@ const classes = computed(() => {
|
|||||||
iconSize: props.size ?? '16px',
|
iconSize: props.size ?? '16px',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const needsBrandGradientBackground = computed(() => {
|
const needsBrandGradientBackground = computed(() => {
|
||||||
return ['outline-solid', 'outline-primary'].includes(props.variant ?? '');
|
return ['outline-solid', 'outline-primary'].includes(props.variant ?? '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isLink = computed(() => Boolean(props.href));
|
||||||
|
const isButton = computed(() => !isLink.value);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="href ? 'a' : 'button'"
|
:is="isLink ? 'a' : 'span'"
|
||||||
:disabled="disabled"
|
:role="isButton ? 'button' : undefined"
|
||||||
|
:tabindex="isButton && !disabled ? 0 : undefined"
|
||||||
|
:aria-disabled="isButton && disabled ? true : undefined"
|
||||||
:href="href"
|
:href="href"
|
||||||
:rel="external ? 'noopener noreferrer' : ''"
|
:rel="external ? 'noopener noreferrer' : ''"
|
||||||
:target="external ? '_blank' : ''"
|
:target="external ? '_blank' : ''"
|
||||||
:type="!href ? btnType : ''"
|
|
||||||
:class="classes.button"
|
:class="classes.button"
|
||||||
:title="title"
|
:title="title"
|
||||||
@click="click ?? $emit('click')"
|
@click="!disabled && (click ?? $emit('click'))"
|
||||||
|
@keydown="
|
||||||
|
isButton &&
|
||||||
|
!disabled &&
|
||||||
|
((e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
(click ?? $emit('click'))();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="variant === 'fill'"
|
v-if="variant === 'fill'"
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export const brandButtonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
fill: '[&]:text-white bg-transparent border-transparent',
|
fill: '[&]:text-white bg-transparent border-transparent',
|
||||||
|
'pill-orange':
|
||||||
|
'text-white bg-orange border-transparent rounded-full hover:bg-orange-dark focus:bg-orange-dark',
|
||||||
|
'pill-gray':
|
||||||
|
'text-gray-800 bg-gray-200 border-transparent rounded-full hover:bg-gray-300 focus:bg-gray-300',
|
||||||
black:
|
black:
|
||||||
'[&]:text-white bg-black border-black transition hover:text-black focus:text-black hover:bg-grey focus:bg-grey hover:border-grey focus:border-grey',
|
'[&]:text-white bg-black border-black transition hover:text-black focus:text-black hover:bg-grey focus:bg-grey hover:border-grey focus:border-grey',
|
||||||
gray: 'text-black bg-grey transition hover:text-white focus:text-white hover:bg-grey-mid focus:bg-grey-mid hover:border-grey-mid focus:border-grey-mid',
|
gray: 'text-black bg-grey transition hover:text-white focus:text-white hover:bg-grey-mid focus:bg-grey-mid hover:border-grey-mid focus:border-grey-mid',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
|
|||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
|
|
||||||
export const badgeVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -7,20 +7,57 @@ export interface ButtonProps {
|
|||||||
variant?: ButtonVariants['variant'];
|
variant?: ButtonVariants['variant'];
|
||||||
size?: ButtonVariants['size'];
|
size?: ButtonVariants['size'];
|
||||||
class?: string;
|
class?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
size: 'md',
|
size: 'md',
|
||||||
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [event: MouseEvent];
|
||||||
|
}>();
|
||||||
|
|
||||||
const buttonClass = computed(() => {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button :class="buttonClass">
|
<span
|
||||||
|
:class="buttonClass"
|
||||||
|
role="button"
|
||||||
|
:tabindex="disabled ? -1 : 0"
|
||||||
|
:aria-disabled="disabled"
|
||||||
|
@click="handleClick"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</button>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
|
|||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -12,12 +12,18 @@ export const buttonVariants = cva(
|
|||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
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]',
|
||||||
|
'pill-orange': 'bg-orange text-white hover:bg-orange-dark rounded-full text-xs px-2 py-1 gap-1',
|
||||||
|
'pill-gray': 'bg-gray-200 text-gray-800 hover:bg-gray-300 rounded-full text-xs px-2 py-1 gap-1',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
sm: 'rounded-md px-3 py-1',
|
sm: 'rounded-md px-3 py-1',
|
||||||
md: 'h-10 px-4 py-2',
|
md: 'h-10 px-4 py-2',
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: 'h-11 rounded-md px-8',
|
||||||
icon: 'h-10 w-10',
|
icon: 'h-10 w-10',
|
||||||
|
header: 'h-10 px-2 py-6',
|
||||||
|
'header-icon': 'h-9 w-9 p-2',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from './dialog/index.js';
|
|||||||
export * from './dropdown-menu/index.js';
|
export * from './dropdown-menu/index.js';
|
||||||
export * from './loading/index.js';
|
export * from './loading/index.js';
|
||||||
export * from './popover/index.js';
|
export * from './popover/index.js';
|
||||||
|
export * from './responsive-modal/index.js';
|
||||||
export * from './scroll-area/index.js';
|
export * from './scroll-area/index.js';
|
||||||
export * from './sheet/index.js';
|
export * from './sheet/index.js';
|
||||||
export * from './stepper/index.js';
|
export * from './stepper/index.js';
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<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(() => {
|
||||||
|
// Add safe area padding for iOS devices on mobile
|
||||||
|
const safePadding = isMobile.value ? 'pt-[max(1.5rem,env(safe-area-inset-top))]' : 'pt-6';
|
||||||
|
return cn('px-6 flex-shrink-0', safePadding, props.class);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SheetHeader v-if="isMobile" :class="headerClass">
|
||||||
|
<slot />
|
||||||
|
</SheetHeader>
|
||||||
|
<DialogHeader v-else :class="headerClass">
|
||||||
|
<slot />
|
||||||
|
</DialogHeader>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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';
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import Button from '@/components/common/button/Button.vue';
|
||||||
import { sheetVariants, type SheetVariants } from '@/components/common/sheet/sheet.variants';
|
import { sheetVariants, type SheetVariants } from '@/components/common/sheet/sheet.variants';
|
||||||
|
import SheetClose from '@/components/common/sheet/SheetClose.vue';
|
||||||
import useTeleport from '@/composables/useTeleport';
|
import useTeleport from '@/composables/useTeleport';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { X } from 'lucide-vue-next';
|
import { X } from 'lucide-vue-next';
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
@@ -50,11 +51,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
/>
|
/>
|
||||||
<DialogContent :class="sheetClass" v-bind="forwarded">
|
<DialogContent :class="sheetClass" v-bind="forwarded">
|
||||||
<slot />
|
<slot />
|
||||||
<DialogClose
|
<SheetClose as="span" class="absolute top-[max(1rem,env(safe-area-inset-top))] right-4">
|
||||||
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"
|
<Button variant="ghost" size="sm" class="h-auto w-auto p-1">
|
||||||
>
|
<X class="h-4 w-4" />
|
||||||
<X class="text-muted-foreground h-4 w-4" />
|
</Button>
|
||||||
</DialogClose>
|
</SheetClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export const sheetVariants = cva(
|
|||||||
},
|
},
|
||||||
padding: {
|
padding: {
|
||||||
none: '',
|
none: '',
|
||||||
md: 'p-6',
|
sm: 'p-2',
|
||||||
|
md: 'p-4',
|
||||||
|
lg: 'p-6',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -17,15 +17,17 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||||||
<template>
|
<template>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
|
as="span"
|
||||||
|
tabindex="0"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span class="truncate">
|
<slot />
|
||||||
<slot />
|
|
||||||
</span>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const { teleportTarget } = useTeleport();
|
|||||||
v-bind="{ ...forwarded, ...$attrs }"
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TooltipTrigger, type TooltipTriggerProps } from 'reka-ui';
|
import { TooltipTrigger, type TooltipTriggerProps } from 'reka-ui';
|
||||||
|
|
||||||
const props = defineProps<TooltipTriggerProps>();
|
const props = withDefaults(defineProps<TooltipTriggerProps>(), {
|
||||||
|
asChild: true,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
v-bind="{ ...forwarded, ...$attrs }"
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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' &&
|
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',
|
'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
|
props.class
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
<template>
|
<template>
|
||||||
<SwitchRoot
|
<SwitchRoot
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
|
as="span"
|
||||||
|
:tabindex="props.disabled ? -1 : 0"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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',
|
'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',
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ const delegatedProps = reactiveOmit(props, 'class');
|
|||||||
<AccordionHeader class="flex">
|
<AccordionHeader class="flex">
|
||||||
<AccordionTrigger
|
<AccordionTrigger
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
|
as="span"
|
||||||
|
tabindex="0"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -9,3 +9,28 @@ const props = defineProps<DialogCloseProps>();
|
|||||||
<slot />
|
<slot />
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import Button from '@/components/common/button/Button.vue';
|
||||||
import useTeleport from '@/composables/useTeleport';
|
import useTeleport from '@/composables/useTeleport';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { reactiveOmit } from '@vueuse/core';
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
@@ -39,10 +40,10 @@ const { teleportTarget } = useTeleport();
|
|||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
// Only apply zoom animation if not fullscreen
|
||||||
!props.class?.includes('min-h-screen') &&
|
!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%]',
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
// Apply slide-up animation for fullscreen modals
|
// Apply slide-up animation for fullscreen modals
|
||||||
props.class?.includes('min-h-screen') &&
|
props.class?.includes('min-h-screen') &&
|
||||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||||
@@ -52,12 +53,15 @@ const { teleportTarget } = useTeleport();
|
|||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<DialogClose
|
<DialogClose v-if="showCloseButton !== false" as-child class="absolute top-4 right-4">
|
||||||
v-if="showCloseButton !== false"
|
<Button
|
||||||
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"
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<X class="h-4 w-4" />
|
class="h-8 w-8 rounded-sm opacity-70 transition-opacity hover:opacity-100"
|
||||||
<span class="sr-only">Close</span>
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const props = defineProps<DialogTriggerProps>();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogTrigger v-bind="props">
|
<DialogTrigger v-bind="props" as="span" tabindex="0">
|
||||||
<slot />
|
<slot />
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const { teleportTarget } = useTeleport();
|
|||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||||||
<template>
|
<template>
|
||||||
<DropdownMenuSubTrigger
|
<DropdownMenuSubTrigger
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
|
as="span"
|
||||||
|
tabindex="0"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const forwardedProps = useForwardProps(props);
|
|||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
class="cursor-pointer outline-hidden data-[state=open]:cursor-pointer"
|
class="cursor-pointer outline-hidden data-[state=open]:cursor-pointer"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
|
as="span"
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const { teleportTarget } = useTeleport();
|
|||||||
v-bind="{ ...forwarded, ...$attrs }"
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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' &&
|
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',
|
'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
|
props.class
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||||||
<template>
|
<template>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
|
as="span"
|
||||||
|
tabindex="0"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
|
import { ensureTeleportContainer } from '@/helpers/ensure-teleport-container';
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
const useTeleport = () => {
|
const useTeleport = () => {
|
||||||
const teleportTarget = ref<string | HTMLElement>('#modals');
|
const teleportTarget = ref<string | HTMLElement>('body');
|
||||||
|
|
||||||
const determineTeleportTarget = (): HTMLElement | undefined => {
|
|
||||||
const myModalsComponent =
|
|
||||||
document.querySelector('unraid-modals') ||
|
|
||||||
document.querySelector('uui-modals') ||
|
|
||||||
document.querySelector('uui-modal-target');
|
|
||||||
if (!myModalsComponent?.shadowRoot) return;
|
|
||||||
|
|
||||||
const potentialTarget = myModalsComponent.shadowRoot.querySelector('#modals');
|
|
||||||
if (!potentialTarget) return;
|
|
||||||
|
|
||||||
teleportTarget.value = potentialTarget as HTMLElement;
|
|
||||||
return teleportTarget.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
determineTeleportTarget();
|
const container = ensureTeleportContainer();
|
||||||
|
teleportTarget.value = container;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teleportTarget,
|
teleportTarget,
|
||||||
determineTeleportTarget,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
v-for="(element, index) in elements"
|
v-for="(element, index) in elements"
|
||||||
:key="`${layout.path || ''}-${index}`"
|
:key="`${layout.path || ''}-${index}`"
|
||||||
:value="`item-${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">
|
<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">
|
<div class="flex flex-col items-start space-y-1 text-left">
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ const updateItem = (index: number, newValue: unknown) => {
|
|||||||
:value="String(index)"
|
:value="String(index)"
|
||||||
class="mt-0 w-full"
|
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">
|
<div class="mb-4 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
v-if="!isItemProtected(item)"
|
v-if="!isItemProtected(item)"
|
||||||
@@ -214,7 +214,7 @@ const updateItem = (index: number, newValue: unknown) => {
|
|||||||
<!-- Show warning if item matches protected condition -->
|
<!-- Show warning if item matches protected condition -->
|
||||||
<div
|
<div
|
||||||
v-if="getItemWarning(item)"
|
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">
|
<div class="flex items-start gap-2">
|
||||||
<span class="text-warning">⚠️</span>
|
<span class="text-warning">⚠️</span>
|
||||||
@@ -240,7 +240,7 @@ const updateItem = (index: number, newValue: unknown) => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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>
|
<p class="text-muted-foreground mb-4">No {{ itemTypeName.toLowerCase() }}s configured</p>
|
||||||
<Button variant="outline" size="md" :disabled="!control.enabled" @click="addItem">
|
<Button variant="outline" size="md" :disabled="!control.enabled" @click="addItem">
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ const getStepState = (stepIndex: number): StepState => {
|
|||||||
<!-- Render elements for the current step -->
|
<!-- Render elements for the current step -->
|
||||||
<!-- Added key to force re-render on step change, ensuring correct elements display -->
|
<!-- Added key to force re-render on step change, ensuring correct elements display -->
|
||||||
<div
|
<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}`"
|
:key="`step-content-${currentStep}`"
|
||||||
>
|
>
|
||||||
<DispatchRenderer
|
<DispatchRenderer
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const elements = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SettingsGrid v-if="isVisible">
|
<SettingsGrid v-if="isVisible" class="[&_.grow]:max-w-3xl">
|
||||||
<template v-for="(element, _i) in elements" :key="_i">
|
<template v-for="(element, _i) in elements" :key="_i">
|
||||||
<DispatchRenderer
|
<DispatchRenderer
|
||||||
:schema="layout.layout.value.schema"
|
:schema="layout.layout.value.schema"
|
||||||
|
|||||||
23
unraid-ui/src/helpers/ensure-teleport-container.ts
Normal file
23
unraid-ui/src/helpers/ensure-teleport-container.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Ensures the teleport container exists in the DOM.
|
||||||
|
* This is used by both the standalone mount script and unraid-ui components
|
||||||
|
* to ensure modals and other teleported content have a target.
|
||||||
|
*/
|
||||||
|
export function ensureTeleportContainer(): HTMLElement {
|
||||||
|
const containerId = 'unraid-teleport-container';
|
||||||
|
|
||||||
|
// Check if container already exists
|
||||||
|
let container = document.getElementById(containerId);
|
||||||
|
|
||||||
|
// If it doesn't exist, create it
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.id = containerId;
|
||||||
|
container.style.position = 'relative';
|
||||||
|
container.classList.add('unapi');
|
||||||
|
container.style.zIndex = '999999'; // Very high z-index to ensure it's always on top
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
@@ -15,3 +15,6 @@ export * from '@/lib/utils';
|
|||||||
export { default as useTeleport } from '@/composables/useTeleport';
|
export { default as useTeleport } from '@/composables/useTeleport';
|
||||||
export { useToast } from '@/composables/useToast';
|
export { useToast } from '@/composables/useToast';
|
||||||
export type { ToastInstance } from '@/composables/useToast';
|
export type { ToastInstance } from '@/composables/useToast';
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
export { ensureTeleportContainer } from '@/helpers/ensure-teleport-container';
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import DOMPurify from 'dompurify';
|
|||||||
import { Marked, type MarkedExtension } from 'marked';
|
import { Marked, type MarkedExtension } from 'marked';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export type { ClassValue };
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ vi.mock('@unraid/ui', async (importOriginal) => {
|
|||||||
</div>
|
</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);
|
expect(button.exists()).toBe(true);
|
||||||
|
|
||||||
// Initially dialog should be visible
|
// Initially dialog should be visible
|
||||||
let dialog = wrapper.findComponent({ name: 'Dialog' });
|
const dialog = wrapper.findComponent({ name: 'Dialog' });
|
||||||
expect(dialog.exists()).toBe(true);
|
expect(dialog.exists()).toBe(true);
|
||||||
expect(dialog.props('modelValue')).toBe(true);
|
expect(dialog.props('modelValue')).toBe(true);
|
||||||
|
|
||||||
await button.trigger('click');
|
await button.trigger('click');
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
// After click, the dialog should be hidden (modelValue should be false)
|
// After click, the dialog should be hidden - check if the dialog div is no longer rendered
|
||||||
dialog = wrapper.findComponent({ name: 'Dialog' });
|
const dialogDiv = wrapper.find('[role="dialog"]');
|
||||||
expect(dialog.exists()).toBe(true);
|
expect(dialogDiv.exists()).toBe(false);
|
||||||
expect(dialog.props('modelValue')).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables the Create a password button when loading', async () => {
|
it('disables the Create a password button when loading', async () => {
|
||||||
@@ -188,7 +193,7 @@ describe('Activation/WelcomeModal.ce.vue', () => {
|
|||||||
const button = wrapper.find('button');
|
const button = wrapper.find('button');
|
||||||
|
|
||||||
expect(button.exists()).toBe(true);
|
expect(button.exists()).toBe(true);
|
||||||
expect(button.attributes('disabled')).toBe('');
|
expect(button.attributes('disabled')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders activation steps with correct active step', async () => {
|
it('renders activation steps with correct active step', async () => {
|
||||||
|
|||||||
@@ -23,36 +23,6 @@ vi.mock('@unraid/shared-callbacks', () => ({
|
|||||||
useCallback: vi.fn(() => ({ send: vi.fn(), watcher: vi.fn() })),
|
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', () => ({
|
vi.mock('@vue/apollo-composable', () => ({
|
||||||
useQuery: () => ({
|
useQuery: () => ({
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ import type { Props as ModalProps } from '~/components/Modal.vue';
|
|||||||
|
|
||||||
import Modal 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 mockSetProperty = vi.fn();
|
||||||
const mockRemoveProperty = vi.fn();
|
const mockRemoveProperty = vi.fn();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* Registration Component Test Coverage
|
* Registration Component Test Coverage
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ import type { ServerconnectPluginInstalled } from '~/types/server';
|
|||||||
import type { Pinia } from 'pinia';
|
import type { Pinia } from 'pinia';
|
||||||
|
|
||||||
import Registration from '~/components/Registration.ce.vue';
|
import Registration from '~/components/Registration.ce.vue';
|
||||||
import MockedRegistrationItem from '~/components/Registration/Item.vue';
|
|
||||||
import { usePurchaseStore } from '~/store/purchase';
|
import { usePurchaseStore } from '~/store/purchase';
|
||||||
import { useReplaceRenewStore } from '~/store/replaceRenew';
|
import { useReplaceRenewStore } from '~/store/replaceRenew';
|
||||||
import { useServerStore } from '~/store/server';
|
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'] },
|
BrandButton: { template: '<button><slot /></button>', props: ['text', 'title', 'icon', 'disabled'] },
|
||||||
CardWrapper: { template: '<div><slot /></div>' },
|
CardWrapper: { template: '<div><slot /></div>' },
|
||||||
PageContainer: { 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
|
// Define initial state for the server store for testing
|
||||||
const initialServerState = {
|
const initialServerState = {
|
||||||
@@ -146,9 +125,22 @@ describe('Registration.ce.vue', () => {
|
|||||||
let purchaseStore: ReturnType<typeof usePurchaseStore>;
|
let purchaseStore: ReturnType<typeof usePurchaseStore>;
|
||||||
|
|
||||||
const findItemByLabel = (labelKey: string) => {
|
const findItemByLabel = (labelKey: string) => {
|
||||||
const items = wrapper.findAllComponents({ name: 'RegistrationItem' });
|
const allLabels = wrapper.findAll('.font-semibold');
|
||||||
|
const label = allLabels.find((el) => el.html().includes(t(labelKey)));
|
||||||
return items.find((item) => item.props('label') === 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(() => {
|
beforeEach(() => {
|
||||||
@@ -175,8 +167,9 @@ describe('Registration.ce.vue', () => {
|
|||||||
wrapper = mount(Registration, {
|
wrapper = mount(Registration, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [pinia],
|
plugins: [pinia],
|
||||||
components: {
|
stubs: {
|
||||||
RegistrationItem: MockedRegistrationItem,
|
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();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
const items = wrapper.findAllComponents({ name: 'RegistrationItem' });
|
const keyActionsElement = wrapper.find('[data-testid="key-actions"]');
|
||||||
const keyActionsItem = items.find((item) => {
|
|
||||||
const componentProp = item.props('component');
|
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(
|
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();
|
expect(expectedActions, 'No expected actions found in store for TRIAL state').toBeDefined();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* UpdateOs Component Test Coverage
|
* UpdateOs Component Test Coverage
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref } from 'vue';
|
import { nextTick, ref } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
@@ -100,7 +100,7 @@ describe('UpdateOs.ce.vue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Initial Rendering and onBeforeMount Logic', () => {
|
describe('Initial Rendering and onBeforeMount Logic', () => {
|
||||||
it('shows loader and calls updateOs when path matches and rebootType is empty', () => {
|
it('shows loader and calls updateOs when path matches and rebootType is empty', async () => {
|
||||||
mockLocation.pathname = '/Tools/Update';
|
mockLocation.pathname = '/Tools/Update';
|
||||||
mockRebootType.value = '';
|
mockRebootType.value = '';
|
||||||
|
|
||||||
@@ -115,13 +115,18 @@ describe('UpdateOs.ce.vue', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
expect(mockAccountStore.updateOs).toHaveBeenCalledTimes(1);
|
expect(mockAccountStore.updateOs).toHaveBeenCalledTimes(1);
|
||||||
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
|
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
|
||||||
|
// Since v-show is used, both elements exist in DOM but visibility is toggled
|
||||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
|
||||||
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(false);
|
expect(wrapper.find('[data-testid="brand-loading-mock"]').isVisible()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="update-os-status"]').isVisible()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows status and does not call updateOs when path does not match', () => {
|
it('shows status and does not call updateOs when path does not match', async () => {
|
||||||
mockLocation.pathname = '/some/other/path';
|
mockLocation.pathname = '/some/other/path';
|
||||||
mockRebootType.value = '';
|
mockRebootType.value = '';
|
||||||
|
|
||||||
@@ -136,12 +141,17 @@ describe('UpdateOs.ce.vue', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
|
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
|
||||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(false);
|
// Since v-show is used, both elements exist in DOM but visibility is toggled
|
||||||
|
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="brand-loading-mock"]').isVisible()).toBe(false);
|
||||||
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="update-os-status"]').isVisible()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows status and does not call updateOs when path matches but rebootType is not empty', () => {
|
it('shows status and does not call updateOs when path matches but rebootType is not empty', async () => {
|
||||||
mockLocation.pathname = '/Tools/Update';
|
mockLocation.pathname = '/Tools/Update';
|
||||||
mockRebootType.value = 'downgrade';
|
mockRebootType.value = 'downgrade';
|
||||||
|
|
||||||
@@ -156,9 +166,14 @@ describe('UpdateOs.ce.vue', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
|
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
|
||||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(false);
|
// Since v-show is used, both elements exist in DOM but visibility is toggled
|
||||||
|
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="brand-loading-mock"]').isVisible()).toBe(false);
|
||||||
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="update-os-status"]').isVisible()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import type { VueWrapper } from '@vue/test-utils';
|
import type { VueWrapper } from '@vue/test-utils';
|
||||||
import type { Server, ServerconnectPluginInstalled, ServerState } from '~/types/server';
|
import type { Server, ServerconnectPluginInstalled, ServerState } from '~/types/server';
|
||||||
import type { Pinia } from 'pinia';
|
import type { Pinia } from 'pinia';
|
||||||
import type { MaybeRef } from '@vueuse/core';
|
|
||||||
|
|
||||||
import UserProfile from '~/components/UserProfile.ce.vue';
|
import UserProfile from '~/components/UserProfile.ce.vue';
|
||||||
import { useServerStore } from '~/store/server';
|
import { useServerStore } from '~/store/server';
|
||||||
@@ -21,7 +20,7 @@ const mockCopied = ref(false);
|
|||||||
const mockIsSupported = ref(true);
|
const mockIsSupported = ref(true);
|
||||||
|
|
||||||
vi.mock('@vueuse/core', () => ({
|
vi.mock('@vueuse/core', () => ({
|
||||||
useClipboard: ({ _source }: { _source: MaybeRef<string> }) => {
|
useClipboard: () => {
|
||||||
const actualCopy = (text: string) => {
|
const actualCopy = (text: string) => {
|
||||||
if (mockIsSupported.value) {
|
if (mockIsSupported.value) {
|
||||||
mockCopy(text);
|
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();
|
const mockWatcher = vi.fn();
|
||||||
|
|
||||||
vi.mock('~/store/callbackActions', () => ({
|
vi.mock('~/store/callbackActions', () => ({
|
||||||
@@ -77,14 +87,18 @@ const initialServerData: Server = {
|
|||||||
|
|
||||||
// Component stubs for mount options
|
// Component stubs for mount options
|
||||||
const stubs = {
|
const stubs = {
|
||||||
UpcUptimeExpire: { template: '<div data-testid="uptime-expire"></div>', props: ['t'] },
|
UpcUptimeExpire: { template: '<div data-testid="uptime-expire"></div>' },
|
||||||
UpcServerState: { template: '<div data-testid="server-state"></div>', props: ['t'] },
|
UpcServerState: { template: '<div data-testid="server-state"></div>' },
|
||||||
|
UpcServerStatus: {
|
||||||
|
template: '<div><div data-testid="uptime-expire"></div><div data-testid="server-state"></div></div>',
|
||||||
|
props: ['class']
|
||||||
|
},
|
||||||
NotificationsSidebar: { template: '<div data-testid="notifications-sidebar"></div>' },
|
NotificationsSidebar: { template: '<div data-testid="notifications-sidebar"></div>' },
|
||||||
DropdownMenu: {
|
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'] },
|
UpcDropdownContent: { template: '<div data-testid="dropdown-content"></div>' },
|
||||||
UpcDropdownTrigger: { template: '<button data-testid="dropdown-trigger"></button>', props: ['t'] },
|
UpcDropdownTrigger: { template: '<button data-testid="dropdown-trigger"></button>' },
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('UserProfile.ce.vue', () => {
|
describe('UserProfile.ce.vue', () => {
|
||||||
@@ -201,9 +215,9 @@ describe('UserProfile.ce.vue', () => {
|
|||||||
|
|
||||||
await wrapper.vm.$nextTick();
|
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="uptime-expire"]').exists()).toBe(true);
|
||||||
expect(wrapper.find('[data-testid="server-state"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="server-state"]').exists()).toBe(true);
|
||||||
expect(wrapper.find('[data-testid="notifications-sidebar"]').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).toHaveBeenCalledTimes(2);
|
||||||
expect(serverStore.setServer).toHaveBeenLastCalledWith(initialServerData);
|
expect(serverStore.setServer).toHaveBeenLastCalledWith(initialServerData);
|
||||||
expect(wrapperObjectProp.find('h1').text()).toContain(initialServerData.name);
|
expect(wrapperObjectProp.find('button').text()).toContain(initialServerData.name);
|
||||||
wrapperObjectProp.unmount();
|
wrapperObjectProp.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,7 +268,7 @@ describe('UserProfile.ce.vue', () => {
|
|||||||
const copyLanIpSpy = vi.spyOn(wrapper.vm as unknown as { copyLanIp: () => void }, 'copyLanIp');
|
const copyLanIpSpy = vi.spyOn(wrapper.vm as unknown as { copyLanIp: () => void }, 'copyLanIp');
|
||||||
mockIsSupported.value = true;
|
mockIsSupported.value = true;
|
||||||
|
|
||||||
const serverNameButton = wrapper.find('h1 > button');
|
const serverNameButton = wrapper.find('button');
|
||||||
|
|
||||||
await serverNameButton.trigger('click');
|
await serverNameButton.trigger('click');
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
@@ -263,10 +277,8 @@ describe('UserProfile.ce.vue', () => {
|
|||||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||||
expect(mockCopy).toHaveBeenCalledWith(initialServerData.lanIp);
|
expect(mockCopy).toHaveBeenCalledWith(initialServerData.lanIp);
|
||||||
|
|
||||||
const copiedMessage = wrapper.find('.text-white.text-xs');
|
// We're not testing the toast message, just that the copy function was called
|
||||||
|
expect(mockCopied.value).toBe(true);
|
||||||
expect(copiedMessage.exists()).toBe(true);
|
|
||||||
expect(copiedMessage.text()).toContain(t('LAN IP Copied'));
|
|
||||||
|
|
||||||
copyLanIpSpy.mockRestore();
|
copyLanIpSpy.mockRestore();
|
||||||
});
|
});
|
||||||
@@ -275,7 +287,7 @@ describe('UserProfile.ce.vue', () => {
|
|||||||
const copyLanIpSpy = vi.spyOn(wrapper.vm as unknown as { copyLanIp: () => void }, 'copyLanIp');
|
const copyLanIpSpy = vi.spyOn(wrapper.vm as unknown as { copyLanIp: () => void }, 'copyLanIp');
|
||||||
mockIsSupported.value = false;
|
mockIsSupported.value = false;
|
||||||
|
|
||||||
const serverNameButton = wrapper.find('h1 > button');
|
const serverNameButton = wrapper.find('button');
|
||||||
|
|
||||||
await serverNameButton.trigger('click');
|
await serverNameButton.trigger('click');
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
@@ -283,10 +295,8 @@ describe('UserProfile.ce.vue', () => {
|
|||||||
expect(copyLanIpSpy).toHaveBeenCalledTimes(1);
|
expect(copyLanIpSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(mockCopy).not.toHaveBeenCalled();
|
expect(mockCopy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const notSupportedMessage = wrapper.find('.text-white.text-xs');
|
// When clipboard is not supported, the copy function should not be called
|
||||||
|
expect(mockCopied.value).toBe(false);
|
||||||
expect(notSupportedMessage.exists()).toBe(true);
|
|
||||||
expect(notSupportedMessage.text()).toContain(t('LAN IP {0}', [initialServerData.lanIp]));
|
|
||||||
|
|
||||||
copyLanIpSpy.mockRestore();
|
copyLanIpSpy.mockRestore();
|
||||||
});
|
});
|
||||||
@@ -299,18 +309,24 @@ describe('UserProfile.ce.vue', () => {
|
|||||||
themeStore.theme!.descriptionShow = true;
|
themeStore.theme!.descriptionShow = true;
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
const heading = wrapper.find('h1');
|
// Look for the description in a span element
|
||||||
expect(heading.html()).toContain(initialServerData.description);
|
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;
|
themeStore.theme!.descriptionShow = false;
|
||||||
await wrapper.vm.$nextTick();
|
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;
|
themeStore.theme!.descriptionShow = true;
|
||||||
await wrapper.vm.$nextTick();
|
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 () => {
|
it('always renders notifications sidebar, regardless of connectPluginInstalled', async () => {
|
||||||
|
|||||||
@@ -44,5 +44,55 @@ vi.mock('@unraid/ui', () => ({
|
|||||||
name: 'DropdownMenu',
|
name: 'DropdownMenu',
|
||||||
template: '<div><slot name="trigger" /><slot /></div>',
|
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
|
// Add other UI components as needed
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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 'tw-animate-css';
|
||||||
@import '../../@tailwind-shared/index.css';
|
@import '../../@tailwind-shared/index.css';
|
||||||
@import '@nuxt/ui';
|
@import '@nuxt/ui';
|
||||||
@@ -7,3 +14,68 @@
|
|||||||
@source "../../unraid-ui/dist/**/*.{js,mjs}";
|
@source "../../unraid-ui/dist/**/*.{js,mjs}";
|
||||||
@source "../../unraid-ui/src/**/*.{vue,ts}";
|
@source "../../unraid-ui/src/**/*.{vue,ts}";
|
||||||
@source "../**/*.{vue,ts,js}";
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||||
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
|
|
||||||
|
|
||||||
import { ClipboardDocumentIcon } from '@heroicons/vue/24/solid';
|
import { ClipboardDocumentIcon } from '@heroicons/vue/24/solid';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
jsonFormsAjv,
|
||||||
jsonFormsAjv,
|
jsonFormsRenderers,
|
||||||
jsonFormsRenderers
|
ResponsiveModal,
|
||||||
|
ResponsiveModalFooter,
|
||||||
|
ResponsiveModalHeader,
|
||||||
|
ResponsiveModalTitle,
|
||||||
} from '@unraid/ui';
|
} from '@unraid/ui';
|
||||||
import { JsonForms } from '@jsonforms/vue';
|
import { JsonForms } from '@jsonforms/vue';
|
||||||
import { extractGraphQLErrorMessage } from '~/helpers/functions';
|
import { extractGraphQLErrorMessage } from '~/helpers/functions';
|
||||||
@@ -22,15 +24,17 @@ import type { ApolloError } from '@apollo/client/errors';
|
|||||||
import type { FragmentType } from '~/composables/gql/fragment-masking';
|
import type { FragmentType } from '~/composables/gql/fragment-masking';
|
||||||
import type {
|
import type {
|
||||||
ApiKeyFormSettings,
|
ApiKeyFormSettings,
|
||||||
|
ApiKeyFragment,
|
||||||
AuthAction,
|
AuthAction,
|
||||||
CreateApiKeyInput,
|
CreateApiKeyInput,
|
||||||
Resource,
|
Resource,
|
||||||
Role,
|
Role,
|
||||||
} from '~/composables/gql/graphql';
|
} from '~/composables/gql/graphql';
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
import type { AuthorizationFormData } from '~/utils/authorizationScopes';
|
||||||
|
|
||||||
import { useFragment } from '~/composables/gql/fragment-masking';
|
import { useFragment } from '~/composables/gql/fragment-masking';
|
||||||
import { useApiKeyPermissionPresets } from '~/composables/useApiKeyPermissionPresets';
|
import { useApiKeyPermissionPresets } from '~/composables/useApiKeyPermissionPresets';
|
||||||
|
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
|
||||||
import { useApiKeyStore } from '~/store/apiKey';
|
import { useApiKeyStore } from '~/store/apiKey';
|
||||||
import { GET_API_KEY_CREATION_FORM_SCHEMA } from './api-key-form.query';
|
import { GET_API_KEY_CREATION_FORM_SCHEMA } from './api-key-form.query';
|
||||||
import { API_KEY_FRAGMENT, CREATE_API_KEY, UPDATE_API_KEY } from './apikey.query';
|
import { API_KEY_FRAGMENT, CREATE_API_KEY, UPDATE_API_KEY } from './apikey.query';
|
||||||
@@ -38,15 +42,38 @@ import DeveloperAuthorizationLink from './DeveloperAuthorizationLink.vue';
|
|||||||
import EffectivePermissions from './EffectivePermissions.vue';
|
import EffectivePermissions from './EffectivePermissions.vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
t?: ComposerTranslation;
|
open?: boolean;
|
||||||
|
editingKey?: ApiKeyFragment | null;
|
||||||
|
isAuthorizationMode?: boolean;
|
||||||
|
authorizationData?: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
scopes: string[];
|
||||||
|
formData?: AuthorizationFormData;
|
||||||
|
onAuthorize?: (apiKey: string) => void;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
const { t } = props;
|
open: false,
|
||||||
|
editingKey: null,
|
||||||
|
isAuthorizationMode: false,
|
||||||
|
authorizationData: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean];
|
||||||
|
created: [key: ApiKeyFragment];
|
||||||
|
updated: [key: ApiKeyFragment];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Local state for created key
|
||||||
|
const createdKey = ref<ApiKeyFragment | null>(null);
|
||||||
|
|
||||||
|
// Store is only used for legacy compatibility
|
||||||
const apiKeyStore = useApiKeyStore();
|
const apiKeyStore = useApiKeyStore();
|
||||||
const { modalVisible, editingKey, isAuthorizationMode, authorizationData, createdKey } =
|
|
||||||
storeToRefs(apiKeyStore);
|
|
||||||
|
|
||||||
// Form data that matches what the backend expects
|
// Form data that matches what the backend expects
|
||||||
// This will be transformed into CreateApiKeyInput or UpdateApiKeyInput
|
// This will be transformed into CreateApiKeyInput or UpdateApiKeyInput
|
||||||
@@ -84,7 +111,7 @@ const formDataPermissions = computed(() => {
|
|||||||
// Explicitly depend on the array length to ensure reactivity when going to/from empty
|
// Explicitly depend on the array length to ensure reactivity when going to/from empty
|
||||||
const permissions = formData.value.customPermissions;
|
const permissions = formData.value.customPermissions;
|
||||||
const permissionCount = permissions?.length ?? 0;
|
const permissionCount = permissions?.length ?? 0;
|
||||||
|
|
||||||
if (!permissions || permissionCount === 0) return [];
|
if (!permissions || permissionCount === 0) return [];
|
||||||
|
|
||||||
// Flatten the resources array into individual permission entries
|
// Flatten the resources array into individual permission entries
|
||||||
@@ -106,7 +133,7 @@ const error = computed<ApolloError | null>(() => createError.value || updateErro
|
|||||||
// Computed property for button disabled state
|
// Computed property for button disabled state
|
||||||
const isButtonDisabled = computed<boolean>(() => {
|
const isButtonDisabled = computed<boolean>(() => {
|
||||||
// In authorization mode, only check loading states if we have a name
|
// In authorization mode, only check loading states if we have a name
|
||||||
if (isAuthorizationMode.value && (formData.value.name || authorizationData.value?.formData?.name)) {
|
if (props.isAuthorizationMode && (formData.value.name || props.authorizationData?.formData?.name)) {
|
||||||
return loading.value || postCreateLoading.value;
|
return loading.value || postCreateLoading.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,12 +150,12 @@ const loadFormSchema = () => {
|
|||||||
if (result.data?.getApiKeyCreationFormSchema) {
|
if (result.data?.getApiKeyCreationFormSchema) {
|
||||||
formSchema.value = result.data.getApiKeyCreationFormSchema;
|
formSchema.value = result.data.getApiKeyCreationFormSchema;
|
||||||
|
|
||||||
if (isAuthorizationMode.value && authorizationData.value?.formData) {
|
if (props.isAuthorizationMode && props.authorizationData?.formData) {
|
||||||
// In authorization mode, use the form data from the authorization store
|
// In authorization mode, use the form data from the authorization store
|
||||||
formData.value = { ...authorizationData.value.formData };
|
formData.value = { ...props.authorizationData.formData };
|
||||||
// Ensure the name field is set for validation
|
// Ensure the name field is set for validation
|
||||||
if (!formData.value.name && authorizationData.value.name) {
|
if (!formData.value.name && props.authorizationData.name) {
|
||||||
formData.value.name = authorizationData.value.name;
|
formData.value.name = props.authorizationData.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In auth mode, if we have all required fields, consider it valid initially
|
// In auth mode, if we have all required fields, consider it valid initially
|
||||||
@@ -136,7 +163,7 @@ const loadFormSchema = () => {
|
|||||||
if (formData.value.name) {
|
if (formData.value.name) {
|
||||||
formValid.value = true;
|
formValid.value = true;
|
||||||
}
|
}
|
||||||
} else if (editingKey.value) {
|
} else if (props.editingKey) {
|
||||||
// If editing, populate form data from existing key
|
// If editing, populate form data from existing key
|
||||||
populateFormFromExistingKey();
|
populateFormFromExistingKey();
|
||||||
} else {
|
} else {
|
||||||
@@ -164,9 +191,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
// Watch for editing key changes
|
// Watch for editing key changes
|
||||||
watch(
|
watch(
|
||||||
() => editingKey.value,
|
() => props.editingKey,
|
||||||
() => {
|
() => {
|
||||||
if (!isAuthorizationMode.value) {
|
if (!props.isAuthorizationMode) {
|
||||||
populateFormFromExistingKey();
|
populateFormFromExistingKey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,13 +201,13 @@ watch(
|
|||||||
|
|
||||||
// Watch for authorization mode changes
|
// Watch for authorization mode changes
|
||||||
watch(
|
watch(
|
||||||
() => isAuthorizationMode.value,
|
() => props.isAuthorizationMode,
|
||||||
async (newValue) => {
|
async (newValue) => {
|
||||||
if (newValue && authorizationData.value?.formData) {
|
if (newValue && props.authorizationData?.formData) {
|
||||||
formData.value = { ...authorizationData.value.formData };
|
formData.value = { ...props.authorizationData.formData };
|
||||||
// Ensure the name field is set for validation
|
// Ensure the name field is set for validation
|
||||||
if (!formData.value.name && authorizationData.value.name) {
|
if (!formData.value.name && props.authorizationData.name) {
|
||||||
formData.value.name = authorizationData.value.name;
|
formData.value.name = props.authorizationData.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial valid state if we have required fields
|
// Set initial valid state if we have required fields
|
||||||
@@ -193,13 +220,13 @@ watch(
|
|||||||
|
|
||||||
// Watch for authorization form data changes
|
// Watch for authorization form data changes
|
||||||
watch(
|
watch(
|
||||||
() => authorizationData.value?.formData,
|
() => props.authorizationData?.formData,
|
||||||
(newFormData) => {
|
(newFormData) => {
|
||||||
if (isAuthorizationMode.value && newFormData) {
|
if (props.isAuthorizationMode && newFormData) {
|
||||||
formData.value = { ...newFormData };
|
formData.value = { ...newFormData };
|
||||||
// Ensure the name field is set for validation
|
// Ensure the name field is set for validation
|
||||||
if (!formData.value.name && authorizationData.value?.name) {
|
if (!formData.value.name && props.authorizationData?.name) {
|
||||||
formData.value.name = authorizationData.value.name;
|
formData.value.name = props.authorizationData.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -225,11 +252,11 @@ watch(
|
|||||||
|
|
||||||
// Populate form data from existing key
|
// Populate form data from existing key
|
||||||
const populateFormFromExistingKey = async () => {
|
const populateFormFromExistingKey = async () => {
|
||||||
if (!editingKey.value || !formSchema.value) return;
|
if (!props.editingKey || !formSchema.value) return;
|
||||||
|
|
||||||
const fragmentKey = useFragment(
|
const fragmentKey = useFragment(
|
||||||
API_KEY_FRAGMENT,
|
API_KEY_FRAGMENT,
|
||||||
editingKey.value as FragmentType<typeof API_KEY_FRAGMENT>
|
props.editingKey as FragmentType<typeof API_KEY_FRAGMENT>
|
||||||
);
|
);
|
||||||
if (fragmentKey) {
|
if (fragmentKey) {
|
||||||
// Group permissions by actions for better UI
|
// Group permissions by actions for better UI
|
||||||
@@ -290,7 +317,7 @@ const transformFormDataForApi = (): CreateApiKeyInput => {
|
|||||||
} else {
|
} else {
|
||||||
// If customPermissions is undefined or null, and we're editing,
|
// If customPermissions is undefined or null, and we're editing,
|
||||||
// we should still send an empty array to clear permissions
|
// we should still send an empty array to clear permissions
|
||||||
if (editingKey.value) {
|
if (props.editingKey) {
|
||||||
apiData.permissions = [];
|
apiData.permissions = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,20 +331,21 @@ const transformFormDataForApi = (): CreateApiKeyInput => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
apiKeyStore.hideModal();
|
emit('update:open', false);
|
||||||
formData.value = {
|
formData.value = {
|
||||||
customPermissions: [],
|
customPermissions: [],
|
||||||
roles: [],
|
roles: [],
|
||||||
} as FormData;
|
} as FormData;
|
||||||
|
createdKey.value = null; // Reset local created key
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
async function upsertKey() {
|
async function upsertKey() {
|
||||||
// In authorization mode, skip validation if we have a name
|
// In authorization mode, skip validation if we have a name
|
||||||
if (!isAuthorizationMode.value && !formValid.value) {
|
if (!props.isAuthorizationMode && !formValid.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isAuthorizationMode.value && !formData.value.name) {
|
if (props.isAuthorizationMode && !formData.value.name) {
|
||||||
console.error('Cannot authorize without a name');
|
console.error('Cannot authorize without a name');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -328,13 +356,13 @@ async function upsertKey() {
|
|||||||
try {
|
try {
|
||||||
const apiData = transformFormDataForApi();
|
const apiData = transformFormDataForApi();
|
||||||
|
|
||||||
const isEdit = !!editingKey.value?.id;
|
const isEdit = !!props.editingKey?.id;
|
||||||
|
|
||||||
let res;
|
let res;
|
||||||
if (isEdit && editingKey.value) {
|
if (isEdit && props.editingKey) {
|
||||||
res = await updateApiKey({
|
res = await updateApiKey({
|
||||||
input: {
|
input: {
|
||||||
id: editingKey.value.id,
|
id: props.editingKey.id,
|
||||||
...apiData,
|
...apiData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -348,19 +376,22 @@ async function upsertKey() {
|
|||||||
if (isEdit && apiKeyResult && 'update' in apiKeyResult) {
|
if (isEdit && apiKeyResult && 'update' in apiKeyResult) {
|
||||||
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.update);
|
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.update);
|
||||||
apiKeyStore.setCreatedKey(fragmentData);
|
apiKeyStore.setCreatedKey(fragmentData);
|
||||||
|
emit('updated', fragmentData);
|
||||||
} else if (!isEdit && apiKeyResult && 'create' in apiKeyResult) {
|
} else if (!isEdit && apiKeyResult && 'create' in apiKeyResult) {
|
||||||
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.create);
|
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.create);
|
||||||
apiKeyStore.setCreatedKey(fragmentData);
|
apiKeyStore.setCreatedKey(fragmentData);
|
||||||
|
emit('created', fragmentData);
|
||||||
|
|
||||||
// If in authorization mode, call the callback with the API key
|
// If in authorization mode, call the callback with the API key
|
||||||
if (isAuthorizationMode.value && authorizationData.value?.onAuthorize && 'key' in fragmentData) {
|
if (props.isAuthorizationMode && props.authorizationData?.onAuthorize && 'key' in fragmentData) {
|
||||||
authorizationData.value.onAuthorize(fragmentData.key);
|
props.authorizationData.onAuthorize(fragmentData.key);
|
||||||
// Don't close the modal or reset form - let the callback handle it
|
// Don't close the modal or reset form - let the callback handle it
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKeyStore.hideModal();
|
apiKeyStore.hideModal();
|
||||||
|
emit('update:open', false);
|
||||||
formData.value = {
|
formData.value = {
|
||||||
customPermissions: [],
|
customPermissions: [],
|
||||||
roles: [],
|
roles: [],
|
||||||
@@ -382,41 +413,39 @@ const copyApiKey = async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Modal mode (handles both regular creation and authorization) -->
|
<!-- Modal mode (handles both regular creation and authorization) -->
|
||||||
<Dialog
|
<ResponsiveModal
|
||||||
v-if="modalVisible"
|
:open="props.open"
|
||||||
v-model="modalVisible"
|
sheet-side="bottom"
|
||||||
size="xl"
|
:sheet-class="'h-[100vh] flex flex-col'"
|
||||||
:title="
|
:dialog-class="'max-w-4xl max-h-[90vh] overflow-hidden'"
|
||||||
isAuthorizationMode
|
:show-close-button="true"
|
||||||
? 'Authorize API Key Access'
|
@update:open="
|
||||||
: 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="
|
|
||||||
(v) => {
|
(v) => {
|
||||||
if (!v) close();
|
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="w-full flex-1 overflow-y-auto p-6">
|
||||||
<!-- Show authorization description if in authorization mode -->
|
<!-- Show authorization description if in authorization mode -->
|
||||||
<div
|
<div
|
||||||
v-if="isAuthorizationMode && formSchema?.dataSchema?.description"
|
v-if="isAuthorizationMode && formSchema?.dataSchema?.description"
|
||||||
class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg"
|
class="mb-4 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20"
|
||||||
>
|
>
|
||||||
<p class="text-sm">{{ formSchema.dataSchema.description }}</p>
|
<p class="text-sm">{{ formSchema.dataSchema.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -447,20 +476,20 @@ const copyApiKey = async () => {
|
|||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-else class="flex items-center justify-center py-8">
|
<div v-else class="flex items-center justify-center py-8">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
<div class="border-primary mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2" />
|
||||||
<p class="text-sm text-muted-foreground">Loading form...</p>
|
<p class="text-muted-foreground text-sm">Loading form...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error display -->
|
<!-- Error display -->
|
||||||
<div v-if="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
<div v-if="error" class="mt-4 rounded-lg bg-red-50 p-4 dark:bg-red-900/20">
|
||||||
<p class="text-sm text-red-600 dark:text-red-400">
|
<p class="text-sm text-red-600 dark:text-red-400">
|
||||||
{{ extractGraphQLErrorMessage(error) }}
|
{{ extractGraphQLErrorMessage(error) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Permissions Preview -->
|
<!-- Permissions Preview -->
|
||||||
<div class="mt-6 p-4 bg-muted/50 rounded-lg border border-muted">
|
<div class="bg-muted/50 border-muted mt-6 rounded-lg border p-4">
|
||||||
<EffectivePermissions
|
<EffectivePermissions
|
||||||
:roles="formData.roles || []"
|
:roles="formData.roles || []"
|
||||||
:raw-permissions="formDataPermissions"
|
:raw-permissions="formDataPermissions"
|
||||||
@@ -470,14 +499,14 @@ const copyApiKey = async () => {
|
|||||||
<!-- Show selected roles for context -->
|
<!-- Show selected roles for context -->
|
||||||
<div
|
<div
|
||||||
v-if="formData.roles && formData.roles.length > 0"
|
v-if="formData.roles && formData.roles.length > 0"
|
||||||
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
|
class="border-muted mt-3 border-t border-gray-200 pt-3 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<div class="text-xs text-gray-600 dark:text-gray-400 mb-1">Selected Roles:</div>
|
<div class="mb-1 text-xs text-gray-600 dark:text-gray-400">Selected Roles:</div>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<span
|
<span
|
||||||
v-for="role in formData.roles"
|
v-for="role in formData.roles"
|
||||||
:key="role"
|
:key="role"
|
||||||
class="px-2 py-1 bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300 rounded text-xs"
|
class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-800 dark:bg-blue-900/50 dark:text-blue-300"
|
||||||
>
|
>
|
||||||
{{ role }}
|
{{ role }}
|
||||||
</span>
|
</span>
|
||||||
@@ -509,20 +538,38 @@ const copyApiKey = async () => {
|
|||||||
<!-- Success state for authorization mode -->
|
<!-- Success state for authorization mode -->
|
||||||
<div
|
<div
|
||||||
v-if="isAuthorizationMode && createdKey && 'key' in createdKey"
|
v-if="isAuthorizationMode && createdKey && 'key' in createdKey"
|
||||||
class="mt-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg"
|
class="mt-4 rounded-lg bg-green-50 p-4 dark:bg-green-900/20"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<span class="text-sm font-medium">API Key created successfully!</span>
|
<span class="text-sm font-medium">API Key created successfully!</span>
|
||||||
<Button type="button" variant="ghost" size="sm" @click="copyApiKey">
|
<Button type="button" variant="ghost" size="sm" @click="copyApiKey">
|
||||||
<ClipboardDocumentIcon class="w-4 h-4 mr-2" />
|
<ClipboardDocumentIcon class="mr-2 h-4 w-4" />
|
||||||
{{ copied ? 'Copied!' : 'Copy Key' }}
|
{{ copied ? 'Copied!' : 'Copy Key' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<code class="block mt-2 p-2 bg-white dark:bg-gray-800 rounded text-xs break-all border">
|
<code class="mt-2 block rounded border bg-white p-2 text-xs break-all dark:bg-gray-800">
|
||||||
{{ createdKey.key }}
|
{{ createdKey.key }}
|
||||||
</code>
|
</code>
|
||||||
<p class="text-xs text-muted-foreground mt-2">Save this key securely for your application.</p>
|
<p class="text-muted-foreground mt-2 text-xs">Save this key securely for your application.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
|
||||||
|
<ResponsiveModalFooter>
|
||||||
|
<div class="flex w-full justify-end gap-2">
|
||||||
|
<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>
|
</template>
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
CardWrapper,
|
CardWrapper,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
DropdownMenuRoot,
|
DropdownMenuRoot,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
@@ -32,6 +38,7 @@ import { useFragment } from '~/composables/gql/fragment-masking';
|
|||||||
import { useApiKeyStore } from '~/store/apiKey';
|
import { useApiKeyStore } from '~/store/apiKey';
|
||||||
import { API_KEY_FRAGMENT, DELETE_API_KEY, GET_API_KEY_META, GET_API_KEYS } from './apikey.query';
|
import { API_KEY_FRAGMENT, DELETE_API_KEY, GET_API_KEY_META, GET_API_KEYS } from './apikey.query';
|
||||||
import EffectivePermissions from '~/components/ApiKey/EffectivePermissions.vue';
|
import EffectivePermissions from '~/components/ApiKey/EffectivePermissions.vue';
|
||||||
|
import ApiKeyCreate from '~/components/ApiKey/ApiKeyCreate.vue';
|
||||||
import { generateScopes } from '~/utils/authorizationLink';
|
import { generateScopes } from '~/utils/authorizationLink';
|
||||||
|
|
||||||
const { result, refetch } = useQuery(GET_API_KEYS);
|
const { result, refetch } = useQuery(GET_API_KEYS);
|
||||||
@@ -40,6 +47,10 @@ const apiKeyStore = useApiKeyStore();
|
|||||||
const { createdKey } = storeToRefs(apiKeyStore);
|
const { createdKey } = storeToRefs(apiKeyStore);
|
||||||
const apiKeys = ref<ApiKeyFragment[]>([]);
|
const apiKeys = ref<ApiKeyFragment[]>([]);
|
||||||
|
|
||||||
|
// Local modal state
|
||||||
|
const showCreateModal = ref(false);
|
||||||
|
const editingKey = ref<ApiKeyFragment | null>(null);
|
||||||
|
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
const baseKeys: ApiKeyFragment[] =
|
const baseKeys: ApiKeyFragment[] =
|
||||||
@@ -89,10 +100,28 @@ function toggleShowKey(keyId: string) {
|
|||||||
|
|
||||||
function openCreateModal(key: ApiKeyFragment | ApiKeyFragment | null = null) {
|
function openCreateModal(key: ApiKeyFragment | ApiKeyFragment | null = null) {
|
||||||
apiKeyStore.clearCreatedKey();
|
apiKeyStore.clearCreatedKey();
|
||||||
apiKeyStore.showModal(key as ApiKeyFragment | null);
|
editingKey.value = key as ApiKeyFragment | null;
|
||||||
|
showCreateModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateFromTemplate() {
|
function handleKeyCreated(key: ApiKeyFragment) {
|
||||||
|
// Add the new key to the list
|
||||||
|
apiKeys.value.unshift(key);
|
||||||
|
showCreateModal.value = false;
|
||||||
|
editingKey.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyUpdated(key: ApiKeyFragment) {
|
||||||
|
// Update the key in the list
|
||||||
|
const index = apiKeys.value.findIndex(k => k.id === key.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
apiKeys.value[index] = key;
|
||||||
|
}
|
||||||
|
showCreateModal.value = false;
|
||||||
|
editingKey.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateFromTemplate() {
|
||||||
showTemplateInput.value = true;
|
showTemplateInput.value = true;
|
||||||
templateUrl.value = '';
|
templateUrl.value = '';
|
||||||
templateError.value = '';
|
templateError.value = '';
|
||||||
@@ -237,14 +266,14 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
|
|||||||
class="w-full font-mono text-xs px-2 py-1 rounded pr-10"
|
class="w-full font-mono text-xs px-2 py-1 rounded pr-10"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
class="absolute inset-y-0 right-2 flex items-center px-1 text-gray-500 hover:text-gray-700"
|
size="icon"
|
||||||
tabindex="-1"
|
class="absolute inset-y-0 right-2 h-auto w-auto px-1 text-gray-500 hover:text-gray-700"
|
||||||
@click="toggleShowKey(key.id)"
|
@click="toggleShowKey(key.id)"
|
||||||
>
|
>
|
||||||
<component :is="showKey[key.id] ? EyeSlashIcon : EyeIcon" class="w-5 h-5" />
|
<component :is="showKey[key.id] ? EyeSlashIcon : EyeIcon" class="w-5 h-5" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip :delay-duration="0">
|
<Tooltip :delay-duration="0">
|
||||||
@@ -260,7 +289,7 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Accordion
|
||||||
type="single"
|
type="single"
|
||||||
collapsible
|
collapsible
|
||||||
@@ -285,11 +314,11 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
|
|||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</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>
|
<Button variant="secondary" size="sm" @click="openCreateModal(key)">Edit</Button>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip :delay-duration="0">
|
<Tooltip :delay-duration="0">
|
||||||
<TooltipTrigger>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="outline" size="sm" @click="copyKeyTemplate(key)">
|
<Button variant="outline" size="sm" @click="copyKeyTemplate(key)">
|
||||||
<LinkIcon class="w-4 h-4 mr-1" />
|
<LinkIcon class="w-4 h-4 mr-1" />
|
||||||
Copy Template
|
Copy Template
|
||||||
@@ -311,27 +340,38 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Template Input Dialog -->
|
<!-- Template Input Dialog -->
|
||||||
<div v-if="showTemplateInput" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Dialog v-model:open="showTemplateInput">
|
||||||
<div class="bg-background rounded-lg p-6 max-w-lg w-full mx-4">
|
<DialogContent class="max-w-lg">
|
||||||
<h3 class="text-lg font-semibold mb-4">Create from Template</h3>
|
<DialogHeader>
|
||||||
<p class="text-sm text-muted-foreground mb-4">
|
<DialogTitle>Create from Template</DialogTitle>
|
||||||
Paste a template URL or query string to pre-fill the API key creation form with permissions.
|
<DialogDescription>
|
||||||
</p>
|
Paste a template URL or query string to pre-fill the API key creation form with permissions.
|
||||||
<Input
|
</DialogDescription>
|
||||||
v-model="templateUrl"
|
</DialogHeader>
|
||||||
placeholder="Paste template URL or query string (e.g., ?name=MyApp&scopes=role:admin)"
|
<div class="space-y-4">
|
||||||
class="mb-4"
|
<Input
|
||||||
@keydown.enter="applyTemplate"
|
v-model="templateUrl"
|
||||||
/>
|
placeholder="Paste template URL or query string (e.g., ?name=MyApp&scopes=role:admin)"
|
||||||
<div v-if="templateError" class="mb-4 p-3 rounded border border-destructive bg-destructive/10 text-destructive text-sm">
|
@keydown.enter="applyTemplate"
|
||||||
{{ templateError }}
|
/>
|
||||||
|
<div v-if="templateError" class="p-3 rounded border border-destructive bg-destructive/10 text-destructive text-sm">
|
||||||
|
{{ templateError }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 justify-end">
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="cancelTemplateInput">Cancel</Button>
|
<Button variant="outline" @click="cancelTemplateInput">Cancel</Button>
|
||||||
<Button variant="primary" @click="applyTemplate">Apply Template</Button>
|
<Button @click="applyTemplate">Apply Template</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- API Key Create Modal -->
|
||||||
|
<ApiKeyCreate
|
||||||
|
v-model:open="showCreateModal"
|
||||||
|
:editing-key="editingKey"
|
||||||
|
@created="handleKeyCreated"
|
||||||
|
@updated="handleKeyUpdated"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useAuthorizationLink } from '~/composables/useAuthorizationLink.js';
|
import { useAuthorizationLink } from '~/composables/useAuthorizationLink.js';
|
||||||
import { useApiKeyStore } from '~/store/apiKey.js';
|
import { useApiKeyStore } from '~/store/apiKey.js';
|
||||||
|
import ApiKeyCreate from './ApiKey/ApiKeyCreate.vue';
|
||||||
|
|
||||||
// Use the composables for authorization logic
|
// Use the composables for authorization logic
|
||||||
const {
|
const {
|
||||||
@@ -20,7 +21,7 @@ const {
|
|||||||
|
|
||||||
// Use the API key store to control the global modal
|
// Use the API key store to control the global modal
|
||||||
const apiKeyStore = useApiKeyStore();
|
const apiKeyStore = useApiKeyStore();
|
||||||
const { createdKey, modalVisible } = storeToRefs(apiKeyStore);
|
const { createdKey, modalVisible, isAuthorizationMode, authorizationData, editingKey } = storeToRefs(apiKeyStore);
|
||||||
|
|
||||||
// Component state
|
// Component state
|
||||||
const showSuccess = ref(false);
|
const showSuccess = ref(false);
|
||||||
@@ -286,7 +287,7 @@ const returnToApp = () => {
|
|||||||
class="flex-1"
|
class="flex-1"
|
||||||
@click="openAuthorizationModal"
|
@click="openAuthorizationModal"
|
||||||
>
|
>
|
||||||
{{ hasValidRedirectUri ? 'Review Permissions & Authorize' : 'Review Permissions' }}
|
{{ hasValidRedirectUri ? 'Authorize' : 'Continue' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,5 +296,16 @@ const returnToApp = () => {
|
|||||||
<div v-if="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
<div v-if="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
|
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key Create Modal (for authorization flow) -->
|
||||||
|
<ApiKeyCreate
|
||||||
|
:open="modalVisible"
|
||||||
|
:editing-key="editingKey"
|
||||||
|
:is-authorization-mode="isAuthorizationMode"
|
||||||
|
:authorization-data="authorizationData"
|
||||||
|
@update:open="(v) => v ? apiKeyStore.showModal() : apiKeyStore.hideModal()"
|
||||||
|
@created="(key) => apiKeyStore.setCreatedKey(key)"
|
||||||
|
@updated="(key) => apiKeyStore.setCreatedKey(key)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ const { authAction, stateData } = storeToRefs(serverStore);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="whitespace-normal flex flex-col gap-y-4 max-w-3xl">
|
<div class="whitespace-normal flex flex-col gap-y-4 max-w-3xl">
|
||||||
<span v-if="stateData.error" class="text-unraid-red font-semibold">
|
<span v-if="stateData?.error" class="text-unraid-red font-semibold">
|
||||||
<h3 class="text-base mb-2">{{ t(stateData.heading) }}</h3>
|
<h3 class="text-base mb-2">{{ stateData?.heading ? t(stateData.heading) : '' }}</h3>
|
||||||
<span class="text-sm" v-html="t(stateData.message)" />
|
<span class="text-sm" v-html="stateData?.message ? t(stateData.message) : ''" />
|
||||||
</span>
|
</span>
|
||||||
<span v-if="authAction">
|
<span v-if="authAction">
|
||||||
<BrandButton
|
<BrandButton
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ const { avatar, connectPluginInstalled, registered, username } = storeToRefs(ser
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<img
|
||||||
v-if="avatar && connectPluginInstalled && registered"
|
v-if="avatar && connectPluginInstalled && registered"
|
||||||
:src="avatar"
|
:src="avatar"
|
||||||
:alt="username"
|
: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>
|
<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>
|
</template>
|
||||||
</figure>
|
</figure>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const items = [
|
|||||||
<AccordionItem value="color-theme-customization">
|
<AccordionItem value="color-theme-customization">
|
||||||
<AccordionTrigger>Color Theme Customization</AccordionTrigger>
|
<AccordionTrigger>Color Theme Customization</AccordionTrigger>
|
||||||
<AccordionContent>
|
<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>
|
<h1 class="text-lg">Color Theme Customization</h1>
|
||||||
|
|
||||||
<Label for="theme-select">Theme</Label>
|
<Label for="theme-select">Theme</Label>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { BrandButton, jsonFormsRenderers, jsonFormsAjv, Label, SettingsGrid } fr
|
|||||||
import { JsonForms } from '@jsonforms/vue';
|
import { JsonForms } from '@jsonforms/vue';
|
||||||
|
|
||||||
import { useServerStore } from '~/store/server';
|
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
|
// unified settings values are returned as JSON, so use a generic record type
|
||||||
// import type { ConnectSettingsValues } from '~/composables/gql/graphql';
|
// import type { ConnectSettingsValues } from '~/composables/gql/graphql';
|
||||||
|
|
||||||
@@ -99,14 +101,10 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
|||||||
<SettingsGrid>
|
<SettingsGrid>
|
||||||
<template v-if="connectPluginInstalled">
|
<template v-if="connectPluginInstalled">
|
||||||
<Label>Account Status:</Label>
|
<Label>Account Status:</Label>
|
||||||
<div v-html="'<unraid-auth></unraid-auth>'"/>
|
<Auth />
|
||||||
</template>
|
</template>
|
||||||
<Label>Download Unraid API Logs:</Label>
|
<Label>Download Unraid API Logs:</Label>
|
||||||
<div
|
<DownloadApiLogs />
|
||||||
v-html="
|
|
||||||
'<unraid-download-api-logs></unraid-download-api-logs>'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</SettingsGrid>
|
</SettingsGrid>
|
||||||
<!-- auto-generated settings form -->
|
<!-- auto-generated settings form -->
|
||||||
<div class="mt-6 pl-3 [&_.vertical-layout]:space-y-6">
|
<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">
|
<div class="text-sm text-end">
|
||||||
<p v-if="isUpdating">Applying Settings...</p>
|
<p v-if="isUpdating">Applying Settings...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-start-2 ml-10 space-y-4">
|
<div class="col-start-2 space-y-4 max-w-3xl">
|
||||||
<BrandButton
|
<BrandButton
|
||||||
padding="lean"
|
padding="lean"
|
||||||
size="12px"
|
size="12px"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const downloadUrl = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="whitespace-normal flex flex-col gap-y-4 max-w-3xl">
|
<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('The primary method of support for Unraid Connect is through our forums and Discord.') }}
|
||||||
{{
|
{{
|
||||||
t(
|
t(
|
||||||
@@ -25,7 +25,7 @@ const downloadUrl = computed(() => {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
{{ t('The logs may contain sensitive information so do not post them publicly.') }}
|
{{ t('The logs may contain sensitive information so do not post them publicly.') }}
|
||||||
</span>
|
</p>
|
||||||
<span class="flex flex-col gap-y-4">
|
<span class="flex flex-col gap-y-4">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<BrandButton
|
<BrandButton
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useQuery } from '@vue/apollo-composable';
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
|
|
||||||
import { BellAlertIcon, ExclamationTriangleIcon, InformationCircleIcon, DocumentTextIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
import { BellAlertIcon, ExclamationTriangleIcon, InformationCircleIcon, DocumentTextIcon, ArrowTopRightOnSquareIcon, ClipboardDocumentIcon } from '@heroicons/vue/24/solid';
|
||||||
import { Badge, DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@unraid/ui';
|
import { Button, DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@unraid/ui';
|
||||||
import { WEBGUI_TOOLS_DOWNGRADE, WEBGUI_TOOLS_UPDATE, getReleaseNotesUrl } from '~/helpers/urls';
|
import { WEBGUI_TOOLS_DOWNGRADE, WEBGUI_TOOLS_UPDATE, getReleaseNotesUrl } from '~/helpers/urls';
|
||||||
|
|
||||||
import { useActivationCodeDataStore } from '~/components/Activation/store/activationCodeData';
|
import { useActivationCodeDataStore } from '~/components/Activation/store/activationCodeData';
|
||||||
@@ -14,8 +14,15 @@ import { useUpdateOsStore } from '~/store/updateOs';
|
|||||||
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
|
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
|
||||||
import { INFO_VERSIONS_QUERY } from './UserProfile/versions.query';
|
import { INFO_VERSIONS_QUERY } from './UserProfile/versions.query';
|
||||||
import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
|
import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
|
||||||
|
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { copyWithNotification } = useClipboardWithToast();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const logoWrapper = document.querySelector('.logo');
|
||||||
|
logoWrapper?.classList.remove('logo');
|
||||||
|
});
|
||||||
|
|
||||||
const serverStore = useServerStore();
|
const serverStore = useServerStore();
|
||||||
const updateOsStore = useUpdateOsStore();
|
const updateOsStore = useUpdateOsStore();
|
||||||
@@ -57,6 +64,18 @@ const openApiChangelog = () => {
|
|||||||
window.open('https://github.com/unraid/api/releases', '_blank');
|
window.open('https://github.com/unraid/api/releases', '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyOsVersion = () => {
|
||||||
|
if (displayOsVersion.value) {
|
||||||
|
copyWithNotification(displayOsVersion.value, t('OS version copied to clipboard'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyApiVersion = () => {
|
||||||
|
if (apiVersion.value) {
|
||||||
|
copyWithNotification(apiVersion.value, t('API version copied to clipboard'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const unraidLogoHeaderLink = computed<{ href: string; title: string }>(() => {
|
const unraidLogoHeaderLink = computed<{ href: string; title: string }>(() => {
|
||||||
if (partnerInfo.value?.partnerUrl) {
|
if (partnerInfo.value?.partnerUrl) {
|
||||||
return {
|
return {
|
||||||
@@ -71,6 +90,16 @@ const unraidLogoHeaderLink = computed<{ href: string; title: string }>(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleUpdateStatusClick = () => {
|
||||||
|
if (!updateOsStatus.value) return;
|
||||||
|
|
||||||
|
if (updateOsStatus.value.click) {
|
||||||
|
updateOsStatus.value.click();
|
||||||
|
} else if (updateOsStatus.value.href) {
|
||||||
|
window.location.href = updateOsStatus.value.href;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateOsStatus = computed(() => {
|
const updateOsStatus = computed(() => {
|
||||||
if (stateDataError.value) {
|
if (stateDataError.value) {
|
||||||
// only allowed to update when server is does not have a state error
|
// only allowed to update when server is does not have a state error
|
||||||
@@ -112,7 +141,7 @@ const updateOsStatus = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-y-2 mt-6">
|
<div class="flex flex-col gap-y-2 mt-2 ml-2">
|
||||||
<a
|
<a
|
||||||
:href="unraidLogoHeaderLink.href"
|
:href="unraidLogoHeaderLink.href"
|
||||||
:title="unraidLogoHeaderLink.title"
|
:title="unraidLogoHeaderLink.title"
|
||||||
@@ -122,7 +151,7 @@ const updateOsStatus = computed(() => {
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="'/webGui/images/UN-logotype-gradient.svg'"
|
: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"
|
alt="Unraid Logo"
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
@@ -130,13 +159,16 @@ const updateOsStatus = computed(() => {
|
|||||||
<div class="flex flex-wrap justify-start gap-2">
|
<div class="flex flex-wrap justify-start gap-2">
|
||||||
<DropdownMenuRoot>
|
<DropdownMenuRoot>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<button
|
<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"
|
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')"
|
: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"
|
||||||
|
/>
|
||||||
{{ displayOsVersion }}
|
{{ displayOsVersion }}
|
||||||
</button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent class="min-w-[200px]" align="start" :side-offset="4">
|
<DropdownMenuContent class="min-w-[200px]" align="start" :side-offset="4">
|
||||||
@@ -144,16 +176,30 @@ const updateOsStatus = computed(() => {
|
|||||||
{{ t('Version Information') }}
|
{{ t('Version Information') }}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem disabled class="text-xs opacity-100">
|
<DropdownMenuItem
|
||||||
<span class="flex justify-between w-full">
|
:disabled="!displayOsVersion"
|
||||||
<span>{{ t('Unraid OS') }}</span>
|
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 class="w-3 h-3 opacity-60" />
|
||||||
|
</span>
|
||||||
<span class="font-semibold">{{ displayOsVersion || t('Unknown') }}</span>
|
<span class="font-semibold">{{ displayOsVersion || t('Unknown') }}</span>
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled class="text-xs opacity-100">
|
<DropdownMenuItem
|
||||||
<span class="flex justify-between w-full">
|
:disabled="!apiVersion"
|
||||||
<span>{{ t('Unraid API') }}</span>
|
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 class="w-3 h-3 opacity-60" />
|
||||||
|
</span>
|
||||||
<span class="font-semibold">{{ apiVersion || t('Unknown') }}</span>
|
<span class="font-semibold">{{ apiVersion || t('Unknown') }}</span>
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -178,26 +224,22 @@ const updateOsStatus = computed(() => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuRoot>
|
</DropdownMenuRoot>
|
||||||
<component
|
<Button
|
||||||
:is="updateOsStatus.href ? 'a' : 'button'"
|
|
||||||
v-if="updateOsStatus"
|
v-if="updateOsStatus"
|
||||||
:href="updateOsStatus.href ?? undefined"
|
:variant="updateOsStatus.badge?.color === 'orange' ? 'pill-orange' : 'pill-gray'"
|
||||||
:title="updateOsStatus.title ?? undefined"
|
:title="updateOsStatus.title ?? updateOsStatus.text"
|
||||||
class="group"
|
:disabled="!updateOsStatus.href && !updateOsStatus.click"
|
||||||
@click="updateOsStatus.click?.()"
|
size="sm"
|
||||||
|
@click="handleUpdateStatusClick"
|
||||||
>
|
>
|
||||||
<Badge
|
<span v-if="updateOsStatus.badge?.icon" class="inline-flex shrink-0 w-4 h-4">
|
||||||
v-if="updateOsStatus.badge"
|
<component
|
||||||
:color="updateOsStatus.badge.color"
|
:is="updateOsStatus.badge.icon"
|
||||||
:icon="updateOsStatus.badge.icon"
|
class="w-full h-full"
|
||||||
size="xs"
|
/>
|
||||||
>
|
</span>
|
||||||
{{ updateOsStatus.text }}
|
{{ updateOsStatus.text || '' }}
|
||||||
</Badge>
|
</Button>
|
||||||
<template v-else>
|
|
||||||
{{ updateOsStatus.text }}
|
|
||||||
</template>
|
|
||||||
</component>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OS Release Notes Modal -->
|
<!-- OS Release Notes Modal -->
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, watchEffect } from 'vue';
|
import { computed, watchEffect } from 'vue';
|
||||||
|
|
||||||
import { XMarkIcon } from '@heroicons/vue/24/outline';
|
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 { TransitionChild, TransitionRoot } from '@headlessui/vue';
|
||||||
|
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
import type { ComposerTranslation } from 'vue-i18n';
|
||||||
@@ -117,19 +117,20 @@ const computedVerticalCenter = computed<string>(() => {
|
|||||||
disableShadow ? 'shadow-none border-none' : 'shadow-xl',
|
disableShadow ? 'shadow-none border-none' : 'shadow-xl',
|
||||||
error ? 'shadow-unraid-red/30 border-unraid-red/10' : '',
|
error ? 'shadow-unraid-red/30 border-unraid-red/10' : '',
|
||||||
success ? 'shadow-green-600/30 border-green-600/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">
|
<div v-if="showCloseX" class="absolute z-20 right-0 top-0 pt-1 pr-1 sm:block">
|
||||||
<button
|
<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"
|
variant="ghost"
|
||||||
type="button"
|
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"
|
@click="closeModal"
|
||||||
>
|
>
|
||||||
<span class="sr-only">{{ t('Close') }}</span>
|
|
||||||
<XMarkIcon class="h-6 w-6" aria-hidden="true" />
|
<XMarkIcon class="h-6 w-6" aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { storeToRefs } from 'pinia';
|
|||||||
import { useCallbackActionsStore } from '~/store/callbackActions';
|
import { useCallbackActionsStore } from '~/store/callbackActions';
|
||||||
import { useTrialStore } from '~/store/trial';
|
import { useTrialStore } from '~/store/trial';
|
||||||
import { useUpdateOsStore } from '~/store/updateOs';
|
import { useUpdateOsStore } from '~/store/updateOs';
|
||||||
import ApiKeyCreate from '~/components/ApiKey/ApiKeyCreate.vue';
|
|
||||||
import UpcCallbackFeedback from '~/components/UserProfile/CallbackFeedback.vue';
|
import UpcCallbackFeedback from '~/components/UserProfile/CallbackFeedback.vue';
|
||||||
import UpcTrial from '~/components/UserProfile/Trial.vue';
|
import UpcTrial from '~/components/UserProfile/Trial.vue';
|
||||||
import UpdateOsCheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue';
|
import UpdateOsCheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue';
|
||||||
@@ -26,6 +25,5 @@ const { updateOsModalVisible, changelogModalVisible } = storeToRefs(useUpdateOsS
|
|||||||
<UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" />
|
<UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" />
|
||||||
<UpdateOsChangelogModal :t="t" :open="changelogModalVisible" />
|
<UpdateOsChangelogModal :t="t" :open="changelogModalVisible" />
|
||||||
<ActivationModal :t="t" />
|
<ActivationModal :t="t" />
|
||||||
<ApiKeyCreate :t="t" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ const icon = computed<{ component: Component; color: string } | null>(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative flex items-center justify-center">
|
||||||
<BellIcon class="w-6 h-6 text-header-text-primary" />
|
<BellIcon class="w-6 h-6 text-header-text-primary" />
|
||||||
<div
|
<div
|
||||||
v-if="!seen && indicatorLevel === 'UNREAD'"
|
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
|
<component
|
||||||
:is="icon.component"
|
:is="icon.component"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive } from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
import type { Component } from 'vue';
|
import { useMutation } from '@vue/apollo-composable';
|
||||||
import { computedAsync } from '@vueuse/core';
|
import { computedAsync } from '@vueuse/core';
|
||||||
import { Markdown } from '@/helpers/markdown';
|
|
||||||
import {
|
import {
|
||||||
ArchiveBoxIcon,
|
ArchiveBoxIcon,
|
||||||
CheckBadgeIcon,
|
CheckBadgeIcon,
|
||||||
@@ -12,8 +12,11 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@heroicons/vue/24/solid';
|
} from '@heroicons/vue/24/solid';
|
||||||
import { Button } from '@unraid/ui';
|
import { Button } from '@unraid/ui';
|
||||||
import { useMutation } from '@vue/apollo-composable';
|
import { Markdown } from '@/helpers/markdown';
|
||||||
|
|
||||||
import type { NotificationFragmentFragment } from '~/composables/gql/graphql';
|
import type { NotificationFragmentFragment } from '~/composables/gql/graphql';
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
import { NotificationType } from '~/composables/gql/graphql';
|
import { NotificationType } from '~/composables/gql/graphql';
|
||||||
import {
|
import {
|
||||||
archiveNotification as archiveMutation,
|
archiveNotification as archiveMutation,
|
||||||
@@ -85,10 +88,10 @@ const reformattedTimestamp = computed<string>(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="group/item relative py-5 flex flex-col gap-2 text-base">
|
<div class="group/item relative flex flex-col gap-2 py-3 text-base">
|
||||||
<header class="flex flex-row items-baseline justify-between gap-2 -translate-y-1">
|
<header class="flex -translate-y-1 flex-row items-baseline justify-between gap-2">
|
||||||
<h3
|
<h3
|
||||||
class="tracking-normal flex flex-row items-baseline gap-2 uppercase font-bold overflow-x-hidden"
|
class="m-0 flex flex-row items-baseline gap-2 overflow-x-hidden text-base font-semibold normal-case"
|
||||||
>
|
>
|
||||||
<!-- the `translate` compensates for extra space added by the `svg` element when rendered -->
|
<!-- the `translate` compensates for extra space added by the `svg` element when rendered -->
|
||||||
<component
|
<component
|
||||||
@@ -97,18 +100,18 @@ const reformattedTimestamp = computed<string>(() => {
|
|||||||
class="size-5 shrink-0 translate-y-1"
|
class="size-5 shrink-0 translate-y-1"
|
||||||
:class="icon.color"
|
:class="icon.color"
|
||||||
/>
|
/>
|
||||||
<span class="truncate flex-1" :title="title">{{ title }}</span>
|
<span class="flex-1 truncate" :title="title">{{ title }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="shrink-0 flex flex-row items-baseline justify-end gap-2 mt-1"
|
class="mt-1 flex shrink-0 flex-row items-baseline justify-end gap-2"
|
||||||
:title="formattedTimestamp ?? reformattedTimestamp"
|
:title="formattedTimestamp ?? reformattedTimestamp"
|
||||||
>
|
>
|
||||||
<p class="text-secondary-foreground text-sm">{{ reformattedTimestamp }}</p>
|
<p class="text-secondary-foreground text-sm">{{ reformattedTimestamp }}</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<h4 class="font-bold">
|
<h4 class="m-0 font-normal">
|
||||||
{{ subject }}
|
{{ subject }}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
@@ -118,27 +121,29 @@ const reformattedTimestamp = computed<string>(() => {
|
|||||||
|
|
||||||
<p v-if="mutationError" class="text-red-600">Error: {{ mutationError }}</p>
|
<p v-if="mutationError" class="text-red-600">Error: {{ mutationError }}</p>
|
||||||
|
|
||||||
<div class="flex justify-end items-baseline gap-4">
|
<div class="flex items-baseline justify-end gap-4">
|
||||||
<a v-if="link" :href="link">
|
<a
|
||||||
<Button type="button" variant="outline">
|
v-if="link"
|
||||||
<LinkIcon class="size-4 mr-2" />
|
:href="link"
|
||||||
<span class="text-sm">View</span>
|
class="text-primary inline-flex items-center justify-center text-sm font-medium hover:underline focus:underline"
|
||||||
</Button>
|
>
|
||||||
|
<LinkIcon class="mr-2 size-4" />
|
||||||
|
<span class="text-sm">View</span>
|
||||||
</a>
|
</a>
|
||||||
<Button
|
<Button
|
||||||
v-if="type === NotificationType.UNREAD"
|
v-if="type === NotificationType.UNREAD"
|
||||||
:disabled="archive.loading"
|
:disabled="archive.loading"
|
||||||
@click="archive.mutate"
|
@click="() => archive.mutate({ id: props.id })"
|
||||||
>
|
>
|
||||||
<ArchiveBoxIcon class="size-4 mr-2" />
|
<ArchiveBoxIcon class="mr-2 size-4" />
|
||||||
<span class="text-sm">Archive</span>
|
<span class="text-sm">Archive</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="type === NotificationType.ARCHIVE"
|
v-if="type === NotificationType.ARCHIVE"
|
||||||
:disabled="deleteNotification.loading"
|
:disabled="deleteNotification.loading"
|
||||||
@click="deleteNotification.mutate"
|
@click="() => deleteNotification.mutate({ id: props.id, type: props.type })"
|
||||||
>
|
>
|
||||||
<TrashIcon class="size-4 mr-2" />
|
<TrashIcon class="mr-2 size-4" />
|
||||||
<span class="text-sm">Delete</span>
|
<span class="text-sm">Delete</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,17 +93,28 @@ async function onLoadMore() {
|
|||||||
<div
|
<div
|
||||||
v-if="notifications?.length > 0"
|
v-if="notifications?.length > 0"
|
||||||
v-infinite-scroll="[onLoadMore, { canLoadMore: () => canLoadMore }]"
|
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
|
<TransitionGroup
|
||||||
v-for="notification in notifications"
|
name="notification-list"
|
||||||
:key="notification.id"
|
tag="div"
|
||||||
v-bind="notification"
|
class="divide-y"
|
||||||
/>
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
<div v-if="loading" class="py-5 grid place-content-center">
|
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 />
|
<LoadingSpinner />
|
||||||
</div>
|
</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...
|
You've reached the end...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
} from '@unraid/ui';
|
} from '@unraid/ui';
|
||||||
|
import { Settings } from 'lucide-vue-next';
|
||||||
|
|
||||||
import { useTrackLatestSeenNotification } from '~/composables/api/use-notifications';
|
import { useTrackLatestSeenNotification } from '~/composables/api/use-notifications';
|
||||||
import { useFragment } from '~/composables/gql';
|
import { useFragment } from '~/composables/gql';
|
||||||
@@ -94,7 +99,7 @@ onNotificationAdded(({ data }) => {
|
|||||||
[Importance.INFO]: globalThis.toast.info,
|
[Importance.INFO]: globalThis.toast.info,
|
||||||
};
|
};
|
||||||
const toast = funcMapping[notif.importance];
|
const toast = funcMapping[notif.importance];
|
||||||
const createOpener = () => ({ label: 'Open', onClick: () => location.assign(notif.link as string) });
|
const createOpener = () => ({ label: 'Open', onClick: () => window.location.assign(notif.link as string) });
|
||||||
|
|
||||||
requestAnimationFrame(() =>
|
requestAnimationFrame(() =>
|
||||||
toast(notif.title, {
|
toast(notif.title, {
|
||||||
@@ -125,34 +130,42 @@ const prepareToViewNotifications = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<SheetTrigger @click="prepareToViewNotifications">
|
<SheetTrigger as-child>
|
||||||
<span class="sr-only">Notifications</span>
|
<Button
|
||||||
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
|
variant="header"
|
||||||
|
size="header"
|
||||||
|
@click="prepareToViewNotifications"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Notifications</span>
|
||||||
|
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
|
||||||
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
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"
|
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">
|
<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>
|
<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>
|
</SheetHeader>
|
||||||
<Tabs
|
<Tabs
|
||||||
default-value="unread"
|
default-value="unread"
|
||||||
class="flex flex-1 flex-col min-h-0"
|
class="flex flex-1 flex-col min-h-0"
|
||||||
aria-label="Notification filters"
|
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">
|
<TabsList class="flex" aria-label="Filter notifications by status">
|
||||||
<TabsTrigger value="unread">
|
<TabsTrigger value="unread" as-child>
|
||||||
Unread <span v-if="overview">({{ overview.unread.total }})</span>
|
<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>
|
||||||
<TabsTrigger value="archived">
|
<TabsTrigger value="archived" as-child>
|
||||||
Archived
|
<Button variant="ghost" size="sm" class="inline-flex items-center gap-1 px-3 py-1">
|
||||||
<span v-if="overview">({{ readArchivedCount }})</span>
|
<span>Archived</span>
|
||||||
|
<span v-if="overview" class="font-normal">({{ readArchivedCount }})</span>
|
||||||
|
</Button>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="unread" class="flex-col items-end">
|
<TabsContent value="unread" class="flex-col items-end">
|
||||||
@@ -177,11 +190,13 @@ const prepareToViewNotifications = () => {
|
|||||||
Delete All
|
Delete All
|
||||||
</Button>
|
</Button>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center px-3 gap-2 mt-2">
|
||||||
<Select
|
<Select
|
||||||
:items="filterItems"
|
:items="filterItems"
|
||||||
placeholder="Filter By"
|
placeholder="Filter By"
|
||||||
class="h-auto"
|
class="h-8 px-3 text-sm"
|
||||||
@update:model-value="
|
@update:model-value="
|
||||||
(val: unknown) => {
|
(val: unknown) => {
|
||||||
const strVal = String(val);
|
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>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="unread" class="flex-col flex-1 min-h-0">
|
<TabsContent value="unread" class="flex-col flex-1 min-h-0">
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ declare global {
|
|||||||
|
|
||||||
<div v-else-if="error" class="py-8 text-center text-red-500">
|
<div v-else-if="error" class="py-8 text-center text-red-500">
|
||||||
<p class="mb-4">Failed to load remotes</p>
|
<p class="mb-4">Failed to load remotes</p>
|
||||||
<Button @click="refetchRemotes">Retry</Button>
|
<Button @click="() => refetchRemotes()">Retry</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="py-8 text-center">
|
<div v-else class="py-8 text-center">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
import { ShieldCheckIcon, ShieldExclamationIcon } from '@heroicons/vue/24/solid';
|
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 { RegistrationItemProps } from '~/types/registration';
|
||||||
import type { ServerStateDataAction } from '~/types/server';
|
import type { ServerStateDataAction } from '~/types/server';
|
||||||
@@ -30,7 +30,6 @@ import RegistrationKeyLinkedStatus from '~/components/Registration/KeyLinkedStat
|
|||||||
import RegistrationReplaceCheck from '~/components/Registration/ReplaceCheck.vue';
|
import RegistrationReplaceCheck from '~/components/Registration/ReplaceCheck.vue';
|
||||||
import RegistrationUpdateExpirationAction from '~/components/Registration/UpdateExpirationAction.vue';
|
import RegistrationUpdateExpirationAction from '~/components/Registration/UpdateExpirationAction.vue';
|
||||||
import UserProfileUptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
|
import UserProfileUptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
|
||||||
import RegistrationItem from '~/components/Registration/Item.vue';
|
|
||||||
import useDateTimeHelper from '~/composables/dateTime';
|
import useDateTimeHelper from '~/composables/dateTime';
|
||||||
import { useReplaceRenewStore } from '~/store/replaceRenew';
|
import { useReplaceRenewStore } from '~/store/replaceRenew';
|
||||||
import { useServerStore } from '~/store/server';
|
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 [
|
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
|
...(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
|
...(keyInstalled.value
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -235,6 +240,11 @@ const items = computed((): RegistrationItemProps[] => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionItems = computed((): RegistrationItemProps[] => {
|
||||||
|
return [
|
||||||
...(showLinkedAndTransferStatus.value
|
...(showLinkedAndTransferStatus.value
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -253,7 +263,6 @@ const items = computed((): RegistrationItemProps[] => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|
||||||
...(showFilteredKeyActions.value
|
...(showFilteredKeyActions.value
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -299,26 +308,84 @@ const items = computed((): RegistrationItemProps[] => {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<dl>
|
|
||||||
<RegistrationItem
|
<!-- Flash Drive Section -->
|
||||||
v-for="item in items"
|
<div v-if="flashDriveItems.length > 0" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
:key="item.label"
|
<h4 class="text-lg font-semibold mb-3">{{ t('Flash Drive') }}</h4>
|
||||||
:component="item?.component"
|
<SettingsGrid>
|
||||||
:component-props="item?.componentProps"
|
<template v-for="item in flashDriveItems" :key="item.label">
|
||||||
:error="item.error ?? false"
|
<div class="font-semibold flex items-center gap-x-2">
|
||||||
:warning="item.warning ?? false"
|
<ShieldExclamationIcon v-if="item.error" class="w-4 h-4 text-unraid-red" />
|
||||||
:label="item.label"
|
<span v-html="item.label" />
|
||||||
:text="item.text"
|
</div>
|
||||||
>
|
<div class="select-all" :class="[item.error ? 'text-unraid-red' : 'opacity-75']">
|
||||||
<template v-if="item.component" #right>
|
{{ item.text }}
|
||||||
<component
|
</div>
|
||||||
:is="item.component"
|
|
||||||
v-bind="item.componentProps"
|
|
||||||
:class="[item.componentOpacity && !item.error ? 'opacity-75' : '']"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</RegistrationItem>
|
</SettingsGrid>
|
||||||
</dl>
|
</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>
|
</div>
|
||||||
</CardWrapper>
|
</CardWrapper>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -6,25 +6,5 @@ import SsoButtons from './sso/SsoButtons.vue';
|
|||||||
<SsoButtons />
|
<SsoButtons />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<!-- Font size overrides are handled in standalone-mount.ts for custom elements -->
|
||||||
/* Font size overrides for 16px base (standard Tailwind sizing) */
|
|
||||||
:host {
|
|
||||||
/* Text sizes - standard Tailwind rem values */
|
|
||||||
--text-xs: 0.75rem; /* 12px */
|
|
||||||
--text-sm: 0.875rem; /* 14px */
|
|
||||||
--text-base: 1rem; /* 16px */
|
|
||||||
--text-lg: 1.125rem; /* 18px */
|
|
||||||
--text-xl: 1.25rem; /* 20px */
|
|
||||||
--text-2xl: 1.5rem; /* 24px */
|
|
||||||
--text-3xl: 1.875rem; /* 30px */
|
|
||||||
--text-4xl: 2.25rem; /* 36px */
|
|
||||||
--text-5xl: 3rem; /* 48px */
|
|
||||||
--text-6xl: 3.75rem; /* 60px */
|
|
||||||
--text-7xl: 4.5rem; /* 72px */
|
|
||||||
--text-8xl: 6rem; /* 96px */
|
|
||||||
--text-9xl: 8rem; /* 128px */
|
|
||||||
|
|
||||||
/* Spacing - standard Tailwind value */
|
|
||||||
--spacing: 0.25rem; /* 4px */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -61,15 +61,18 @@ onBeforeMount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<BrandLoading v-if="showLoader" class="mx-auto my-12 max-w-[160px]" />
|
<div v-show="showLoader">
|
||||||
|
<BrandLoading class="mx-auto my-12 max-w-[160px]" />
|
||||||
|
</div>
|
||||||
|
<div v-show="!showLoader">
|
||||||
<UpdateOsStatus
|
<UpdateOsStatus
|
||||||
v-else
|
|
||||||
:show-update-check="true"
|
:show-update-check="true"
|
||||||
:title="t('Update Unraid OS')"
|
:title="t('Update Unraid OS')"
|
||||||
:subtitle="subtitle"
|
:subtitle="subtitle"
|
||||||
:t="t"
|
:t="t"
|
||||||
/>
|
/>
|
||||||
<UpdateOsThirdPartyDrivers v-if="rebootType === 'thirdPartyDriversDownloading'" :t="t" />
|
<UpdateOsThirdPartyDrivers v-if="rebootType === 'thirdPartyDriversDownloading'" :t="t" />
|
||||||
</PageContainer>
|
</div>
|
||||||
|
</PageContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ import {
|
|||||||
KeyIcon,
|
KeyIcon,
|
||||||
ServerStackIcon,
|
ServerStackIcon,
|
||||||
} from '@heroicons/vue/24/solid';
|
} 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 { allowedDocsOriginRegex, allowedDocsUrlRegex } from '~/helpers/urls';
|
||||||
|
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
import type { ComposerTranslation } from 'vue-i18n';
|
||||||
@@ -18,7 +26,6 @@ import RawChangelogRenderer from '~/components/UpdateOs/RawChangelogRenderer.vue
|
|||||||
import { usePurchaseStore } from '~/store/purchase';
|
import { usePurchaseStore } from '~/store/purchase';
|
||||||
import { useThemeStore } from '~/store/theme';
|
import { useThemeStore } from '~/store/theme';
|
||||||
import { useUpdateOsStore } from '~/store/updateOs';
|
import { useUpdateOsStore } from '~/store/updateOs';
|
||||||
import Modal from '~/components/Modal.vue';
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
@@ -53,6 +60,7 @@ const isDarkMode = computed(() => {
|
|||||||
}
|
}
|
||||||
return darkMode.value;
|
return darkMode.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { availableWithRenewal, releaseForUpdate, changelogModalVisible } = storeToRefs(updateOsStore);
|
const { availableWithRenewal, releaseForUpdate, changelogModalVisible } = storeToRefs(updateOsStore);
|
||||||
const { setReleaseForUpdate, fetchAndConfirmInstall } = updateOsStore;
|
const { setReleaseForUpdate, fetchAndConfirmInstall } = updateOsStore;
|
||||||
|
|
||||||
@@ -166,22 +174,25 @@ watch(isDarkMode, () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal
|
<ResponsiveModal
|
||||||
v-if="currentRelease?.version"
|
v-if="currentRelease?.version"
|
||||||
:center-content="false"
|
|
||||||
max-width="max-w-[800px]"
|
|
||||||
:open="modalVisible"
|
:open="modalVisible"
|
||||||
:show-close-x="true"
|
sheet-side="bottom"
|
||||||
:t="t"
|
sheet-padding="none"
|
||||||
:tall-content="true"
|
:dialog-class="'max-w-[80rem] p-0'"
|
||||||
:title="t('Unraid OS {0} Changelog', [currentRelease.version])"
|
:show-close-button="true"
|
||||||
:disable-overlay-close="false"
|
@update:open="(value: boolean) => !value && handleClose()"
|
||||||
@close="handleClose"
|
|
||||||
>
|
>
|
||||||
<template #main>
|
<ResponsiveModalHeader>
|
||||||
<div class="flex flex-col gap-4 min-w-[280px] sm:min-w-[400px]">
|
<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 -->
|
<!-- 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
|
<iframe
|
||||||
v-if="actualIframeSrc"
|
v-if="actualIframeSrc"
|
||||||
ref="iframeRef"
|
ref="iframeRef"
|
||||||
@@ -205,17 +216,17 @@ watch(isDarkMode, () => {
|
|||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div
|
<div
|
||||||
v-else
|
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>
|
<p>{{ props.t('Loading changelog…') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<ResponsiveModalFooter>
|
||||||
<div :class="cn('flex flex-col-reverse xs:!flex-row justify-between gap-3 md:gap-4')">
|
<div :class="cn('flex flex-wrap justify-between gap-3 md:gap-4 w-full')">
|
||||||
<div :class="cn('flex flex-col-reverse xs:!flex-row xs:justify-start gap-3 md:gap-4')">
|
<div :class="cn('flex flex-wrap justify-start gap-3 md:gap-4')">
|
||||||
<!-- Back to changelog button (when navigated away) -->
|
<!-- Back to changelog button (when navigated away) -->
|
||||||
<BrandButton
|
<BrandButton
|
||||||
v-if="hasNavigated && docsChangelogUrl"
|
v-if="hasNavigated && docsChangelogUrl"
|
||||||
@@ -256,6 +267,6 @@ watch(isDarkMode, () => {
|
|||||||
</BrandButton>
|
</BrandButton>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</ResponsiveModalFooter>
|
||||||
</Modal>
|
</ResponsiveModal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,8 +10,20 @@ import {
|
|||||||
KeyIcon,
|
KeyIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/vue/24/solid';
|
} from '@heroicons/vue/24/solid';
|
||||||
import { BrandButton, BrandLoading, cn } from '@unraid/ui';
|
import {
|
||||||
import { Switch, SwitchGroup, SwitchLabel } from '@headlessui/vue';
|
Button,
|
||||||
|
BrandButton,
|
||||||
|
BrandLoading,
|
||||||
|
cn,
|
||||||
|
DialogRoot,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
Switch,
|
||||||
|
Label,
|
||||||
|
} from '@unraid/ui';
|
||||||
|
|
||||||
import type { BrandButtonProps } from '@unraid/ui';
|
import type { BrandButtonProps } from '@unraid/ui';
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
import type { ComposerTranslation } from 'vue-i18n';
|
||||||
@@ -21,7 +33,6 @@ import { useAccountStore } from '~/store/account';
|
|||||||
import { usePurchaseStore } from '~/store/purchase';
|
import { usePurchaseStore } from '~/store/purchase';
|
||||||
import { useServerStore } from '~/store/server';
|
import { useServerStore } from '~/store/server';
|
||||||
import { useUpdateOsStore } from '~/store/updateOs';
|
import { useUpdateOsStore } from '~/store/updateOs';
|
||||||
import Modal from '~/components/Modal.vue';
|
|
||||||
import UpdateOsIgnoredRelease from './IgnoredRelease.vue';
|
import UpdateOsIgnoredRelease from './IgnoredRelease.vue';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
@@ -264,110 +275,109 @@ const modalWidth = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal
|
<DialogRoot
|
||||||
:t="t"
|
|
||||||
:open="open"
|
:open="open"
|
||||||
:title="modalCopy?.title"
|
@update:open="(value) => !value && close()"
|
||||||
:description="modalCopy?.description"
|
|
||||||
:show-close-x="!checkForUpdatesLoading"
|
|
||||||
:max-width="modalWidth"
|
|
||||||
@close="close"
|
|
||||||
>
|
>
|
||||||
<template v-if="renderMainSlot" #main>
|
<DialogContent
|
||||||
<BrandLoading v-if="checkForUpdatesLoading" class="w-[150px] mx-auto" />
|
:class="modalWidth"
|
||||||
<div v-else class="flex flex-col gap-y-4">
|
:show-close-button="!checkForUpdatesLoading"
|
||||||
<div v-if="extraLinks.length > 0" :class="cn('flex flex-col xs:!flex-row justify-center gap-2')">
|
>
|
||||||
<BrandButton
|
<DialogHeader v-if="modalCopy?.title">
|
||||||
v-for="item in extraLinks"
|
<DialogTitle>
|
||||||
:key="item.text"
|
{{ modalCopy.title }}
|
||||||
:btn-style="item.variant ?? undefined"
|
</DialogTitle>
|
||||||
:href="item.href ?? undefined"
|
<DialogDescription v-if="modalCopy?.description">
|
||||||
:icon="item.icon"
|
<span v-html="modalCopy.description" />
|
||||||
:icon-right="item.iconRight"
|
</DialogDescription>
|
||||||
:icon-right-hover-display="item.iconRightHoverDisplay"
|
</DialogHeader>
|
||||||
:text="t(item.text ?? '')"
|
|
||||||
:title="item.title ? t(item.title) : undefined"
|
|
||||||
@click="item.click?.()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="available || availableWithRenewal" class="mx-auto">
|
<div v-if="renderMainSlot" class="flex flex-col gap-4">
|
||||||
<SwitchGroup>
|
<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">
|
<div class="flex justify-center items-center gap-2 p-2 rounded">
|
||||||
<Switch
|
<Switch
|
||||||
v-model="ignoreThisRelease"
|
v-model="ignoreThisRelease"
|
||||||
:class="
|
: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"
|
/>
|
||||||
>
|
<Label class="text-base">
|
||||||
<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">
|
|
||||||
{{ t('Ignore this release until next reboot') }}
|
{{ t('Ignore this release until next reboot') }}
|
||||||
</SwitchLabel>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</SwitchGroup>
|
</div>
|
||||||
</div>
|
<div
|
||||||
<div
|
v-else-if="updateOsIgnoredReleases.length > 0"
|
||||||
v-else-if="updateOsIgnoredReleases.length > 0"
|
class="w-full max-w-[640px] mx-auto flex flex-col gap-2"
|
||||||
class="w-full max-w-[640px] mx-auto flex flex-col gap-2"
|
>
|
||||||
>
|
<h3 class="text-left text-base font-semibold italic">
|
||||||
<h3 class="text-left text-base font-semibold italic">
|
{{ t('Ignored Releases') }}
|
||||||
{{ t('Ignored Releases') }}
|
</h3>
|
||||||
</h3>
|
<UpdateOsIgnoredRelease
|
||||||
<UpdateOsIgnoredRelease
|
v-for="ignoredRelease in updateOsIgnoredReleases"
|
||||||
v-for="ignoredRelease in updateOsIgnoredReleases"
|
:key="ignoredRelease"
|
||||||
:key="ignoredRelease"
|
:label="ignoredRelease"
|
||||||
:label="ignoredRelease"
|
:t="t"
|
||||||
:t="t"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #footer>
|
<DialogFooter>
|
||||||
<div
|
<div
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'w-full flex gap-2 mx-auto',
|
'w-full flex gap-2 mx-auto',
|
||||||
actionButtons ? 'flex-col-reverse xs:!flex-row justify-between' : 'justify-center'
|
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')">
|
<div :class="cn('flex flex-col-reverse xs:!flex-row justify-start gap-2')">
|
||||||
<BrandButton
|
<Button
|
||||||
variant="underline-hover-red"
|
variant="ghost"
|
||||||
:icon="XMarkIcon"
|
@click="close"
|
||||||
:text="t('Close')"
|
>
|
||||||
@click="close"
|
<XMarkIcon class="w-4 h-4 mr-2" />
|
||||||
/>
|
{{ t('Close') }}
|
||||||
<BrandButton
|
</Button>
|
||||||
variant="underline"
|
<Button
|
||||||
:icon="ArrowTopRightOnSquareIcon"
|
variant="ghost"
|
||||||
:text="t('More options')"
|
@click="accountStore.updateOs()"
|
||||||
@click="accountStore.updateOs()"
|
>
|
||||||
/>
|
<ArrowTopRightOnSquareIcon class="w-4 h-4 mr-2" />
|
||||||
|
{{ t('More options') }}
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
<div v-if="actionButtons" :class="cn('flex flex-col xs:!flex-row justify-end gap-2')">
|
</DialogFooter>
|
||||||
<BrandButton
|
</DialogContent>
|
||||||
v-for="item in actionButtons"
|
</DialogRoot>
|
||||||
: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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const mutatedParsedChangelog = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div v-if="parseChangelogFailed" class="text-center flex flex-col gap-4 prose">
|
||||||
<h2 class="text-lg text-unraid-red italic font-semibold">
|
<h2 class="text-lg text-unraid-red italic font-semibold">
|
||||||
{{ props.t(`Error Parsing Changelog • {0}`, [parseChangelogFailed]) }}
|
{{ props.t(`Error Parsing Changelog • {0}`, [parseChangelogFailed]) }}
|
||||||
@@ -138,4 +138,4 @@ const mutatedParsedChangelog = computed(() => {
|
|||||||
<p>{{ props.t('No changelog content available') }}</p>
|
<p>{{ props.t('No changelog content available') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import {
|
|||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from '@heroicons/vue/24/solid';
|
} from '@heroicons/vue/24/solid';
|
||||||
import { Badge, BrandButton, BrandLoading } from '@unraid/ui';
|
import { Badge, BrandLoading, Button } from '@unraid/ui';
|
||||||
import { WEBGUI_TOOLS_REGISTRATION } from '~/helpers/urls';
|
import { WEBGUI_TOOLS_REGISTRATION } from '~/helpers/urls';
|
||||||
|
|
||||||
import type { BrandButtonProps } from '@unraid/ui';
|
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
import type { ComposerTranslation } from 'vue-i18n';
|
||||||
|
|
||||||
import useDateTimeHelper from '~/composables/dateTime';
|
import useDateTimeHelper from '~/composables/dateTime';
|
||||||
@@ -44,7 +43,7 @@ const serverStore = useServerStore();
|
|||||||
const updateOsStore = useUpdateOsStore();
|
const updateOsStore = useUpdateOsStore();
|
||||||
const updateOsActionsStore = useUpdateOsActionsStore();
|
const updateOsActionsStore = useUpdateOsActionsStore();
|
||||||
|
|
||||||
const LoadingIcon = () => h(BrandLoading, { variant: 'white' });
|
const LoadingIcon = () => h(BrandLoading, { variant: 'white', style: 'width: 16px; height: 16px;' });
|
||||||
|
|
||||||
const { dateTimeFormat, osVersion, rebootType, rebootVersion, regExp, regUpdatesExpired } =
|
const { dateTimeFormat, osVersion, rebootType, rebootVersion, regExp, regUpdatesExpired } =
|
||||||
storeToRefs(serverStore);
|
storeToRefs(serverStore);
|
||||||
@@ -74,7 +73,7 @@ const showRebootButton = computed(
|
|||||||
() => rebootType.value === 'downgrade' || rebootType.value === 'update'
|
() => rebootType.value === 'downgrade' || rebootType.value === 'update'
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkButton = computed((): BrandButtonProps => {
|
const checkButton = computed(() => {
|
||||||
if (showRebootButton.value || props.showExternalDowngrade) {
|
if (showRebootButton.value || props.showExternalDowngrade) {
|
||||||
return {
|
return {
|
||||||
click: () => {
|
click: () => {
|
||||||
@@ -84,7 +83,7 @@ const checkButton = computed((): BrandButtonProps => {
|
|||||||
accountStore.updateOs();
|
accountStore.updateOs();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: ArrowTopRightOnSquareIcon,
|
icon: () => h(ArrowTopRightOnSquareIcon, { style: 'width: 16px; height: 16px;' }),
|
||||||
text: props.t('More options'),
|
text: props.t('More options'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -94,7 +93,7 @@ const checkButton = computed((): BrandButtonProps => {
|
|||||||
click: () => {
|
click: () => {
|
||||||
updateOsStore.localCheckForUpdate();
|
updateOsStore.localCheckForUpdate();
|
||||||
},
|
},
|
||||||
icon: ArrowPathIcon,
|
icon: () => h(ArrowPathIcon, { style: 'width: 16px; height: 16px;' }),
|
||||||
text: props.t('Check for Update'),
|
text: props.t('Check for Update'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -104,12 +103,18 @@ const checkButton = computed((): BrandButtonProps => {
|
|||||||
click: () => {
|
click: () => {
|
||||||
updateOsStore.setModalOpen(true);
|
updateOsStore.setModalOpen(true);
|
||||||
},
|
},
|
||||||
icon: BellAlertIcon,
|
icon: () => h(BellAlertIcon, { style: 'width: 16px; height: 16px;' }),
|
||||||
text: availableWithRenewal.value
|
text: availableWithRenewal.value
|
||||||
? props.t('Unraid OS {0} Released', [availableWithRenewal.value])
|
? props.t('Unraid OS {0} Released', [availableWithRenewal.value])
|
||||||
: props.t('Unraid OS {0} Update Available', [available.value]),
|
: props.t('Unraid OS {0} Update Available', [available.value]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const navigateToRegistration = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = WEBGUI_TOOLS_REGISTRATION.toString();
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -123,32 +128,34 @@ const checkButton = computed((): BrandButtonProps => {
|
|||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
<div class="flex flex-col md:flex-row gap-4 justify-start md:items-start md:justify-between">
|
<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">
|
<div class="inline-flex flex-wrap justify-start items-center gap-2">
|
||||||
<button
|
<Button
|
||||||
class="group"
|
variant="ghost"
|
||||||
|
class="p-0 h-auto hover:bg-transparent"
|
||||||
:title="t('View release notes')"
|
:title="t('View release notes')"
|
||||||
@click="updateOsActionsStore.viewReleaseNotes(t('{0} Release Notes', [osVersion]))"
|
@click="updateOsActionsStore.viewReleaseNotes(t('{0} Release Notes', [osVersion]))"
|
||||||
>
|
>
|
||||||
<Badge :icon="InformationCircleIcon" variant="gray" size="md">
|
<Badge :icon="() => h(InformationCircleIcon, { style: 'width: 16px; height: 16px;' })" variant="gray" size="md">
|
||||||
{{ t('Current Version {0}', [osVersion]) }}
|
{{ t('Current Version {0}', [osVersion]) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<a
|
<Button
|
||||||
v-if="ineligibleText && !availableWithRenewal"
|
v-if="ineligibleText && !availableWithRenewal"
|
||||||
:href="WEBGUI_TOOLS_REGISTRATION.toString()"
|
variant="ghost"
|
||||||
class="group"
|
class="p-0 h-auto hover:bg-transparent"
|
||||||
:title="t('Learn more and fix')"
|
:title="t('Learn more and fix')"
|
||||||
|
@click="navigateToRegistration"
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
variant="yellow"
|
variant="yellow"
|
||||||
:icon="ExclamationTriangleIcon"
|
:icon="() => h(ExclamationTriangleIcon, { style: 'width: 16px; height: 16px;' })"
|
||||||
:title="regExpOutput?.text"
|
:title="regExpOutput?.text"
|
||||||
class="underline"
|
class="underline"
|
||||||
>
|
>
|
||||||
{{ t('Key ineligible for future releases') }}
|
{{ t('Key ineligible for future releases') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</a>
|
</Button>
|
||||||
<Badge
|
<Badge
|
||||||
v-else-if="ineligibleText && availableWithRenewal"
|
v-else-if="ineligibleText && availableWithRenewal"
|
||||||
variant="yellow"
|
variant="yellow"
|
||||||
@@ -165,7 +172,7 @@ const checkButton = computed((): BrandButtonProps => {
|
|||||||
<Badge
|
<Badge
|
||||||
v-if="rebootType === ''"
|
v-if="rebootType === ''"
|
||||||
:variant="updateAvailable ? 'orange' : 'green'"
|
:variant="updateAvailable ? 'orange' : 'green'"
|
||||||
:icon="updateAvailable ? BellAlertIcon : CheckCircleIcon"
|
:icon="updateAvailable ? () => h(BellAlertIcon, { style: 'width: 16px; height: 16px;' }) : () => h(CheckCircleIcon, { style: 'width: 16px; height: 16px;' })"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
available
|
available
|
||||||
@@ -175,46 +182,54 @@ const checkButton = computed((): BrandButtonProps => {
|
|||||||
: t('Up-to-date')
|
: t('Up-to-date')
|
||||||
}}
|
}}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-else variant="yellow" :icon="ExclamationTriangleIcon">
|
<Badge v-else variant="yellow" :icon="() => h(ExclamationTriangleIcon, { style: 'width: 16px; height: 16px;' })">
|
||||||
{{ t(rebootTypeText) }}
|
{{ t(rebootTypeText) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Badge v-if="downgradeNotAvailable" variant="gray" :icon="XCircleIcon">
|
<Badge v-if="downgradeNotAvailable" variant="gray" :icon="() => h(XCircleIcon, { style: 'width: 16px; height: 16px;' })">
|
||||||
{{ t('No downgrade available') }}
|
{{ t('No downgrade available') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline-flex flex-col shrink-0 gap-4 grow items-center md:items-end">
|
<div class="inline-flex flex-col shrink-0 gap-4 grow items-center md:items-end">
|
||||||
<span v-if="showRebootButton">
|
<Button
|
||||||
<BrandButton
|
v-if="showRebootButton"
|
||||||
:icon="ArrowPathIcon"
|
variant="primary"
|
||||||
:text="
|
:title="
|
||||||
rebootType === 'downgrade'
|
rebootType === 'downgrade'
|
||||||
? t('Reboot Now to Downgrade to {0}', [rebootVersion])
|
? t('Reboot Now to Downgrade to {0}', [rebootVersion])
|
||||||
: t('Reboot Now to Update to {0}', [rebootVersion])
|
: t('Reboot Now to Update to {0}', [rebootVersion])
|
||||||
"
|
"
|
||||||
@click="updateOsActionsStore.rebootServer()"
|
@click="updateOsActionsStore.rebootServer()"
|
||||||
/>
|
>
|
||||||
</span>
|
<ArrowPathIcon class="shrink-0" style="width: 16px; height: 16px;" />
|
||||||
|
{{
|
||||||
|
rebootType === 'downgrade'
|
||||||
|
? t('Reboot Now to Downgrade to {0}', [rebootVersion])
|
||||||
|
: t('Reboot Now to Update to {0}', [rebootVersion])
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<span>
|
<Button
|
||||||
<BrandButton
|
:variant="checkButton.variant === 'fill' ? 'pill-orange' : 'pill-gray'"
|
||||||
:variant="checkButton.variant"
|
:title="checkButton.text"
|
||||||
:icon="checkButton.icon"
|
:disabled="status === 'checking'"
|
||||||
:text="checkButton.text"
|
@click="checkButton.click"
|
||||||
@click="checkButton.click"
|
>
|
||||||
/>
|
<component :is="checkButton.icon" class="shrink-0" style="width: 16px; height: 16px;" />
|
||||||
</span>
|
{{ checkButton.text }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<span v-if="rebootType !== ''">
|
<Button
|
||||||
<BrandButton
|
v-if="rebootType !== ''"
|
||||||
variant="outline"
|
variant="pill-gray"
|
||||||
:icon="XCircleIcon"
|
:title="t('Cancel {0}', [rebootType === 'downgrade' ? t('Downgrade') : t('Update')])"
|
||||||
:text="t('Cancel {0}', [rebootType === 'downgrade' ? t('Downgrade') : t('Update')])"
|
@click="updateOsStore.cancelUpdate()"
|
||||||
@click="updateOsStore.cancelUpdate()"
|
>
|
||||||
/>
|
<XCircleIcon class="shrink-0" style="width: 16px; height: 16px;" />
|
||||||
</span>
|
{{ t('Cancel {0}', [rebootType === 'downgrade' ? t('Downgrade') : t('Update')]) }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onBeforeMount, onMounted, ref, watch } from 'vue';
|
import { onBeforeMount, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { storeToRefs } from 'pinia';
|
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 { devConfig } from '~/helpers/env';
|
||||||
|
|
||||||
import type { Server } from '~/types/server';
|
import type { Server } from '~/types/server';
|
||||||
@@ -12,9 +12,7 @@ import type { Server } from '~/types/server';
|
|||||||
import NotificationsSidebar from '~/components/Notifications/Sidebar.vue';
|
import NotificationsSidebar from '~/components/Notifications/Sidebar.vue';
|
||||||
import UpcDropdownContent from '~/components/UserProfile/DropdownContent.vue';
|
import UpcDropdownContent from '~/components/UserProfile/DropdownContent.vue';
|
||||||
import UpcDropdownTrigger from '~/components/UserProfile/DropdownTrigger.vue';
|
import UpcDropdownTrigger from '~/components/UserProfile/DropdownTrigger.vue';
|
||||||
import UpcServerState from '~/components/UserProfile/ServerState.vue';
|
import UpcServerStatus from '~/components/UserProfile/ServerStatus.vue';
|
||||||
// Auto-imported components - now manually imported
|
|
||||||
import UpcUptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
|
|
||||||
import { useCallbackActionsStore } from '~/store/callbackActions';
|
import { useCallbackActionsStore } from '~/store/callbackActions';
|
||||||
import { useServerStore } from '~/store/server';
|
import { useServerStore } from '~/store/server';
|
||||||
import { useThemeStore } from '~/store/theme';
|
import { useThemeStore } from '~/store/theme';
|
||||||
@@ -33,28 +31,18 @@ const { callbackData } = storeToRefs(callbackStore);
|
|||||||
const { name, description, guid, keyfile, lanIp } = storeToRefs(serverStore);
|
const { name, description, guid, keyfile, lanIp } = storeToRefs(serverStore);
|
||||||
const { bannerGradient, theme } = storeToRefs(useThemeStore());
|
const { bannerGradient, theme } = storeToRefs(useThemeStore());
|
||||||
|
|
||||||
|
// Control dropdown open state
|
||||||
|
const dropdownOpen = ref(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy LAN IP on server name click
|
* Copy LAN IP on server name click
|
||||||
*/
|
*/
|
||||||
let copyIpInterval: string | number | NodeJS.Timeout | undefined;
|
const { copyWithNotification } = useClipboardWithToast();
|
||||||
const { copy, copied, isSupported } = useClipboard({ source: lanIp.value ?? '' });
|
const copyLanIp = async () => {
|
||||||
const showCopyNotSupported = ref<boolean>(false);
|
if (lanIp.value) {
|
||||||
const copyLanIp = () => {
|
await copyWithNotification(lanIp.value, t('LAN IP Copied'));
|
||||||
// if http then clipboard is not supported
|
|
||||||
if (!isSupported || window.location.protocol === 'http:') {
|
|
||||||
showCopyNotSupported.value = true;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
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
|
* Sets the server store and locale messages then listen for callbacks
|
||||||
@@ -104,57 +92,46 @@ onMounted(() => {
|
|||||||
:style="bannerGradient"
|
:style="bannerGradient"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<UpcServerStatus class="relative z-10" />
|
||||||
: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">•</span>
|
|
||||||
<UpcServerState :t="t" class="text-xs" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-10 flex flex-row items-center justify-end gap-x-4 h-full">
|
<div class="relative z-10 flex flex-row items-center justify-end gap-x-2 h-full">
|
||||||
<h1
|
<div
|
||||||
class="text-md sm:text-lg relative flex flex-col-reverse items-end md:flex-row border-0 text-header-text-primary"
|
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">
|
<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-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-block px-2">•</span>
|
<span class="text-header-text-secondary hidden md:inline-flex md:items-center px-2">•</span>
|
||||||
</template>
|
</template>
|
||||||
<button
|
<Button
|
||||||
v-if="lanIp"
|
v-if="lanIp"
|
||||||
|
variant="ghost"
|
||||||
:title="t('Click to Copy LAN IP {0}', [lanIp])"
|
: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()"
|
@click="copyLanIp()"
|
||||||
>
|
>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</button>
|
</Button>
|
||||||
<span v-else class="text-header-text-primary">
|
<span v-else class="text-header-text-primary text-sm xs:text-base flex items-center">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
</div>
|
||||||
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" />
|
|
||||||
|
|
||||||
<NotificationsSidebar />
|
<NotificationsSidebar />
|
||||||
|
|
||||||
<DropdownMenu align="end" side="bottom" :side-offset="4">
|
<DropdownMenu
|
||||||
|
v-model:open="dropdownOpen"
|
||||||
|
align="end"
|
||||||
|
side="bottom"
|
||||||
|
:side-offset="4"
|
||||||
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<UpcDropdownTrigger :t="t" />
|
<UpcDropdownTrigger />
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="max-w-[350px] sm:min-w-[350px]">
|
<div class="max-w-[350px] sm:min-w-[350px]">
|
||||||
<UpcDropdownContent :t="t" />
|
<UpcDropdownContent
|
||||||
|
@close-dropdown="dropdownOpen = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ export interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
colorClasses: 'text-grey-mid border-grey-mid',
|
colorClasses: 'text-grey-mid border-muted',
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<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"
|
:class="colorClasses"
|
||||||
>
|
>
|
||||||
{{ 'Beta' }}
|
{{ 'Beta' }}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from '~/helpers/urls';
|
} from '~/helpers/urls';
|
||||||
|
|
||||||
import type { UserProfileLink } from '~/types/userProfile';
|
import type { UserProfileLink } from '~/types/userProfile';
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import { useAccountStore } from '~/store/account';
|
import { useAccountStore } from '~/store/account';
|
||||||
import { useErrorsStore } from '~/store/errors';
|
import { useErrorsStore } from '~/store/errors';
|
||||||
@@ -34,7 +34,11 @@ import DropdownError from './DropdownError.vue';
|
|||||||
import DropdownItem from './DropdownItem.vue';
|
import DropdownItem from './DropdownItem.vue';
|
||||||
import Keyline from './Keyline.vue';
|
import Keyline from './Keyline.vue';
|
||||||
|
|
||||||
const props = defineProps<{ t: ComposerTranslation }>();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'close-dropdown': []
|
||||||
|
}>();
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const errorsStore = useErrorsStore();
|
const errorsStore = useErrorsStore();
|
||||||
@@ -72,10 +76,11 @@ const manageUnraidNetAccount = computed((): UserProfileLink => {
|
|||||||
external: true,
|
external: true,
|
||||||
click: () => {
|
click: () => {
|
||||||
accountStore.manage();
|
accountStore.manage();
|
||||||
|
emit('close-dropdown');
|
||||||
},
|
},
|
||||||
icon: UserIcon,
|
icon: UserIcon,
|
||||||
text: props.t('Manage Unraid.net Account'),
|
text: t('Manage Unraid.net Account'),
|
||||||
title: props.t('Manage Unraid.net Account in new tab'),
|
title: t('Manage Unraid.net Account in new tab'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,21 +88,23 @@ const updateOsCheckForUpdatesButton = computed((): UserProfileLink => {
|
|||||||
return {
|
return {
|
||||||
click: () => {
|
click: () => {
|
||||||
updateOsStore.localCheckForUpdate();
|
updateOsStore.localCheckForUpdate();
|
||||||
|
emit('close-dropdown');
|
||||||
},
|
},
|
||||||
icon: ArrowPathIcon,
|
icon: ArrowPathIcon,
|
||||||
text: props.t('Check for Update'),
|
text: t('Check for Update'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const updateOsResponseModalOpenButton = computed((): UserProfileLink => {
|
const updateOsResponseModalOpenButton = computed((): UserProfileLink => {
|
||||||
return {
|
return {
|
||||||
click: () => {
|
click: () => {
|
||||||
updateOsStore.setModalOpen(true);
|
updateOsStore.setModalOpen(true);
|
||||||
|
emit('close-dropdown');
|
||||||
},
|
},
|
||||||
emphasize: true,
|
emphasize: true,
|
||||||
icon: BellAlertIcon,
|
icon: BellAlertIcon,
|
||||||
text: osUpdateAvailableWithRenewal.value
|
text: osUpdateAvailableWithRenewal.value
|
||||||
? props.t('Unraid OS {0} Released', [osUpdateAvailableWithRenewal.value])
|
? t('Unraid OS {0} Released', [osUpdateAvailableWithRenewal.value])
|
||||||
: props.t('Unraid OS {0} Update Available', [osUpdateAvailable.value]),
|
: t('Unraid OS {0} Update Available', [osUpdateAvailable.value]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const rebootDetectedButton = computed((): UserProfileLink => {
|
const rebootDetectedButton = computed((): UserProfileLink => {
|
||||||
@@ -109,8 +116,8 @@ const rebootDetectedButton = computed((): UserProfileLink => {
|
|||||||
icon: ExclamationTriangleIcon,
|
icon: ExclamationTriangleIcon,
|
||||||
text:
|
text:
|
||||||
rebootType.value === 'downgrade'
|
rebootType.value === 'downgrade'
|
||||||
? props.t('Reboot Required for Downgrade')
|
? t('Reboot Required for Downgrade')
|
||||||
: props.t('Reboot Required for Update'),
|
: t('Reboot Required for Update'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,8 +143,8 @@ const links = computed((): UserProfileLink[] => {
|
|||||||
{
|
{
|
||||||
href: WEBGUI_TOOLS_REGISTRATION.toString(),
|
href: WEBGUI_TOOLS_REGISTRATION.toString(),
|
||||||
icon: KeyIcon,
|
icon: KeyIcon,
|
||||||
text: props.t('OS Update Eligibility Expired'),
|
text: t('OS Update Eligibility Expired'),
|
||||||
title: props.t('Go to Tools > Registration to Learn More'),
|
title: t('Go to Tools > Registration to Learn More'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -153,8 +160,8 @@ const links = computed((): UserProfileLink[] => {
|
|||||||
external: true,
|
external: true,
|
||||||
href: CONNECT_DASHBOARD.toString(),
|
href: CONNECT_DASHBOARD.toString(),
|
||||||
icon: ArrowTopRightOnSquareIcon,
|
icon: ArrowTopRightOnSquareIcon,
|
||||||
text: props.t('Go to Connect'),
|
text: t('Go to Connect'),
|
||||||
title: props.t('Opens Connect in new tab'),
|
title: t('Opens Connect in new tab'),
|
||||||
},
|
},
|
||||||
...[manageUnraidNetAccount.value],
|
...[manageUnraidNetAccount.value],
|
||||||
...signOutAction.value,
|
...signOutAction.value,
|
||||||
@@ -163,8 +170,8 @@ const links = computed((): UserProfileLink[] => {
|
|||||||
{
|
{
|
||||||
href: WEBGUI_CONNECT_SETTINGS.toString(),
|
href: WEBGUI_CONNECT_SETTINGS.toString(),
|
||||||
icon: CogIcon,
|
icon: CogIcon,
|
||||||
text: props.t('Settings'),
|
text: t('Settings'),
|
||||||
title: props.t('Go to API Settings'),
|
title: t('Go to API Settings'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -187,8 +194,8 @@ const unraidConnectWelcome = computed(() => {
|
|||||||
!stateDataError.value
|
!stateDataError.value
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
heading: props.t('Thank you for installing Connect!'),
|
heading: t('Thank you for installing Connect!'),
|
||||||
message: props.t('Sign In to your Unraid.net account to get started'),
|
message: t('Sign In to your Unraid.net account to get started'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -199,7 +206,7 @@ const unraidConnectWelcome = computed(() => {
|
|||||||
<div class="flex flex-col grow gap-y-2">
|
<div class="flex flex-col grow gap-y-2">
|
||||||
<header
|
<header
|
||||||
v-if="connectPluginInstalled"
|
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">
|
<h2 class="text-lg leading-none flex flex-row gap-x-1 items-center justify-between">
|
||||||
<BrandLogoConnect
|
<BrandLogoConnect
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const { errors } = storeToRefs(errorsStore);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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">
|
<h3 class="text-lg py-1 px-3 text-white bg-unraid-red/90 font-semibold">
|
||||||
<span>{{ t(error.heading) }}</span>
|
<span>{{ t(error.heading) }}</span>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
||||||
|
import { Button } from '@unraid/ui';
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
import type { ComposerTranslation } from 'vue-i18n';
|
||||||
|
|
||||||
import type { ServerStateDataAction } from '~/types/server';
|
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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component
|
<Button
|
||||||
:is="item?.click ? 'button' : 'a'"
|
:as="item?.click ? 'button' : 'a'"
|
||||||
:disabled="item?.disabled"
|
:disabled="item?.disabled"
|
||||||
:href="item?.href ?? null"
|
:href="item?.href ?? null"
|
||||||
:target="item?.external ? '_blank' : null"
|
:target="item?.external ? '_blank' : null"
|
||||||
:rel="item?.external ? 'noopener noreferrer' : 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"
|
variant="ghost"
|
||||||
:class="{
|
:class="buttonClass"
|
||||||
'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,
|
|
||||||
}"
|
|
||||||
@click.stop="item?.click ? item?.click(item?.clickParams ?? []) : null"
|
@click.stop="item?.click ? item?.click(item?.clickParams ?? []) : null"
|
||||||
>
|
>
|
||||||
<span class="leading-snug inline-flex flex-row items-center gap-x-2">
|
<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"
|
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"
|
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>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,54 +1,53 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { Button } from '@unraid/ui';
|
||||||
import {
|
import {
|
||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
BellAlertIcon,
|
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
ShieldExclamationIcon,
|
ShieldExclamationIcon,
|
||||||
} from '@heroicons/vue/24/solid';
|
} from '@heroicons/vue/24/solid';
|
||||||
|
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
|
||||||
|
|
||||||
import BrandAvatar from '~/components/Brand/Avatar.vue';
|
import BrandAvatar from '~/components/Brand/Avatar.vue';
|
||||||
import { useErrorsStore } from '~/store/errors';
|
import { useErrorsStore } from '~/store/errors';
|
||||||
import { useServerStore } from '~/store/server';
|
import { useServerStore } from '~/store/server';
|
||||||
import { useUpdateOsStore } from '~/store/updateOs';
|
|
||||||
|
|
||||||
const props = defineProps<{ t: ComposerTranslation }>();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { errors } = storeToRefs(useErrorsStore());
|
const { errors } = storeToRefs(useErrorsStore());
|
||||||
const { connectPluginInstalled, rebootType, state, stateData } = storeToRefs(useServerStore());
|
const { connectPluginInstalled, state, stateData } = storeToRefs(useServerStore());
|
||||||
const { available: osUpdateAvailable } = storeToRefs(useUpdateOsStore());
|
|
||||||
|
|
||||||
const showErrorIcon = computed(() => errors.value.length || stateData.value.error);
|
const showErrorIcon = computed(() => errors.value.length || stateData.value.error);
|
||||||
|
|
||||||
const text = computed((): string => {
|
const text = computed((): string => {
|
||||||
if (stateData.value.error && state.value !== 'EEXPIRED') {
|
if (stateData.value.error && state.value !== 'EEXPIRED') {
|
||||||
return props.t('Fix Error');
|
return t('Fix Error');
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const title = computed((): string => {
|
const title = computed((): string => {
|
||||||
if (state.value === 'ENOKEYFILE') {
|
if (state.value === 'ENOKEYFILE') {
|
||||||
return props.t('Get Started');
|
return t('Get Started');
|
||||||
}
|
}
|
||||||
if (state.value === 'EEXPIRED') {
|
if (state.value === 'EEXPIRED') {
|
||||||
return props.t('Trial Expired, see options below');
|
return t('Trial Expired, see options below');
|
||||||
}
|
}
|
||||||
if (showErrorIcon.value) {
|
if (showErrorIcon.value) {
|
||||||
return props.t('Learn more about the error');
|
return t('Learn more about the error');
|
||||||
}
|
}
|
||||||
return props.t('Open Dropdown');
|
return t('Open Dropdown');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<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"
|
variant="header"
|
||||||
|
size="header"
|
||||||
|
class="justify-center gap-x-1.5 pl-0"
|
||||||
:title="title"
|
:title="title"
|
||||||
>
|
>
|
||||||
<template v-if="errors.length && errors[0].level">
|
<template v-if="errors.length && errors[0].level">
|
||||||
@@ -72,13 +71,8 @@ const title = computed((): string => {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<BellAlertIcon
|
|
||||||
v-if="osUpdateAvailable && !rebootType"
|
|
||||||
class="hover:animate-pulse fill-current relative w-4 h-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Bars3Icon class="w-5" />
|
<Bars3Icon class="w-5" />
|
||||||
|
|
||||||
<BrandAvatar v-if="connectPluginInstalled" />
|
<BrandAvatar v-if="connectPluginInstalled" />
|
||||||
</button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import { useServerStore } from '~/store/server';
|
import { useServerStore } from '~/store/server';
|
||||||
import type { ServerStateDataAction } from '~/types/server';
|
import type { ServerStateDataAction } from '~/types/server';
|
||||||
import UpcServerStateBuy from './ServerStateBuy.vue';
|
import UpcServerStateBuy from './ServerStateBuy.vue';
|
||||||
|
|
||||||
defineProps<{ t: ComposerTranslation; }>();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { state, stateData } = storeToRefs(useServerStore());
|
const { state, stateData } = storeToRefs(useServerStore());
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@ const upgradeAction = computed((): ServerStateDataAction | undefined => {
|
|||||||
:title="t('Upgrade Key')"
|
:title="t('Upgrade Key')"
|
||||||
@click="upgradeAction.click?.()"
|
@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>
|
</UpcServerStateBuy>
|
||||||
</template>
|
</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>
|
Unraid OS <em :class="{ 'text-unraid-red': stateData.error || state === 'EEXPIRED' }"><strong>{{ t(stateData.humanReadable) }}</strong></em>
|
||||||
</h5>
|
</span>
|
||||||
|
|
||||||
<template v-if="purchaseAction">
|
<template v-if="purchaseAction">
|
||||||
<UpcServerStateBuy
|
<UpcServerStateBuy
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Button } from '@unraid/ui';
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 />
|
<slot />
|
||||||
</button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
23
web/components/UserProfile/ServerStatus.vue
Normal file
23
web/components/UserProfile/ServerStatus.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn, type ClassValue } from '@unraid/ui';
|
||||||
|
import UpcUptimeExpire from './UptimeExpire.vue';
|
||||||
|
import UpcServerState from './ServerState.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'text-header-text-secondary 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',
|
||||||
|
$attrs.class as ClassValue
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<UpcUptimeExpire :as="'span'" :short-text="true" class="text-xs" />
|
||||||
|
<span class="hidden xs:inline">•</span>
|
||||||
|
<UpcServerState class="text-xs" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
import { BrandLoading } from '@unraid/ui';
|
import { BrandLoading, Button } from '@unraid/ui';
|
||||||
|
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
import type { ComposerTranslation } from 'vue-i18n';
|
||||||
|
|
||||||
@@ -78,13 +78,14 @@ const close = () => {
|
|||||||
<template v-if="!trialModalLoading" #footer>
|
<template v-if="!trialModalLoading" #footer>
|
||||||
<div class="w-full max-w-xs flex flex-col items-center gap-y-4 mx-auto">
|
<div class="w-full max-w-xs flex flex-col items-center gap-y-4 mx-auto">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<Button
|
||||||
class="text-xs tracking-wide inline-block mx-2 opacity-60 hover:opacity-100 focus:opacity-100 underline transition"
|
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')"
|
:title="t('Close Modal')"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
{{ t('Close') }}
|
{{ t('Close') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import type { ComposerTranslation } from 'vue-i18n';
|
|
||||||
|
|
||||||
import useDateTimeHelper from '~/composables/dateTime';
|
import useDateTimeHelper from '~/composables/dateTime';
|
||||||
import { useServerStore } from '~/store/server';
|
import { useServerStore } from '~/store/server';
|
||||||
@@ -10,7 +10,6 @@ export interface Props {
|
|||||||
forExpire?: boolean;
|
forExpire?: boolean;
|
||||||
shortText?: boolean;
|
shortText?: boolean;
|
||||||
as?: 'p' | 'span';
|
as?: 'p' | 'span';
|
||||||
t: ComposerTranslation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -19,14 +18,27 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
as: 'p',
|
as: 'p',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const serverStore = useServerStore();
|
const serverStore = useServerStore();
|
||||||
const { dateTimeFormat, uptime, expireTime, state } = storeToRefs(serverStore);
|
const { dateTimeFormat, uptime, expireTime, state } = storeToRefs(serverStore);
|
||||||
|
|
||||||
|
const style = computed(() => {
|
||||||
|
if (props.as === 'span') {
|
||||||
|
return {
|
||||||
|
'text-align': 'right',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
const time = computed(() => {
|
const time = computed(() => {
|
||||||
if (props.forExpire && expireTime.value) {
|
if (props.forExpire && expireTime.value) {
|
||||||
return expireTime.value;
|
return expireTime.value;
|
||||||
}
|
}
|
||||||
return (state.value === 'TRIAL' || state.value === 'EEXPIRED') && expireTime.value && expireTime.value > 0
|
return (state.value === 'TRIAL' || state.value === 'EEXPIRED') &&
|
||||||
|
expireTime.value &&
|
||||||
|
expireTime.value > 0
|
||||||
? expireTime.value
|
? expireTime.value
|
||||||
: uptime.value;
|
: uptime.value;
|
||||||
});
|
});
|
||||||
@@ -38,31 +50,31 @@ const countUp = computed<boolean>(() => {
|
|||||||
return state.value !== 'TRIAL' && state.value !== 'ENOCONN';
|
return state.value !== 'TRIAL' && state.value !== 'ENOCONN';
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const { outputDateTimeReadableDiff: readableDiff, outputDateTimeFormatted: formatted } =
|
||||||
outputDateTimeReadableDiff: readableDiff,
|
useDateTimeHelper(dateTimeFormat.value, t, false, time.value, countUp.value);
|
||||||
outputDateTimeFormatted: formatted,
|
|
||||||
} = useDateTimeHelper(dateTimeFormat.value, props.t, false, time.value, countUp.value);
|
|
||||||
|
|
||||||
const output = computed(() => {
|
const output = computed(() => {
|
||||||
if (!countUp.value || state.value === 'EEXPIRED') {
|
if (!countUp.value || state.value === 'EEXPIRED') {
|
||||||
return {
|
return {
|
||||||
title: state.value === 'EEXPIRED'
|
title:
|
||||||
? props.t(props.shortText ? 'Expired at {0}' : 'Trial Key Expired at {0}', [formatted.value])
|
state.value === 'EEXPIRED'
|
||||||
: props.t(props.shortText ? 'Expires at {0}' : 'Trial Key Expires at {0}', [formatted.value]),
|
? t(props.shortText ? 'Expired at {0}' : 'Trial Key Expired at {0}', [formatted.value])
|
||||||
text: state.value === 'EEXPIRED'
|
: t(props.shortText ? 'Expires at {0}' : 'Trial Key Expires at {0}', [formatted.value]),
|
||||||
? props.t(props.shortText ? 'Expired {0}' : 'Trial Key Expired {0}', [readableDiff.value])
|
text:
|
||||||
: props.t(props.shortText ? 'Expires in {0}' : 'Trial Key Expires in {0}', [readableDiff.value]),
|
state.value === 'EEXPIRED'
|
||||||
|
? t(props.shortText ? 'Expired {0}' : 'Trial Key Expired {0}', [readableDiff.value])
|
||||||
|
: t(props.shortText ? 'Expires in {0}' : 'Trial Key Expires in {0}', [readableDiff.value]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: props.t('Server Up Since {0}', [formatted.value]),
|
title: t('Server Up Since {0}', [formatted.value]),
|
||||||
text: props.t('Uptime {0}', [readableDiff.value]),
|
text: t('Uptime {0}', [readableDiff.value]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component :is="as" :title="output.title">
|
<component :is="as" :title="output.title" :style="style">
|
||||||
{{ output.text }}
|
{{ output.text }}
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
285
web/components/Wrapper/vue-mount-app.ts
Normal file
285
web/components/Wrapper/vue-mount-app.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import type { App as VueApp, Component } from 'vue';
|
||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
import { DefaultApolloClient } from '@vue/apollo-composable';
|
||||||
|
import { ensureTeleportContainer } from '@unraid/ui';
|
||||||
|
|
||||||
|
// 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(/"/g, '"')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/'/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure teleport container exists before mounting
|
||||||
|
ensureTeleportContainer();
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
try {
|
||||||
|
mountVueApp({ component, selector, ...options });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[VueMountApp] Failed to mount component for selector ${selector}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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, but use setTimeout to ensure all scripts are loaded
|
||||||
|
setTimeout(tryMount, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
221
web/components/standalone-mount.ts
Normal file
221
web/components/standalone-mount.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
// 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';
|
||||||
|
import { parse } from 'graphql';
|
||||||
|
import { ensureTeleportContainer } from '@unraid/ui';
|
||||||
|
|
||||||
|
// Extend window interface for Apollo client
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
apolloClient: typeof apolloClient;
|
||||||
|
gql: typeof parse;
|
||||||
|
graphqlParse: typeof parse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pre-render CSS to hide components until they're mounted
|
||||||
|
function injectPreRenderCSS() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'unraid-prerender-css';
|
||||||
|
style.textContent = `
|
||||||
|
/* Hide unraid components during initial load to prevent FOUC */
|
||||||
|
unraid-auth,
|
||||||
|
unraid-connect-settings,
|
||||||
|
unraid-download-api-logs,
|
||||||
|
unraid-header-os-version,
|
||||||
|
unraid-modals,
|
||||||
|
unraid-user-profile,
|
||||||
|
unraid-update-os,
|
||||||
|
unraid-downgrade-os,
|
||||||
|
unraid-registration,
|
||||||
|
unraid-wan-ip-check,
|
||||||
|
unraid-welcome-modal,
|
||||||
|
unraid-sso-button,
|
||||||
|
unraid-log-viewer,
|
||||||
|
unraid-theme-switcher,
|
||||||
|
unraid-api-key-manager,
|
||||||
|
unraid-dev-modal-test,
|
||||||
|
unraid-api-key-authorize {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show components once they have the unapi class (mounted) */
|
||||||
|
unraid-auth.unapi,
|
||||||
|
unraid-connect-settings.unapi,
|
||||||
|
unraid-download-api-logs.unapi,
|
||||||
|
unraid-header-os-version.unapi,
|
||||||
|
unraid-modals.unapi,
|
||||||
|
unraid-user-profile.unapi,
|
||||||
|
unraid-update-os.unapi,
|
||||||
|
unraid-downgrade-os.unapi,
|
||||||
|
unraid-registration.unapi,
|
||||||
|
unraid-wan-ip-check.unapi,
|
||||||
|
unraid-welcome-modal.unapi,
|
||||||
|
unraid-sso-button.unapi,
|
||||||
|
unraid-log-viewer.unapi,
|
||||||
|
unraid-theme-switcher.unapi,
|
||||||
|
unraid-api-key-manager.unapi,
|
||||||
|
unraid-dev-modal-test.unapi,
|
||||||
|
unraid-api-key-authorize.unapi {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Font size overrides for SSO button component */
|
||||||
|
unraid-sso-button {
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-sm: 0.875rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-lg: 1.125rem;
|
||||||
|
--text-xl: 1.25rem;
|
||||||
|
--text-2xl: 1.5rem;
|
||||||
|
--text-3xl: 1.875rem;
|
||||||
|
--text-4xl: 2.25rem;
|
||||||
|
--text-5xl: 3rem;
|
||||||
|
--text-6xl: 3.75rem;
|
||||||
|
--text-7xl: 4.5rem;
|
||||||
|
--text-8xl: 6rem;
|
||||||
|
--text-9xl: 8rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize global Apollo client context
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Inject pre-render CSS as early as possible
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', injectPreRenderCSS);
|
||||||
|
} else {
|
||||||
|
injectPreRenderCSS();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make Apollo client globally available
|
||||||
|
window.apolloClient = apolloClient;
|
||||||
|
|
||||||
|
// Make graphql parse function available for browser console usage
|
||||||
|
window.graphqlParse = parse;
|
||||||
|
window.gql = parse;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
// Pre-create the teleport container to avoid mounting issues
|
||||||
|
// This ensures the container exists before any components try to teleport to it
|
||||||
|
ensureTeleportContainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -96,19 +96,15 @@ const useDateTimeHelper = (
|
|||||||
formats.find(formatOption => formatOption.format === selectedFormat);
|
formats.find(formatOption => formatOption.format === selectedFormat);
|
||||||
|
|
||||||
const dateFormat = findMatchingFormat(format?.date ?? dateFormatOptions[0].format, dateFormatOptions);
|
const dateFormat = findMatchingFormat(format?.date ?? dateFormatOptions[0].format, dateFormatOptions);
|
||||||
console.debug('[dateFormat]', dateFormat);
|
|
||||||
|
|
||||||
let displayFormat = `${dateFormat?.display}`;
|
let displayFormat = `${dateFormat?.display}`;
|
||||||
console.debug('[displayFormat]', displayFormat);
|
|
||||||
if (!hideMinutesSeconds) {
|
if (!hideMinutesSeconds) {
|
||||||
const timeFormat = findMatchingFormat(format?.time ?? timeFormatOptions[0].format, timeFormatOptions);
|
const timeFormat = findMatchingFormat(format?.time ?? timeFormatOptions[0].format, timeFormatOptions);
|
||||||
displayFormat = `${displayFormat} ${timeFormat?.display}`;
|
displayFormat = `${displayFormat} ${timeFormat?.display}`;
|
||||||
console.debug('[displayFormat] with time', displayFormat);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date: number): string =>
|
const formatDate = (date: number): string =>
|
||||||
dayjs(date).format(displayFormat);
|
dayjs(date).format(displayFormat);
|
||||||
console.debug('[formatDate]', formatDate(Date.now()));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Original meat and potatos from:
|
* Original meat and potatos from:
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useClipboard } from '@vueuse/core';
|
import { useClipboard } from '@vueuse/core';
|
||||||
import { useToast } from '@unraid/ui';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for clipboard operations with toast notifications
|
* Composable for clipboard operations with toast notifications
|
||||||
*/
|
*/
|
||||||
export function useClipboardWithToast() {
|
export function useClipboardWithToast() {
|
||||||
const { copy, copied, isSupported } = useClipboard();
|
const { copy, copied, isSupported } = useClipboard();
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy text and show toast
|
* Copy text and show toast
|
||||||
@@ -17,21 +15,48 @@ export function useClipboardWithToast() {
|
|||||||
text: string,
|
text: string,
|
||||||
successMessage: string = 'Copied to clipboard'
|
successMessage: string = 'Copied to clipboard'
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (!isSupported.value) {
|
// Try modern Clipboard API first
|
||||||
console.warn('Clipboard API is not supported');
|
if (isSupported.value) {
|
||||||
toast.error('Clipboard not supported');
|
try {
|
||||||
return false;
|
await copy(text);
|
||||||
|
// Use global toast if available
|
||||||
|
if (globalThis.toast) {
|
||||||
|
globalThis.toast.success(successMessage);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback to execCommand for HTTP contexts
|
||||||
try {
|
try {
|
||||||
await copy(text);
|
const textarea = document.createElement('textarea');
|
||||||
toast.success(successMessage);
|
textarea.value = text;
|
||||||
return true;
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.pointerEvents = 'none';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
const success = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
if (globalThis.toast) {
|
||||||
|
globalThis.toast.success(successMessage);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy to clipboard:', error);
|
console.error('Fallback copy failed:', error);
|
||||||
toast.error('Failed to copy to clipboard');
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both methods failed
|
||||||
|
if (globalThis.toast) {
|
||||||
|
globalThis.toast.error('Failed to copy to clipboard');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user