mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
27 Commits
v4.18.2
...
feat/build
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d8d3ef62 | ||
|
|
5d4a16fe8f | ||
|
|
8a3c1b3ba8 | ||
|
|
59b257a50f | ||
|
|
4f8fd18a39 | ||
|
|
95eb841110 | ||
|
|
d06b0db923 | ||
|
|
1c4dc154e8 | ||
|
|
1f67f63513 | ||
|
|
761b3964a9 | ||
|
|
b9a4d4a864 | ||
|
|
c82e3d8427 | ||
|
|
3211312b0e | ||
|
|
fb575acc4f | ||
|
|
4a0b481a2d | ||
|
|
cd15e12cdd | ||
|
|
0e20fd0ab0 | ||
|
|
f44b4a87e9 | ||
|
|
4986f4251d | ||
|
|
8213738e26 | ||
|
|
f32493e728 | ||
|
|
78ce64e357 | ||
|
|
bb8c4a133e | ||
|
|
c3222cc6c4 | ||
|
|
71621072f8 | ||
|
|
d4a8edab49 | ||
|
|
88087d5201 |
16
.github/workflows/main.yml
vendored
16
.github/workflows/main.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
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
|
||||
|
||||
- name: Install pnpm
|
||||
@@ -147,12 +147,17 @@ jobs:
|
||||
(cd ../unraid-ui && pnpm test --coverage 2>/dev/null || pnpm test) > ui-test.log 2>&1 &
|
||||
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 $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 $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 $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
|
||||
echo "📋 API Test Results:" && cat api-test.log
|
||||
@@ -160,9 +165,10 @@ jobs:
|
||||
echo "📋 Shared Package Test Results:" && cat shared-test.log
|
||||
echo "📋 Web Package Test Results:" && cat web-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
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -327,7 +333,9 @@ jobs:
|
||||
echo VITE_CONNECT=${{ secrets.VITE_CONNECT }} >> .env
|
||||
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
|
||||
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
|
||||
cat .env
|
||||
|
||||
touch .env.production
|
||||
echo NUXT_UI_PRO_LICENSE=${{ secrets.NUXT_UI_PRO_LICENSE }} >> .env.production
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -379,7 +387,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unraid-wc-rich
|
||||
path: web/.nuxt/nuxt-custom-elements/dist/unraid-components
|
||||
path: web/.nuxt/standalone-apps
|
||||
|
||||
build-plugin-staging-pr:
|
||||
name: Build and Deploy Plugin
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -118,3 +118,6 @@ api/dev/Unraid.net/myservers.cfg
|
||||
|
||||
# local Mise settings
|
||||
.mise.toml
|
||||
|
||||
# environment variables
|
||||
web/.env.production
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@layer utilities {
|
||||
:host {
|
||||
/* Utility defaults for web components (when we were using shadow DOM) */
|
||||
:host {
|
||||
--tw-divide-y-reverse: 0;
|
||||
--tw-border-style: solid;
|
||||
--tw-font-weight: initial;
|
||||
@@ -48,21 +48,20 @@
|
||||
--tw-drop-shadow: initial;
|
||||
--tw-duration: initial;
|
||||
--tw-ease: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
/* Global border color - this is what's causing the issue! */
|
||||
/* Commenting out since it affects all elements globally
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
|
||||
body {
|
||||
body {
|
||||
--color-alpha: #1c1b1b;
|
||||
--color-beta: #f2f2f2;
|
||||
--color-gamma: #999999;
|
||||
@@ -74,8 +73,7 @@
|
||||
--ring-shadow: 0 0 var(--color-beta);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role='button']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[role='button']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
/* Hybrid theme system: Native CSS + Theme Store fallback */
|
||||
@layer base {
|
||||
/* Light mode defaults */
|
||||
:root {
|
||||
|
||||
/* Light mode defaults */
|
||||
:root {
|
||||
/* Override Tailwind v4 global styles to use webgui variables */
|
||||
--ui-bg: var(--background-color) !important;
|
||||
--ui-text: var(--text-color) !important;
|
||||
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
@@ -30,6 +34,10 @@
|
||||
|
||||
/* Dark mode */
|
||||
.dark {
|
||||
/* Override Tailwind v4 global styles to use webgui variables */
|
||||
--ui-bg: var(--background-color) !important;
|
||||
--ui-text: var(--text-color) !important;
|
||||
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
@@ -62,69 +70,4 @@
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
}
|
||||
|
||||
/* For web components: inherit CSS variables from the host */
|
||||
:host {
|
||||
--background: inherit;
|
||||
--foreground: inherit;
|
||||
--muted: inherit;
|
||||
--muted-foreground: inherit;
|
||||
--popover: inherit;
|
||||
--popover-foreground: inherit;
|
||||
--card: inherit;
|
||||
--card-foreground: inherit;
|
||||
--border: inherit;
|
||||
--input: inherit;
|
||||
--primary: inherit;
|
||||
--primary-foreground: inherit;
|
||||
--secondary: inherit;
|
||||
--secondary-foreground: inherit;
|
||||
--accent: inherit;
|
||||
--accent-foreground: inherit;
|
||||
--destructive: inherit;
|
||||
--destructive-foreground: inherit;
|
||||
--ring: inherit;
|
||||
--chart-1: inherit;
|
||||
--chart-2: inherit;
|
||||
--chart-3: inherit;
|
||||
--chart-4: inherit;
|
||||
--chart-5: inherit;
|
||||
}
|
||||
|
||||
/* Class-based dark mode support for web components using :host-context */
|
||||
:host-context(.dark) {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
|
||||
/* Alternative class-based dark mode support for specific Unraid themes */
|
||||
:host-context(.dark[data-theme='black']),
|
||||
:host-context(.dark[data-theme='gray']) {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@
|
||||
@import './css-variables.css';
|
||||
@import './unraid-theme.css';
|
||||
@import './base-utilities.css';
|
||||
@import './sonner.css';
|
||||
@import './sonner.css';
|
||||
|
||||
@@ -161,3 +161,4 @@ Enables GraphQL playground at `http://tower.local/graphql`
|
||||
- Never use the `any` type. Always prefer proper typing
|
||||
- Avoid using casting whenever possible, prefer proper typing from the start
|
||||
- **IMPORTANT:** cache-manager v7 expects TTL values in **milliseconds**, not seconds. Always use milliseconds when setting cache TTL (e.g., 600000 for 10 minutes, not 600)
|
||||
- Use the nuxt UI library in VUE MODE NOT IN NUXT MODE
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"@nestjs/core": "11.1.6",
|
||||
"@nestjs/graphql": "13.1.0",
|
||||
"nest-authz": "2.17.0",
|
||||
"typescript": "5.9.2"
|
||||
"typescript": "5.9.2",
|
||||
"pify": "^6.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "11.1.6",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"graphql-ws": "6.0.6",
|
||||
"lodash-es": "4.17.21",
|
||||
"nest-authz": "2.17.0",
|
||||
"pify": "^6.1.0",
|
||||
"rimraf": "6.0.1",
|
||||
"type-fest": "4.41.0",
|
||||
"typescript": "5.9.2",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
setupPluginEnv,
|
||||
} from "../../cli/setup-plugin-environment";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
// Mock 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(() => {
|
||||
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) => {
|
||||
console.log("Mock readFile called with:", path, encoding);
|
||||
|
||||
@@ -42,6 +54,7 @@ describe("validatePluginEnv", () => {
|
||||
|
||||
it("validates required fields", async () => {
|
||||
const validEnv = {
|
||||
apiVersion: "4.17.0",
|
||||
baseUrl: "https://example.com",
|
||||
txzPath: "./test.txz",
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
@@ -53,6 +66,7 @@ describe("validatePluginEnv", () => {
|
||||
|
||||
it("throws on invalid URL", async () => {
|
||||
const invalidEnv = {
|
||||
apiVersion: "4.17.0",
|
||||
baseUrl: "not-a-url",
|
||||
txzPath: "./test.txz",
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
@@ -63,6 +77,7 @@ describe("validatePluginEnv", () => {
|
||||
|
||||
it("handles tag option in non-CI mode", async () => {
|
||||
const envWithTag = {
|
||||
apiVersion: "4.17.0",
|
||||
baseUrl: "https://example.com",
|
||||
txzPath: "./test.txz",
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
@@ -77,6 +92,7 @@ describe("validatePluginEnv", () => {
|
||||
|
||||
it("reads release notes when release-notes-path is provided", async () => {
|
||||
const envWithNotes = {
|
||||
apiVersion: "4.17.0",
|
||||
baseUrl: "https://example.com",
|
||||
txzPath: "./test.txz",
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
@@ -100,6 +116,7 @@ describe("validatePluginEnv", () => {
|
||||
});
|
||||
|
||||
const envWithEmptyNotes = {
|
||||
apiVersion: "4.17.0",
|
||||
baseUrl: "https://example.com",
|
||||
txzPath: "./test.txz",
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
|
||||
@@ -6,8 +6,20 @@ import { deployDir } from "../../utils/paths";
|
||||
|
||||
describe("setupTxzEnvironment", () => {
|
||||
it("should return default values when no arguments are provided", async () => {
|
||||
const envArgs = {};
|
||||
const expected: TxzEnv = { ci: false, skipValidation: "false", compress: "1", txzOutputDir: join(startingDir, deployDir) };
|
||||
const envArgs = {
|
||||
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);
|
||||
|
||||
@@ -15,8 +27,24 @@ describe("setupTxzEnvironment", () => {
|
||||
});
|
||||
|
||||
it("should parse and return provided environment arguments", async () => {
|
||||
const envArgs = { ci: true, skipValidation: "true", txzOutputDir: join(startingDir, "deploy/release/test"), compress: '8' };
|
||||
const expected: TxzEnv = { ci: true, skipValidation: "true", compress: "8", txzOutputDir: join(startingDir, "deploy/release/test") };
|
||||
const envArgs = {
|
||||
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);
|
||||
|
||||
@@ -24,7 +52,11 @@ describe("setupTxzEnvironment", () => {
|
||||
});
|
||||
|
||||
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
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation(() => {});
|
||||
@@ -38,7 +70,11 @@ describe("setupTxzEnvironment", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
"Must be true or false"
|
||||
|
||||
@@ -29,7 +29,9 @@ const findManifestFiles = async (dir: string): Promise<string[]> => {
|
||||
}
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(entry.name === "manifest.json" || entry.name === "ui.manifest.json")
|
||||
(entry.name === "manifest.json" ||
|
||||
entry.name === "ui.manifest.json" ||
|
||||
entry.name === "standalone.manifest.json")
|
||||
) {
|
||||
files.push(entry.name);
|
||||
}
|
||||
@@ -124,19 +126,21 @@ const validateSourceDir = async (validatedEnv: TxzEnv) => {
|
||||
|
||||
const manifestFiles = await findManifestFiles(webcomponentDir);
|
||||
const hasManifest = manifestFiles.includes("manifest.json");
|
||||
const hasStandaloneManifest = manifestFiles.includes("standalone.manifest.json");
|
||||
const hasUiManifest = manifestFiles.includes("ui.manifest.json");
|
||||
|
||||
if (!hasManifest || !hasUiManifest) {
|
||||
// Accept either manifest.json (old web components) or standalone.manifest.json (new standalone apps)
|
||||
if ((!hasManifest && !hasStandaloneManifest) || !hasUiManifest) {
|
||||
console.log("Existing Manifest Files:", manifestFiles);
|
||||
const missingFiles: string[] = [];
|
||||
if (!hasManifest) missingFiles.push("manifest.json");
|
||||
if (!hasManifest && !hasStandaloneManifest) missingFiles.push("manifest.json or standalone.manifest.json");
|
||||
if (!hasUiManifest) missingFiles.push("ui.manifest.json");
|
||||
|
||||
throw new Error(
|
||||
`Webcomponents missing required file(s): ${missingFiles.join(", ")} - ` +
|
||||
`${!hasUiManifest ? "run 'pnpm build:wc' in unraid-ui for ui.manifest.json" : ""}` +
|
||||
`${!hasManifest && !hasUiManifest ? " and " : ""}` +
|
||||
`${!hasManifest ? "run 'pnpm build' in web for manifest.json" : ""}`
|
||||
`${(!hasManifest && !hasStandaloneManifest) && !hasUiManifest ? " and " : ""}` +
|
||||
`${(!hasManifest && !hasStandaloneManifest) ? "run 'pnpm build' in web for standalone.manifest.json" : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export const validateTxzEnv = async (
|
||||
): Promise<TxzEnv> => {
|
||||
const validatedEnv = txzEnvSchema.parse(envArgs);
|
||||
|
||||
if ("skipValidation" in validatedEnv) {
|
||||
if (validatedEnv.skipValidation === "true") {
|
||||
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:clean": "rm -f .env",
|
||||
"// Testing": "",
|
||||
"test": "vitest"
|
||||
"test": "vitest && pnpm run test:extractor",
|
||||
"test:extractor": "bash ./tests/test-extractor.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"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"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
]]>
|
||||
</INLINE>
|
||||
</FILE>
|
||||
@@ -272,6 +272,27 @@ exit 0
|
||||
[ -f "$FILE-" ] && mv -f "$FILE-" "$FILE"
|
||||
done
|
||||
|
||||
# Restore CSS files from backup
|
||||
echo "Restoring original CSS files..."
|
||||
CSS_DIR="/usr/local/emhttp/plugins/dynamix/styles"
|
||||
BACKUP_DIR="$CSS_DIR/.unraid-api-backup"
|
||||
|
||||
if [ -d "$BACKUP_DIR" ]; then
|
||||
for backup_file in "$BACKUP_DIR"/*.css; do
|
||||
if [ -f "$backup_file" ]; then
|
||||
filename=$(basename "$backup_file")
|
||||
original_file="$CSS_DIR/$filename"
|
||||
echo " Restoring $filename..."
|
||||
cp "$backup_file" "$original_file"
|
||||
fi
|
||||
done
|
||||
# Remove backup directory after restoration
|
||||
rm -rf "$BACKUP_DIR"
|
||||
echo "CSS restoration complete."
|
||||
else
|
||||
echo "No CSS backup found, skipping restoration."
|
||||
fi
|
||||
|
||||
# Handle the unraid-components directory
|
||||
DIR=/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components
|
||||
# Remove the archive's contents before restoring
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Menu="UNRAID-OS"
|
||||
Title="Detail Layout"
|
||||
Icon="icon-u-globe"
|
||||
Tag="globe"
|
||||
---
|
||||
<unraid-detail-test />
|
||||
@@ -4,10 +4,6 @@ $docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
|
||||
class WebComponentsExtractor
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -21,15 +17,6 @@ class WebComponentsExtractor
|
||||
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
|
||||
{
|
||||
@@ -42,6 +29,11 @@ class WebComponentsExtractor
|
||||
$relative = str_replace($basePath, '', $fullPath);
|
||||
return dirname($relative);
|
||||
}
|
||||
|
||||
private function sanitizeForId(string $input): string
|
||||
{
|
||||
return preg_replace('/[^a-zA-Z0-9-]/', '-', $input);
|
||||
}
|
||||
|
||||
public function getManifestContents(string $manifestPath): array
|
||||
{
|
||||
@@ -49,117 +41,108 @@ class WebComponentsExtractor
|
||||
return $contents ? json_decode($contents, true) : [];
|
||||
}
|
||||
|
||||
|
||||
private function getRichComponentsFile(): string
|
||||
private function processManifestFiles(): 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) {
|
||||
$manifest = $this->getManifestContents($manifestPath);
|
||||
if (empty($manifest)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subfolder = $this->getRelativePath($manifestPath);
|
||||
|
||||
foreach ($manifest as $key => $value) {
|
||||
// Skip timestamp entries
|
||||
if ($key === 'ts' || !is_array($value)) {
|
||||
// Process each entry in the manifest
|
||||
foreach ($manifest as $key => $entry) {
|
||||
// Skip if not an array with a 'file' key
|
||||
if (!is_array($entry) || !isset($entry['file']) || empty($entry['file'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for both old format (direct key match) and new format (path-based key)
|
||||
$matchesMjs = strpos($key, self::RICH_COMPONENTS_ENTRY) !== false;
|
||||
$matchesJs = strpos($key, self::RICH_COMPONENTS_ENTRY_JS) !== false;
|
||||
// Build the file path
|
||||
$filePath = ($subfolder ? $subfolder . '/' : '') . $entry['file'];
|
||||
$fullPath = $this->getAssetPath($filePath);
|
||||
|
||||
if (($matchesMjs || $matchesJs) && isset($value["file"])) {
|
||||
return ($subfolder ? $subfolder . '/' : '') . $value["file"];
|
||||
// Determine file type and generate appropriate tag
|
||||
$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 '';
|
||||
}
|
||||
|
||||
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>';
|
||||
|
||||
if (empty($scripts)) {
|
||||
return '';
|
||||
}
|
||||
// Add a unique identifier to prevent duplicate script loading
|
||||
$scriptId = 'unraid-rich-components-script';
|
||||
return '<script id="' . $scriptId . '" src="' . $this->getAssetPath($jsFile) . '"></script>
|
||||
|
||||
// Add deduplication script
|
||||
$deduplicationScript = '
|
||||
<script>
|
||||
// Remove duplicate script tags to prevent multiple loads
|
||||
// Remove duplicate resource tags to prevent multiple loads
|
||||
(function() {
|
||||
var scripts = document.querySelectorAll(\'script[id="' . $scriptId . '"]\');
|
||||
if (scripts.length > 1) {
|
||||
for (var i = 1; i < scripts.length; i++) {
|
||||
scripts[i].remove();
|
||||
var elements = document.querySelectorAll(\'[data-unraid="1"]\');
|
||||
var seen = {};
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
var el = elements[i];
|
||||
if (seen[el.id]) {
|
||||
el.remove();
|
||||
} else {
|
||||
seen[el.id] = true;
|
||||
}
|
||||
}
|
||||
})();
|
||||
</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)) {
|
||||
error_log("No ui.manifest.json found");
|
||||
return '';
|
||||
}
|
||||
|
||||
$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'];
|
||||
// Find all files ending with .manifest.json or exactly named manifest.json
|
||||
$command = "find {$escapedBasePath} -type f \\( -name '*.manifest.json' -o -name 'manifest.json' \\) 2>/dev/null";
|
||||
exec($command, $files);
|
||||
|
||||
// Read the CSS file content
|
||||
$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>';
|
||||
return $files;
|
||||
}
|
||||
|
||||
public function getScriptTagHtml(): string
|
||||
@@ -168,12 +151,12 @@ class WebComponentsExtractor
|
||||
static $scriptsOutput = false;
|
||||
|
||||
if ($scriptsOutput) {
|
||||
return '<!-- Web components scripts already loaded -->';
|
||||
return '<!-- Resources already loaded -->';
|
||||
}
|
||||
|
||||
try {
|
||||
$scriptsOutput = true;
|
||||
return $this->getRichComponentsScript() . $this->getUnraidUiScriptHtml();
|
||||
return $this->processManifestFiles();
|
||||
} catch (\Exception $e) {
|
||||
error_log("Error in WebComponentsExtractor::getScriptTagHtml: " . $e->getMessage());
|
||||
$scriptsOutput = false; // Reset on error
|
||||
|
||||
@@ -100,6 +100,57 @@ class UnraidOsCheck
|
||||
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 */
|
||||
public function checkForUpdate()
|
||||
{
|
||||
@@ -136,7 +187,7 @@ class UnraidOsCheck
|
||||
$urlbase = $parsedAltUrl ?? $defaultUrl;
|
||||
$url = $urlbase.'?'.http_build_query($params);
|
||||
$curlinfo = [];
|
||||
$response = http_get_contents($url,[],$curlinfo);
|
||||
$response = $this->safe_http_get_contents($url,[],$curlinfo);
|
||||
if (array_key_exists('error', $curlinfo)) {
|
||||
$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({
|
||||
test: {
|
||||
globals: true,
|
||||
env: {
|
||||
TEST: "true",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
874
pnpm-lock.yaml
generated
874
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,13 @@ const commonGlobals = {
|
||||
HTMLElement: 'readonly',
|
||||
HTMLInputElement: 'readonly',
|
||||
CustomEvent: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
FocusEvent: 'readonly',
|
||||
PointerEvent: 'readonly',
|
||||
TouchEvent: 'readonly',
|
||||
WheelEvent: 'readonly',
|
||||
DragEvent: 'readonly',
|
||||
};
|
||||
|
||||
export default [// Base config from recommended configs
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from '@/components/common/tabs';
|
||||
export * from '@/components/common/tooltip';
|
||||
export * from '@/components/common/toast';
|
||||
export * from '@/components/common/popover';
|
||||
export * from '@/components/common/responsive-modal';
|
||||
export * from '@/components/modals';
|
||||
export * from '@/components/common/accordion';
|
||||
export * from '@/components/common/dialog';
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface BrandButtonProps {
|
||||
variant?: BrandButtonVariants['variant'];
|
||||
size?: BrandButtonVariants['size'];
|
||||
padding?: BrandButtonVariants['padding'];
|
||||
btnType?: 'button' | 'submit' | 'reset';
|
||||
class?: string;
|
||||
click?: () => void;
|
||||
disabled?: boolean;
|
||||
@@ -26,7 +25,6 @@ const props = withDefaults(defineProps<BrandButtonProps>(), {
|
||||
variant: 'fill',
|
||||
size: '16px',
|
||||
padding: 'default',
|
||||
btnType: 'button',
|
||||
class: undefined,
|
||||
click: undefined,
|
||||
disabled: false,
|
||||
@@ -51,22 +49,37 @@ const classes = computed(() => {
|
||||
iconSize: props.size ?? '16px',
|
||||
};
|
||||
});
|
||||
|
||||
const needsBrandGradientBackground = computed(() => {
|
||||
return ['outline-solid', 'outline-primary'].includes(props.variant ?? '');
|
||||
});
|
||||
|
||||
const isLink = computed(() => Boolean(props.href));
|
||||
const isButton = computed(() => !isLink.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="href ? 'a' : 'button'"
|
||||
:disabled="disabled"
|
||||
:is="isLink ? 'a' : 'span'"
|
||||
:role="isButton ? 'button' : undefined"
|
||||
:tabindex="isButton && !disabled ? 0 : undefined"
|
||||
:aria-disabled="isButton && disabled ? true : undefined"
|
||||
:href="href"
|
||||
:rel="external ? 'noopener noreferrer' : ''"
|
||||
:target="external ? '_blank' : ''"
|
||||
:type="!href ? btnType : ''"
|
||||
:class="classes.button"
|
||||
:title="title"
|
||||
@click="click ?? $emit('click')"
|
||||
@click="!disabled && (click ?? $emit('click'))"
|
||||
@keydown="
|
||||
isButton &&
|
||||
!disabled &&
|
||||
((e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(click ?? $emit('click'))();
|
||||
}
|
||||
})
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="variant === 'fill'"
|
||||
|
||||
@@ -6,6 +6,10 @@ export const brandButtonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
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:
|
||||
'[&]: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',
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full font-semibold leading-none transition-all duration-200 ease-in-out unraid-ui-badge-test',
|
||||
'inline-flex items-center rounded-full font-semibold leading-tight transition-all duration-200 ease-in-out h-fit unraid-ui-badge-test',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -7,20 +7,57 @@ export interface ButtonProps {
|
||||
variant?: ButtonVariants['variant'];
|
||||
size?: ButtonVariants['size'];
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent];
|
||||
}>();
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
return cn(buttonVariants({ variant: props.variant, size: props.size }), props.class);
|
||||
return cn(
|
||||
buttonVariants({ variant: props.variant, size: props.size }),
|
||||
'cursor-pointer select-none',
|
||||
props.disabled && 'pointer-events-none opacity-50',
|
||||
props.class
|
||||
);
|
||||
});
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!props.disabled) {
|
||||
emit('click', event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!props.disabled && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
// Create a synthetic click event
|
||||
const clickEvent = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
});
|
||||
emit('click', clickEvent);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :class="buttonClass">
|
||||
<span
|
||||
:class="buttonClass"
|
||||
role="button"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
:aria-disabled="disabled"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-base font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'inline-flex items-center justify-center rounded-md text-base font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -12,12 +12,18 @@ export const buttonVariants = cva(
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
header:
|
||||
'group relative flex flex-row items-center text-base border-0 text-header-text-primary bg-transparent hover:bg-accent/20 focus-visible:bg-accent/20 focus-visible:ring-1 focus-visible:ring-accent/20 focus-visible:ring-offset-0 rounded-lg min-h-[36px]',
|
||||
'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: {
|
||||
sm: 'rounded-md px-3 py-1',
|
||||
md: 'h-10 px-4 py-2',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
header: 'h-10 px-2 py-6',
|
||||
'header-icon': 'h-9 w-9 p-2',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './dialog/index.js';
|
||||
export * from './dropdown-menu/index.js';
|
||||
export * from './loading/index.js';
|
||||
export * from './popover/index.js';
|
||||
export * from './responsive-modal/index.js';
|
||||
export * from './scroll-area/index.js';
|
||||
export * from './sheet/index.js';
|
||||
export * from './stepper/index.js';
|
||||
|
||||
@@ -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">
|
||||
import Button from '@/components/common/button/Button.vue';
|
||||
import { sheetVariants, type SheetVariants } from '@/components/common/sheet/sheet.variants';
|
||||
import SheetClose from '@/components/common/sheet/SheetClose.vue';
|
||||
import useTeleport from '@/composables/useTeleport';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { X } from 'lucide-vue-next';
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
@@ -50,11 +51,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
/>
|
||||
<DialogContent :class="sheetClass" v-bind="forwarded">
|
||||
<slot />
|
||||
<DialogClose
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="text-muted-foreground h-4 w-4" />
|
||||
</DialogClose>
|
||||
<SheetClose as="span" class="absolute top-[max(1rem,env(safe-area-inset-top))] right-4">
|
||||
<Button variant="ghost" size="sm" class="h-auto w-auto p-1">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,9 @@ export const sheetVariants = cva(
|
||||
},
|
||||
padding: {
|
||||
none: '',
|
||||
md: 'p-6',
|
||||
sm: 'p-2',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -17,15 +17,17 @@ const forwardedProps = useForwardProps(delegatedProps);
|
||||
<template>
|
||||
<TabsTrigger
|
||||
v-bind="forwardedProps"
|
||||
as="span"
|
||||
tabindex="0"
|
||||
:class="
|
||||
cn(
|
||||
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded px-4.5 py-2.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-xs',
|
||||
'ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors select-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:hover:bg-primary/90',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="truncate">
|
||||
<slot />
|
||||
</span>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
|
||||
@@ -38,7 +38,7 @@ const { teleportTarget } = useTeleport();
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { TooltipTrigger, type TooltipTriggerProps } from 'reka-ui';
|
||||
|
||||
const props = defineProps<TooltipTriggerProps>();
|
||||
const props = withDefaults(defineProps<TooltipTriggerProps>(), {
|
||||
asChild: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -33,7 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-[200px] rounded-md border shadow-md outline-hidden',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-50 w-[200px] rounded-md border shadow-md outline-hidden',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class
|
||||
|
||||
@@ -25,6 +25,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-bind="forwarded"
|
||||
as="span"
|
||||
:tabindex="props.disabled ? -1 : 0"
|
||||
:class="
|
||||
cn(
|
||||
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
|
||||
@@ -14,9 +14,11 @@ const delegatedProps = reactiveOmit(props, 'class');
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
v-bind="delegatedProps"
|
||||
as="span"
|
||||
tabindex="0"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
'flex flex-1 cursor-pointer items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -9,3 +9,28 @@ const props = defineProps<DialogCloseProps>();
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Reset webgui button styles for dialog close buttons */
|
||||
[role='dialog'] button[type='button'],
|
||||
button[aria-label*='close' i],
|
||||
button[aria-label*='dismiss' i] {
|
||||
/* Reset ALL webgui button styles using !important where needed */
|
||||
all: unset !important;
|
||||
|
||||
/* Re-apply necessary styles after reset */
|
||||
display: inline-flex !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
cursor: pointer !important;
|
||||
box-sizing: border-box !important;
|
||||
|
||||
/* Reset any webgui CSS variables */
|
||||
--button-border: none !important;
|
||||
--button-text-color: inherit !important;
|
||||
--button-background: transparent !important;
|
||||
--button-background-size: auto !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/common/button/Button.vue';
|
||||
import useTeleport from '@/composables/useTeleport';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
@@ -39,10 +40,10 @@ const { teleportTarget } = useTeleport();
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed top-1/2 left-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg duration-200',
|
||||
// Only apply zoom and slide animations if not fullscreen
|
||||
'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 animation if not fullscreen
|
||||
!props.class?.includes('min-h-screen') &&
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
// Apply slide-up animation for fullscreen modals
|
||||
props.class?.includes('min-h-screen') &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
@@ -52,12 +53,15 @@ const { teleportTarget } = useTeleport();
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton !== false"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
<DialogClose v-if="showCloseButton !== false" as-child class="absolute top-4 right-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 rounded-sm opacity-70 transition-opacity hover:opacity-100"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
|
||||
@@ -5,7 +5,7 @@ const props = defineProps<DialogTriggerProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger v-bind="props">
|
||||
<DialogTrigger v-bind="props" as="span" tabindex="0">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
|
||||
@@ -32,7 +32,7 @@ const { teleportTarget } = useTeleport();
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-lg border p-1 shadow-md',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-50 min-w-32 overflow-hidden rounded-lg border p-1 shadow-md',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -15,9 +15,11 @@ const forwardedProps = useForwardProps(delegatedProps);
|
||||
<template>
|
||||
<DropdownMenuSubTrigger
|
||||
v-bind="forwardedProps"
|
||||
as="span"
|
||||
tabindex="0"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none',
|
||||
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -10,6 +10,8 @@ const forwardedProps = useForwardProps(props);
|
||||
<DropdownMenuTrigger
|
||||
class="cursor-pointer outline-hidden data-[state=open]:cursor-pointer"
|
||||
v-bind="forwardedProps"
|
||||
as="span"
|
||||
tabindex="0"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -35,7 +35,7 @@ const { teleportTarget } = useTeleport();
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border shadow-md',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class
|
||||
|
||||
@@ -15,9 +15,11 @@ const forwardedProps = useForwardProps(delegatedProps);
|
||||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="forwardedProps"
|
||||
as="span"
|
||||
tabindex="0"
|
||||
:class="
|
||||
cn(
|
||||
'border-input bg-background ring-offset-background data-placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-start text-sm focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate',
|
||||
'border-input bg-background ring-offset-background data-placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full cursor-pointer items-center justify-between rounded-md border px-3 py-2 text-start text-sm focus:ring-2 focus:ring-offset-2 focus:outline-hidden data-disabled:cursor-not-allowed data-disabled:opacity-50 [&>span]:truncate',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
import { ensureTeleportContainer } from '@/helpers/ensure-teleport-container';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const useTeleport = () => {
|
||||
const teleportTarget = ref<string | HTMLElement>('#modals');
|
||||
|
||||
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;
|
||||
};
|
||||
const teleportTarget = ref<string | HTMLElement>('body');
|
||||
|
||||
onMounted(() => {
|
||||
determineTeleportTarget();
|
||||
const container = ensureTeleportContainer();
|
||||
teleportTarget.value = container;
|
||||
});
|
||||
|
||||
return {
|
||||
teleportTarget,
|
||||
determineTeleportTarget,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
v-for="(element, index) in elements"
|
||||
:key="`${layout.path || ''}-${index}`"
|
||||
:value="`item-${index}`"
|
||||
class="bg-background rounded-lg border"
|
||||
class="bg-background border-muted rounded-lg border"
|
||||
>
|
||||
<AccordionTrigger class="hover:bg-muted/50 px-4 py-3 [&[data-state=open]>svg]:rotate-180">
|
||||
<div class="flex flex-col items-start space-y-1 text-left">
|
||||
|
||||
@@ -196,7 +196,7 @@ const updateItem = (index: number, newValue: unknown) => {
|
||||
:value="String(index)"
|
||||
class="mt-0 w-full"
|
||||
>
|
||||
<div class="w-full rounded-lg border p-1 sm:p-6">
|
||||
<div class="border-muted w-full rounded-lg border p-1 sm:p-6">
|
||||
<div class="mb-4 flex justify-end">
|
||||
<Button
|
||||
v-if="!isItemProtected(item)"
|
||||
@@ -214,7 +214,7 @@ const updateItem = (index: number, newValue: unknown) => {
|
||||
<!-- Show warning if item matches protected condition -->
|
||||
<div
|
||||
v-if="getItemWarning(item)"
|
||||
class="bg-warning/10 border-warning/20 mb-4 rounded-lg border p-3"
|
||||
class="bg-warning/10 border-warning/20 border-muted mb-4 rounded-lg border p-3"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-warning">⚠️</span>
|
||||
@@ -240,7 +240,7 @@ const updateItem = (index: number, newValue: unknown) => {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div v-else class="rounded-lg border-2 border-dashed py-8 text-center">
|
||||
<div v-else class="border-muted rounded-lg border-2 border-dashed py-8 text-center">
|
||||
<p class="text-muted-foreground mb-4">No {{ itemTypeName.toLowerCase() }}s configured</p>
|
||||
<Button variant="outline" size="md" :disabled="!control.enabled" @click="addItem">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -155,7 +155,7 @@ const getStepState = (stepIndex: number): StepState => {
|
||||
<!-- Render elements for the current step -->
|
||||
<!-- Added key to force re-render on step change, ensuring correct elements display -->
|
||||
<div
|
||||
class="current-step-content rounded-md border p-4 shadow-sm"
|
||||
class="current-step-content border-muted rounded-md border p-4 shadow-sm"
|
||||
:key="`step-content-${currentStep}`"
|
||||
>
|
||||
<DispatchRenderer
|
||||
|
||||
@@ -33,7 +33,7 @@ const elements = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsGrid v-if="isVisible">
|
||||
<SettingsGrid v-if="isVisible" class="[&_.grow]:max-w-3xl">
|
||||
<template v-for="(element, _i) in elements" :key="_i">
|
||||
<DispatchRenderer
|
||||
:schema="layout.layout.value.schema"
|
||||
|
||||
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 { useToast } 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 { twMerge } from 'tailwind-merge';
|
||||
|
||||
export type { ClassValue };
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
VITE_ACCOUNT=http://localhost:5555
|
||||
VITE_ACCOUNT=https://account.unraid.net
|
||||
VITE_CONNECT=https://connect.myunraid.net
|
||||
VITE_UNRAID_NET=https://unraid.ddev.site
|
||||
VITE_OS_RELEASES="https://releases.unraid.net/os"
|
||||
@@ -14,3 +14,5 @@ VITE_WEBGUI=http://localhost:3001
|
||||
VITE_CSRF_TOKEN="0000000000000000"
|
||||
# Flag for mocking a user session during development via an unsecure cookie
|
||||
VITE_MOCK_USER_SESSION=true
|
||||
# Get the license key from one of the devs
|
||||
NUXT_UI_PRO_LICENSE=
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
VITE_ACCOUNT=https://account.unraid.net
|
||||
VITE_CONNECT=https://connect.myunraid.net
|
||||
VITE_UNRAID_NET=https://unraid.net
|
||||
VITE_CALLBACK_KEY=Uyv2o8e*FiQe8VeLekTqyX6Z*8XonB
|
||||
3
web/.gitignore
vendored
3
web/.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.env.*
|
||||
!.env.staging
|
||||
!.env.production
|
||||
!.env.example
|
||||
|
||||
@@ -27,6 +27,12 @@ vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
BrandButton: {
|
||||
name: 'BrandButton',
|
||||
props: ['text', 'disabled'],
|
||||
emits: ['click'],
|
||||
template: '<button :disabled="disabled" @click="$emit(\'click\')">{{ text }}</button>',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -168,17 +174,16 @@ describe('Activation/WelcomeModal.ce.vue', () => {
|
||||
expect(button.exists()).toBe(true);
|
||||
|
||||
// Initially dialog should be visible
|
||||
let dialog = wrapper.findComponent({ name: 'Dialog' });
|
||||
const dialog = wrapper.findComponent({ name: 'Dialog' });
|
||||
expect(dialog.exists()).toBe(true);
|
||||
expect(dialog.props('modelValue')).toBe(true);
|
||||
|
||||
await button.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// After click, the dialog should be hidden (modelValue should be false)
|
||||
dialog = wrapper.findComponent({ name: 'Dialog' });
|
||||
expect(dialog.exists()).toBe(true);
|
||||
expect(dialog.props('modelValue')).toBe(false);
|
||||
// After click, the dialog should be hidden - check if the dialog div is no longer rendered
|
||||
const dialogDiv = wrapper.find('[role="dialog"]');
|
||||
expect(dialogDiv.exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('disables the Create a password button when loading', async () => {
|
||||
@@ -188,7 +193,7 @@ describe('Activation/WelcomeModal.ce.vue', () => {
|
||||
const button = wrapper.find('button');
|
||||
|
||||
expect(button.exists()).toBe(true);
|
||||
expect(button.attributes('disabled')).toBe('');
|
||||
expect(button.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders activation steps with correct active step', async () => {
|
||||
|
||||
@@ -23,36 +23,6 @@ vi.mock('@unraid/shared-callbacks', () => ({
|
||||
useCallback: vi.fn(() => ({ send: vi.fn(), watcher: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
Badge: {
|
||||
name: 'Badge',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuRoot: {
|
||||
name: 'DropdownMenuRoot',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuTrigger: {
|
||||
name: 'DropdownMenuTrigger',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuContent: {
|
||||
name: 'DropdownMenuContent',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuItem: {
|
||||
name: 'DropdownMenuItem',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuLabel: {
|
||||
name: 'DropdownMenuLabel',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuSeparator: {
|
||||
name: 'DropdownMenuSeparator',
|
||||
template: '<div />',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useQuery: () => ({
|
||||
|
||||
@@ -12,9 +12,6 @@ import type { Props as ModalProps } from '~/components/Modal.vue';
|
||||
|
||||
import Modal from '~/components/Modal.vue';
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
const mockSetProperty = vi.fn();
|
||||
const mockRemoveProperty = vi.fn();
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Registration Component Test Coverage
|
||||
*/
|
||||
|
||||
import { defineComponent } from 'vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
@@ -14,7 +13,6 @@ import type { ServerconnectPluginInstalled } from '~/types/server';
|
||||
import type { Pinia } from 'pinia';
|
||||
|
||||
import Registration from '~/components/Registration.ce.vue';
|
||||
import MockedRegistrationItem from '~/components/Registration/Item.vue';
|
||||
import { usePurchaseStore } from '~/store/purchase';
|
||||
import { useReplaceRenewStore } from '~/store/replaceRenew';
|
||||
import { useServerStore } from '~/store/server';
|
||||
@@ -57,6 +55,7 @@ vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
BrandButton: { template: '<button><slot /></button>', props: ['text', 'title', 'icon', 'disabled'] },
|
||||
CardWrapper: { template: '<div><slot /></div>' },
|
||||
PageContainer: { template: '<div><slot /></div>' },
|
||||
SettingsGrid: { template: '<div class="settings-grid"><slot /></div>' },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -83,26 +82,6 @@ vi.mock('~/components/UserProfile/UptimeExpire.vue', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('~/components/Registration/Item.vue', () => ({
|
||||
default: defineComponent({
|
||||
props: ['label', 'text', 'component', 'componentProps', 'error', 'warning', 'componentOpacity'],
|
||||
name: 'RegistrationItem',
|
||||
template: `
|
||||
<div class="registration-item">
|
||||
<dt v-if="label">{{ label }}</dt>
|
||||
<dd>
|
||||
<span v-if="text">{{ text }}</span>
|
||||
<template v-if="component">
|
||||
<component :is="component" v-bind="componentProps" :class="[componentOpacity && !error ? 'opacity-75' : '']" />
|
||||
</template>
|
||||
</dd>
|
||||
</div>
|
||||
`,
|
||||
setup(props) {
|
||||
return { ...props };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Define initial state for the server store for testing
|
||||
const initialServerState = {
|
||||
@@ -146,9 +125,22 @@ describe('Registration.ce.vue', () => {
|
||||
let purchaseStore: ReturnType<typeof usePurchaseStore>;
|
||||
|
||||
const findItemByLabel = (labelKey: string) => {
|
||||
const items = wrapper.findAllComponents({ name: 'RegistrationItem' });
|
||||
|
||||
return items.find((item) => item.props('label') === t(labelKey));
|
||||
const allLabels = wrapper.findAll('.font-semibold');
|
||||
const label = allLabels.find((el) => el.html().includes(t(labelKey)));
|
||||
|
||||
if (!label) return undefined;
|
||||
|
||||
const nextSibling = label.element.nextElementSibling;
|
||||
|
||||
return {
|
||||
exists: () => true,
|
||||
props: (prop: string) => {
|
||||
if (prop === 'text' && nextSibling) {
|
||||
return nextSibling.textContent?.trim();
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -175,8 +167,9 @@ describe('Registration.ce.vue', () => {
|
||||
wrapper = mount(Registration, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
components: {
|
||||
RegistrationItem: MockedRegistrationItem,
|
||||
stubs: {
|
||||
ShieldCheckIcon: { template: '<div class="shield-check-icon"/>' },
|
||||
ShieldExclamationIcon: { template: '<div class="shield-exclamation-icon"/>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -205,21 +198,12 @@ describe('Registration.ce.vue', () => {
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const items = wrapper.findAllComponents({ name: 'RegistrationItem' });
|
||||
const keyActionsItem = items.find((item) => {
|
||||
const componentProp = item.props('component');
|
||||
const keyActionsElement = wrapper.find('[data-testid="key-actions"]');
|
||||
|
||||
expect(keyActionsElement.exists(), 'KeyActions element not found').toBe(true);
|
||||
|
||||
return componentProp?.template?.includes('data-testid="key-actions"');
|
||||
});
|
||||
|
||||
expect(keyActionsItem, 'RegistrationItem for KeyActions not found').toBeDefined();
|
||||
|
||||
const componentProps = keyActionsItem!.props('componentProps') as {
|
||||
filterOut?: string[];
|
||||
t: unknown;
|
||||
};
|
||||
const expectedActions = serverStore.keyActions?.filter(
|
||||
(action) => !componentProps?.filterOut?.includes(action.name)
|
||||
(action) => !['renew'].includes(action.name)
|
||||
);
|
||||
|
||||
expect(expectedActions, 'No expected actions found in store for TRIAL state').toBeDefined();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* UpdateOs Component Test Coverage
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
@@ -100,7 +100,7 @@ describe('UpdateOs.ce.vue', () => {
|
||||
});
|
||||
|
||||
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';
|
||||
mockRebootType.value = '';
|
||||
|
||||
@@ -115,13 +115,18 @@ describe('UpdateOs.ce.vue', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(mockAccountStore.updateOs).toHaveBeenCalledTimes(1);
|
||||
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="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';
|
||||
mockRebootType.value = '';
|
||||
|
||||
@@ -136,12 +141,17 @@ describe('UpdateOs.ce.vue', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
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"]').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';
|
||||
mockRebootType.value = 'downgrade';
|
||||
|
||||
@@ -156,9 +166,14 @@ describe('UpdateOs.ce.vue', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
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"]').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 { Server, ServerconnectPluginInstalled, ServerState } from '~/types/server';
|
||||
import type { Pinia } from 'pinia';
|
||||
import type { MaybeRef } from '@vueuse/core';
|
||||
|
||||
import UserProfile from '~/components/UserProfile.ce.vue';
|
||||
import { useServerStore } from '~/store/server';
|
||||
@@ -21,7 +20,7 @@ const mockCopied = ref(false);
|
||||
const mockIsSupported = ref(true);
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useClipboard: ({ _source }: { _source: MaybeRef<string> }) => {
|
||||
useClipboard: () => {
|
||||
const actualCopy = (text: string) => {
|
||||
if (mockIsSupported.value) {
|
||||
mockCopy(text);
|
||||
@@ -38,6 +37,17 @@ vi.mock('@vueuse/core', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
DropdownMenu: {
|
||||
template: '<div data-testid="dropdown-menu"><slot name="trigger" /><slot /></div>',
|
||||
},
|
||||
Button: {
|
||||
template: '<button><slot /></button>',
|
||||
props: ['variant', 'size'],
|
||||
},
|
||||
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
const mockWatcher = vi.fn();
|
||||
|
||||
vi.mock('~/store/callbackActions', () => ({
|
||||
@@ -77,14 +87,18 @@ const initialServerData: Server = {
|
||||
|
||||
// Component stubs for mount options
|
||||
const stubs = {
|
||||
UpcUptimeExpire: { template: '<div data-testid="uptime-expire"></div>', props: ['t'] },
|
||||
UpcServerState: { template: '<div data-testid="server-state"></div>', props: ['t'] },
|
||||
UpcUptimeExpire: { template: '<div data-testid="uptime-expire"></div>' },
|
||||
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>' },
|
||||
DropdownMenu: {
|
||||
template: '<div data-testid="dropdown-menu"><slot name="trigger" /><slot /></div>',
|
||||
template: '<div data-testid="dropdown-menu"><slot name="trigger" /><slot name="content" /></div>',
|
||||
},
|
||||
UpcDropdownContent: { template: '<div data-testid="dropdown-content"></div>', props: ['t'] },
|
||||
UpcDropdownTrigger: { template: '<button data-testid="dropdown-trigger"></button>', props: ['t'] },
|
||||
UpcDropdownContent: { template: '<div data-testid="dropdown-content"></div>' },
|
||||
UpcDropdownTrigger: { template: '<button data-testid="dropdown-trigger"></button>' },
|
||||
};
|
||||
|
||||
describe('UserProfile.ce.vue', () => {
|
||||
@@ -201,9 +215,9 @@ describe('UserProfile.ce.vue', () => {
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const heading = wrapper.find('h1');
|
||||
const nameButton = wrapper.find('button');
|
||||
|
||||
expect(heading.text()).toContain(initialServerData.name);
|
||||
expect(nameButton.text()).toContain(initialServerData.name);
|
||||
expect(wrapper.find('[data-testid="uptime-expire"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="server-state"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="notifications-sidebar"]').exists()).toBe(true);
|
||||
@@ -230,7 +244,7 @@ describe('UserProfile.ce.vue', () => {
|
||||
|
||||
expect(serverStore.setServer).toHaveBeenCalledTimes(2);
|
||||
expect(serverStore.setServer).toHaveBeenLastCalledWith(initialServerData);
|
||||
expect(wrapperObjectProp.find('h1').text()).toContain(initialServerData.name);
|
||||
expect(wrapperObjectProp.find('button').text()).toContain(initialServerData.name);
|
||||
wrapperObjectProp.unmount();
|
||||
});
|
||||
|
||||
@@ -254,7 +268,7 @@ describe('UserProfile.ce.vue', () => {
|
||||
const copyLanIpSpy = vi.spyOn(wrapper.vm as unknown as { copyLanIp: () => void }, 'copyLanIp');
|
||||
mockIsSupported.value = true;
|
||||
|
||||
const serverNameButton = wrapper.find('h1 > button');
|
||||
const serverNameButton = wrapper.find('button');
|
||||
|
||||
await serverNameButton.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -263,10 +277,8 @@ describe('UserProfile.ce.vue', () => {
|
||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||
expect(mockCopy).toHaveBeenCalledWith(initialServerData.lanIp);
|
||||
|
||||
const copiedMessage = wrapper.find('.text-white.text-xs');
|
||||
|
||||
expect(copiedMessage.exists()).toBe(true);
|
||||
expect(copiedMessage.text()).toContain(t('LAN IP Copied'));
|
||||
// We're not testing the toast message, just that the copy function was called
|
||||
expect(mockCopied.value).toBe(true);
|
||||
|
||||
copyLanIpSpy.mockRestore();
|
||||
});
|
||||
@@ -275,7 +287,7 @@ describe('UserProfile.ce.vue', () => {
|
||||
const copyLanIpSpy = vi.spyOn(wrapper.vm as unknown as { copyLanIp: () => void }, 'copyLanIp');
|
||||
mockIsSupported.value = false;
|
||||
|
||||
const serverNameButton = wrapper.find('h1 > button');
|
||||
const serverNameButton = wrapper.find('button');
|
||||
|
||||
await serverNameButton.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -283,10 +295,8 @@ describe('UserProfile.ce.vue', () => {
|
||||
expect(copyLanIpSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockCopy).not.toHaveBeenCalled();
|
||||
|
||||
const notSupportedMessage = wrapper.find('.text-white.text-xs');
|
||||
|
||||
expect(notSupportedMessage.exists()).toBe(true);
|
||||
expect(notSupportedMessage.text()).toContain(t('LAN IP {0}', [initialServerData.lanIp]));
|
||||
// When clipboard is not supported, the copy function should not be called
|
||||
expect(mockCopied.value).toBe(false);
|
||||
|
||||
copyLanIpSpy.mockRestore();
|
||||
});
|
||||
@@ -299,18 +309,24 @@ describe('UserProfile.ce.vue', () => {
|
||||
themeStore.theme!.descriptionShow = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const heading = wrapper.find('h1');
|
||||
expect(heading.html()).toContain(initialServerData.description);
|
||||
// Look for the description in a span element
|
||||
let descriptionElement = wrapper.find('span.text-center.md\\:text-right');
|
||||
expect(descriptionElement.exists()).toBe(true);
|
||||
expect(descriptionElement.html()).toContain(initialServerData.description);
|
||||
|
||||
themeStore.theme!.descriptionShow = false;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(heading.html()).not.toContain(initialServerData.description);
|
||||
// When descriptionShow is false, the element should not exist
|
||||
descriptionElement = wrapper.find('span.text-center.md\\:text-right');
|
||||
expect(descriptionElement.exists()).toBe(false);
|
||||
|
||||
themeStore.theme!.descriptionShow = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(heading.html()).toContain(initialServerData.description);
|
||||
descriptionElement = wrapper.find('span.text-center.md\\:text-right');
|
||||
expect(descriptionElement.exists()).toBe(true);
|
||||
expect(descriptionElement.html()).toContain(initialServerData.description);
|
||||
});
|
||||
|
||||
it('always renders notifications sidebar, regardless of connectPluginInstalled', async () => {
|
||||
|
||||
@@ -44,5 +44,55 @@ vi.mock('@unraid/ui', () => ({
|
||||
name: 'DropdownMenu',
|
||||
template: '<div><slot name="trigger" /><slot /></div>',
|
||||
},
|
||||
Badge: {
|
||||
name: 'Badge',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
Button: {
|
||||
name: 'Button',
|
||||
template: '<button><slot /></button>',
|
||||
props: ['variant', 'size'],
|
||||
},
|
||||
DropdownMenuRoot: {
|
||||
name: 'DropdownMenuRoot',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuTrigger: {
|
||||
name: 'DropdownMenuTrigger',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuContent: {
|
||||
name: 'DropdownMenuContent',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuItem: {
|
||||
name: 'DropdownMenuItem',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuLabel: {
|
||||
name: 'DropdownMenuLabel',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
DropdownMenuSeparator: {
|
||||
name: 'DropdownMenuSeparator',
|
||||
template: '<div />',
|
||||
},
|
||||
ResponsiveModal: {
|
||||
name: 'ResponsiveModal',
|
||||
template: '<div><slot /></div>',
|
||||
props: ['open'],
|
||||
},
|
||||
ResponsiveModalHeader: {
|
||||
name: 'ResponsiveModalHeader',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
ResponsiveModalFooter: {
|
||||
name: 'ResponsiveModalFooter',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
ResponsiveModalTitle: {
|
||||
name: 'ResponsiveModalTitle',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
// Add other UI components as needed
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
export default {
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'primary',
|
||||
},
|
||||
primary: 'blue',
|
||||
neutral: 'gray'
|
||||
}
|
||||
},
|
||||
toaster: {
|
||||
position: 'bottom-right' as const,
|
||||
expand: true,
|
||||
duration: 5000
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import { NuxtLayout, NuxtPage, UApp } from '#components';
|
||||
import { devConfig } from '~/helpers/env';
|
||||
|
||||
onMounted(() => {
|
||||
@@ -28,9 +27,9 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<div>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,82 @@
|
||||
@import 'tailwindcss';
|
||||
/*
|
||||
* Tailwind v4 configuration without global preflight
|
||||
* This prevents Tailwind from applying global resets that affect webgui
|
||||
*/
|
||||
|
||||
/* Import only the parts of Tailwind we need - NO PREFLIGHT */
|
||||
@import 'tailwindcss/theme.css';
|
||||
@import 'tailwindcss/utilities.css';
|
||||
@import 'tw-animate-css';
|
||||
@import '../../@tailwind-shared/index.css';
|
||||
|
||||
@import '@nuxt/ui';
|
||||
|
||||
/* Scan unraid-ui package from linked directory for class usage */
|
||||
@source "../../unraid-ui/dist/**/*.{js,mjs}";
|
||||
@source "../../unraid-ui/src/**/*.{vue,ts}";
|
||||
@source "../**/*.{vue,ts,js}";
|
||||
|
||||
/*
|
||||
* Minimal styles for our components
|
||||
* Only essential styles to ensure components work properly
|
||||
*/
|
||||
|
||||
/* Box-sizing for proper layout */
|
||||
.unapi *,
|
||||
.unapi *::before,
|
||||
.unapi *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Reset figure element for logo */
|
||||
.unapi figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset heading elements - only margin/padding */
|
||||
.unapi h1,
|
||||
.unapi h2,
|
||||
.unapi h3,
|
||||
.unapi h4,
|
||||
.unapi h5 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset paragraph element */
|
||||
.unapi p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset toggle/switch button backgrounds */
|
||||
button[role="switch"],
|
||||
button[role="switch"][data-state="checked"],
|
||||
button[role="switch"][data-state="unchecked"] {
|
||||
background-color: transparent !important;
|
||||
background: transparent !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* Style for checked state */
|
||||
button[role="switch"][data-state="checked"] {
|
||||
background-color: #ff8c2f !important; /* Unraid orange */
|
||||
}
|
||||
|
||||
/* Style for unchecked state */
|
||||
button[role="switch"][data-state="unchecked"] {
|
||||
background-color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
/* Dark mode toggle styles */
|
||||
.dark button[role="switch"][data-state="unchecked"] {
|
||||
background-color: #333 !important;
|
||||
border-color: #555 !important;
|
||||
}
|
||||
|
||||
/* Toggle thumb/handle */
|
||||
button[role="switch"] span {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
|
||||
62
web/auto-imports.d.ts
vendored
Normal file
62
web/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const avatarGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['avatarGroupInjectionKey']
|
||||
const defineLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['defineLocale']
|
||||
const defineShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['defineShortcuts']
|
||||
const extendLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['extendLocale']
|
||||
const extractShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['extractShortcuts']
|
||||
const fieldGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['fieldGroupInjectionKey']
|
||||
const formBusInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formBusInjectionKey']
|
||||
const formFieldInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formFieldInjectionKey']
|
||||
const formInputsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formInputsInjectionKey']
|
||||
const formLoadingInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formLoadingInjectionKey']
|
||||
const formOptionsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formOptionsInjectionKey']
|
||||
const inputIdInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['inputIdInjectionKey']
|
||||
const kbdKeysMap: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['kbdKeysMap']
|
||||
const localeContextInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['localeContextInjectionKey']
|
||||
const portalTargetInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['portalTargetInjectionKey']
|
||||
const useAppConfig: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/vue/composables/useAppConfig.js')['useAppConfig']
|
||||
const useAvatarGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['useAvatarGroup']
|
||||
const useComponentIcons: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.js')['useComponentIcons']
|
||||
const useContentSearch: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useContentSearch.js')['useContentSearch']
|
||||
const useFieldGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['useFieldGroup']
|
||||
const useFileUpload: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.js')['useFileUpload']
|
||||
const useFormField: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['useFormField']
|
||||
const useKbd: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['useKbd']
|
||||
const useLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['useLocale']
|
||||
const useOverlay: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.js')['useOverlay']
|
||||
const usePortal: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['usePortal']
|
||||
const useResizable: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.js')['useResizable']
|
||||
const useScrollspy: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useScrollspy.js')['useScrollspy']
|
||||
const useToast: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.js')['useToast']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { ShortcutConfig, ShortcutsConfig, ShortcutsOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d')
|
||||
// @ts-ignore
|
||||
export type { UseComponentIconsProps } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d')
|
||||
// @ts-ignore
|
||||
export type { UseFileUploadOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d')
|
||||
// @ts-ignore
|
||||
export type { KbdKey, KbdKeySpecific } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d')
|
||||
// @ts-ignore
|
||||
export type { OverlayOptions, Overlay } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d')
|
||||
// @ts-ignore
|
||||
export type { UseResizableProps, UseResizableReturn } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d')
|
||||
// @ts-ignore
|
||||
export type { Toast } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d')
|
||||
}
|
||||
25
web/components.d.ts
vendored
Normal file
25
web/components.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
|
||||
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
|
||||
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
|
||||
UCheckbox: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
|
||||
UDrawer: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
|
||||
UDropdownMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/DropdownMenu.vue')['default']
|
||||
UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
|
||||
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
|
||||
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
|
||||
UNavigationMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default']
|
||||
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
|
||||
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
|
||||
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
|
||||
|
||||
import { ClipboardDocumentIcon } from '@heroicons/vue/24/solid';
|
||||
import {
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
Dialog,
|
||||
jsonFormsAjv,
|
||||
jsonFormsRenderers
|
||||
Button,
|
||||
jsonFormsAjv,
|
||||
jsonFormsRenderers,
|
||||
ResponsiveModal,
|
||||
ResponsiveModalFooter,
|
||||
ResponsiveModalHeader,
|
||||
ResponsiveModalTitle,
|
||||
} from '@unraid/ui';
|
||||
import { JsonForms } from '@jsonforms/vue';
|
||||
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 {
|
||||
ApiKeyFormSettings,
|
||||
ApiKeyFragment,
|
||||
AuthAction,
|
||||
CreateApiKeyInput,
|
||||
Resource,
|
||||
Role,
|
||||
} from '~/composables/gql/graphql';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
import type { AuthorizationFormData } from '~/utils/authorizationScopes';
|
||||
|
||||
import { useFragment } from '~/composables/gql/fragment-masking';
|
||||
import { useApiKeyPermissionPresets } from '~/composables/useApiKeyPermissionPresets';
|
||||
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
|
||||
import { useApiKeyStore } from '~/store/apiKey';
|
||||
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';
|
||||
@@ -38,15 +42,38 @@ import DeveloperAuthorizationLink from './DeveloperAuthorizationLink.vue';
|
||||
import EffectivePermissions from './EffectivePermissions.vue';
|
||||
|
||||
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 { t } = props;
|
||||
const props = withDefaults(defineProps<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 { modalVisible, editingKey, isAuthorizationMode, authorizationData, createdKey } =
|
||||
storeToRefs(apiKeyStore);
|
||||
|
||||
// Form data that matches what the backend expects
|
||||
// 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
|
||||
const permissions = formData.value.customPermissions;
|
||||
const permissionCount = permissions?.length ?? 0;
|
||||
|
||||
|
||||
if (!permissions || permissionCount === 0) return [];
|
||||
|
||||
// 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
|
||||
const isButtonDisabled = computed<boolean>(() => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -123,12 +150,12 @@ const loadFormSchema = () => {
|
||||
if (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
|
||||
formData.value = { ...authorizationData.value.formData };
|
||||
formData.value = { ...props.authorizationData.formData };
|
||||
// Ensure the name field is set for validation
|
||||
if (!formData.value.name && authorizationData.value.name) {
|
||||
formData.value.name = authorizationData.value.name;
|
||||
if (!formData.value.name && props.authorizationData.name) {
|
||||
formData.value.name = props.authorizationData.name;
|
||||
}
|
||||
|
||||
// In auth mode, if we have all required fields, consider it valid initially
|
||||
@@ -136,7 +163,7 @@ const loadFormSchema = () => {
|
||||
if (formData.value.name) {
|
||||
formValid.value = true;
|
||||
}
|
||||
} else if (editingKey.value) {
|
||||
} else if (props.editingKey) {
|
||||
// If editing, populate form data from existing key
|
||||
populateFormFromExistingKey();
|
||||
} else {
|
||||
@@ -164,9 +191,9 @@ onMounted(() => {
|
||||
|
||||
// Watch for editing key changes
|
||||
watch(
|
||||
() => editingKey.value,
|
||||
() => props.editingKey,
|
||||
() => {
|
||||
if (!isAuthorizationMode.value) {
|
||||
if (!props.isAuthorizationMode) {
|
||||
populateFormFromExistingKey();
|
||||
}
|
||||
}
|
||||
@@ -174,13 +201,13 @@ watch(
|
||||
|
||||
// Watch for authorization mode changes
|
||||
watch(
|
||||
() => isAuthorizationMode.value,
|
||||
() => props.isAuthorizationMode,
|
||||
async (newValue) => {
|
||||
if (newValue && authorizationData.value?.formData) {
|
||||
formData.value = { ...authorizationData.value.formData };
|
||||
if (newValue && props.authorizationData?.formData) {
|
||||
formData.value = { ...props.authorizationData.formData };
|
||||
// Ensure the name field is set for validation
|
||||
if (!formData.value.name && authorizationData.value.name) {
|
||||
formData.value.name = authorizationData.value.name;
|
||||
if (!formData.value.name && props.authorizationData.name) {
|
||||
formData.value.name = props.authorizationData.name;
|
||||
}
|
||||
|
||||
// Set initial valid state if we have required fields
|
||||
@@ -193,13 +220,13 @@ watch(
|
||||
|
||||
// Watch for authorization form data changes
|
||||
watch(
|
||||
() => authorizationData.value?.formData,
|
||||
() => props.authorizationData?.formData,
|
||||
(newFormData) => {
|
||||
if (isAuthorizationMode.value && newFormData) {
|
||||
if (props.isAuthorizationMode && newFormData) {
|
||||
formData.value = { ...newFormData };
|
||||
// Ensure the name field is set for validation
|
||||
if (!formData.value.name && authorizationData.value?.name) {
|
||||
formData.value.name = authorizationData.value.name;
|
||||
if (!formData.value.name && props.authorizationData?.name) {
|
||||
formData.value.name = props.authorizationData.name;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -225,11 +252,11 @@ watch(
|
||||
|
||||
// Populate form data from existing key
|
||||
const populateFormFromExistingKey = async () => {
|
||||
if (!editingKey.value || !formSchema.value) return;
|
||||
if (!props.editingKey || !formSchema.value) return;
|
||||
|
||||
const fragmentKey = useFragment(
|
||||
API_KEY_FRAGMENT,
|
||||
editingKey.value as FragmentType<typeof API_KEY_FRAGMENT>
|
||||
props.editingKey as FragmentType<typeof API_KEY_FRAGMENT>
|
||||
);
|
||||
if (fragmentKey) {
|
||||
// Group permissions by actions for better UI
|
||||
@@ -290,7 +317,7 @@ const transformFormDataForApi = (): CreateApiKeyInput => {
|
||||
} else {
|
||||
// If customPermissions is undefined or null, and we're editing,
|
||||
// we should still send an empty array to clear permissions
|
||||
if (editingKey.value) {
|
||||
if (props.editingKey) {
|
||||
apiData.permissions = [];
|
||||
}
|
||||
}
|
||||
@@ -304,20 +331,21 @@ const transformFormDataForApi = (): CreateApiKeyInput => {
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
apiKeyStore.hideModal();
|
||||
emit('update:open', false);
|
||||
formData.value = {
|
||||
customPermissions: [],
|
||||
roles: [],
|
||||
} as FormData;
|
||||
createdKey.value = null; // Reset local created key
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
async function upsertKey() {
|
||||
// In authorization mode, skip validation if we have a name
|
||||
if (!isAuthorizationMode.value && !formValid.value) {
|
||||
if (!props.isAuthorizationMode && !formValid.value) {
|
||||
return;
|
||||
}
|
||||
if (isAuthorizationMode.value && !formData.value.name) {
|
||||
if (props.isAuthorizationMode && !formData.value.name) {
|
||||
console.error('Cannot authorize without a name');
|
||||
return;
|
||||
}
|
||||
@@ -328,13 +356,13 @@ async function upsertKey() {
|
||||
try {
|
||||
const apiData = transformFormDataForApi();
|
||||
|
||||
const isEdit = !!editingKey.value?.id;
|
||||
const isEdit = !!props.editingKey?.id;
|
||||
|
||||
let res;
|
||||
if (isEdit && editingKey.value) {
|
||||
if (isEdit && props.editingKey) {
|
||||
res = await updateApiKey({
|
||||
input: {
|
||||
id: editingKey.value.id,
|
||||
id: props.editingKey.id,
|
||||
...apiData,
|
||||
},
|
||||
});
|
||||
@@ -348,19 +376,22 @@ async function upsertKey() {
|
||||
if (isEdit && apiKeyResult && 'update' in apiKeyResult) {
|
||||
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.update);
|
||||
apiKeyStore.setCreatedKey(fragmentData);
|
||||
emit('updated', fragmentData);
|
||||
} else if (!isEdit && apiKeyResult && 'create' in apiKeyResult) {
|
||||
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.create);
|
||||
apiKeyStore.setCreatedKey(fragmentData);
|
||||
emit('created', fragmentData);
|
||||
|
||||
// If in authorization mode, call the callback with the API key
|
||||
if (isAuthorizationMode.value && authorizationData.value?.onAuthorize && 'key' in fragmentData) {
|
||||
authorizationData.value.onAuthorize(fragmentData.key);
|
||||
if (props.isAuthorizationMode && props.authorizationData?.onAuthorize && 'key' in fragmentData) {
|
||||
props.authorizationData.onAuthorize(fragmentData.key);
|
||||
// Don't close the modal or reset form - let the callback handle it
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
apiKeyStore.hideModal();
|
||||
emit('update:open', false);
|
||||
formData.value = {
|
||||
customPermissions: [],
|
||||
roles: [],
|
||||
@@ -382,41 +413,39 @@ const copyApiKey = async () => {
|
||||
|
||||
<template>
|
||||
<!-- Modal mode (handles both regular creation and authorization) -->
|
||||
<Dialog
|
||||
v-if="modalVisible"
|
||||
v-model="modalVisible"
|
||||
size="xl"
|
||||
:title="
|
||||
isAuthorizationMode
|
||||
? 'Authorize API Key Access'
|
||||
: editingKey
|
||||
? t
|
||||
? t('Edit API Key')
|
||||
: 'Edit API Key'
|
||||
: t
|
||||
? t('Create API Key')
|
||||
: 'Create API Key'
|
||||
"
|
||||
:scrollable="true"
|
||||
close-button-text="Cancel"
|
||||
:primary-button-text="isAuthorizationMode ? 'Authorize' : editingKey ? 'Save' : 'Create'"
|
||||
:primary-button-loading="loading || postCreateLoading"
|
||||
:primary-button-loading-text="
|
||||
isAuthorizationMode ? 'Authorizing...' : editingKey ? 'Saving...' : 'Creating...'
|
||||
"
|
||||
:primary-button-disabled="isButtonDisabled"
|
||||
@update:model-value="
|
||||
<ResponsiveModal
|
||||
:open="props.open"
|
||||
sheet-side="bottom"
|
||||
:sheet-class="'h-[100vh] flex flex-col'"
|
||||
:dialog-class="'max-w-4xl max-h-[90vh] overflow-hidden'"
|
||||
:show-close-button="true"
|
||||
@update:open="
|
||||
(v) => {
|
||||
if (!v) close();
|
||||
}
|
||||
"
|
||||
@primary-click="upsertKey"
|
||||
>
|
||||
<div class="w-full">
|
||||
<ResponsiveModalHeader>
|
||||
<ResponsiveModalTitle>
|
||||
{{
|
||||
isAuthorizationMode
|
||||
? 'Authorize API Key Access'
|
||||
: editingKey
|
||||
? t
|
||||
? t('Edit API Key')
|
||||
: 'Edit API Key'
|
||||
: t
|
||||
? t('Create API Key')
|
||||
: 'Create API Key'
|
||||
}}
|
||||
</ResponsiveModalTitle>
|
||||
</ResponsiveModalHeader>
|
||||
|
||||
<div class="w-full flex-1 overflow-y-auto p-6">
|
||||
<!-- Show authorization description if in authorization mode -->
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
@@ -447,20 +476,20 @@ const copyApiKey = async () => {
|
||||
<!-- Loading state -->
|
||||
<div v-else class="flex items-center justify-center py-8">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p class="text-sm text-muted-foreground">Loading form...</p>
|
||||
<div class="border-primary mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2" />
|
||||
<p class="text-muted-foreground text-sm">Loading form...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
{{ extractGraphQLErrorMessage(error) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
:roles="formData.roles || []"
|
||||
:raw-permissions="formDataPermissions"
|
||||
@@ -470,14 +499,14 @@ const copyApiKey = async () => {
|
||||
<!-- Show selected roles for context -->
|
||||
<div
|
||||
v-if="formData.roles && formData.roles.length > 0"
|
||||
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
|
||||
class="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">
|
||||
<span
|
||||
v-for="role in formData.roles"
|
||||
: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 }}
|
||||
</span>
|
||||
@@ -509,20 +538,38 @@ const copyApiKey = async () => {
|
||||
<!-- Success state for authorization mode -->
|
||||
<div
|
||||
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>
|
||||
<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' }}
|
||||
</Button>
|
||||
</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 }}
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
CardWrapper,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -32,6 +38,7 @@ import { useFragment } from '~/composables/gql/fragment-masking';
|
||||
import { useApiKeyStore } from '~/store/apiKey';
|
||||
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 ApiKeyCreate from '~/components/ApiKey/ApiKeyCreate.vue';
|
||||
import { generateScopes } from '~/utils/authorizationLink';
|
||||
|
||||
const { result, refetch } = useQuery(GET_API_KEYS);
|
||||
@@ -40,6 +47,10 @@ const apiKeyStore = useApiKeyStore();
|
||||
const { createdKey } = storeToRefs(apiKeyStore);
|
||||
const apiKeys = ref<ApiKeyFragment[]>([]);
|
||||
|
||||
// Local modal state
|
||||
const showCreateModal = ref(false);
|
||||
const editingKey = ref<ApiKeyFragment | null>(null);
|
||||
|
||||
|
||||
watchEffect(() => {
|
||||
const baseKeys: ApiKeyFragment[] =
|
||||
@@ -89,10 +100,28 @@ function toggleShowKey(keyId: string) {
|
||||
|
||||
function openCreateModal(key: ApiKeyFragment | ApiKeyFragment | null = null) {
|
||||
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;
|
||||
templateUrl.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"
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-2 flex items-center px-1 text-gray-500 hover:text-gray-700"
|
||||
tabindex="-1"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="absolute inset-y-0 right-2 h-auto w-auto px-1 text-gray-500 hover:text-gray-700"
|
||||
@click="toggleShowKey(key.id)"
|
||||
>
|
||||
<component :is="showKey[key.id] ? EyeSlashIcon : EyeIcon" class="w-5 h-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip :delay-duration="0">
|
||||
@@ -260,7 +289,7 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="key.permissions?.length || key.roles?.length" class="mt-4 pt-4 border-t">
|
||||
<div v-if="key.permissions?.length || key.roles?.length" class="mt-4 pt-4 border-t border-muted">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
@@ -285,11 +314,11 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t flex flex-wrap gap-2">
|
||||
<div class="mt-4 pt-4 border-t border-muted flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" @click="openCreateModal(key)">Edit</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip :delay-duration="0">
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="outline" size="sm" @click="copyKeyTemplate(key)">
|
||||
<LinkIcon class="w-4 h-4 mr-1" />
|
||||
Copy Template
|
||||
@@ -311,27 +340,38 @@ async function copyKeyTemplate(key: ApiKeyFragment) {
|
||||
</div>
|
||||
|
||||
<!-- Template Input Dialog -->
|
||||
<div v-if="showTemplateInput" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-background rounded-lg p-6 max-w-lg w-full mx-4">
|
||||
<h3 class="text-lg font-semibold mb-4">Create from Template</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Paste a template URL or query string to pre-fill the API key creation form with permissions.
|
||||
</p>
|
||||
<Input
|
||||
v-model="templateUrl"
|
||||
placeholder="Paste template URL or query string (e.g., ?name=MyApp&scopes=role:admin)"
|
||||
class="mb-4"
|
||||
@keydown.enter="applyTemplate"
|
||||
/>
|
||||
<div v-if="templateError" class="mb-4 p-3 rounded border border-destructive bg-destructive/10 text-destructive text-sm">
|
||||
{{ templateError }}
|
||||
<Dialog v-model:open="showTemplateInput">
|
||||
<DialogContent class="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create from Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste a template URL or query string to pre-fill the API key creation form with permissions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
v-model="templateUrl"
|
||||
placeholder="Paste template URL or query string (e.g., ?name=MyApp&scopes=role:admin)"
|
||||
@keydown.enter="applyTemplate"
|
||||
/>
|
||||
<div v-if="templateError" class="p-3 rounded border border-destructive bg-destructive/10 text-destructive text-sm">
|
||||
{{ templateError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="cancelTemplateInput">Cancel</Button>
|
||||
<Button variant="primary" @click="applyTemplate">Apply Template</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="applyTemplate">Apply Template</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- API Key Create Modal -->
|
||||
<ApiKeyCreate
|
||||
v-model:open="showCreateModal"
|
||||
:editing-key="editingKey"
|
||||
@created="handleKeyCreated"
|
||||
@updated="handleKeyUpdated"
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useAuthorizationLink } from '~/composables/useAuthorizationLink.js';
|
||||
import { useApiKeyStore } from '~/store/apiKey.js';
|
||||
import ApiKeyCreate from './ApiKey/ApiKeyCreate.vue';
|
||||
|
||||
// Use the composables for authorization logic
|
||||
const {
|
||||
@@ -20,7 +21,7 @@ const {
|
||||
|
||||
// Use the API key store to control the global modal
|
||||
const apiKeyStore = useApiKeyStore();
|
||||
const { createdKey, modalVisible } = storeToRefs(apiKeyStore);
|
||||
const { createdKey, modalVisible, isAuthorizationMode, authorizationData, editingKey } = storeToRefs(apiKeyStore);
|
||||
|
||||
// Component state
|
||||
const showSuccess = ref(false);
|
||||
@@ -286,7 +287,7 @@ const returnToApp = () => {
|
||||
class="flex-1"
|
||||
@click="openAuthorizationModal"
|
||||
>
|
||||
{{ hasValidRedirectUri ? 'Review Permissions & Authorize' : 'Review Permissions' }}
|
||||
{{ hasValidRedirectUri ? 'Authorize' : 'Continue' }}
|
||||
</Button>
|
||||
</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">
|
||||
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -14,9 +14,9 @@ const { authAction, stateData } = storeToRefs(serverStore);
|
||||
|
||||
<template>
|
||||
<div class="whitespace-normal flex flex-col gap-y-4 max-w-3xl">
|
||||
<span v-if="stateData.error" class="text-unraid-red font-semibold">
|
||||
<h3 class="text-base mb-2">{{ t(stateData.heading) }}</h3>
|
||||
<span class="text-sm" v-html="t(stateData.message)" />
|
||||
<span v-if="stateData?.error" class="text-unraid-red font-semibold">
|
||||
<h3 class="text-base mb-2">{{ stateData?.heading ? t(stateData.heading) : '' }}</h3>
|
||||
<span class="text-sm" v-html="stateData?.message ? t(stateData.message) : ''" />
|
||||
</span>
|
||||
<span v-if="authAction">
|
||||
<BrandButton
|
||||
|
||||
@@ -19,15 +19,15 @@ const { avatar, connectPluginInstalled, registered, username } = storeToRefs(ser
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure class="group relative z-0 flex items-center justify-center w-9 h-9 rounded-full bg-linear-to-r from-unraid-red to-orange">
|
||||
<figure class="group relative z-0 flex items-center justify-center min-w-9 w-9 h-9 rounded-full bg-linear-to-r from-unraid-red to-orange flex-shrink-0">
|
||||
<img
|
||||
v-if="avatar && connectPluginInstalled && registered"
|
||||
:src="avatar"
|
||||
:alt="username"
|
||||
class="absolute z-10 inset-0 w-9 h-9 rounded-full overflow-hidden"
|
||||
class="absolute z-10 inset-0 w-9 h-9 rounded-full overflow-hidden object-cover"
|
||||
>
|
||||
<template v-else>
|
||||
<BrandMark gradient-start="#fff" gradient-stop="#fff" class="opacity-100 absolute z-10 w-9 px-[4px]" />
|
||||
<BrandMark gradient-start="#fff" gradient-stop="#fff" class="opacity-100 absolute z-10 w-9 h-9 p-[6px]" />
|
||||
</template>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
@@ -53,7 +53,7 @@ const items = [
|
||||
<AccordionItem value="color-theme-customization">
|
||||
<AccordionTrigger>Color Theme Customization</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2">
|
||||
<div class="flex flex-col gap-2 border-solid border-2 border-muted p-2">
|
||||
<h1 class="text-lg">Color Theme Customization</h1>
|
||||
|
||||
<Label for="theme-select">Theme</Label>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { BrandButton, jsonFormsRenderers, jsonFormsAjv, Label, SettingsGrid } fr
|
||||
import { JsonForms } from '@jsonforms/vue';
|
||||
|
||||
import { useServerStore } from '~/store/server';
|
||||
import Auth from '~/components/Auth.ce.vue';
|
||||
import DownloadApiLogs from '~/components/DownloadApiLogs.ce.vue';
|
||||
// unified settings values are returned as JSON, so use a generic record type
|
||||
// import type { ConnectSettingsValues } from '~/composables/gql/graphql';
|
||||
|
||||
@@ -99,14 +101,10 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
<SettingsGrid>
|
||||
<template v-if="connectPluginInstalled">
|
||||
<Label>Account Status:</Label>
|
||||
<div v-html="'<unraid-auth></unraid-auth>'"/>
|
||||
<Auth />
|
||||
</template>
|
||||
<Label>Download Unraid API Logs:</Label>
|
||||
<div
|
||||
v-html="
|
||||
'<unraid-download-api-logs></unraid-download-api-logs>'
|
||||
"
|
||||
/>
|
||||
<DownloadApiLogs />
|
||||
</SettingsGrid>
|
||||
<!-- auto-generated settings form -->
|
||||
<div class="mt-6 pl-3 [&_.vertical-layout]:space-y-6">
|
||||
@@ -129,7 +127,7 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
<div class="text-sm text-end">
|
||||
<p v-if="isUpdating">Applying Settings...</p>
|
||||
</div>
|
||||
<div class="col-start-2 ml-10 space-y-4">
|
||||
<div class="col-start-2 space-y-4 max-w-3xl">
|
||||
<BrandButton
|
||||
padding="lean"
|
||||
size="12px"
|
||||
|
||||
59
web/components/Docker/Console.vue
Normal file
59
web/components/Docker/Console.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
item: {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
badge?: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const command = ref('');
|
||||
const output = ref<string[]>([
|
||||
`root@${props.item.id}:/# echo "Welcome to ${props.item.label}"`,
|
||||
`Welcome to ${props.item.label}`,
|
||||
`root@${props.item.id}:/#`,
|
||||
]);
|
||||
|
||||
const executeCommand = () => {
|
||||
if (command.value.trim()) {
|
||||
output.value.push(`root@${props.item.id}:/# ${command.value}`);
|
||||
output.value.push(`${command.value}: command executed`);
|
||||
output.value.push(`root@${props.item.id}:/#`);
|
||||
command.value = '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center sm:mx-4">
|
||||
<h3 class="text-lg font-medium">Terminal</h3>
|
||||
<div class="flex gap-2">
|
||||
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-maximize-2">
|
||||
<span class="hidden sm:inline">Fullscreen</span>
|
||||
</UButton>
|
||||
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-refresh-cw">
|
||||
<span class="hidden sm:inline">Restart</span>
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-black text-green-400 p-4 rounded-lg font-mono text-sm h-96 overflow-y-auto sm:mx-4">
|
||||
<div v-for="(line, index) in output" :key="index">
|
||||
{{ line }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span>root@{{ item.id }}:/# </span>
|
||||
<input
|
||||
v-model="command"
|
||||
class="bg-transparent outline-none flex-1 ml-1"
|
||||
type="text"
|
||||
@keyup.enter="executeCommand"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
126
web/components/Docker/Edit.vue
Normal file
126
web/components/Docker/Edit.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
item: {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
badge?: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const config = ref({
|
||||
name: props.item.label,
|
||||
image: 'ghcr.io/imagegenius/immich:latest',
|
||||
network: 'bridge',
|
||||
restartPolicy: 'unless-stopped',
|
||||
cpuLimit: '',
|
||||
memoryLimit: '',
|
||||
ports: [{ container: '7878', host: '7878', protocol: 'tcp' }],
|
||||
volumes: [
|
||||
{ container: '/config', host: '/mnt/user/appdata/immich' },
|
||||
{ container: '/media', host: '/mnt/user/media' },
|
||||
],
|
||||
environment: [
|
||||
{ key: 'PUID', value: '99' },
|
||||
{ key: 'PGID', value: '100' },
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center sm:mx-4">
|
||||
<h3 class="text-lg font-medium">Container Configuration</h3>
|
||||
<div class="flex gap-2">
|
||||
<UButton color="primary" variant="outline">Cancel</UButton>
|
||||
<UButton color="primary">Save Changes</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UCard class="sm:mx-4">
|
||||
<template #header>
|
||||
<h4 class="font-medium">Basic Settings</h4>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Container Name">
|
||||
<UInput v-model="config.name" />
|
||||
</UFormField>
|
||||
<UFormField label="Image">
|
||||
<UInput v-model="config.image" />
|
||||
</UFormField>
|
||||
<UFormField label="Network Mode">
|
||||
<USelectMenu v-model="config.network" :options="['bridge', 'host', 'none', 'custom']" />
|
||||
</UFormField>
|
||||
<UFormField label="Restart Policy">
|
||||
<USelectMenu
|
||||
v-model="config.restartPolicy"
|
||||
:options="['no', 'always', 'unless-stopped', 'on-failure']"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="sm:mx-4">
|
||||
<template #header>
|
||||
<h4 class="font-medium">Resource Limits</h4>
|
||||
</template>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="CPU Limit">
|
||||
<UInput v-model="config.cpuLimit" placeholder="e.g., 0.5 or 2" />
|
||||
</UFormField>
|
||||
<UFormField label="Memory Limit">
|
||||
<UInput v-model="config.memoryLimit" placeholder="e.g., 512m or 2g" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="sm:mx-4">
|
||||
<template #header>
|
||||
<h4 class="font-medium">Port Mappings</h4>
|
||||
</template>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(port, index) in config.ports" :key="index" class="flex gap-2 items-center">
|
||||
<UInput v-model="port.host" placeholder="Host Port" class="flex-1" />
|
||||
<UIcon name="i-lucide-arrow-right" class="text-gray-400" />
|
||||
<UInput v-model="port.container" placeholder="Container Port" class="flex-1" />
|
||||
<USelectMenu v-model="port.protocol" :options="['tcp', 'udp']" class="w-24" />
|
||||
<UButton icon="i-lucide-trash-2" color="primary" variant="ghost" size="sm" />
|
||||
</div>
|
||||
<UButton icon="i-lucide-plus" size="sm" variant="outline">Add Port</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="sm:mx-4">
|
||||
<template #header>
|
||||
<h4 class="font-medium">Volume Mappings</h4>
|
||||
</template>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(volume, index) in config.volumes" :key="index" class="flex gap-2 items-center">
|
||||
<UInput v-model="volume.host" placeholder="Host Path" class="flex-1" />
|
||||
<UIcon name="i-lucide-arrow-right" class="text-gray-400" />
|
||||
<UInput v-model="volume.container" placeholder="Container Path" class="flex-1" />
|
||||
<UButton icon="i-lucide-trash-2" color="primary" variant="ghost" size="sm" />
|
||||
</div>
|
||||
<UButton icon="i-lucide-plus" size="sm" variant="outline">Add Volume</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h4 class="font-medium">Environment Variables</h4>
|
||||
</template>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(env, index) in config.environment" :key="index" class="flex gap-2 items-center">
|
||||
<UInput v-model="env.key" placeholder="Variable Name" class="flex-1" />
|
||||
<UInput v-model="env.value" placeholder="Value" class="flex-1" />
|
||||
<UButton icon="i-lucide-trash-2" color="primary" variant="ghost" size="sm" />
|
||||
</div>
|
||||
<UButton icon="i-lucide-plus" size="sm" variant="outline">Add Variable</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
63
web/components/Docker/HeaderContent.vue
Normal file
63
web/components/Docker/HeaderContent.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
autostartValue?: boolean;
|
||||
showAutostart?: boolean;
|
||||
manageActions?: Array<Array<{ label: string; icon: string; onClick?: () => void }>>;
|
||||
}
|
||||
|
||||
const _props = withDefaults(defineProps<Props>(), {
|
||||
autostartValue: true,
|
||||
showAutostart: true,
|
||||
manageActions: () => [
|
||||
[
|
||||
{ label: 'Start', icon: 'i-lucide-play' },
|
||||
{ label: 'Stop', icon: 'i-lucide-square' },
|
||||
{ label: 'Pause', icon: 'i-lucide-pause' },
|
||||
{ label: 'Restart', icon: 'i-lucide-refresh-cw' },
|
||||
],
|
||||
[
|
||||
{ label: 'Update', icon: 'i-lucide-download' },
|
||||
{ label: 'Force Update', icon: 'i-lucide-download-cloud' },
|
||||
{ label: 'Remove', icon: 'i-lucide-trash-2' },
|
||||
],
|
||||
[
|
||||
{ label: 'Docker Allocations', icon: 'i-lucide-hard-drive' },
|
||||
],
|
||||
[
|
||||
{ label: 'Project Page', icon: 'i-lucide-external-link' },
|
||||
{ label: 'Support', icon: 'i-lucide-help-circle' },
|
||||
{ label: 'More Info', icon: 'i-lucide-info' },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:autostart': [value: boolean];
|
||||
manageAction: [action: string];
|
||||
}>();
|
||||
|
||||
const handleManageAction = (action: { label: string; icon: string }) => {
|
||||
emit('manageAction', action.label);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-3 sm:gap-6">
|
||||
<div v-if="showAutostart" class="flex items-center gap-2 sm:gap-3">
|
||||
<span class="text-xs sm:text-sm font-medium">Autostart</span>
|
||||
<USwitch :model-value="autostartValue" @update:model-value="$emit('update:autostart', $event)" />
|
||||
</div>
|
||||
|
||||
<UDropdownMenu
|
||||
:items="manageActions.map(group => group.map(action => ({
|
||||
...action,
|
||||
onSelect: () => handleManageAction(action)
|
||||
})))"
|
||||
size="md"
|
||||
>
|
||||
<UButton variant="subtle" color="primary" size="sm" trailing-icon="i-lucide-chevron-down">
|
||||
Manage
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
34
web/components/Docker/Logs.vue
Normal file
34
web/components/Docker/Logs.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
item: {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
badge?: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const sampleLogs = [
|
||||
{ timestamp: '2024-01-22 10:15:23', message: `Starting ${props.item.label}...` },
|
||||
{ timestamp: '2024-01-22 10:15:24', message: 'Container initialized successfully' },
|
||||
{ timestamp: '2024-01-22 10:15:25', message: 'Listening on configured port' },
|
||||
{ timestamp: '2024-01-22 10:15:26', message: 'Health check passed' },
|
||||
{ timestamp: '2024-01-22 10:15:27', message: 'Ready to accept connections' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center sm:mx-4">
|
||||
<h3 class="text-lg font-medium">Container Logs</h3>
|
||||
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-download"> Export </UButton>
|
||||
</div>
|
||||
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg font-mono sm:mx-4 text-sm overflow-x-auto">
|
||||
<div v-for="(log, index) in sampleLogs" :key="index" class="whitespace-nowrap">
|
||||
<span class="text-gray-500">[{{ log.timestamp }}]</span> {{ log.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
66
web/components/Docker/Overview.vue
Normal file
66
web/components/Docker/Overview.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
interface ContainerDetails {
|
||||
network: string;
|
||||
lanIpPort: string;
|
||||
containerIp: string;
|
||||
uptime: string;
|
||||
containerPort: string;
|
||||
creationDate: string;
|
||||
containerId: string;
|
||||
maintainer: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
item: {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
badge?: string | number;
|
||||
};
|
||||
details?: ContainerDetails;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="details" class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:mx-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Network:</p>
|
||||
<p class="mt-1">{{ details.network }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">LAN IP:Port</p>
|
||||
<p class="mt-1">{{ details.lanIpPort }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Container IP:</p>
|
||||
<p class="mt-1">{{ details.containerIp }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Uptime:</p>
|
||||
<p class="mt-1">{{ details.uptime }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Container Port:</p>
|
||||
<p class="mt-1">{{ details.containerPort }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Creation Date:</p>
|
||||
<p class="mt-1">{{ details.creationDate }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Container Id:</p>
|
||||
<p class="mt-1 font-mono text-sm">{{ details.containerId }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Maintainer:</p>
|
||||
<p class="mt-1 text-sm">{{ details.maintainer }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-500 dark:text-gray-400 sm:mx-4">
|
||||
No details available for {{ item.label }}
|
||||
</div>
|
||||
</template>
|
||||
56
web/components/Docker/Preview.vue
Normal file
56
web/components/Docker/Preview.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
item: {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
badge?: string | number;
|
||||
};
|
||||
port?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const previewUrl = props.port ? `http://localhost:${props.port}` : null;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 sm:mx-4">
|
||||
<h3 class="text-lg font-medium">Web Preview</h3>
|
||||
<div class="flex gap-2">
|
||||
<UButton
|
||||
v-if="previewUrl"
|
||||
size="sm"
|
||||
color="primary"
|
||||
variant="outline"
|
||||
icon="i-lucide-external-link"
|
||||
:to="previewUrl"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="hidden sm:inline">Open in new tab</span>
|
||||
</UButton>
|
||||
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-refresh-cw">
|
||||
<span class="hidden sm:inline">Refresh</span>
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden sm:mx-4">
|
||||
<div v-if="previewUrl" class="bg-gray-100 dark:bg-gray-800 px-4 py-2 flex items-center gap-2">
|
||||
<UIcon name="i-lucide-lock" class="w-4 h-4 text-gray-500" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ previewUrl }}</span>
|
||||
</div>
|
||||
<div class="p-8 text-center h-96 flex items-center justify-center">
|
||||
<div v-if="previewUrl" class="text-gray-500 dark:text-gray-400">
|
||||
<UIcon name="i-lucide-globe" class="w-16 h-16 mx-auto mb-4" />
|
||||
<p>Web interface preview for {{ item.label }}</p>
|
||||
<p class="text-sm mt-2">Container must be running and accessible on port {{ port }}</p>
|
||||
</div>
|
||||
<div v-else class="text-gray-500 dark:text-gray-400">
|
||||
<UIcon name="i-lucide-alert-circle" class="w-16 h-16 mx-auto mb-4" />
|
||||
<p>No web interface available for {{ item.label }}</p>
|
||||
<p class="text-sm mt-2">This container does not expose a web interface</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,7 +17,7 @@ const downloadUrl = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="whitespace-normal flex flex-col gap-y-4 max-w-3xl">
|
||||
<span>
|
||||
<p class="text-sm text-start">
|
||||
{{ t('The primary method of support for Unraid Connect is through our forums and Discord.') }}
|
||||
{{
|
||||
t(
|
||||
@@ -25,7 +25,7 @@ const downloadUrl = computed(() => {
|
||||
)
|
||||
}}
|
||||
{{ t('The logs may contain sensitive information so do not post them publicly.') }}
|
||||
</span>
|
||||
</p>
|
||||
<span class="flex flex-col gap-y-4">
|
||||
<div class="flex">
|
||||
<BrandButton
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import { BellAlertIcon, ExclamationTriangleIcon, InformationCircleIcon, DocumentTextIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
||||
import { Badge, DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@unraid/ui';
|
||||
import { BellAlertIcon, ExclamationTriangleIcon, InformationCircleIcon, DocumentTextIcon, ArrowTopRightOnSquareIcon, ClipboardDocumentIcon } from '@heroicons/vue/24/solid';
|
||||
import { Button, DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@unraid/ui';
|
||||
import { WEBGUI_TOOLS_DOWNGRADE, WEBGUI_TOOLS_UPDATE, getReleaseNotesUrl } from '~/helpers/urls';
|
||||
|
||||
import { useActivationCodeDataStore } from '~/components/Activation/store/activationCodeData';
|
||||
@@ -14,8 +14,15 @@ import { useUpdateOsStore } from '~/store/updateOs';
|
||||
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
|
||||
import { INFO_VERSIONS_QUERY } from './UserProfile/versions.query';
|
||||
import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
|
||||
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { copyWithNotification } = useClipboardWithToast();
|
||||
|
||||
onMounted(() => {
|
||||
const logoWrapper = document.querySelector('.logo');
|
||||
logoWrapper?.classList.remove('logo');
|
||||
});
|
||||
|
||||
const serverStore = useServerStore();
|
||||
const updateOsStore = useUpdateOsStore();
|
||||
@@ -57,6 +64,18 @@ const openApiChangelog = () => {
|
||||
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 }>(() => {
|
||||
if (partnerInfo.value?.partnerUrl) {
|
||||
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(() => {
|
||||
if (stateDataError.value) {
|
||||
// only allowed to update when server is does not have a state error
|
||||
@@ -112,7 +141,7 @@ const updateOsStatus = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-2 mt-6">
|
||||
<div class="flex flex-col gap-y-2 mt-2 ml-2">
|
||||
<a
|
||||
:href="unraidLogoHeaderLink.href"
|
||||
:title="unraidLogoHeaderLink.title"
|
||||
@@ -122,7 +151,7 @@ const updateOsStatus = computed(() => {
|
||||
>
|
||||
<img
|
||||
:src="'/webGui/images/UN-logotype-gradient.svg'"
|
||||
class="w-[160px] h-auto max-h-[30px] object-contain"
|
||||
class="w-[14rem] xs:w-[16rem] h-auto max-h-[3rem] object-contain"
|
||||
alt="Unraid Logo"
|
||||
>
|
||||
</a>
|
||||
@@ -130,13 +159,16 @@ const updateOsStatus = computed(() => {
|
||||
<div class="flex flex-wrap justify-start gap-2">
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="text-xs xs:text-sm flex flex-row items-center gap-x-1 font-semibold text-header-text-secondary hover:text-orange-dark focus:text-orange-dark hover:underline focus:underline leading-none"
|
||||
<Button
|
||||
variant="link"
|
||||
class="text-xs xs:text-sm flex flex-row items-center gap-x-1 font-semibold text-header-text-secondary hover:text-orange-dark focus:text-orange-dark hover:underline focus:underline leading-none h-auto p-0"
|
||||
:title="t('Version Information')"
|
||||
>
|
||||
<InformationCircleIcon class="fill-current w-3 h-3 xs:w-4 xs:h-4 shrink-0" />
|
||||
<InformationCircleIcon
|
||||
class="fill-current w-3 h-3 xs:w-4 xs:h-4 shrink-0"
|
||||
/>
|
||||
{{ displayOsVersion }}
|
||||
</button>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent class="min-w-[200px]" align="start" :side-offset="4">
|
||||
@@ -144,16 +176,30 @@ const updateOsStatus = computed(() => {
|
||||
{{ t('Version Information') }}
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled class="text-xs opacity-100">
|
||||
<span class="flex justify-between w-full">
|
||||
<span>{{ t('Unraid OS') }}</span>
|
||||
<DropdownMenuItem
|
||||
:disabled="!displayOsVersion"
|
||||
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>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled class="text-xs opacity-100">
|
||||
<span class="flex justify-between w-full">
|
||||
<span>{{ t('Unraid API') }}</span>
|
||||
<DropdownMenuItem
|
||||
:disabled="!apiVersion"
|
||||
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>
|
||||
</DropdownMenuItem>
|
||||
@@ -178,26 +224,22 @@ const updateOsStatus = computed(() => {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
<component
|
||||
:is="updateOsStatus.href ? 'a' : 'button'"
|
||||
<Button
|
||||
v-if="updateOsStatus"
|
||||
:href="updateOsStatus.href ?? undefined"
|
||||
:title="updateOsStatus.title ?? undefined"
|
||||
class="group"
|
||||
@click="updateOsStatus.click?.()"
|
||||
:variant="updateOsStatus.badge?.color === 'orange' ? 'pill-orange' : 'pill-gray'"
|
||||
:title="updateOsStatus.title ?? updateOsStatus.text"
|
||||
:disabled="!updateOsStatus.href && !updateOsStatus.click"
|
||||
size="sm"
|
||||
@click="handleUpdateStatusClick"
|
||||
>
|
||||
<Badge
|
||||
v-if="updateOsStatus.badge"
|
||||
:color="updateOsStatus.badge.color"
|
||||
:icon="updateOsStatus.badge.icon"
|
||||
size="xs"
|
||||
>
|
||||
{{ updateOsStatus.text }}
|
||||
</Badge>
|
||||
<template v-else>
|
||||
{{ updateOsStatus.text }}
|
||||
</template>
|
||||
</component>
|
||||
<span v-if="updateOsStatus.badge?.icon" class="inline-flex shrink-0 w-4 h-4">
|
||||
<component
|
||||
:is="updateOsStatus.badge.icon"
|
||||
class="w-full h-full"
|
||||
/>
|
||||
</span>
|
||||
{{ updateOsStatus.text || '' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- OS Release Notes Modal -->
|
||||
|
||||
191
web/components/LayoutViews/Card/Card.vue
Normal file
191
web/components/LayoutViews/Card/Card.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import CardGrid from './CardGrid.vue';
|
||||
import CardHeader from './CardHeader.vue';
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
badge?: string | number;
|
||||
slot?: string;
|
||||
status?: {
|
||||
label: string;
|
||||
dotColor: string;
|
||||
}[];
|
||||
children?: Item[];
|
||||
isGroup?: boolean;
|
||||
}
|
||||
|
||||
export interface TabItem {
|
||||
key: string;
|
||||
label: string;
|
||||
component?: Component;
|
||||
props?: Record<string, unknown>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items?: Item[];
|
||||
tabs?: TabItem[];
|
||||
defaultItemId?: string;
|
||||
defaultTabKey?: string;
|
||||
navigationLabel?: string;
|
||||
showFilter?: boolean;
|
||||
showGrouping?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
items: () => [],
|
||||
tabs: () => [],
|
||||
defaultItemId: undefined,
|
||||
defaultTabKey: undefined,
|
||||
navigationLabel: 'Docker Overview',
|
||||
showFilter: true,
|
||||
showGrouping: true,
|
||||
});
|
||||
|
||||
const selectedItemId = ref(props.defaultItemId || props.items[0]?.id || '');
|
||||
const selectedItems = ref<string[]>([]);
|
||||
const expandedGroups = ref<Record<string, boolean>>({});
|
||||
const filterQuery = ref('');
|
||||
const groupBy = ref<string>('none');
|
||||
const autostartStates = ref<Record<string, boolean>>({});
|
||||
const runningStates = ref<Record<string, boolean>>({});
|
||||
|
||||
// Initialize expanded state for groups
|
||||
const initializeExpandedState = () => {
|
||||
props.items.forEach((item) => {
|
||||
if (item.isGroup) {
|
||||
expandedGroups.value[item.id] = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
initializeExpandedState();
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
initializeExpandedState();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!filterQuery.value) return props.items;
|
||||
|
||||
const query = filterQuery.value.toLowerCase();
|
||||
return props.items.filter((item) => {
|
||||
const matchesItem = item.label.toLowerCase().includes(query);
|
||||
const matchesChildren = item.children?.some((child) => child.label.toLowerCase().includes(query));
|
||||
return matchesItem || matchesChildren;
|
||||
});
|
||||
});
|
||||
|
||||
const groupedItems = computed(() => {
|
||||
if (groupBy.value === 'none') {
|
||||
return filteredItems.value;
|
||||
}
|
||||
|
||||
// For now, return items as-is since grouping logic depends on data structure
|
||||
return filteredItems.value;
|
||||
});
|
||||
|
||||
// Reusable function to collect all selectable items
|
||||
const collectSelectableItems = (items: Item[]): string[] => {
|
||||
const selectableItems: string[] = [];
|
||||
|
||||
const collect = (items: Item[]) => {
|
||||
for (const item of items) {
|
||||
if (!item.isGroup) {
|
||||
selectableItems.push(item.id);
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
collect(item.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collect(items);
|
||||
|
||||
return selectableItems;
|
||||
};
|
||||
|
||||
const selectAllItems = () => {
|
||||
selectedItems.value = [...collectSelectableItems(props.items)];
|
||||
};
|
||||
|
||||
const clearAllSelections = () => {
|
||||
selectedItems.value = [];
|
||||
};
|
||||
|
||||
const handleAddAction = () => {
|
||||
console.log('Add action triggered');
|
||||
};
|
||||
|
||||
const handleManageSelectedAction = (action: string) => {
|
||||
console.log('Manage selected action:', action);
|
||||
};
|
||||
|
||||
const handleItemSelect = (itemId: string) => {
|
||||
selectedItemId.value = itemId;
|
||||
};
|
||||
|
||||
const handleItemsSelectionUpdate = (items: string[]) => {
|
||||
selectedItems.value = items;
|
||||
};
|
||||
|
||||
const handleAutostartUpdate = (itemId: string, value: boolean) => {
|
||||
console.log('Autostart update for item:', itemId, 'value:', value);
|
||||
autostartStates.value[itemId] = value;
|
||||
};
|
||||
|
||||
const handleToggleRunning = (itemId: string) => {
|
||||
// TODO: Wire up to actual docker/VM start/stop API
|
||||
const currentState = runningStates.value[itemId] || false;
|
||||
runningStates.value[itemId] = !currentState;
|
||||
console.log('Toggle running for item:', itemId, 'new state:', !currentState);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<CardHeader
|
||||
:title="navigationLabel"
|
||||
:filter-query="filterQuery"
|
||||
:group-by="groupBy"
|
||||
:selected-items="selectedItems"
|
||||
:show-filter="showFilter"
|
||||
:show-grouping="showGrouping"
|
||||
@update:filter-query="filterQuery = $event"
|
||||
@update:group-by="groupBy = $event"
|
||||
@add="handleAddAction"
|
||||
@select-all="selectAllItems"
|
||||
@clear-all="clearAllSelections"
|
||||
@manage-action="handleManageSelectedAction"
|
||||
/>
|
||||
|
||||
<!-- Card Grid -->
|
||||
<div class="flex-1 overflow-auto w-full">
|
||||
<CardGrid
|
||||
:items="groupedItems"
|
||||
:selected-items="selectedItems"
|
||||
:selected-item-id="selectedItemId"
|
||||
:expanded-groups="expandedGroups"
|
||||
:autostart-states="autostartStates"
|
||||
:running-states="runningStates"
|
||||
@update:selected-items="handleItemsSelectionUpdate"
|
||||
@update:expanded-groups="expandedGroups = $event"
|
||||
@item-select="handleItemSelect"
|
||||
@update:autostart="handleAutostartUpdate"
|
||||
@toggle-running="handleToggleRunning"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
120
web/components/LayoutViews/Card/CardGrid.vue
Normal file
120
web/components/LayoutViews/Card/CardGrid.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { Item } from './Card.vue';
|
||||
|
||||
import CardGroupHeader from './CardGroupHeader.vue';
|
||||
import CardItem from './CardItem.vue';
|
||||
|
||||
interface Props {
|
||||
items: Item[];
|
||||
selectedItems: string[];
|
||||
selectedItemId?: string;
|
||||
expandedGroups: Record<string, boolean>;
|
||||
autostartStates: Record<string, boolean>;
|
||||
runningStates: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedItems': [items: string[]];
|
||||
'item-select': [itemId: string];
|
||||
'update:expandedGroups': [groups: Record<string, boolean>];
|
||||
'update:autostart': [itemId: string, value: boolean];
|
||||
'toggle-running': [itemId: string];
|
||||
}>();
|
||||
|
||||
const flattenedItems = computed(() => {
|
||||
const result: Array<Item & { isGroupChild?: boolean; parentGroup?: string }> = [];
|
||||
|
||||
for (const item of props.items) {
|
||||
if (item.isGroup && item.children) {
|
||||
// Add group header
|
||||
result.push(item);
|
||||
// Add children only if group is expanded
|
||||
if (props.expandedGroups[item.id]) {
|
||||
for (const child of item.children) {
|
||||
result.push({
|
||||
...child,
|
||||
isGroupChild: true,
|
||||
parentGroup: item.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const toggleItemSelection = (itemId: string) => {
|
||||
const newItems = [...props.selectedItems];
|
||||
const index = newItems.indexOf(itemId);
|
||||
|
||||
if (index > -1) {
|
||||
newItems.splice(index, 1);
|
||||
} else {
|
||||
newItems.push(itemId);
|
||||
}
|
||||
|
||||
emit('update:selectedItems', newItems);
|
||||
};
|
||||
|
||||
const isItemSelected = (itemId: string) => {
|
||||
return props.selectedItems.includes(itemId);
|
||||
};
|
||||
|
||||
const handleItemClick = (itemId: string) => {
|
||||
emit('item-select', itemId);
|
||||
};
|
||||
|
||||
const toggleGroupExpansion = (groupId: string) => {
|
||||
const newGroups = { ...props.expandedGroups };
|
||||
newGroups[groupId] = !newGroups[groupId];
|
||||
emit('update:expandedGroups', newGroups);
|
||||
};
|
||||
|
||||
const handleAutostartUpdate = (itemId: string, value: boolean) => {
|
||||
emit('update:autostart', itemId, value);
|
||||
};
|
||||
|
||||
const handleToggleRunning = (itemId: string) => {
|
||||
emit('toggle-running', itemId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 w-full">
|
||||
<div class="space-y-4 w-full max-w-full">
|
||||
<template v-for="item in flattenedItems" :key="item.id">
|
||||
<!-- Group Header -->
|
||||
<CardGroupHeader
|
||||
v-if="item.isGroup"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
:badge="item.badge"
|
||||
:is-expanded="expandedGroups[item.id]"
|
||||
@toggle="toggleGroupExpansion(item.id)"
|
||||
/>
|
||||
|
||||
<!-- Regular Card Item -->
|
||||
<CardItem
|
||||
v-else
|
||||
:item="item"
|
||||
:is-selected="isItemSelected(item.id)"
|
||||
:is-active="selectedItemId === item.id"
|
||||
:is-group-child="item.isGroupChild"
|
||||
:autostart-value="autostartStates[item.id] || false"
|
||||
:is-running="runningStates[item.id] || false"
|
||||
@toggle-selection="toggleItemSelection"
|
||||
@click="handleItemClick"
|
||||
@update:autostart="handleAutostartUpdate(item.id, $event)"
|
||||
@toggle-running="handleToggleRunning"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
53
web/components/LayoutViews/Card/CardGroupHeader.vue
Normal file
53
web/components/LayoutViews/Card/CardGroupHeader.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
icon?: string;
|
||||
badge?: string | number;
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
class="w-full cursor-pointer transition-all duration-200 hover:shadow-md"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<div class="flex items-center gap-4 py-2">
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0 pl-2">
|
||||
<UIcon v-if="icon" :name="icon" class="h-8 w-8" />
|
||||
<div v-else class="h-8 w-8 rounded flex items-center justify-center">
|
||||
<UIcon name="i-lucide-folder" class="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-semibold truncate">
|
||||
{{ label }}
|
||||
</h2>
|
||||
<UBadge v-if="badge" size="sm" :label="String(badge)" variant="subtle" />
|
||||
<!-- Edit icon -->
|
||||
<UIcon
|
||||
name="i-lucide-pencil"
|
||||
class="h-4 w-4 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expansion Arrow on right side -->
|
||||
<div class="flex-shrink-0 pr-2">
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
:class="[
|
||||
'h-5 w-5 text-gray-500 transform transition-transform duration-200',
|
||||
isExpanded ? 'rotate-90' : 'rotate-0',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
134
web/components/LayoutViews/Card/CardHeader.vue
Normal file
134
web/components/LayoutViews/Card/CardHeader.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
filterQuery?: string;
|
||||
groupBy?: string;
|
||||
selectedItems?: string[];
|
||||
showFilter?: boolean;
|
||||
showGrouping?: boolean;
|
||||
manageActions?: Array<Array<{ label: string; icon: string; onClick?: () => void }>>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: 'Docker Overview',
|
||||
filterQuery: '',
|
||||
groupBy: 'none',
|
||||
selectedItems: () => [],
|
||||
showFilter: true,
|
||||
showGrouping: true,
|
||||
manageActions: () => [
|
||||
[
|
||||
{ label: 'Sort Alpha Asc', icon: 'i-lucide-arrow-up-a-z' },
|
||||
{ label: 'Sort Alpha Dec', icon: 'i-lucide-arrow-down-z-a' },
|
||||
],
|
||||
[
|
||||
{ label: 'Start Selected', icon: 'i-lucide-play' },
|
||||
{ label: 'Stop Selected', icon: 'i-lucide-square' },
|
||||
{ label: 'Pause Selected', icon: 'i-lucide-pause' },
|
||||
{ label: 'Restart Selected', icon: 'i-lucide-refresh-cw' },
|
||||
{ label: 'Autostart Selected', icon: 'i-lucide-timer' },
|
||||
],
|
||||
[
|
||||
{ label: 'Check for Updates', icon: 'i-lucide-refresh-ccw' },
|
||||
{ label: 'Update Selected', icon: 'i-lucide-download' },
|
||||
{ label: 'Remove Selected', icon: 'i-lucide-trash-2' },
|
||||
],
|
||||
[{ label: 'Add Container', icon: 'i-lucide-plus' }],
|
||||
],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:filterQuery': [query: string];
|
||||
'update:groupBy': [groupBy: string];
|
||||
add: [];
|
||||
selectAll: [];
|
||||
clearAll: [];
|
||||
manageAction: [action: string];
|
||||
}>();
|
||||
|
||||
const selectedCount = computed(() => props.selectedItems?.length || 0);
|
||||
|
||||
const handleManageAction = (action: { label: string; icon: string }) => {
|
||||
emit('manageAction', action.label);
|
||||
};
|
||||
|
||||
const dropdownItems = computed(() =>
|
||||
props.manageActions.map((group) =>
|
||||
group.map((action) => ({
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
onSelect: () => handleManageAction(action),
|
||||
}))
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950">
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Title Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ title }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Controls Row -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<!-- Left Side: Filter and Configure View -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Filter Input with Configure View -->
|
||||
<div v-if="showFilter" class="flex items-center gap-2 max-w-sm">
|
||||
<UInput
|
||||
:model-value="filterQuery"
|
||||
placeholder="Filter"
|
||||
icon="i-lucide-search"
|
||||
size="md"
|
||||
class="flex-1"
|
||||
@update:model-value="$emit('update:filterQuery', $event)"
|
||||
/>
|
||||
|
||||
<!-- Configure View Button -->
|
||||
<UButton
|
||||
v-if="showGrouping"
|
||||
color="primary"
|
||||
variant="outline"
|
||||
>
|
||||
Configure View
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: All action buttons -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Select All / Clear All -->
|
||||
<UButton
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="selectedCount > 0 ? $emit('clearAll') : $emit('selectAll')"
|
||||
>
|
||||
{{ selectedCount > 0 ? 'Clear all' : 'Select all' }}
|
||||
</UButton>
|
||||
|
||||
<!-- Manage Selected Dropdown -->
|
||||
<UDropdownMenu :items="dropdownItems" size="md">
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="primary"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
:disabled="selectedCount === 0"
|
||||
>
|
||||
Manage Selected ({{ selectedCount }})
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
|
||||
<!-- Add Folder Button -->
|
||||
<UButton icon="i-lucide-plus" color="primary" variant="solid" @click="$emit('add')">
|
||||
Add Folder
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
136
web/components/LayoutViews/Card/CardItem.vue
Normal file
136
web/components/LayoutViews/Card/CardItem.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import type { Item } from './Card.vue';
|
||||
|
||||
interface Props {
|
||||
item: Item;
|
||||
isSelected: boolean;
|
||||
isActive?: boolean;
|
||||
isGroupChild?: boolean;
|
||||
autostartValue?: boolean;
|
||||
isRunning?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
autostartValue: false,
|
||||
isRunning: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleSelection: [itemId: string];
|
||||
click: [itemId: string];
|
||||
'update:autostart': [value: boolean];
|
||||
toggleRunning: [itemId: string];
|
||||
}>();
|
||||
|
||||
const handleCardClick = () => {
|
||||
emit('click', props.item.id);
|
||||
};
|
||||
|
||||
const handleCheckboxClick = (_value: boolean | 'indeterminate') => {
|
||||
emit('toggleSelection', props.item.id);
|
||||
};
|
||||
|
||||
const handleToggleRunning = () => {
|
||||
// TODO: Wire up to actual start/stop functionality for docker containers and VMs
|
||||
console.log('Toggle running state for:', props.item.id, 'Current state:', props.isRunning);
|
||||
emit('toggleRunning', props.item.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="isGroupChild ? 'ml-4' : ''">
|
||||
<UCard
|
||||
:class="[
|
||||
'w-full cursor-pointer transition-all duration-200 hover:shadow-md group',
|
||||
isActive ? 'ring-2 ring-primary-500' : '',
|
||||
]"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<div class="flex items-center gap-4 py-2">
|
||||
<!-- Selection Checkbox -->
|
||||
<div class="flex-shrink-0 pl-2 flex items-center">
|
||||
<UCheckbox :model-value="isSelected" @update:model-value="handleCheckboxClick" @click.stop />
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<UIcon v-if="item.icon" :name="item.icon" class="h-8 w-8" />
|
||||
<div v-else class="h-8 w-8 rounded flex items-center justify-center">
|
||||
<UIcon name="i-lucide-box" class="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Play/Stop Button -->
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<button
|
||||
class="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors flex items-center justify-center"
|
||||
:aria-label="isRunning ? 'Stop' : 'Start'"
|
||||
@click.stop="handleToggleRunning"
|
||||
>
|
||||
<svg
|
||||
v-if="!isRunning"
|
||||
class="w-4 h-4 fill-green-500 hover:fill-green-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="w-4 h-4 fill-red-500 hover:fill-red-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="6" y="6" width="12" height="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0 flex items-center">
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<h3 class="font-semibold truncate">
|
||||
{{ item.label }}
|
||||
</h3>
|
||||
<UBadge v-if="item.badge" size="xs" :label="String(item.badge)" />
|
||||
<div v-if="item.status && item.status.length > 0" class="flex flex-wrap gap-2 ml-4">
|
||||
<UBadge
|
||||
v-for="(statusItem, index) in item.status"
|
||||
:key="index"
|
||||
variant="subtle"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
class="flex items-center"
|
||||
>
|
||||
<div :class="['h-2 w-2 rounded-full mr-2', statusItem.dotColor]" />
|
||||
{{ statusItem.label }}
|
||||
</UBadge>
|
||||
<div class="text-sm ml-4">Uptime: 10 hours</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side content -->
|
||||
<div class="flex items-center gap-4 flex-shrink-0 pr-2">
|
||||
<!-- Action Buttons - only visible on hover -->
|
||||
<div class="hidden group-hover:flex items-center gap-2">
|
||||
<UButton color="primary" variant="outline" size="sm" @click.stop> Manage </UButton>
|
||||
<UButton color="primary" variant="solid" size="sm" @click.stop> Visit </UButton>
|
||||
</div>
|
||||
|
||||
<!-- Autostart Toggle - only visible on hover -->
|
||||
<div class="hidden group-hover:flex items-center gap-2">
|
||||
<span class="text-sm font-medium">Autostart</span>
|
||||
<USwitch
|
||||
:model-value="autostartValue"
|
||||
@update:model-value="$emit('update:autostart', $event)"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
185
web/components/LayoutViews/Detail/Detail.vue
Normal file
185
web/components/LayoutViews/Detail/Detail.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import HeaderContent from '../../Docker/HeaderContent.vue';
|
||||
import DetailContentHeader from './DetailContentHeader.vue';
|
||||
import DetailLeftNavigation from './DetailLeftNavigation.vue';
|
||||
import DetailRightContent from './DetailRightContent.vue';
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
badge?: string | number;
|
||||
slot?: string;
|
||||
status?: {
|
||||
label: string;
|
||||
dotColor: string;
|
||||
}[];
|
||||
children?: Item[];
|
||||
isGroup?: boolean;
|
||||
}
|
||||
|
||||
export interface TabItem {
|
||||
key: string;
|
||||
label: string;
|
||||
component?: Component;
|
||||
props?: Record<string, unknown>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items?: Item[];
|
||||
tabs?: TabItem[];
|
||||
defaultItemId?: string;
|
||||
defaultTabKey?: string;
|
||||
navigationLabel?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
items: () => [],
|
||||
tabs: () => [],
|
||||
defaultItemId: undefined,
|
||||
defaultTabKey: undefined,
|
||||
navigationLabel: 'Select Item',
|
||||
});
|
||||
|
||||
const selectedItemId = ref(props.defaultItemId || props.items[0]?.id || '');
|
||||
const selectedTab = ref(props.defaultTabKey || '0');
|
||||
const selectedItems = ref<string[]>([]);
|
||||
const expandedGroups = ref<Record<string, boolean>>({});
|
||||
const autostartEnabled = ref(true);
|
||||
|
||||
// Initialize expanded state for groups
|
||||
const initializeExpandedState = () => {
|
||||
props.items.forEach((item) => {
|
||||
if (item.isGroup) {
|
||||
expandedGroups.value[item.id] = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
initializeExpandedState();
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
initializeExpandedState();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const selectedItem = computed(() => {
|
||||
const topLevel = props.items.find((item) => item.id === selectedItemId.value);
|
||||
|
||||
if (topLevel) return topLevel;
|
||||
|
||||
for (const item of props.items) {
|
||||
if (item.children) {
|
||||
const nested = item.children.find((child) => child.id === selectedItemId.value);
|
||||
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Reusable function to collect all selectable items
|
||||
const collectSelectableItems = (items: Item[]): string[] => {
|
||||
const selectableItems: string[] = [];
|
||||
|
||||
const collect = (items: Item[]) => {
|
||||
for (const item of items) {
|
||||
if (!item.isGroup) {
|
||||
selectableItems.push(item.id);
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
collect(item.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collect(items);
|
||||
|
||||
return selectableItems;
|
||||
};
|
||||
|
||||
const selectAllItems = () => {
|
||||
selectedItems.value = [...collectSelectableItems(props.items)];
|
||||
};
|
||||
|
||||
const clearAllSelections = () => {
|
||||
selectedItems.value = [];
|
||||
};
|
||||
|
||||
const handleAddAction = () => {
|
||||
console.log('Add action triggered');
|
||||
};
|
||||
|
||||
const handleManageSelectedAction = (action: string) => {
|
||||
console.log('Manage selected action:', action);
|
||||
};
|
||||
|
||||
const handleManageItemAction = (action: string) => {
|
||||
console.log('Manage item action:', action);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col p-4 lg:flex-row h-full">
|
||||
<!-- Navigation -->
|
||||
<DetailLeftNavigation
|
||||
:items="items"
|
||||
:selected-id="selectedItemId"
|
||||
:selected-items="selectedItems"
|
||||
:expanded-groups="expandedGroups"
|
||||
:navigation-label="navigationLabel"
|
||||
@update:selected-id="selectedItemId = $event"
|
||||
@update:selected-items="selectedItems = $event"
|
||||
@update:expanded-groups="expandedGroups = $event"
|
||||
@add="handleAddAction"
|
||||
@select-all="selectAllItems"
|
||||
@clear-all="clearAllSelections"
|
||||
@manage-action="handleManageSelectedAction"
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<DetailRightContent
|
||||
:selected-item="selectedItem"
|
||||
:tabs="tabs"
|
||||
:selected-tab="selectedTab"
|
||||
@update:selected-tab="selectedTab = $event"
|
||||
>
|
||||
<template #header="{ item }">
|
||||
<DetailContentHeader :icon="item.icon" :title="item.label">
|
||||
<template #right-content>
|
||||
<template v-if="item.status && item.status.length > 0">
|
||||
<UBadge
|
||||
v-for="(statusItem, index) in item.status"
|
||||
:key="index"
|
||||
variant="subtle"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
>
|
||||
<div :class="['h-2 w-2 rounded-full mr-2', statusItem.dotColor]" />
|
||||
{{ statusItem.label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
</template>
|
||||
<template #controls>
|
||||
<HeaderContent
|
||||
:autostart-value="autostartEnabled"
|
||||
@update:autostart="autostartEnabled = $event"
|
||||
@manage-action="handleManageItemAction"
|
||||
/>
|
||||
</template>
|
||||
</DetailContentHeader>
|
||||
</template>
|
||||
</DetailRightContent>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
28
web/components/LayoutViews/Detail/DetailContentHeader.vue
Normal file
28
web/components/LayoutViews/Detail/DetailContentHeader.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
icon?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const _props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 sm:gap-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-2 sm:gap-4 lg:mb-0">
|
||||
<UIcon v-if="icon" :name="icon" class="h-8 w-8 flex-shrink-0" />
|
||||
<h1 class="text-2xl font-semibold truncate leading-none">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<slot name="right-content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center flex-shrink-0">
|
||||
<slot name="controls" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
357
web/components/LayoutViews/Detail/DetailLeftNavigation.vue
Normal file
357
web/components/LayoutViews/Detail/DetailLeftNavigation.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import type { Item } from './Detail.vue';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
items: Item[];
|
||||
selectedId: string;
|
||||
selectedItems: string[];
|
||||
expandedGroups: Record<string, boolean>;
|
||||
showHeader?: boolean;
|
||||
manageActions?: Array<Array<{ label: string; icon: string; onClick?: () => void }>>;
|
||||
navigationLabel?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: 'Service Name',
|
||||
showHeader: true,
|
||||
manageActions: () => [
|
||||
[
|
||||
{ label: 'Sort Alpha Asc', icon: 'i-lucide-arrow-up-a-z' },
|
||||
{ label: 'Sort Alpha Dec', icon: 'i-lucide-arrow-down-z-a' },
|
||||
],
|
||||
[
|
||||
{ label: 'Start Selected', icon: 'i-lucide-play' },
|
||||
{ label: 'Stop Selected', icon: 'i-lucide-square' },
|
||||
{ label: 'Pause Selected', icon: 'i-lucide-pause' },
|
||||
{ label: 'Restart Selected', icon: 'i-lucide-refresh-cw' },
|
||||
{ label: 'Autostart Selected', icon: 'i-lucide-timer' },
|
||||
],
|
||||
[
|
||||
{ label: 'Check for Updates', icon: 'i-lucide-refresh-ccw' },
|
||||
{ label: 'Update Selected', icon: 'i-lucide-download' },
|
||||
{ label: 'Remove Selected', icon: 'i-lucide-trash-2' },
|
||||
],
|
||||
[{ label: 'Add Container', icon: 'i-lucide-plus' }],
|
||||
],
|
||||
navigationLabel: 'Select Item',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedId': [id: string];
|
||||
'update:selectedItems': [items: string[]];
|
||||
'update:expandedGroups': [groups: Record<string, boolean>];
|
||||
add: [];
|
||||
selectAll: [];
|
||||
clearAll: [];
|
||||
manageAction: [action: string];
|
||||
}>();
|
||||
|
||||
// Internal drawer state for mobile
|
||||
const sidebarOpen = ref(false);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarOpen.value = !sidebarOpen.value;
|
||||
};
|
||||
|
||||
const navigationMenuItems = computed(() =>
|
||||
props.items.map((item) => ({
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
id: item.id,
|
||||
badge: String(item.badge || ''),
|
||||
slot: item.slot,
|
||||
...(item.isGroup ? {} : { onClick: () => selectNavigationItem(item.id) }),
|
||||
isGroup: item.isGroup,
|
||||
status: item.status,
|
||||
...(item.isGroup ? {} : { to: '#' }),
|
||||
defaultOpen: item.isGroup ? true : undefined,
|
||||
children: item.children?.map((child) => ({
|
||||
label: child.label,
|
||||
icon: child.icon,
|
||||
id: child.id,
|
||||
badge: String(child.badge || ''),
|
||||
slot: child.slot,
|
||||
onClick: () => selectNavigationItem(child.id),
|
||||
status: child.status,
|
||||
to: '#',
|
||||
})),
|
||||
}))
|
||||
);
|
||||
|
||||
interface NavigationMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
badge: string;
|
||||
slot?: string;
|
||||
onClick?: () => void;
|
||||
isGroup?: boolean;
|
||||
status?: {
|
||||
label: string;
|
||||
dotColor: string;
|
||||
}[];
|
||||
children?: NavigationMenuItem[];
|
||||
to?: string;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
const allItemsWithSlots = computed(() => {
|
||||
const items: NavigationMenuItem[] = [];
|
||||
const collectItems = (navItems: NavigationMenuItem[]) => {
|
||||
for (const item of navItems) {
|
||||
if (item.slot) {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
collectItems(item.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectItems(navigationMenuItems.value);
|
||||
return items;
|
||||
});
|
||||
|
||||
const allItemsSelected = computed(() => {
|
||||
const allSelectableItems: string[] = [];
|
||||
const collectSelectableItems = (items: Item[]) => {
|
||||
for (const item of items) {
|
||||
if (!item.isGroup) {
|
||||
allSelectableItems.push(item.id);
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
collectSelectableItems(item.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectSelectableItems(props.items);
|
||||
|
||||
return (
|
||||
allSelectableItems.length > 0 && allSelectableItems.every((id) => props.selectedItems.includes(id))
|
||||
);
|
||||
});
|
||||
|
||||
const selectedItemsCount = computed(() => props.selectedItems.length);
|
||||
|
||||
const selectNavigationItem = (id: string) => {
|
||||
const actualItem =
|
||||
props.items.find((item) => item.id === id) ||
|
||||
props.items.flatMap((item) => item.children || []).find((child) => child.id === id);
|
||||
|
||||
if (actualItem && !actualItem.isGroup) {
|
||||
emit('update:selectedId', id);
|
||||
sidebarOpen.value = false; // Close drawer on mobile when item is selected
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemSelection = (itemId: string) => {
|
||||
const newItems = [...props.selectedItems];
|
||||
const index = newItems.indexOf(itemId);
|
||||
|
||||
if (index > -1) {
|
||||
newItems.splice(index, 1);
|
||||
} else {
|
||||
newItems.push(itemId);
|
||||
}
|
||||
|
||||
emit('update:selectedItems', newItems);
|
||||
};
|
||||
|
||||
const isItemSelected = (itemId: string) => {
|
||||
return props.selectedItems.includes(itemId);
|
||||
};
|
||||
|
||||
const toggleGroupExpansion = (groupId: string) => {
|
||||
const newGroups = { ...props.expandedGroups };
|
||||
|
||||
newGroups[groupId] = !newGroups[groupId];
|
||||
emit('update:expandedGroups', newGroups);
|
||||
};
|
||||
|
||||
const handleManageAction = (action: { label: string; icon: string }) => {
|
||||
emit('manageAction', action.label);
|
||||
};
|
||||
|
||||
const dropdownItems = computed(() =>
|
||||
props.manageActions.map((group) =>
|
||||
group.map((action) => ({
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
onSelect: () => handleManageAction(action),
|
||||
}))
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Desktop navigation -->
|
||||
<div class="hidden lg:block lg:mr-16">
|
||||
<div class="h-full overflow-y-auto overflow-x-hidden">
|
||||
<!-- Navigation Header -->
|
||||
<div v-if="showHeader" class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold">{{ title }}</h2>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
square
|
||||
@click="$emit('add')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<UButton
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
:label="allItemsSelected ? 'Clear all' : 'Select all'"
|
||||
@click="allItemsSelected ? $emit('clearAll') : $emit('selectAll')"
|
||||
/>
|
||||
|
||||
<UDropdownMenu :items="dropdownItems" size="md">
|
||||
<UButton
|
||||
variant="subtle"
|
||||
color="primary"
|
||||
size="sm"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
:disabled="selectedItemsCount === 0"
|
||||
class="w-full sm:w-auto"
|
||||
>
|
||||
<span class="sm:hidden">Manage ({{ selectedItemsCount }})</span>
|
||||
<span class="hidden sm:inline">Manage Selected ({{ selectedItemsCount }})</span>
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Menu -->
|
||||
<UNavigationMenu :items="navigationMenuItems" orientation="vertical">
|
||||
<template v-for="item in allItemsWithSlots" :key="`slot-${item.id}`" #[item.slot!]>
|
||||
<div
|
||||
class="flex items-center gap-3 mb-2 min-w-0"
|
||||
@click="
|
||||
item.children && item.children.length > 0 ? toggleGroupExpansion(item.id) : undefined
|
||||
"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="isItemSelected(item.id)"
|
||||
class="flex-shrink-0"
|
||||
@update:model-value="toggleItemSelection(item.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<UIcon v-if="item.icon" :name="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span class="truncate flex-1 min-w-0">{{ item.label }}</span>
|
||||
<UBadge v-if="item.badge" size="xs" :label="String(item.badge)" class="flex-shrink-0" />
|
||||
|
||||
<UIcon
|
||||
v-if="item.children?.length"
|
||||
name="i-lucide-chevron-down"
|
||||
:class="[
|
||||
'h-5 w-5 text-gray-400 transition-transform duration-200 flex-shrink-0',
|
||||
expandedGroups[item.id] ? 'rotate-180' : 'rotate-0',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UNavigationMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile UDrawer -->
|
||||
<div class="lg:hidden">
|
||||
<div class="m-4">
|
||||
<UButton color="primary" size="md" class="w-full justify-center" @click="toggleSidebar">
|
||||
{{ navigationLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UDrawer v-model:open="sidebarOpen" direction="left" size="md">
|
||||
<template #content>
|
||||
<div class="h-full overflow-y-auto overflow-x-hidden p-4">
|
||||
<!-- Navigation Header -->
|
||||
<div v-if="showHeader" class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold">{{ title }}</h2>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
square
|
||||
@click="$emit('add')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<UButton
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
:label="allItemsSelected ? 'Clear all' : 'Select all'"
|
||||
@click="allItemsSelected ? $emit('clearAll') : $emit('selectAll')"
|
||||
/>
|
||||
|
||||
<UDropdownMenu :items="dropdownItems" size="md">
|
||||
<UButton
|
||||
variant="subtle"
|
||||
color="primary"
|
||||
size="sm"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
:disabled="selectedItemsCount === 0"
|
||||
class="w-full sm:w-auto"
|
||||
>
|
||||
<span class="sm:hidden">Manage ({{ selectedItemsCount }})</span>
|
||||
<span class="hidden sm:inline">Manage Selected ({{ selectedItemsCount }})</span>
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Menu -->
|
||||
<UNavigationMenu :items="navigationMenuItems" orientation="vertical">
|
||||
<template v-for="item in allItemsWithSlots" :key="`slot-${item.id}`" #[item.slot!]>
|
||||
<div
|
||||
class="flex items-center gap-4 mb-2 min-w-0"
|
||||
@click="
|
||||
item.children && item.children.length > 0 ? toggleGroupExpansion(item.id) : undefined
|
||||
"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="isItemSelected(item.id)"
|
||||
class="flex-shrink-0"
|
||||
@update:model-value="toggleItemSelection(item.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<UIcon v-if="item.icon" :name="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span class="truncate flex-1 min-w-0">{{ item.label }}</span>
|
||||
<UBadge
|
||||
v-if="item.badge"
|
||||
size="xs"
|
||||
:label="String(item.badge)"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
|
||||
<UIcon
|
||||
v-if="item.children?.length"
|
||||
name="i-lucide-chevron-down"
|
||||
:class="[
|
||||
'h-5 w-5 text-gray-400 transition-transform duration-200 flex-shrink-0',
|
||||
expandedGroups[item.id] ? 'rotate-180' : 'rotate-0',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UNavigationMenu>
|
||||
</div>
|
||||
</template>
|
||||
</UDrawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
97
web/components/LayoutViews/Detail/DetailRightContent.vue
Normal file
97
web/components/LayoutViews/Detail/DetailRightContent.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { Item, TabItem } from './Detail.vue';
|
||||
|
||||
interface Props {
|
||||
selectedItem?: Item;
|
||||
tabs: TabItem[];
|
||||
selectedTab: string;
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selectedItem: undefined,
|
||||
showHeader: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedTab': [value: string];
|
||||
}>();
|
||||
|
||||
const tabItems = computed(() =>
|
||||
props.tabs.map((tab) => ({
|
||||
label: tab.label,
|
||||
key: tab.key,
|
||||
disabled: tab.disabled,
|
||||
}))
|
||||
);
|
||||
|
||||
const getCurrentTabComponent = () => {
|
||||
const tabIndex = parseInt(props.selectedTab);
|
||||
|
||||
// Validate that tabIndex is a valid number and within bounds
|
||||
if (isNaN(tabIndex) || tabIndex < 0 || tabIndex >= props.tabs.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return props.tabs[tabIndex]?.component || null;
|
||||
};
|
||||
|
||||
const getCurrentTabProps = () => {
|
||||
const tabIndex = parseInt(props.selectedTab);
|
||||
|
||||
// Validate that tabIndex is a valid number and within bounds
|
||||
if (isNaN(tabIndex) || tabIndex < 0 || tabIndex >= props.tabs.length) {
|
||||
return {
|
||||
item: props.selectedItem,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTab = props.tabs[tabIndex];
|
||||
|
||||
return {
|
||||
item: props.selectedItem,
|
||||
...currentTab?.props,
|
||||
};
|
||||
};
|
||||
|
||||
const updateSelectedTab = (value: string | number) => {
|
||||
emit('update:selectedTab', String(value));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 min-w-0 px-4 lg:px-0">
|
||||
<div v-if="showHeader && selectedItem" class="mb-6">
|
||||
<slot name="header" :item="selectedItem" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto -mx-4 px-4">
|
||||
<UTabs
|
||||
:model-value="selectedTab"
|
||||
variant="link"
|
||||
:items="tabItems"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
list: 'gap-3 sm:gap-6 md:gap-8 whitespace-nowrap text-sm sm:text-base',
|
||||
}"
|
||||
@update:model-value="updateSelectedTab"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 sm:mt-6">
|
||||
<component
|
||||
:is="getCurrentTabComponent()"
|
||||
v-if="getCurrentTabComponent() && selectedItem"
|
||||
v-bind="getCurrentTabProps()"
|
||||
/>
|
||||
<div v-else-if="!selectedItem">
|
||||
<slot name="empty">No item selected</slot>
|
||||
</div>
|
||||
<div v-else>
|
||||
<slot name="no-content">No content available</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
133
web/components/LayoutViews/Detail/DetailTest.ce.vue
Normal file
133
web/components/LayoutViews/Detail/DetailTest.ce.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import Console from '../../Docker/Console.vue';
|
||||
import Edit from '../../Docker/Edit.vue';
|
||||
import Logs from '../../Docker/Logs.vue';
|
||||
import Overview from '../../Docker/Overview.vue';
|
||||
import Preview from '../../Docker/Preview.vue';
|
||||
import Detail from './Detail.vue';
|
||||
|
||||
interface ContainerDetails {
|
||||
network: string;
|
||||
lanIpPort: string;
|
||||
containerIp: string;
|
||||
uptime: string;
|
||||
containerPort: string;
|
||||
creationDate: string;
|
||||
containerId: string;
|
||||
maintainer: string;
|
||||
}
|
||||
|
||||
const dockerContainers = [
|
||||
{
|
||||
id: 'immich',
|
||||
label: 'immich',
|
||||
icon: 'i-lucide-play-circle',
|
||||
slot: 'immich' as const,
|
||||
status: [
|
||||
{ label: 'Update available', dotColor: 'bg-orange-500' },
|
||||
{ label: 'Started', dotColor: 'bg-green-500' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'organizrv2',
|
||||
label: 'organizrv2',
|
||||
icon: 'i-lucide-layers',
|
||||
slot: 'organizrv2' as const,
|
||||
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
|
||||
},
|
||||
{
|
||||
id: 'jellyfin',
|
||||
label: 'Jellyfin',
|
||||
icon: 'i-lucide-film',
|
||||
slot: 'jellyfin' as const,
|
||||
status: [{ label: 'Stopped', dotColor: 'bg-red-500' }],
|
||||
},
|
||||
{
|
||||
id: 'databases',
|
||||
label: 'Databases',
|
||||
icon: 'i-lucide-database',
|
||||
slot: 'databases' as const,
|
||||
isGroup: true,
|
||||
children: [
|
||||
{
|
||||
id: 'mongodb',
|
||||
label: 'MongoDB',
|
||||
icon: 'i-lucide-leafy-green',
|
||||
badge: 'DB',
|
||||
slot: 'mongodb' as const,
|
||||
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
|
||||
},
|
||||
{
|
||||
id: 'postgres17',
|
||||
label: 'postgres17',
|
||||
icon: 'i-lucide-pyramid',
|
||||
badge: 'DB',
|
||||
slot: 'postgres17' as const,
|
||||
status: [
|
||||
{ label: 'Update available', dotColor: 'bg-orange-500' },
|
||||
{ label: 'Paused', dotColor: 'bg-blue-500' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
label: 'Redis',
|
||||
icon: 'i-lucide-panda',
|
||||
badge: 'DB',
|
||||
slot: 'redis' as const,
|
||||
status: [{ label: 'Started', dotColor: 'bg-green-500' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const containerDetails: Record<string, ContainerDetails> = {
|
||||
immich: {
|
||||
network: 'Bridge',
|
||||
lanIpPort: '7878',
|
||||
containerIp: '172.17.0.4',
|
||||
uptime: '13 hours',
|
||||
containerPort: '9696:TCP',
|
||||
creationDate: '2 weeks ago',
|
||||
containerId: '472b4c2442b9',
|
||||
maintainer: 'ghcr.io/imagegenius/immich',
|
||||
},
|
||||
};
|
||||
|
||||
const getTabsWithProps = (containerId: string) => [
|
||||
{
|
||||
key: 'overview',
|
||||
label: 'Overview',
|
||||
component: Overview,
|
||||
props: { details: containerDetails[containerId] },
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
label: 'Logs',
|
||||
component: Logs,
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
label: 'Console',
|
||||
component: Console,
|
||||
},
|
||||
{
|
||||
key: 'preview',
|
||||
label: 'Preview',
|
||||
component: Preview,
|
||||
props: { port: containerDetails[containerId]?.lanIpPort || '8080' },
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
component: Edit,
|
||||
},
|
||||
];
|
||||
|
||||
const tabs = getTabsWithProps('immich');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<Detail :items="dockerContainers" :tabs="tabs" default-item-id="immich" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, watchEffect } from 'vue';
|
||||
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import { cn } from '@unraid/ui';
|
||||
import { Button, cn } from '@unraid/ui';
|
||||
import { TransitionChild, TransitionRoot } from '@headlessui/vue';
|
||||
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
@@ -117,19 +117,20 @@ const computedVerticalCenter = computed<string>(() => {
|
||||
disableShadow ? 'shadow-none border-none' : 'shadow-xl',
|
||||
error ? 'shadow-unraid-red/30 border-unraid-red/10' : '',
|
||||
success ? 'shadow-green-600/30 border-green-600/10' : '',
|
||||
!error && !success && !disableShadow ? 'shadow-orange/10 border-white/10' : '',
|
||||
!error && !success && !disableShadow ? 'shadow-orange/10' : '',
|
||||
]"
|
||||
class="text-base text-foreground bg-background text-left relative z-10 mx-auto flex flex-col justify-around border-2 border-solid transform overflow-hidden rounded-lg transition-all"
|
||||
class="text-base text-foreground bg-background text-left relative z-10 mx-auto flex flex-col justify-around border-2 border-solid border-muted transform overflow-hidden rounded-lg transition-all"
|
||||
>
|
||||
<div v-if="showCloseX" class="absolute z-20 right-0 top-0 pt-1 pr-1 sm:block">
|
||||
<button
|
||||
class="rounded-md text-foreground bg-transparent p-2 hover:text-white focus:text-white hover:bg-unraid-red focus:bg-unraid-red focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="rounded-md text-foreground hover:text-white focus:text-white hover:bg-unraid-red focus:bg-unraid-red"
|
||||
:aria-label="t('Close')"
|
||||
@click="closeModal"
|
||||
>
|
||||
<span class="sr-only">{{ t('Close') }}</span>
|
||||
<XMarkIcon class="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<header
|
||||
|
||||
@@ -5,7 +5,6 @@ import { storeToRefs } from 'pinia';
|
||||
import { useCallbackActionsStore } from '~/store/callbackActions';
|
||||
import { useTrialStore } from '~/store/trial';
|
||||
import { useUpdateOsStore } from '~/store/updateOs';
|
||||
import ApiKeyCreate from '~/components/ApiKey/ApiKeyCreate.vue';
|
||||
import UpcCallbackFeedback from '~/components/UserProfile/CallbackFeedback.vue';
|
||||
import UpcTrial from '~/components/UserProfile/Trial.vue';
|
||||
import UpdateOsCheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue';
|
||||
@@ -26,6 +25,5 @@ const { updateOsModalVisible, changelogModalVisible } = storeToRefs(useUpdateOsS
|
||||
<UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" />
|
||||
<UpdateOsChangelogModal :t="t" :open="changelogModalVisible" />
|
||||
<ActivationModal :t="t" />
|
||||
<ApiKeyCreate :t="t" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user