mirror of
https://github.com/unraid/api.git
synced 2026-01-05 16:09:49 -06:00
feat: mount vue apps, not web components (#1639)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Standalone web bundle with auto-mount utilities and a self-contained test page. * New responsive modal components for consistent mobile/desktop dialogs. * Header actions to copy OS/API versions. * **Improvements** * Refreshed UI styles (muted borders), accessibility and animation refinements. * Theming updates and Tailwind v4–aligned, component-scoped styles. * Runtime GraphQL endpoint override and CSRF header support. * **Bug Fixes** * Safer network fetching and improved manifest/asset loading with duplicate protection. * **Tests/Chores** * Parallel plugin tests, new extractor test suite, and updated build/test scripts. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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,4 +1,11 @@
|
||||
@import 'tailwindcss';
|
||||
/*
|
||||
* Tailwind v4 configuration without global preflight
|
||||
* This prevents Tailwind from applying global resets that affect webgui
|
||||
*/
|
||||
|
||||
/* Import only the parts of Tailwind we need - NO PREFLIGHT */
|
||||
@import 'tailwindcss/theme.css';
|
||||
@import 'tailwindcss/utilities.css';
|
||||
@import 'tw-animate-css';
|
||||
@import '../../@tailwind-shared/index.css';
|
||||
@import '@nuxt/ui';
|
||||
@@ -7,3 +14,68 @@
|
||||
@source "../../unraid-ui/dist/**/*.{js,mjs}";
|
||||
@source "../../unraid-ui/src/**/*.{vue,ts}";
|
||||
@source "../**/*.{vue,ts,js}";
|
||||
|
||||
/*
|
||||
* Minimal styles for our components
|
||||
* Only essential styles to ensure components work properly
|
||||
*/
|
||||
|
||||
/* Box-sizing for proper layout */
|
||||
.unapi *,
|
||||
.unapi *::before,
|
||||
.unapi *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Reset figure element for logo */
|
||||
.unapi figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset heading elements - only margin/padding */
|
||||
.unapi h1,
|
||||
.unapi h2,
|
||||
.unapi h3,
|
||||
.unapi h4,
|
||||
.unapi h5 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset paragraph element */
|
||||
.unapi p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset toggle/switch button backgrounds */
|
||||
button[role="switch"],
|
||||
button[role="switch"][data-state="checked"],
|
||||
button[role="switch"][data-state="unchecked"] {
|
||||
background-color: transparent !important;
|
||||
background: transparent !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* Style for checked state */
|
||||
button[role="switch"][data-state="checked"] {
|
||||
background-color: #ff8c2f !important; /* Unraid orange */
|
||||
}
|
||||
|
||||
/* Style for unchecked state */
|
||||
button[role="switch"][data-state="unchecked"] {
|
||||
background-color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
/* Dark mode toggle styles */
|
||||
.dark button[role="switch"][data-state="unchecked"] {
|
||||
background-color: #333 !important;
|
||||
border-color: #555 !important;
|
||||
}
|
||||
|
||||
/* Toggle thumb/handle */
|
||||
button[role="switch"] span {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -44,11 +44,11 @@ const icon = computed<{ component: Component; color: string } | null>(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="relative flex items-center justify-center">
|
||||
<BellIcon class="w-6 h-6 text-header-text-primary" />
|
||||
<div
|
||||
v-if="!seen && indicatorLevel === 'UNREAD'"
|
||||
class="absolute top-0 right-0 size-2.5 rounded-full border border-neutral-800 bg-unraid-green"
|
||||
class="absolute top-0 right-0 size-2.5 rounded-full border border-muted bg-unraid-green"
|
||||
/>
|
||||
<component
|
||||
:is="icon.component"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { useMutation } from '@vue/apollo-composable';
|
||||
import { computedAsync } from '@vueuse/core';
|
||||
import { Markdown } from '@/helpers/markdown';
|
||||
|
||||
import {
|
||||
ArchiveBoxIcon,
|
||||
CheckBadgeIcon,
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
TrashIcon,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
import { Button } from '@unraid/ui';
|
||||
import { useMutation } from '@vue/apollo-composable';
|
||||
import { Markdown } from '@/helpers/markdown';
|
||||
|
||||
import type { NotificationFragmentFragment } from '~/composables/gql/graphql';
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { NotificationType } from '~/composables/gql/graphql';
|
||||
import {
|
||||
archiveNotification as archiveMutation,
|
||||
@@ -85,10 +88,10 @@ const reformattedTimestamp = computed<string>(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group/item relative py-5 flex flex-col gap-2 text-base">
|
||||
<header class="flex flex-row items-baseline justify-between gap-2 -translate-y-1">
|
||||
<div class="group/item relative flex flex-col gap-2 py-3 text-base">
|
||||
<header class="flex -translate-y-1 flex-row items-baseline justify-between gap-2">
|
||||
<h3
|
||||
class="tracking-normal flex flex-row items-baseline gap-2 uppercase font-bold overflow-x-hidden"
|
||||
class="m-0 flex flex-row items-baseline gap-2 overflow-x-hidden text-base font-semibold normal-case"
|
||||
>
|
||||
<!-- the `translate` compensates for extra space added by the `svg` element when rendered -->
|
||||
<component
|
||||
@@ -97,18 +100,18 @@ const reformattedTimestamp = computed<string>(() => {
|
||||
class="size-5 shrink-0 translate-y-1"
|
||||
:class="icon.color"
|
||||
/>
|
||||
<span class="truncate flex-1" :title="title">{{ title }}</span>
|
||||
<span class="flex-1 truncate" :title="title">{{ title }}</span>
|
||||
</h3>
|
||||
|
||||
<div
|
||||
class="shrink-0 flex flex-row items-baseline justify-end gap-2 mt-1"
|
||||
class="mt-1 flex shrink-0 flex-row items-baseline justify-end gap-2"
|
||||
:title="formattedTimestamp ?? reformattedTimestamp"
|
||||
>
|
||||
<p class="text-secondary-foreground text-sm">{{ reformattedTimestamp }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h4 class="font-bold">
|
||||
<h4 class="m-0 font-normal">
|
||||
{{ subject }}
|
||||
</h4>
|
||||
|
||||
@@ -118,27 +121,29 @@ const reformattedTimestamp = computed<string>(() => {
|
||||
|
||||
<p v-if="mutationError" class="text-red-600">Error: {{ mutationError }}</p>
|
||||
|
||||
<div class="flex justify-end items-baseline gap-4">
|
||||
<a v-if="link" :href="link">
|
||||
<Button type="button" variant="outline">
|
||||
<LinkIcon class="size-4 mr-2" />
|
||||
<span class="text-sm">View</span>
|
||||
</Button>
|
||||
<div class="flex items-baseline justify-end gap-4">
|
||||
<a
|
||||
v-if="link"
|
||||
:href="link"
|
||||
class="text-primary inline-flex items-center justify-center text-sm font-medium hover:underline focus:underline"
|
||||
>
|
||||
<LinkIcon class="mr-2 size-4" />
|
||||
<span class="text-sm">View</span>
|
||||
</a>
|
||||
<Button
|
||||
v-if="type === NotificationType.UNREAD"
|
||||
:disabled="archive.loading"
|
||||
@click="archive.mutate"
|
||||
@click="() => archive.mutate({ id: props.id })"
|
||||
>
|
||||
<ArchiveBoxIcon class="size-4 mr-2" />
|
||||
<ArchiveBoxIcon class="mr-2 size-4" />
|
||||
<span class="text-sm">Archive</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="type === NotificationType.ARCHIVE"
|
||||
:disabled="deleteNotification.loading"
|
||||
@click="deleteNotification.mutate"
|
||||
@click="() => deleteNotification.mutate({ id: props.id, type: props.type })"
|
||||
>
|
||||
<TrashIcon class="size-4 mr-2" />
|
||||
<TrashIcon class="mr-2 size-4" />
|
||||
<span class="text-sm">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -93,17 +93,28 @@ async function onLoadMore() {
|
||||
<div
|
||||
v-if="notifications?.length > 0"
|
||||
v-infinite-scroll="[onLoadMore, { canLoadMore: () => canLoadMore }]"
|
||||
class="divide-y px-7 flex flex-col overflow-y-scroll flex-1 min-h-0"
|
||||
class="px-3 flex flex-col overflow-y-scroll flex-1 min-h-0"
|
||||
>
|
||||
<NotificationsItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
v-bind="notification"
|
||||
/>
|
||||
<div v-if="loading" class="py-5 grid place-content-center">
|
||||
<TransitionGroup
|
||||
name="notification-list"
|
||||
tag="div"
|
||||
class="divide-y"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-in absolute right-0 left-0"
|
||||
enter-from-class="opacity-0 -translate-x-4"
|
||||
leave-to-class="opacity-0 translate-x-4"
|
||||
move-class="transition-transform duration-300"
|
||||
>
|
||||
<NotificationsItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
v-bind="notification"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="loading" class="py-3 grid place-content-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-if="!canLoadMore" class="py-5 grid place-content-center text-secondary-foreground">
|
||||
<div v-if="!canLoadMore" class="py-3 grid place-content-center text-secondary-foreground">
|
||||
You've reached the end...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@unraid/ui';
|
||||
import { Settings } from 'lucide-vue-next';
|
||||
|
||||
import { useTrackLatestSeenNotification } from '~/composables/api/use-notifications';
|
||||
import { useFragment } from '~/composables/gql';
|
||||
@@ -94,7 +99,7 @@ onNotificationAdded(({ data }) => {
|
||||
[Importance.INFO]: globalThis.toast.info,
|
||||
};
|
||||
const toast = funcMapping[notif.importance];
|
||||
const createOpener = () => ({ label: 'Open', onClick: () => location.assign(notif.link as string) });
|
||||
const createOpener = () => ({ label: 'Open', onClick: () => window.location.assign(notif.link as string) });
|
||||
|
||||
requestAnimationFrame(() =>
|
||||
toast(notif.title, {
|
||||
@@ -125,34 +130,42 @@ const prepareToViewNotifications = () => {
|
||||
|
||||
<template>
|
||||
<Sheet>
|
||||
<SheetTrigger @click="prepareToViewNotifications">
|
||||
<span class="sr-only">Notifications</span>
|
||||
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
|
||||
<SheetTrigger as-child>
|
||||
<Button
|
||||
variant="header"
|
||||
size="header"
|
||||
@click="prepareToViewNotifications"
|
||||
>
|
||||
<span class="sr-only">Notifications</span>
|
||||
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="right"
|
||||
class="w-full max-w-screen sm:max-w-[540px] max-h-screen h-screen min-h-screen px-0 flex flex-col gap-5 pb-0"
|
||||
>
|
||||
<div class="relative flex flex-col h-full w-full">
|
||||
<SheetHeader class="ml-1 px-6 items-baseline gap-1 pb-2">
|
||||
<SheetHeader class="ml-1 px-3 items-baseline gap-1 pb-2">
|
||||
<SheetTitle class="text-2xl">Notifications</SheetTitle>
|
||||
<a href="/Settings/Notifications">
|
||||
<Button variant="link" size="sm" class="p-0 h-auto">Edit Settings</Button>
|
||||
</a>
|
||||
</SheetHeader>
|
||||
<Tabs
|
||||
default-value="unread"
|
||||
class="flex flex-1 flex-col min-h-0"
|
||||
aria-label="Notification filters"
|
||||
>
|
||||
<div class="flex flex-row justify-between items-center flex-wrap gap-5 px-6">
|
||||
<div class="flex flex-row justify-between items-center flex-wrap gap-3 px-3">
|
||||
<TabsList class="flex" aria-label="Filter notifications by status">
|
||||
<TabsTrigger value="unread">
|
||||
Unread <span v-if="overview">({{ overview.unread.total }})</span>
|
||||
<TabsTrigger value="unread" as-child>
|
||||
<Button variant="ghost" size="sm" class="inline-flex items-center gap-1 px-3 py-1">
|
||||
<span>Unread</span>
|
||||
<span v-if="overview" class="font-normal">({{ overview.unread.total }})</span>
|
||||
</Button>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="archived">
|
||||
Archived
|
||||
<span v-if="overview">({{ readArchivedCount }})</span>
|
||||
<TabsTrigger value="archived" as-child>
|
||||
<Button variant="ghost" size="sm" class="inline-flex items-center gap-1 px-3 py-1">
|
||||
<span>Archived</span>
|
||||
<span v-if="overview" class="font-normal">({{ readArchivedCount }})</span>
|
||||
</Button>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="unread" class="flex-col items-end">
|
||||
@@ -177,11 +190,13 @@ const prepareToViewNotifications = () => {
|
||||
Delete All
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center px-3 gap-2 mt-2">
|
||||
<Select
|
||||
:items="filterItems"
|
||||
placeholder="Filter By"
|
||||
class="h-auto"
|
||||
class="h-8 px-3 text-sm"
|
||||
@update:model-value="
|
||||
(val: unknown) => {
|
||||
const strVal = String(val);
|
||||
@@ -189,6 +204,20 @@ const prepareToViewNotifications = () => {
|
||||
}
|
||||
"
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<a href="/Settings/Notifications">
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit Notification Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<TabsContent value="unread" class="flex-col flex-1 min-h-0">
|
||||
|
||||
@@ -116,7 +116,7 @@ declare global {
|
||||
|
||||
<div v-else-if="error" class="py-8 text-center text-red-500">
|
||||
<p class="mb-4">Failed to load remotes</p>
|
||||
<Button @click="refetchRemotes">Retry</Button>
|
||||
<Button @click="() => refetchRemotes()">Retry</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="py-8 text-center">
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { ShieldCheckIcon, ShieldExclamationIcon } from '@heroicons/vue/24/solid';
|
||||
import { BrandButton, CardWrapper, PageContainer } from '@unraid/ui';
|
||||
import { BrandButton, CardWrapper, PageContainer, SettingsGrid } from '@unraid/ui';
|
||||
|
||||
import type { RegistrationItemProps } from '~/types/registration';
|
||||
import type { ServerStateDataAction } from '~/types/server';
|
||||
@@ -30,7 +30,6 @@ import RegistrationKeyLinkedStatus from '~/components/Registration/KeyLinkedStat
|
||||
import RegistrationReplaceCheck from '~/components/Registration/ReplaceCheck.vue';
|
||||
import RegistrationUpdateExpirationAction from '~/components/Registration/UpdateExpirationAction.vue';
|
||||
import UserProfileUptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
|
||||
import RegistrationItem from '~/components/Registration/Item.vue';
|
||||
import useDateTimeHelper from '~/composables/dateTime';
|
||||
import { useReplaceRenewStore } from '~/store/replaceRenew';
|
||||
import { useServerStore } from '~/store/server';
|
||||
@@ -125,75 +124,9 @@ const showFilteredKeyActions = computed(
|
||||
)
|
||||
);
|
||||
|
||||
const items = computed((): RegistrationItemProps[] => {
|
||||
// Organize items into three sections
|
||||
const flashDriveItems = computed((): RegistrationItemProps[] => {
|
||||
return [
|
||||
...(computedArray.value
|
||||
? [
|
||||
{
|
||||
label: t('Array status'),
|
||||
text: computedArray.value,
|
||||
warning: arrayWarning.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(regTy.value
|
||||
? [
|
||||
{
|
||||
label: t('License key type'),
|
||||
text: regTy.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showTrialExpiration.value
|
||||
? [
|
||||
{
|
||||
error: state.value === 'EEXPIRED',
|
||||
label: t('Trial expiration'),
|
||||
component: UserProfileUptimeExpire,
|
||||
componentProps: {
|
||||
forExpire: true,
|
||||
shortText: true,
|
||||
t,
|
||||
},
|
||||
componentOpacity: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(regTo.value
|
||||
? [
|
||||
{
|
||||
label: t('Registered to'),
|
||||
text: regTo.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(regTo.value && regTm.value && formattedRegTm.value
|
||||
? [
|
||||
{
|
||||
label: t('Registered on'),
|
||||
text: formattedRegTm.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showUpdateEligibility.value
|
||||
? [
|
||||
{
|
||||
label: t('OS Update Eligibility'),
|
||||
warning: regUpdatesExpired.value,
|
||||
component: RegistrationUpdateExpirationAction,
|
||||
componentProps: { t },
|
||||
componentOpacity: !regUpdatesExpired.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(state.value === 'EGUID'
|
||||
? [
|
||||
{
|
||||
label: t('Registered GUID'),
|
||||
text: regGuid.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(guid.value
|
||||
? [
|
||||
{
|
||||
@@ -218,6 +151,78 @@ const items = computed((): RegistrationItemProps[] => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(state.value === 'EGUID'
|
||||
? [
|
||||
{
|
||||
label: t('Registered GUID'),
|
||||
text: regGuid.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
|
||||
const licenseItems = computed((): RegistrationItemProps[] => {
|
||||
return [
|
||||
...(computedArray.value
|
||||
? [
|
||||
{
|
||||
label: t('Array status'),
|
||||
text: computedArray.value,
|
||||
warning: arrayWarning.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(regTy.value
|
||||
? [
|
||||
{
|
||||
label: t('License key type'),
|
||||
text: regTy.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(regTo.value
|
||||
? [
|
||||
{
|
||||
label: t('Registered to'),
|
||||
text: regTo.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(regTo.value && regTm.value && formattedRegTm.value
|
||||
? [
|
||||
{
|
||||
label: t('Registered on'),
|
||||
text: formattedRegTm.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showTrialExpiration.value
|
||||
? [
|
||||
{
|
||||
error: state.value === 'EEXPIRED',
|
||||
label: t('Trial expiration'),
|
||||
component: UserProfileUptimeExpire,
|
||||
componentProps: {
|
||||
forExpire: true,
|
||||
shortText: true,
|
||||
t,
|
||||
},
|
||||
componentOpacity: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showUpdateEligibility.value
|
||||
? [
|
||||
{
|
||||
label: t('OS Update Eligibility'),
|
||||
warning: regUpdatesExpired.value,
|
||||
component: RegistrationUpdateExpirationAction,
|
||||
componentProps: { t },
|
||||
componentOpacity: !regUpdatesExpired.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(keyInstalled.value
|
||||
? [
|
||||
{
|
||||
@@ -235,6 +240,11 @@ const items = computed((): RegistrationItemProps[] => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
|
||||
const actionItems = computed((): RegistrationItemProps[] => {
|
||||
return [
|
||||
...(showLinkedAndTransferStatus.value
|
||||
? [
|
||||
{
|
||||
@@ -253,7 +263,6 @@ const items = computed((): RegistrationItemProps[] => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
...(showFilteredKeyActions.value
|
||||
? [
|
||||
{
|
||||
@@ -299,26 +308,84 @@ const items = computed((): RegistrationItemProps[] => {
|
||||
/>
|
||||
</span>
|
||||
</header>
|
||||
<dl>
|
||||
<RegistrationItem
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
:component="item?.component"
|
||||
:component-props="item?.componentProps"
|
||||
:error="item.error ?? false"
|
||||
:warning="item.warning ?? false"
|
||||
:label="item.label"
|
||||
:text="item.text"
|
||||
>
|
||||
<template v-if="item.component" #right>
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.componentProps"
|
||||
:class="[item.componentOpacity && !item.error ? 'opacity-75' : '']"
|
||||
/>
|
||||
|
||||
<!-- Flash Drive Section -->
|
||||
<div v-if="flashDriveItems.length > 0" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 class="text-lg font-semibold mb-3">{{ t('Flash Drive') }}</h4>
|
||||
<SettingsGrid>
|
||||
<template v-for="item in flashDriveItems" :key="item.label">
|
||||
<div class="font-semibold flex items-center gap-x-2">
|
||||
<ShieldExclamationIcon v-if="item.error" class="w-4 h-4 text-unraid-red" />
|
||||
<span v-html="item.label" />
|
||||
</div>
|
||||
<div class="select-all" :class="[item.error ? 'text-unraid-red' : 'opacity-75']">
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</template>
|
||||
</RegistrationItem>
|
||||
</dl>
|
||||
</SettingsGrid>
|
||||
</div>
|
||||
|
||||
<!-- License Section -->
|
||||
<div v-if="licenseItems.length > 0" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 class="text-lg font-semibold mb-3">{{ t('License') }}</h4>
|
||||
<SettingsGrid>
|
||||
<template v-for="item in licenseItems" :key="item.label">
|
||||
<div class="font-semibold flex items-center gap-x-2">
|
||||
<ShieldExclamationIcon v-if="item.error" class="w-4 h-4 text-unraid-red" />
|
||||
<span v-html="item.label" />
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
item.error ? 'text-unraid-red' : item.warning ? 'text-yellow-600' : '',
|
||||
item.text && !item.error && !item.warning ? 'opacity-75' : ''
|
||||
]">
|
||||
<span v-if="item.text" class="select-all">
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<component
|
||||
:is="item.component"
|
||||
v-if="item.component"
|
||||
v-bind="item.componentProps"
|
||||
:class="[item.componentOpacity && !item.error ? 'opacity-75' : '']"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsGrid>
|
||||
</div>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div v-if="actionItems.length > 0" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 class="text-lg font-semibold mb-3">{{ t('Actions') }}</h4>
|
||||
<SettingsGrid>
|
||||
<template v-for="item in actionItems" :key="item.label || 'action-' + actionItems.indexOf(item)">
|
||||
<template v-if="item.label">
|
||||
<div class="font-semibold flex items-center gap-x-2">
|
||||
<ShieldExclamationIcon v-if="item.error" class="w-4 h-4 text-unraid-red" />
|
||||
<span v-html="item.label" />
|
||||
</div>
|
||||
<div :class="[item.error ? 'text-unraid-red' : '']">
|
||||
<span v-if="item.text" class="select-all opacity-75">
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<component
|
||||
:is="item.component"
|
||||
v-if="item.component"
|
||||
v-bind="item.componentProps"
|
||||
:class="[item.componentOpacity && !item.error ? 'opacity-75' : '']"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="md:col-span-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.componentProps"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</SettingsGrid>
|
||||
</div>
|
||||
</div>
|
||||
</CardWrapper>
|
||||
</PageContainer>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { ShieldExclamationIcon } from '@heroicons/vue/24/solid';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
import type { RegistrationItemProps } from '~/types/registration';
|
||||
|
||||
withDefaults(defineProps<RegistrationItemProps>(), {
|
||||
error: false,
|
||||
text: '',
|
||||
warning: false,
|
||||
});
|
||||
|
||||
const { darkMode } = storeToRefs(useThemeStore());
|
||||
|
||||
const evenBgColor = computed(() => {
|
||||
return darkMode.value ? 'even:bg-grey-darkest' : 'even:bg-black/5';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
!error && !warning && evenBgColor,
|
||||
error && 'text-white bg-unraid-red',
|
||||
warning && 'text-black bg-yellow-100',
|
||||
]"
|
||||
class="text-base p-3 grid grid-cols-1 gap-1 sm:px-5 sm:grid-cols-5 sm:gap-4 items-baseline rounded"
|
||||
>
|
||||
<dt v-if="label" class="font-semibold leading-normal sm:col-span-2 flex flex-row sm:justify-end sm:text-right items-center gap-x-2">
|
||||
<ShieldExclamationIcon v-if="error" class="w-4 h-4 fill-current" />
|
||||
<span v-html="label" />
|
||||
</dt>
|
||||
<dd
|
||||
class="leading-normal sm:col-span-3"
|
||||
:class="!label && 'sm:col-start-2'"
|
||||
>
|
||||
<span
|
||||
v-if="text"
|
||||
class="select-all"
|
||||
:class="{
|
||||
'opacity-75': !error,
|
||||
}"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
<template v-if="$slots['right']">
|
||||
<slot name="right" />
|
||||
</template>
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,25 +6,5 @@ import SsoButtons from './sso/SsoButtons.vue';
|
||||
<SsoButtons />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Font size overrides for 16px base (standard Tailwind sizing) */
|
||||
:host {
|
||||
/* Text sizes - standard Tailwind rem values */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
--text-4xl: 2.25rem; /* 36px */
|
||||
--text-5xl: 3rem; /* 48px */
|
||||
--text-6xl: 3.75rem; /* 60px */
|
||||
--text-7xl: 4.5rem; /* 72px */
|
||||
--text-8xl: 6rem; /* 96px */
|
||||
--text-9xl: 8rem; /* 128px */
|
||||
|
||||
/* Spacing - standard Tailwind value */
|
||||
--spacing: 0.25rem; /* 4px */
|
||||
}
|
||||
</style>
|
||||
<!-- Font size overrides are handled in standalone-mount.ts for custom elements -->
|
||||
|
||||
|
||||
@@ -61,15 +61,18 @@ onBeforeMount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer>
|
||||
<BrandLoading v-if="showLoader" class="mx-auto my-12 max-w-[160px]" />
|
||||
<PageContainer>
|
||||
<div v-show="showLoader">
|
||||
<BrandLoading class="mx-auto my-12 max-w-[160px]" />
|
||||
</div>
|
||||
<div v-show="!showLoader">
|
||||
<UpdateOsStatus
|
||||
v-else
|
||||
:show-update-check="true"
|
||||
:title="t('Update Unraid OS')"
|
||||
:subtitle="subtitle"
|
||||
:t="t"
|
||||
/>
|
||||
<UpdateOsThirdPartyDrivers v-if="rebootType === 'thirdPartyDriversDownloading'" :t="t" />
|
||||
</PageContainer>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,15 @@ import {
|
||||
KeyIcon,
|
||||
ServerStackIcon,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
import { BrandButton, BrandLoading, cn } from '@unraid/ui';
|
||||
import {
|
||||
BrandButton,
|
||||
BrandLoading,
|
||||
cn,
|
||||
ResponsiveModal,
|
||||
ResponsiveModalHeader,
|
||||
ResponsiveModalTitle,
|
||||
ResponsiveModalFooter,
|
||||
} from '@unraid/ui';
|
||||
import { allowedDocsOriginRegex, allowedDocsUrlRegex } from '~/helpers/urls';
|
||||
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
@@ -18,7 +26,6 @@ import RawChangelogRenderer from '~/components/UpdateOs/RawChangelogRenderer.vue
|
||||
import { usePurchaseStore } from '~/store/purchase';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
import { useUpdateOsStore } from '~/store/updateOs';
|
||||
import Modal from '~/components/Modal.vue';
|
||||
|
||||
export interface Props {
|
||||
open?: boolean;
|
||||
@@ -53,6 +60,7 @@ const isDarkMode = computed(() => {
|
||||
}
|
||||
return darkMode.value;
|
||||
});
|
||||
|
||||
const { availableWithRenewal, releaseForUpdate, changelogModalVisible } = storeToRefs(updateOsStore);
|
||||
const { setReleaseForUpdate, fetchAndConfirmInstall } = updateOsStore;
|
||||
|
||||
@@ -166,22 +174,25 @@ watch(isDarkMode, () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
<ResponsiveModal
|
||||
v-if="currentRelease?.version"
|
||||
:center-content="false"
|
||||
max-width="max-w-[800px]"
|
||||
:open="modalVisible"
|
||||
:show-close-x="true"
|
||||
:t="t"
|
||||
:tall-content="true"
|
||||
:title="t('Unraid OS {0} Changelog', [currentRelease.version])"
|
||||
:disable-overlay-close="false"
|
||||
@close="handleClose"
|
||||
sheet-side="bottom"
|
||||
sheet-padding="none"
|
||||
:dialog-class="'max-w-[80rem] p-0'"
|
||||
:show-close-button="true"
|
||||
@update:open="(value: boolean) => !value && handleClose()"
|
||||
>
|
||||
<template #main>
|
||||
<div class="flex flex-col gap-4 min-w-[280px] sm:min-w-[400px]">
|
||||
<ResponsiveModalHeader>
|
||||
<ResponsiveModalTitle>
|
||||
{{ t('Unraid OS {0} Changelog', [currentRelease.version]) }}
|
||||
</ResponsiveModalTitle>
|
||||
</ResponsiveModalHeader>
|
||||
|
||||
<div class="px-3 flex-1">
|
||||
<div class="flex flex-col gap-4 sm:min-w-[40rem]">
|
||||
<!-- iframe for changelog if available -->
|
||||
<div v-if="docsChangelogUrl" class="w-[calc(100%+3rem)] h-[475px] -mx-6 -my-6">
|
||||
<div v-if="docsChangelogUrl" class="w-full h-[calc(100vh-15rem)] sm:h-[45rem] overflow-hidden">
|
||||
<iframe
|
||||
v-if="actualIframeSrc"
|
||||
ref="iframeRef"
|
||||
@@ -205,17 +216,17 @@ watch(isDarkMode, () => {
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-else
|
||||
class="text-center flex flex-col justify-center w-full min-h-[250px] min-w-[280px] sm:min-w-[400px]"
|
||||
class="text-center flex flex-col justify-center w-full min-h-[25rem] sm:min-w-[40rem]"
|
||||
>
|
||||
<BrandLoading class="w-[150px] mx-auto mt-6" />
|
||||
<BrandLoading class="w-[15rem] mx-auto mt-6" />
|
||||
<p>{{ props.t('Loading changelog…') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div :class="cn('flex flex-col-reverse xs:!flex-row justify-between gap-3 md:gap-4')">
|
||||
<div :class="cn('flex flex-col-reverse xs:!flex-row xs:justify-start gap-3 md:gap-4')">
|
||||
<ResponsiveModalFooter>
|
||||
<div :class="cn('flex flex-wrap justify-between gap-3 md:gap-4 w-full')">
|
||||
<div :class="cn('flex flex-wrap justify-start gap-3 md:gap-4')">
|
||||
<!-- Back to changelog button (when navigated away) -->
|
||||
<BrandButton
|
||||
v-if="hasNavigated && docsChangelogUrl"
|
||||
@@ -256,6 +267,6 @@ watch(isDarkMode, () => {
|
||||
</BrandButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</ResponsiveModalFooter>
|
||||
</ResponsiveModal>
|
||||
</template>
|
||||
|
||||
@@ -10,8 +10,20 @@ import {
|
||||
KeyIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
import { BrandButton, BrandLoading, cn } from '@unraid/ui';
|
||||
import { Switch, SwitchGroup, SwitchLabel } from '@headlessui/vue';
|
||||
import {
|
||||
Button,
|
||||
BrandButton,
|
||||
BrandLoading,
|
||||
cn,
|
||||
DialogRoot,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
Switch,
|
||||
Label,
|
||||
} from '@unraid/ui';
|
||||
|
||||
import type { BrandButtonProps } from '@unraid/ui';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
@@ -21,7 +33,6 @@ import { useAccountStore } from '~/store/account';
|
||||
import { usePurchaseStore } from '~/store/purchase';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { useUpdateOsStore } from '~/store/updateOs';
|
||||
import Modal from '~/components/Modal.vue';
|
||||
import UpdateOsIgnoredRelease from './IgnoredRelease.vue';
|
||||
|
||||
export interface Props {
|
||||
@@ -264,110 +275,109 @@ const modalWidth = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:t="t"
|
||||
<DialogRoot
|
||||
:open="open"
|
||||
:title="modalCopy?.title"
|
||||
:description="modalCopy?.description"
|
||||
:show-close-x="!checkForUpdatesLoading"
|
||||
:max-width="modalWidth"
|
||||
@close="close"
|
||||
@update:open="(value) => !value && close()"
|
||||
>
|
||||
<template v-if="renderMainSlot" #main>
|
||||
<BrandLoading v-if="checkForUpdatesLoading" class="w-[150px] mx-auto" />
|
||||
<div v-else class="flex flex-col gap-y-4">
|
||||
<div v-if="extraLinks.length > 0" :class="cn('flex flex-col xs:!flex-row justify-center gap-2')">
|
||||
<BrandButton
|
||||
v-for="item in extraLinks"
|
||||
:key="item.text"
|
||||
:btn-style="item.variant ?? undefined"
|
||||
:href="item.href ?? undefined"
|
||||
:icon="item.icon"
|
||||
:icon-right="item.iconRight"
|
||||
:icon-right-hover-display="item.iconRightHoverDisplay"
|
||||
:text="t(item.text ?? '')"
|
||||
:title="item.title ? t(item.title) : undefined"
|
||||
@click="item.click?.()"
|
||||
/>
|
||||
</div>
|
||||
<DialogContent
|
||||
:class="modalWidth"
|
||||
:show-close-button="!checkForUpdatesLoading"
|
||||
>
|
||||
<DialogHeader v-if="modalCopy?.title">
|
||||
<DialogTitle>
|
||||
{{ modalCopy.title }}
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="modalCopy?.description">
|
||||
<span v-html="modalCopy.description" />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="available || availableWithRenewal" class="mx-auto">
|
||||
<SwitchGroup>
|
||||
<div v-if="renderMainSlot" class="flex flex-col gap-4">
|
||||
<BrandLoading v-if="checkForUpdatesLoading" class="w-[150px] mx-auto" />
|
||||
<div v-else class="flex flex-col gap-y-4">
|
||||
<div v-if="extraLinks.length > 0" :class="cn('flex flex-col xs:!flex-row justify-center gap-2')">
|
||||
<BrandButton
|
||||
v-for="item in extraLinks"
|
||||
:key="item.text"
|
||||
:btn-style="item.variant ?? undefined"
|
||||
:href="item.href ?? undefined"
|
||||
:icon="item.icon"
|
||||
:icon-right="item.iconRight"
|
||||
:icon-right-hover-display="item.iconRightHoverDisplay"
|
||||
:text="t(item.text ?? '')"
|
||||
:title="item.title ? t(item.title) : undefined"
|
||||
@click="item.click?.()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="available || availableWithRenewal" class="mx-auto">
|
||||
<div class="flex justify-center items-center gap-2 p-2 rounded">
|
||||
<Switch
|
||||
v-model="ignoreThisRelease"
|
||||
:class="
|
||||
ignoreThisRelease ? 'bg-linear-to-r from-unraid-red to-orange' : 'bg-transparent'
|
||||
ignoreThisRelease ? 'bg-linear-to-r from-unraid-red to-orange' : 'data-[state=unchecked]:bg-transparent data-[state=unchecked]:bg-opacity-10 data-[state=unchecked]:bg-foreground'
|
||||
"
|
||||
class="relative inline-flex h-6 w-12 items-center rounded-full overflow-hidden"
|
||||
>
|
||||
<span
|
||||
v-show="!ignoreThisRelease"
|
||||
class="absolute z-0 inset-0 opacity-10 bg-foreground"
|
||||
/>
|
||||
<span
|
||||
:class="ignoreThisRelease ? 'translate-x-[26px]' : 'translate-x-[2px]'"
|
||||
class="inline-block h-5 w-5 transform rounded-full bg-white transition"
|
||||
/>
|
||||
</Switch>
|
||||
<SwitchLabel class="text-base">
|
||||
/>
|
||||
<Label class="text-base">
|
||||
{{ t('Ignore this release until next reboot') }}
|
||||
</SwitchLabel>
|
||||
</Label>
|
||||
</div>
|
||||
</SwitchGroup>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="updateOsIgnoredReleases.length > 0"
|
||||
class="w-full max-w-[640px] mx-auto flex flex-col gap-2"
|
||||
>
|
||||
<h3 class="text-left text-base font-semibold italic">
|
||||
{{ t('Ignored Releases') }}
|
||||
</h3>
|
||||
<UpdateOsIgnoredRelease
|
||||
v-for="ignoredRelease in updateOsIgnoredReleases"
|
||||
:key="ignoredRelease"
|
||||
:label="ignoredRelease"
|
||||
:t="t"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="updateOsIgnoredReleases.length > 0"
|
||||
class="w-full max-w-[640px] mx-auto flex flex-col gap-2"
|
||||
>
|
||||
<h3 class="text-left text-base font-semibold italic">
|
||||
{{ t('Ignored Releases') }}
|
||||
</h3>
|
||||
<UpdateOsIgnoredRelease
|
||||
v-for="ignoredRelease in updateOsIgnoredReleases"
|
||||
:key="ignoredRelease"
|
||||
:label="ignoredRelease"
|
||||
:t="t"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
:class="cn(
|
||||
'w-full flex gap-2 mx-auto',
|
||||
actionButtons ? 'flex-col-reverse xs:!flex-row justify-between' : 'justify-center'
|
||||
)"
|
||||
>
|
||||
<div :class="cn('flex flex-col-reverse xs:!flex-row justify-start gap-2')">
|
||||
<BrandButton
|
||||
variant="underline-hover-red"
|
||||
:icon="XMarkIcon"
|
||||
:text="t('Close')"
|
||||
@click="close"
|
||||
/>
|
||||
<BrandButton
|
||||
variant="underline"
|
||||
:icon="ArrowTopRightOnSquareIcon"
|
||||
:text="t('More options')"
|
||||
@click="accountStore.updateOs()"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<div
|
||||
:class="cn(
|
||||
'w-full flex gap-2 mx-auto',
|
||||
actionButtons ? 'flex-col-reverse xs:!flex-row justify-between' : 'justify-center'
|
||||
)"
|
||||
>
|
||||
<div :class="cn('flex flex-col-reverse xs:!flex-row justify-start gap-2')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="close"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4 mr-2" />
|
||||
{{ t('Close') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="accountStore.updateOs()"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon class="w-4 h-4 mr-2" />
|
||||
{{ t('More options') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="actionButtons" :class="cn('flex flex-col xs:!flex-row justify-end gap-2')">
|
||||
<BrandButton
|
||||
v-for="item in actionButtons"
|
||||
:key="item.text"
|
||||
:btn-style="item.variant ?? undefined"
|
||||
:icon="item.icon"
|
||||
:icon-right="item.iconRight"
|
||||
:icon-right-hover-display="item.iconRightHoverDisplay"
|
||||
:text="t(item.text ?? '')"
|
||||
:title="item.title ? t(item.title) : undefined"
|
||||
@click="item.click?.()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="actionButtons" :class="cn('flex flex-col xs:!flex-row justify-end gap-2')">
|
||||
<BrandButton
|
||||
v-for="item in actionButtons"
|
||||
:key="item.text"
|
||||
:btn-style="item.variant ?? undefined"
|
||||
:icon="item.icon"
|
||||
:icon-right="item.iconRight"
|
||||
:icon-right-hover-display="item.iconRightHoverDisplay"
|
||||
:text="t(item.text ?? '')"
|
||||
:title="item.title ? t(item.title) : undefined"
|
||||
@click="item.click?.()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
@@ -105,7 +105,7 @@ const mutatedParsedChangelog = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none markdown-body p-4 overflow-auto">
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none p-4 overflow-auto [&_.grid]:!flex [&_.grid]:!flex-wrap [&_.grid]:!gap-8 [&_.grid>*]:!flex-1 [&_.grid>*]:!basis-full md:[&_.grid>*]:!basis-[calc(50%-1rem)]">
|
||||
<div v-if="parseChangelogFailed" class="text-center flex flex-col gap-4 prose">
|
||||
<h2 class="text-lg text-unraid-red italic font-semibold">
|
||||
{{ props.t(`Error Parsing Changelog • {0}`, [parseChangelogFailed]) }}
|
||||
@@ -138,4 +138,4 @@ const mutatedParsedChangelog = computed(() => {
|
||||
<p>{{ props.t('No changelog content available') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
InformationCircleIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
import { Badge, BrandButton, BrandLoading } from '@unraid/ui';
|
||||
import { Badge, BrandLoading, Button } from '@unraid/ui';
|
||||
import { WEBGUI_TOOLS_REGISTRATION } from '~/helpers/urls';
|
||||
|
||||
import type { BrandButtonProps } from '@unraid/ui';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import useDateTimeHelper from '~/composables/dateTime';
|
||||
@@ -44,7 +43,7 @@ const serverStore = useServerStore();
|
||||
const updateOsStore = useUpdateOsStore();
|
||||
const updateOsActionsStore = useUpdateOsActionsStore();
|
||||
|
||||
const LoadingIcon = () => h(BrandLoading, { variant: 'white' });
|
||||
const LoadingIcon = () => h(BrandLoading, { variant: 'white', style: 'width: 16px; height: 16px;' });
|
||||
|
||||
const { dateTimeFormat, osVersion, rebootType, rebootVersion, regExp, regUpdatesExpired } =
|
||||
storeToRefs(serverStore);
|
||||
@@ -74,7 +73,7 @@ const showRebootButton = computed(
|
||||
() => rebootType.value === 'downgrade' || rebootType.value === 'update'
|
||||
);
|
||||
|
||||
const checkButton = computed((): BrandButtonProps => {
|
||||
const checkButton = computed(() => {
|
||||
if (showRebootButton.value || props.showExternalDowngrade) {
|
||||
return {
|
||||
click: () => {
|
||||
@@ -84,7 +83,7 @@ const checkButton = computed((): BrandButtonProps => {
|
||||
accountStore.updateOs();
|
||||
}
|
||||
},
|
||||
icon: ArrowTopRightOnSquareIcon,
|
||||
icon: () => h(ArrowTopRightOnSquareIcon, { style: 'width: 16px; height: 16px;' }),
|
||||
text: props.t('More options'),
|
||||
};
|
||||
}
|
||||
@@ -94,7 +93,7 @@ const checkButton = computed((): BrandButtonProps => {
|
||||
click: () => {
|
||||
updateOsStore.localCheckForUpdate();
|
||||
},
|
||||
icon: ArrowPathIcon,
|
||||
icon: () => h(ArrowPathIcon, { style: 'width: 16px; height: 16px;' }),
|
||||
text: props.t('Check for Update'),
|
||||
};
|
||||
}
|
||||
@@ -104,12 +103,18 @@ const checkButton = computed((): BrandButtonProps => {
|
||||
click: () => {
|
||||
updateOsStore.setModalOpen(true);
|
||||
},
|
||||
icon: BellAlertIcon,
|
||||
icon: () => h(BellAlertIcon, { style: 'width: 16px; height: 16px;' }),
|
||||
text: availableWithRenewal.value
|
||||
? props.t('Unraid OS {0} Released', [availableWithRenewal.value])
|
||||
: props.t('Unraid OS {0} Update Available', [available.value]),
|
||||
};
|
||||
});
|
||||
|
||||
const navigateToRegistration = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = WEBGUI_TOOLS_REGISTRATION.toString();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -123,32 +128,34 @@ const checkButton = computed((): BrandButtonProps => {
|
||||
</h2>
|
||||
</header>
|
||||
<div class="flex flex-col md:flex-row gap-4 justify-start md:items-start md:justify-between">
|
||||
<div class="inline-flex flex-wrap justify-start gap-2">
|
||||
<button
|
||||
class="group"
|
||||
<div class="inline-flex flex-wrap justify-start items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="p-0 h-auto hover:bg-transparent"
|
||||
:title="t('View release notes')"
|
||||
@click="updateOsActionsStore.viewReleaseNotes(t('{0} Release Notes', [osVersion]))"
|
||||
>
|
||||
<Badge :icon="InformationCircleIcon" variant="gray" size="md">
|
||||
<Badge :icon="() => h(InformationCircleIcon, { style: 'width: 16px; height: 16px;' })" variant="gray" size="md">
|
||||
{{ t('Current Version {0}', [osVersion]) }}
|
||||
</Badge>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<a
|
||||
<Button
|
||||
v-if="ineligibleText && !availableWithRenewal"
|
||||
:href="WEBGUI_TOOLS_REGISTRATION.toString()"
|
||||
class="group"
|
||||
variant="ghost"
|
||||
class="p-0 h-auto hover:bg-transparent"
|
||||
:title="t('Learn more and fix')"
|
||||
@click="navigateToRegistration"
|
||||
>
|
||||
<Badge
|
||||
variant="yellow"
|
||||
:icon="ExclamationTriangleIcon"
|
||||
:icon="() => h(ExclamationTriangleIcon, { style: 'width: 16px; height: 16px;' })"
|
||||
:title="regExpOutput?.text"
|
||||
class="underline"
|
||||
>
|
||||
{{ t('Key ineligible for future releases') }}
|
||||
</Badge>
|
||||
</a>
|
||||
</Button>
|
||||
<Badge
|
||||
v-else-if="ineligibleText && availableWithRenewal"
|
||||
variant="yellow"
|
||||
@@ -165,7 +172,7 @@ const checkButton = computed((): BrandButtonProps => {
|
||||
<Badge
|
||||
v-if="rebootType === ''"
|
||||
:variant="updateAvailable ? 'orange' : 'green'"
|
||||
:icon="updateAvailable ? BellAlertIcon : CheckCircleIcon"
|
||||
:icon="updateAvailable ? () => h(BellAlertIcon, { style: 'width: 16px; height: 16px;' }) : () => h(CheckCircleIcon, { style: 'width: 16px; height: 16px;' })"
|
||||
>
|
||||
{{
|
||||
available
|
||||
@@ -175,46 +182,54 @@ const checkButton = computed((): BrandButtonProps => {
|
||||
: t('Up-to-date')
|
||||
}}
|
||||
</Badge>
|
||||
<Badge v-else variant="yellow" :icon="ExclamationTriangleIcon">
|
||||
<Badge v-else variant="yellow" :icon="() => h(ExclamationTriangleIcon, { style: 'width: 16px; height: 16px;' })">
|
||||
{{ t(rebootTypeText) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<Badge v-if="downgradeNotAvailable" variant="gray" :icon="XCircleIcon">
|
||||
<Badge v-if="downgradeNotAvailable" variant="gray" :icon="() => h(XCircleIcon, { style: 'width: 16px; height: 16px;' })">
|
||||
{{ t('No downgrade available') }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex flex-col shrink-0 gap-4 grow items-center md:items-end">
|
||||
<span v-if="showRebootButton">
|
||||
<BrandButton
|
||||
:icon="ArrowPathIcon"
|
||||
:text="
|
||||
rebootType === 'downgrade'
|
||||
? t('Reboot Now to Downgrade to {0}', [rebootVersion])
|
||||
: t('Reboot Now to Update to {0}', [rebootVersion])
|
||||
"
|
||||
@click="updateOsActionsStore.rebootServer()"
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
v-if="showRebootButton"
|
||||
variant="primary"
|
||||
:title="
|
||||
rebootType === 'downgrade'
|
||||
? t('Reboot Now to Downgrade to {0}', [rebootVersion])
|
||||
: t('Reboot Now to Update to {0}', [rebootVersion])
|
||||
"
|
||||
@click="updateOsActionsStore.rebootServer()"
|
||||
>
|
||||
<ArrowPathIcon class="shrink-0" style="width: 16px; height: 16px;" />
|
||||
{{
|
||||
rebootType === 'downgrade'
|
||||
? t('Reboot Now to Downgrade to {0}', [rebootVersion])
|
||||
: t('Reboot Now to Update to {0}', [rebootVersion])
|
||||
}}
|
||||
</Button>
|
||||
|
||||
<span>
|
||||
<BrandButton
|
||||
:variant="checkButton.variant"
|
||||
:icon="checkButton.icon"
|
||||
:text="checkButton.text"
|
||||
@click="checkButton.click"
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
:variant="checkButton.variant === 'fill' ? 'pill-orange' : 'pill-gray'"
|
||||
:title="checkButton.text"
|
||||
:disabled="status === 'checking'"
|
||||
@click="checkButton.click"
|
||||
>
|
||||
<component :is="checkButton.icon" class="shrink-0" style="width: 16px; height: 16px;" />
|
||||
{{ checkButton.text }}
|
||||
</Button>
|
||||
|
||||
<span v-if="rebootType !== ''">
|
||||
<BrandButton
|
||||
variant="outline"
|
||||
:icon="XCircleIcon"
|
||||
:text="t('Cancel {0}', [rebootType === 'downgrade' ? t('Downgrade') : t('Update')])"
|
||||
@click="updateOsStore.cancelUpdate()"
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
v-if="rebootType !== ''"
|
||||
variant="pill-gray"
|
||||
:title="t('Cancel {0}', [rebootType === 'downgrade' ? t('Downgrade') : t('Update')])"
|
||||
@click="updateOsStore.cancelUpdate()"
|
||||
>
|
||||
<XCircleIcon class="shrink-0" style="width: 16px; height: 16px;" />
|
||||
{{ t('Cancel {0}', [rebootType === 'downgrade' ? t('Downgrade') : t('Update')]) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeMount, onMounted, ref, watch } from 'vue';
|
||||
import { onBeforeMount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
import { cn, DropdownMenu } from '@unraid/ui';
|
||||
import { DropdownMenu, Button } from '@unraid/ui';
|
||||
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
|
||||
import { devConfig } from '~/helpers/env';
|
||||
|
||||
import type { Server } from '~/types/server';
|
||||
@@ -12,9 +12,7 @@ import type { Server } from '~/types/server';
|
||||
import NotificationsSidebar from '~/components/Notifications/Sidebar.vue';
|
||||
import UpcDropdownContent from '~/components/UserProfile/DropdownContent.vue';
|
||||
import UpcDropdownTrigger from '~/components/UserProfile/DropdownTrigger.vue';
|
||||
import UpcServerState from '~/components/UserProfile/ServerState.vue';
|
||||
// Auto-imported components - now manually imported
|
||||
import UpcUptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
|
||||
import UpcServerStatus from '~/components/UserProfile/ServerStatus.vue';
|
||||
import { useCallbackActionsStore } from '~/store/callbackActions';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
@@ -33,28 +31,18 @@ const { callbackData } = storeToRefs(callbackStore);
|
||||
const { name, description, guid, keyfile, lanIp } = storeToRefs(serverStore);
|
||||
const { bannerGradient, theme } = storeToRefs(useThemeStore());
|
||||
|
||||
// Control dropdown open state
|
||||
const dropdownOpen = ref(false);
|
||||
|
||||
/**
|
||||
* Copy LAN IP on server name click
|
||||
*/
|
||||
let copyIpInterval: string | number | NodeJS.Timeout | undefined;
|
||||
const { copy, copied, isSupported } = useClipboard({ source: lanIp.value ?? '' });
|
||||
const showCopyNotSupported = ref<boolean>(false);
|
||||
const copyLanIp = () => {
|
||||
// if http then clipboard is not supported
|
||||
if (!isSupported || window.location.protocol === 'http:') {
|
||||
showCopyNotSupported.value = true;
|
||||
return;
|
||||
const { copyWithNotification } = useClipboardWithToast();
|
||||
const copyLanIp = async () => {
|
||||
if (lanIp.value) {
|
||||
await copyWithNotification(lanIp.value, t('LAN IP Copied'));
|
||||
}
|
||||
copy(lanIp.value ?? '');
|
||||
};
|
||||
watch(showCopyNotSupported, (newVal, oldVal) => {
|
||||
if (newVal && oldVal === false) {
|
||||
clearTimeout(copyIpInterval);
|
||||
copyIpInterval = setTimeout(() => {
|
||||
showCopyNotSupported.value = false;
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets the server store and locale messages then listen for callbacks
|
||||
@@ -104,57 +92,46 @@ onMounted(() => {
|
||||
:style="bannerGradient"
|
||||
/>
|
||||
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'text-xs text-header-text-secondary text-right font-semibold leading-normal relative z-10 flex flex-wrap xs:flex-row items-baseline justify-end gap-x-1 xs:gap-x-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<UpcUptimeExpire :as="'span'" :t="t" class="text-xs" />
|
||||
<span class="hidden xs:block">•</span>
|
||||
<UpcServerState :t="t" class="text-xs" />
|
||||
</div>
|
||||
<UpcServerStatus class="relative z-10" />
|
||||
|
||||
<div class="relative z-10 flex flex-row items-center justify-end gap-x-4 h-full">
|
||||
<h1
|
||||
class="text-md sm:text-lg relative flex flex-col-reverse items-end md:flex-row border-0 text-header-text-primary"
|
||||
<div class="relative z-10 flex flex-row items-center justify-end gap-x-2 h-full">
|
||||
<div
|
||||
class="text-base relative flex flex-col-reverse items-center md:items-center md:flex-row border-0 text-header-text-primary"
|
||||
>
|
||||
<template v-if="description && theme?.descriptionShow">
|
||||
<span class="text-right text-xs sm:text-lg hidden md:inline-block" v-html="description" />
|
||||
<span class="text-header-text-secondary hidden md:inline-block px-2">•</span>
|
||||
<span class="text-center md:text-right text-base hidden md:inline-flex md:items-center" v-html="description" />
|
||||
<span class="text-header-text-secondary hidden md:inline-flex md:items-center px-2">•</span>
|
||||
</template>
|
||||
<button
|
||||
<Button
|
||||
v-if="lanIp"
|
||||
variant="ghost"
|
||||
:title="t('Click to Copy LAN IP {0}', [lanIp])"
|
||||
class="text-header-text-primary opacity-100 hover:opacity-75 focus:opacity-75 transition-opacity"
|
||||
class="text-header-text-primary text-base p-0 h-auto opacity-100 hover:opacity-75 focus:opacity-75 transition-opacity flex items-center"
|
||||
@click="copyLanIp()"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
<span v-else class="text-header-text-primary">
|
||||
</Button>
|
||||
<span v-else class="text-header-text-primary text-sm xs:text-base flex items-center">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span
|
||||
v-show="copied || showCopyNotSupported"
|
||||
class="text-white text-xs leading-none py-1 px-2 absolute top-full right-0 bg-linear-to-r from-unraid-red to-orange text-center block rounded"
|
||||
>
|
||||
<template v-if="copied">{{ t('LAN IP Copied') }}</template>
|
||||
<template v-else>{{ t('LAN IP {0}', [lanIp]) }}</template>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div class="block w-[2px] h-6 bg-header-text-secondary" />
|
||||
</div>
|
||||
|
||||
<NotificationsSidebar />
|
||||
|
||||
<DropdownMenu align="end" side="bottom" :side-offset="4">
|
||||
<DropdownMenu
|
||||
v-model:open="dropdownOpen"
|
||||
align="end"
|
||||
side="bottom"
|
||||
:side-offset="4"
|
||||
>
|
||||
<template #trigger>
|
||||
<UpcDropdownTrigger :t="t" />
|
||||
<UpcDropdownTrigger />
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="max-w-[350px] sm:min-w-[350px]">
|
||||
<UpcDropdownContent :t="t" />
|
||||
<UpcDropdownContent
|
||||
@close-dropdown="dropdownOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -4,13 +4,13 @@ export interface Props {
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
colorClasses: 'text-grey-mid border-grey-mid',
|
||||
colorClasses: 'text-grey-mid border-muted',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="text-xs uppercase py-1 px-1.5 border-2 rounded-full"
|
||||
class="text-xs uppercase py-1 px-1.5 border-2 border-muted rounded-full"
|
||||
:class="colorClasses"
|
||||
>
|
||||
{{ 'Beta' }}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '~/helpers/urls';
|
||||
|
||||
import type { UserProfileLink } from '~/types/userProfile';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useAccountStore } from '~/store/account';
|
||||
import { useErrorsStore } from '~/store/errors';
|
||||
@@ -34,7 +34,11 @@ import DropdownError from './DropdownError.vue';
|
||||
import DropdownItem from './DropdownItem.vue';
|
||||
import Keyline from './Keyline.vue';
|
||||
|
||||
const props = defineProps<{ t: ComposerTranslation }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close-dropdown': []
|
||||
}>();
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const errorsStore = useErrorsStore();
|
||||
@@ -72,10 +76,11 @@ const manageUnraidNetAccount = computed((): UserProfileLink => {
|
||||
external: true,
|
||||
click: () => {
|
||||
accountStore.manage();
|
||||
emit('close-dropdown');
|
||||
},
|
||||
icon: UserIcon,
|
||||
text: props.t('Manage Unraid.net Account'),
|
||||
title: props.t('Manage Unraid.net Account in new tab'),
|
||||
text: t('Manage Unraid.net Account'),
|
||||
title: t('Manage Unraid.net Account in new tab'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -83,21 +88,23 @@ const updateOsCheckForUpdatesButton = computed((): UserProfileLink => {
|
||||
return {
|
||||
click: () => {
|
||||
updateOsStore.localCheckForUpdate();
|
||||
emit('close-dropdown');
|
||||
},
|
||||
icon: ArrowPathIcon,
|
||||
text: props.t('Check for Update'),
|
||||
text: t('Check for Update'),
|
||||
};
|
||||
});
|
||||
const updateOsResponseModalOpenButton = computed((): UserProfileLink => {
|
||||
return {
|
||||
click: () => {
|
||||
updateOsStore.setModalOpen(true);
|
||||
emit('close-dropdown');
|
||||
},
|
||||
emphasize: true,
|
||||
icon: BellAlertIcon,
|
||||
text: osUpdateAvailableWithRenewal.value
|
||||
? props.t('Unraid OS {0} Released', [osUpdateAvailableWithRenewal.value])
|
||||
: props.t('Unraid OS {0} Update Available', [osUpdateAvailable.value]),
|
||||
? t('Unraid OS {0} Released', [osUpdateAvailableWithRenewal.value])
|
||||
: t('Unraid OS {0} Update Available', [osUpdateAvailable.value]),
|
||||
};
|
||||
});
|
||||
const rebootDetectedButton = computed((): UserProfileLink => {
|
||||
@@ -109,8 +116,8 @@ const rebootDetectedButton = computed((): UserProfileLink => {
|
||||
icon: ExclamationTriangleIcon,
|
||||
text:
|
||||
rebootType.value === 'downgrade'
|
||||
? props.t('Reboot Required for Downgrade')
|
||||
: props.t('Reboot Required for Update'),
|
||||
? t('Reboot Required for Downgrade')
|
||||
: t('Reboot Required for Update'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -136,8 +143,8 @@ const links = computed((): UserProfileLink[] => {
|
||||
{
|
||||
href: WEBGUI_TOOLS_REGISTRATION.toString(),
|
||||
icon: KeyIcon,
|
||||
text: props.t('OS Update Eligibility Expired'),
|
||||
title: props.t('Go to Tools > Registration to Learn More'),
|
||||
text: t('OS Update Eligibility Expired'),
|
||||
title: t('Go to Tools > Registration to Learn More'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -153,8 +160,8 @@ const links = computed((): UserProfileLink[] => {
|
||||
external: true,
|
||||
href: CONNECT_DASHBOARD.toString(),
|
||||
icon: ArrowTopRightOnSquareIcon,
|
||||
text: props.t('Go to Connect'),
|
||||
title: props.t('Opens Connect in new tab'),
|
||||
text: t('Go to Connect'),
|
||||
title: t('Opens Connect in new tab'),
|
||||
},
|
||||
...[manageUnraidNetAccount.value],
|
||||
...signOutAction.value,
|
||||
@@ -163,8 +170,8 @@ const links = computed((): UserProfileLink[] => {
|
||||
{
|
||||
href: WEBGUI_CONNECT_SETTINGS.toString(),
|
||||
icon: CogIcon,
|
||||
text: props.t('Settings'),
|
||||
title: props.t('Go to API Settings'),
|
||||
text: t('Settings'),
|
||||
title: t('Go to API Settings'),
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -187,8 +194,8 @@ const unraidConnectWelcome = computed(() => {
|
||||
!stateDataError.value
|
||||
) {
|
||||
return {
|
||||
heading: props.t('Thank you for installing Connect!'),
|
||||
message: props.t('Sign In to your Unraid.net account to get started'),
|
||||
heading: t('Thank you for installing Connect!'),
|
||||
message: t('Sign In to your Unraid.net account to get started'),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
@@ -199,7 +206,7 @@ const unraidConnectWelcome = computed(() => {
|
||||
<div class="flex flex-col grow gap-y-2">
|
||||
<header
|
||||
v-if="connectPluginInstalled"
|
||||
class="flex flex-col items-start justify-between mt-2 mx-2"
|
||||
class="flex flex-col items-start justify-between mt-2 mx-2 gap-2"
|
||||
>
|
||||
<h2 class="text-lg leading-none flex flex-row gap-x-1 items-center justify-between">
|
||||
<BrandLogoConnect
|
||||
|
||||
@@ -14,7 +14,7 @@ const { errors } = storeToRefs(errorsStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul v-if="errors.length" class="list-reset flex flex-col gap-y-2 mb-1 border-2 border-solid border-unraid-red/90 rounded-md">
|
||||
<ul v-if="errors.length" class="list-reset flex flex-col gap-y-2 mb-1 border-2 border-solid border-muted rounded-md">
|
||||
<li v-for="(error, index) in errors" :key="index" class="flex flex-col gap-2">
|
||||
<h3 class="text-lg py-1 px-3 text-white bg-unraid-red/90 font-semibold">
|
||||
<span>{{ t(error.heading) }}</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
||||
import { Button } from '@unraid/ui';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import type { ServerStateDataAction } from '~/types/server';
|
||||
@@ -17,23 +18,36 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const showExternalIconOnHover = computed(() => props.item?.external && props.item.icon !== ArrowTopRightOnSquareIcon);
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
const classes = ['text-left', 'text-sm', 'w-full', 'flex', 'flex-row', 'items-center', 'justify-between', 'gap-x-2', 'px-2', 'py-2', 'h-auto'];
|
||||
|
||||
if (!props.item?.emphasize) {
|
||||
classes.push('dropdown-item-hover');
|
||||
}
|
||||
if (props.item?.emphasize) {
|
||||
classes.push('dropdown-item-emphasized');
|
||||
}
|
||||
if (showExternalIconOnHover.value) {
|
||||
classes.push('group');
|
||||
}
|
||||
if (props.rounded) {
|
||||
classes.push('rounded-md');
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="item?.click ? 'button' : 'a'"
|
||||
<Button
|
||||
:as="item?.click ? 'button' : 'a'"
|
||||
:disabled="item?.disabled"
|
||||
:href="item?.href ?? null"
|
||||
:target="item?.external ? '_blank' : null"
|
||||
:rel="item?.external ? 'noopener noreferrer' : null"
|
||||
class="text-left text-sm w-full flex flex-row items-center justify-between gap-x-2 px-2 py-2 cursor-pointer"
|
||||
:class="{
|
||||
'text-foreground bg-transparent hover:text-white focus:text-white focus:outline-hidden dropdown-item-hover': !item?.emphasize,
|
||||
'text-white bg-linear-to-r from-unraid-red to-orange dropdown-item-emphasized': item?.emphasize,
|
||||
'group': showExternalIconOnHover,
|
||||
'rounded-md': rounded,
|
||||
'disabled:opacity-50 disabled:hover:opacity-50 disabled:focus:opacity-50 disabled:cursor-not-allowed': item?.disabled,
|
||||
}"
|
||||
variant="ghost"
|
||||
:class="buttonClass"
|
||||
@click.stop="item?.click ? item?.click(item?.clickParams ?? []) : null"
|
||||
>
|
||||
<span class="leading-snug inline-flex flex-row items-center gap-x-2">
|
||||
@@ -44,7 +58,7 @@ const showExternalIconOnHover = computed(() => props.item?.external && props.ite
|
||||
v-if="showExternalIconOnHover"
|
||||
class="text-white fill-current shrink-0 w-4 h-4 ml-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-in-out"
|
||||
/>
|
||||
</component>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,54 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Button } from '@unraid/ui';
|
||||
import {
|
||||
Bars3Icon,
|
||||
BellAlertIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
ShieldExclamationIcon,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import BrandAvatar from '~/components/Brand/Avatar.vue';
|
||||
import { useErrorsStore } from '~/store/errors';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { useUpdateOsStore } from '~/store/updateOs';
|
||||
|
||||
const props = defineProps<{ t: ComposerTranslation }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { errors } = storeToRefs(useErrorsStore());
|
||||
const { connectPluginInstalled, rebootType, state, stateData } = storeToRefs(useServerStore());
|
||||
const { available: osUpdateAvailable } = storeToRefs(useUpdateOsStore());
|
||||
const { connectPluginInstalled, state, stateData } = storeToRefs(useServerStore());
|
||||
|
||||
const showErrorIcon = computed(() => errors.value.length || stateData.value.error);
|
||||
|
||||
const text = computed((): string => {
|
||||
if (stateData.value.error && state.value !== 'EEXPIRED') {
|
||||
return props.t('Fix Error');
|
||||
return t('Fix Error');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const title = computed((): string => {
|
||||
if (state.value === 'ENOKEYFILE') {
|
||||
return props.t('Get Started');
|
||||
return t('Get Started');
|
||||
}
|
||||
if (state.value === 'EEXPIRED') {
|
||||
return props.t('Trial Expired, see options below');
|
||||
return t('Trial Expired, see options below');
|
||||
}
|
||||
if (showErrorIcon.value) {
|
||||
return props.t('Learn more about the error');
|
||||
return t('Learn more about the error');
|
||||
}
|
||||
return props.t('Open Dropdown');
|
||||
return t('Open Dropdown');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="group text-lg border-0 relative flex flex-row justify-end items-center h-full gap-x-2 opacity-100 hover:opacity-75 transition-opacity text-header-text-primary"
|
||||
<Button
|
||||
variant="header"
|
||||
size="header"
|
||||
class="justify-center gap-x-1.5 pl-0"
|
||||
:title="title"
|
||||
>
|
||||
<template v-if="errors.length && errors[0].level">
|
||||
@@ -72,13 +71,8 @@ const title = computed((): string => {
|
||||
/>
|
||||
</span>
|
||||
|
||||
<BellAlertIcon
|
||||
v-if="osUpdateAvailable && !rebootType"
|
||||
class="hover:animate-pulse fill-current relative w-4 h-4"
|
||||
/>
|
||||
|
||||
<Bars3Icon class="w-5" />
|
||||
|
||||
<BrandAvatar v-if="connectPluginInstalled" />
|
||||
</button>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useServerStore } from '~/store/server';
|
||||
import type { ServerStateDataAction } from '~/types/server';
|
||||
import UpcServerStateBuy from './ServerStateBuy.vue';
|
||||
|
||||
defineProps<{ t: ComposerTranslation; }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { state, stateData } = storeToRefs(useServerStore());
|
||||
|
||||
@@ -27,12 +27,12 @@ const upgradeAction = computed((): ServerStateDataAction | undefined => {
|
||||
:title="t('Upgrade Key')"
|
||||
@click="upgradeAction.click?.()"
|
||||
>
|
||||
<h5>Unraid OS <em><strong>{{ t(stateData.humanReadable) }}</strong></em></h5>
|
||||
<span class="font-semibold">Unraid OS <em><strong>{{ t(stateData.humanReadable) }}</strong></em></span>
|
||||
</UpcServerStateBuy>
|
||||
</template>
|
||||
<h5 v-else>
|
||||
<span v-else class="font-semibold">
|
||||
Unraid OS <em :class="{ 'text-unraid-red': stateData.error || state === 'EEXPIRED' }"><strong>{{ t(stateData.humanReadable) }}</strong></em>
|
||||
</h5>
|
||||
</span>
|
||||
|
||||
<template v-if="purchaseAction">
|
||||
<UpcServerStateBuy
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@unraid/ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="text-xs font-semibold transition-colors duration-150 ease-in-out border-t-0 border-l-0 border-r-0 border-b-2 border-transparent hover:border-orange-dark focus:border-orange-dark focus:outline-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-xs font-semibold transition-colors duration-150 ease-in-out border-t-0 border-l-0 border-r-0 border-b-2 border-transparent hover:border-orange-dark focus:border-orange-dark focus:outline-hidden h-auto p-0"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
23
web/components/UserProfile/ServerStatus.vue
Normal file
23
web/components/UserProfile/ServerStatus.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { cn, type ClassValue } from '@unraid/ui';
|
||||
import UpcUptimeExpire from './UptimeExpire.vue';
|
||||
import UpcServerState from './ServerState.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'text-header-text-secondary font-semibold leading-tight',
|
||||
'flex flex-col items-end gap-y-0.5 justify-end',
|
||||
'xs:flex-row xs:items-baseline xs:gap-x-2 xs:gap-y-0',
|
||||
'text-xs',
|
||||
$attrs.class as ClassValue
|
||||
)
|
||||
"
|
||||
>
|
||||
<UpcUptimeExpire :as="'span'" :short-text="true" class="text-xs" />
|
||||
<span class="hidden xs:inline">•</span>
|
||||
<UpcServerState class="text-xs" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { BrandLoading } from '@unraid/ui';
|
||||
import { BrandLoading, Button } from '@unraid/ui';
|
||||
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
@@ -78,13 +78,14 @@ const close = () => {
|
||||
<template v-if="!trialModalLoading" #footer>
|
||||
<div class="w-full max-w-xs flex flex-col items-center gap-y-4 mx-auto">
|
||||
<div>
|
||||
<button
|
||||
class="text-xs tracking-wide inline-block mx-2 opacity-60 hover:opacity-100 focus:opacity-100 underline transition"
|
||||
<Button
|
||||
variant="link"
|
||||
class="text-xs tracking-wide inline-block mx-2 opacity-60 hover:opacity-100 focus:opacity-100 underline transition h-auto p-0"
|
||||
:title="t('Close Modal')"
|
||||
@click="close"
|
||||
>
|
||||
{{ t('Close') }}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import useDateTimeHelper from '~/composables/dateTime';
|
||||
import { useServerStore } from '~/store/server';
|
||||
@@ -10,7 +10,6 @@ export interface Props {
|
||||
forExpire?: boolean;
|
||||
shortText?: boolean;
|
||||
as?: 'p' | 'span';
|
||||
t: ComposerTranslation;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -19,14 +18,27 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'p',
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const serverStore = useServerStore();
|
||||
const { dateTimeFormat, uptime, expireTime, state } = storeToRefs(serverStore);
|
||||
|
||||
const style = computed(() => {
|
||||
if (props.as === 'span') {
|
||||
return {
|
||||
'text-align': 'right',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const time = computed(() => {
|
||||
if (props.forExpire && expireTime.value) {
|
||||
return expireTime.value;
|
||||
}
|
||||
return (state.value === 'TRIAL' || state.value === 'EEXPIRED') && expireTime.value && expireTime.value > 0
|
||||
return (state.value === 'TRIAL' || state.value === 'EEXPIRED') &&
|
||||
expireTime.value &&
|
||||
expireTime.value > 0
|
||||
? expireTime.value
|
||||
: uptime.value;
|
||||
});
|
||||
@@ -38,31 +50,31 @@ const countUp = computed<boolean>(() => {
|
||||
return state.value !== 'TRIAL' && state.value !== 'ENOCONN';
|
||||
});
|
||||
|
||||
const {
|
||||
outputDateTimeReadableDiff: readableDiff,
|
||||
outputDateTimeFormatted: formatted,
|
||||
} = useDateTimeHelper(dateTimeFormat.value, props.t, false, time.value, countUp.value);
|
||||
const { outputDateTimeReadableDiff: readableDiff, outputDateTimeFormatted: formatted } =
|
||||
useDateTimeHelper(dateTimeFormat.value, t, false, time.value, countUp.value);
|
||||
|
||||
const output = computed(() => {
|
||||
if (!countUp.value || state.value === 'EEXPIRED') {
|
||||
return {
|
||||
title: state.value === 'EEXPIRED'
|
||||
? props.t(props.shortText ? 'Expired at {0}' : 'Trial Key Expired at {0}', [formatted.value])
|
||||
: props.t(props.shortText ? 'Expires at {0}' : 'Trial Key Expires at {0}', [formatted.value]),
|
||||
text: state.value === 'EEXPIRED'
|
||||
? props.t(props.shortText ? 'Expired {0}' : 'Trial Key Expired {0}', [readableDiff.value])
|
||||
: props.t(props.shortText ? 'Expires in {0}' : 'Trial Key Expires in {0}', [readableDiff.value]),
|
||||
title:
|
||||
state.value === 'EEXPIRED'
|
||||
? t(props.shortText ? 'Expired at {0}' : 'Trial Key Expired at {0}', [formatted.value])
|
||||
: t(props.shortText ? 'Expires at {0}' : 'Trial Key Expires at {0}', [formatted.value]),
|
||||
text:
|
||||
state.value === 'EEXPIRED'
|
||||
? t(props.shortText ? 'Expired {0}' : 'Trial Key Expired {0}', [readableDiff.value])
|
||||
: t(props.shortText ? 'Expires in {0}' : 'Trial Key Expires in {0}', [readableDiff.value]),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: props.t('Server Up Since {0}', [formatted.value]),
|
||||
text: props.t('Uptime {0}', [readableDiff.value]),
|
||||
title: t('Server Up Since {0}', [formatted.value]),
|
||||
text: t('Uptime {0}', [readableDiff.value]),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="as" :title="output.title">
|
||||
<component :is="as" :title="output.title" :style="style">
|
||||
{{ output.text }}
|
||||
</component>
|
||||
</template>
|
||||
|
||||
285
web/components/Wrapper/vue-mount-app.ts
Normal file
285
web/components/Wrapper/vue-mount-app.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { createApp } from 'vue';
|
||||
import type { App as VueApp, Component } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { DefaultApolloClient } from '@vue/apollo-composable';
|
||||
import { ensureTeleportContainer } from '@unraid/ui';
|
||||
|
||||
// Import Tailwind CSS for injection
|
||||
import tailwindStyles from '~/assets/main.css?inline';
|
||||
|
||||
import en_US from '~/locales/en_US.json';
|
||||
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
|
||||
import { globalPinia } from '~/store/globalPinia';
|
||||
import { client } from '~/helpers/create-apollo-client';
|
||||
|
||||
// Ensure Apollo client is singleton
|
||||
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
|
||||
|
||||
// Global store for mounted apps
|
||||
const mountedApps = new Map<string, VueApp>();
|
||||
const mountedAppClones = new Map<string, VueApp[]>();
|
||||
const mountedAppContainers = new Map<string, HTMLElement[]>(); // shadow-root containers for cleanup
|
||||
|
||||
// Shared style injection tracking
|
||||
const styleInjected = new WeakSet<Document | ShadowRoot>();
|
||||
|
||||
// Expose globally for debugging
|
||||
declare global {
|
||||
interface Window {
|
||||
mountedApps: Map<string, VueApp>;
|
||||
globalPinia: typeof globalPinia;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.mountedApps = mountedApps;
|
||||
window.globalPinia = globalPinia;
|
||||
}
|
||||
|
||||
function injectStyles(root: Document | ShadowRoot) {
|
||||
// Always inject to document for teleported elements
|
||||
if (!styleInjected.has(document)) {
|
||||
const globalStyleElement = document.createElement('style');
|
||||
globalStyleElement.setAttribute('data-tailwind-global', 'true');
|
||||
globalStyleElement.textContent = tailwindStyles;
|
||||
document.head.appendChild(globalStyleElement);
|
||||
styleInjected.add(document);
|
||||
}
|
||||
|
||||
// Also inject to shadow root if needed
|
||||
if (root !== document && !styleInjected.has(root)) {
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.setAttribute('data-tailwind', 'true');
|
||||
styleElement.textContent = tailwindStyles;
|
||||
root.appendChild(styleElement);
|
||||
styleInjected.add(root);
|
||||
}
|
||||
}
|
||||
|
||||
function setupI18n() {
|
||||
const defaultLocale = 'en_US';
|
||||
let parsedLocale = '';
|
||||
let parsedMessages = {};
|
||||
let nonDefaultLocale = false;
|
||||
|
||||
// Check for window locale data
|
||||
if (typeof window !== 'undefined') {
|
||||
const windowLocaleData = (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA || null;
|
||||
if (windowLocaleData) {
|
||||
try {
|
||||
parsedMessages = JSON.parse(decodeURIComponent(windowLocaleData));
|
||||
parsedLocale = Object.keys(parsedMessages)[0];
|
||||
nonDefaultLocale = parsedLocale !== defaultLocale;
|
||||
} catch (error) {
|
||||
console.error('[VueMountApp] error parsing messages', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createI18n({
|
||||
legacy: false,
|
||||
locale: nonDefaultLocale ? parsedLocale : defaultLocale,
|
||||
fallbackLocale: defaultLocale,
|
||||
messages: {
|
||||
en_US,
|
||||
...(nonDefaultLocale ? parsedMessages : {}),
|
||||
},
|
||||
postTranslation: createHtmlEntityDecoder(),
|
||||
});
|
||||
}
|
||||
|
||||
export interface MountOptions {
|
||||
component: Component;
|
||||
selector: string;
|
||||
appId?: string;
|
||||
useShadowRoot?: boolean;
|
||||
props?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Helper function to parse props from HTML attributes
|
||||
function parsePropsFromElement(element: Element): Record<string, unknown> {
|
||||
const props: Record<string, unknown> = {};
|
||||
|
||||
for (const attr of element.attributes) {
|
||||
const name = attr.name;
|
||||
const value = attr.value;
|
||||
|
||||
// Skip Vue internal attributes and common HTML attributes
|
||||
if (name.startsWith('data-v-') || name === 'class' || name === 'id' || name === 'style') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to parse JSON values (handles HTML-encoded JSON)
|
||||
if (value.startsWith('{') || value.startsWith('[')) {
|
||||
try {
|
||||
// Decode HTML entities first
|
||||
const decoded = value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/'/g, "'");
|
||||
props[name] = JSON.parse(decoded);
|
||||
} catch (_e) {
|
||||
// If JSON parsing fails, use as string
|
||||
props[name] = value;
|
||||
}
|
||||
} else {
|
||||
props[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
export function mountVueApp(options: MountOptions): VueApp | null {
|
||||
const { component, selector, appId = selector, useShadowRoot = false, props = {} } = options;
|
||||
|
||||
// Check if app is already mounted
|
||||
if (mountedApps.has(appId)) {
|
||||
console.warn(`[VueMountApp] App ${appId} is already mounted`);
|
||||
return mountedApps.get(appId)!;
|
||||
}
|
||||
|
||||
// Find all mount targets
|
||||
const targets = document.querySelectorAll(selector);
|
||||
if (targets.length === 0) {
|
||||
console.warn(`[VueMountApp] No elements found for selector: ${selector}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure teleport container exists before mounting
|
||||
ensureTeleportContainer();
|
||||
|
||||
// For the first target, parse props from HTML attributes
|
||||
const firstTarget = targets[0];
|
||||
const parsedProps = { ...parsePropsFromElement(firstTarget), ...props };
|
||||
|
||||
// Create the Vue app with parsed props
|
||||
const app = createApp(component, parsedProps);
|
||||
|
||||
// Setup i18n
|
||||
const i18n = setupI18n();
|
||||
app.use(i18n);
|
||||
|
||||
// Use the shared Pinia instance
|
||||
app.use(globalPinia);
|
||||
|
||||
// Provide Apollo client
|
||||
app.provide(DefaultApolloClient, apolloClient);
|
||||
|
||||
// Mount to all targets
|
||||
const clones: VueApp[] = [];
|
||||
const containers: HTMLElement[] = [];
|
||||
targets.forEach((target, index) => {
|
||||
const mountTarget = target as HTMLElement;
|
||||
|
||||
// Add unapi class for minimal styling
|
||||
mountTarget.classList.add('unapi');
|
||||
|
||||
if (useShadowRoot) {
|
||||
// Create shadow root if needed
|
||||
if (!mountTarget.shadowRoot) {
|
||||
mountTarget.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
// Create mount container in shadow root
|
||||
const container = document.createElement('div');
|
||||
container.id = 'app';
|
||||
container.setAttribute('data-app-id', appId);
|
||||
mountTarget.shadowRoot!.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
// Inject styles into shadow root
|
||||
injectStyles(mountTarget.shadowRoot!);
|
||||
|
||||
// For the first target, use the main app, otherwise create clones
|
||||
if (index === 0) {
|
||||
app.mount(container);
|
||||
} else {
|
||||
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
|
||||
const clonedApp = createApp(component, targetProps);
|
||||
clonedApp.use(i18n);
|
||||
clonedApp.use(globalPinia);
|
||||
clonedApp.provide(DefaultApolloClient, apolloClient);
|
||||
clonedApp.mount(container);
|
||||
clones.push(clonedApp);
|
||||
}
|
||||
} else {
|
||||
// Direct mount without shadow root
|
||||
injectStyles(document);
|
||||
|
||||
// For multiple targets, we need to create separate app instances
|
||||
// but they'll share the same Pinia store
|
||||
if (index === 0) {
|
||||
// First target, use the main app
|
||||
app.mount(mountTarget);
|
||||
} else {
|
||||
// Additional targets, create cloned apps with their own props
|
||||
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
|
||||
const clonedApp = createApp(component, targetProps);
|
||||
clonedApp.use(i18n);
|
||||
clonedApp.use(globalPinia); // Shared Pinia instance
|
||||
clonedApp.provide(DefaultApolloClient, apolloClient);
|
||||
clonedApp.mount(mountTarget);
|
||||
clones.push(clonedApp);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store the app reference
|
||||
mountedApps.set(appId, app);
|
||||
if (clones.length) mountedAppClones.set(appId, clones);
|
||||
if (containers.length) mountedAppContainers.set(appId, containers);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export function unmountVueApp(appId: string): boolean {
|
||||
const app = mountedApps.get(appId);
|
||||
if (!app) {
|
||||
console.warn(`[VueMountApp] No app found with id: ${appId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unmount clones first
|
||||
const clones = mountedAppClones.get(appId) ?? [];
|
||||
for (const c of clones) c.unmount();
|
||||
mountedAppClones.delete(appId);
|
||||
|
||||
// Remove shadow containers
|
||||
const containers = mountedAppContainers.get(appId) ?? [];
|
||||
for (const el of containers) el.remove();
|
||||
mountedAppContainers.delete(appId);
|
||||
|
||||
app.unmount();
|
||||
mountedApps.delete(appId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getMountedApp(appId: string): VueApp | undefined {
|
||||
return mountedApps.get(appId);
|
||||
}
|
||||
|
||||
// Auto-mount function for script tags
|
||||
export function autoMountComponent(component: Component, selector: string, options?: Partial<MountOptions>) {
|
||||
const tryMount = () => {
|
||||
// Check if elements exist before attempting to mount
|
||||
if (document.querySelector(selector)) {
|
||||
try {
|
||||
mountVueApp({ component, selector, ...options });
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Failed to mount component for selector ${selector}:`, error);
|
||||
}
|
||||
}
|
||||
// Silently skip if no elements found - this is expected for most components
|
||||
};
|
||||
|
||||
// Wait for DOM to be ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', tryMount);
|
||||
} else {
|
||||
// DOM is already ready, but use setTimeout to ensure all scripts are loaded
|
||||
setTimeout(tryMount, 0);
|
||||
}
|
||||
}
|
||||
221
web/components/standalone-mount.ts
Normal file
221
web/components/standalone-mount.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// Import all components
|
||||
import type { Component } from 'vue';
|
||||
import Auth from './Auth.ce.vue';
|
||||
import ConnectSettings from './ConnectSettings/ConnectSettings.ce.vue';
|
||||
import DownloadApiLogs from './DownloadApiLogs.ce.vue';
|
||||
import HeaderOsVersion from './HeaderOsVersion.ce.vue';
|
||||
import Modals from './Modals.ce.vue';
|
||||
import UserProfile from './UserProfile.ce.vue';
|
||||
import UpdateOs from './UpdateOs.ce.vue';
|
||||
import DowngradeOs from './DowngradeOs.ce.vue';
|
||||
import Registration from './Registration.ce.vue';
|
||||
import WanIpCheck from './WanIpCheck.ce.vue';
|
||||
import WelcomeModal from './Activation/WelcomeModal.ce.vue';
|
||||
import SsoButton from './SsoButton.ce.vue';
|
||||
import LogViewer from './Logs/LogViewer.ce.vue';
|
||||
import ThemeSwitcher from './ThemeSwitcher.ce.vue';
|
||||
import ApiKeyPage from './ApiKeyPage.ce.vue';
|
||||
import DevModalTest from './DevModalTest.ce.vue';
|
||||
import ApiKeyAuthorize from './ApiKeyAuthorize.ce.vue';
|
||||
|
||||
// Import utilities
|
||||
import { autoMountComponent, mountVueApp, getMountedApp } from './Wrapper/vue-mount-app';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
import { globalPinia } from '~/store/globalPinia';
|
||||
import { client as apolloClient } from '~/helpers/create-apollo-client';
|
||||
import { provideApolloClient } from '@vue/apollo-composable';
|
||||
import { parse } from 'graphql';
|
||||
import { ensureTeleportContainer } from '@unraid/ui';
|
||||
|
||||
// Extend window interface for Apollo client
|
||||
declare global {
|
||||
interface Window {
|
||||
apolloClient: typeof apolloClient;
|
||||
gql: typeof parse;
|
||||
graphqlParse: typeof parse;
|
||||
}
|
||||
}
|
||||
|
||||
// Add pre-render CSS to hide components until they're mounted
|
||||
function injectPreRenderCSS() {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'unraid-prerender-css';
|
||||
style.textContent = `
|
||||
/* Hide unraid components during initial load to prevent FOUC */
|
||||
unraid-auth,
|
||||
unraid-connect-settings,
|
||||
unraid-download-api-logs,
|
||||
unraid-header-os-version,
|
||||
unraid-modals,
|
||||
unraid-user-profile,
|
||||
unraid-update-os,
|
||||
unraid-downgrade-os,
|
||||
unraid-registration,
|
||||
unraid-wan-ip-check,
|
||||
unraid-welcome-modal,
|
||||
unraid-sso-button,
|
||||
unraid-log-viewer,
|
||||
unraid-theme-switcher,
|
||||
unraid-api-key-manager,
|
||||
unraid-dev-modal-test,
|
||||
unraid-api-key-authorize {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Show components once they have the unapi class (mounted) */
|
||||
unraid-auth.unapi,
|
||||
unraid-connect-settings.unapi,
|
||||
unraid-download-api-logs.unapi,
|
||||
unraid-header-os-version.unapi,
|
||||
unraid-modals.unapi,
|
||||
unraid-user-profile.unapi,
|
||||
unraid-update-os.unapi,
|
||||
unraid-downgrade-os.unapi,
|
||||
unraid-registration.unapi,
|
||||
unraid-wan-ip-check.unapi,
|
||||
unraid-welcome-modal.unapi,
|
||||
unraid-sso-button.unapi,
|
||||
unraid-log-viewer.unapi,
|
||||
unraid-theme-switcher.unapi,
|
||||
unraid-api-key-manager.unapi,
|
||||
unraid-dev-modal-test.unapi,
|
||||
unraid-api-key-authorize.unapi {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Font size overrides for SSO button component */
|
||||
unraid-sso-button {
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-7xl: 4.5rem;
|
||||
--text-8xl: 6rem;
|
||||
--text-9xl: 8rem;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Initialize global Apollo client context
|
||||
if (typeof window !== 'undefined') {
|
||||
// Inject pre-render CSS as early as possible
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectPreRenderCSS);
|
||||
} else {
|
||||
injectPreRenderCSS();
|
||||
}
|
||||
|
||||
// Make Apollo client globally available
|
||||
window.apolloClient = apolloClient;
|
||||
|
||||
// Make graphql parse function available for browser console usage
|
||||
window.graphqlParse = parse;
|
||||
window.gql = parse;
|
||||
|
||||
// Provide Apollo client globally for all components
|
||||
provideApolloClient(apolloClient);
|
||||
|
||||
// Initialize theme store and set CSS variables - this is needed by all components
|
||||
const themeStore = useThemeStore(globalPinia);
|
||||
themeStore.setTheme();
|
||||
themeStore.setCssVars();
|
||||
|
||||
// Pre-create the teleport container to avoid mounting issues
|
||||
// This ensures the container exists before any components try to teleport to it
|
||||
ensureTeleportContainer();
|
||||
}
|
||||
|
||||
// Define component mappings
|
||||
const componentMappings = [
|
||||
{ component: Auth, selector: 'unraid-auth', appId: 'auth' },
|
||||
{ component: ConnectSettings, selector: 'unraid-connect-settings', appId: 'connect-settings' },
|
||||
{ component: DownloadApiLogs, selector: 'unraid-download-api-logs', appId: 'download-api-logs' },
|
||||
{ component: HeaderOsVersion, selector: 'unraid-header-os-version', appId: 'header-os-version' },
|
||||
{ component: Modals, selector: 'unraid-modals', appId: 'modals' },
|
||||
{ component: UserProfile, selector: 'unraid-user-profile', appId: 'user-profile' },
|
||||
{ component: UpdateOs, selector: 'unraid-update-os', appId: 'update-os' },
|
||||
{ component: DowngradeOs, selector: 'unraid-downgrade-os', appId: 'downgrade-os' },
|
||||
{ component: Registration, selector: 'unraid-registration', appId: 'registration' },
|
||||
{ component: WanIpCheck, selector: 'unraid-wan-ip-check', appId: 'wan-ip-check' },
|
||||
{ component: WelcomeModal, selector: 'unraid-welcome-modal', appId: 'welcome-modal' },
|
||||
{ component: SsoButton, selector: 'unraid-sso-button', appId: 'sso-button' },
|
||||
{ component: LogViewer, selector: 'unraid-log-viewer', appId: 'log-viewer' },
|
||||
{ component: ThemeSwitcher, selector: 'unraid-theme-switcher', appId: 'theme-switcher' },
|
||||
{ component: ApiKeyPage, selector: 'unraid-api-key-manager', appId: 'api-key-manager' },
|
||||
{ component: DevModalTest, selector: 'unraid-dev-modal-test', appId: 'dev-modal-test' },
|
||||
{ component: ApiKeyAuthorize, selector: 'unraid-api-key-authorize', appId: 'api-key-authorize' },
|
||||
];
|
||||
|
||||
// Auto-mount all components
|
||||
componentMappings.forEach(({ component, selector, appId }) => {
|
||||
autoMountComponent(component, selector, {
|
||||
appId,
|
||||
useShadowRoot: false, // Mount directly to avoid shadow DOM issues
|
||||
});
|
||||
});
|
||||
|
||||
// Special handling for Modals - also mount to #modals
|
||||
autoMountComponent(Modals, '#modals', {
|
||||
appId: 'modals-direct',
|
||||
useShadowRoot: false,
|
||||
});
|
||||
|
||||
// Expose functions globally for testing and dynamic mounting
|
||||
declare global {
|
||||
interface Window {
|
||||
UnraidComponents: Record<string, Component>;
|
||||
mountVueApp: typeof mountVueApp;
|
||||
getMountedApp: typeof getMountedApp;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// Expose all components
|
||||
window.UnraidComponents = {
|
||||
Auth,
|
||||
ConnectSettings,
|
||||
DownloadApiLogs,
|
||||
HeaderOsVersion,
|
||||
Modals,
|
||||
UserProfile,
|
||||
UpdateOs,
|
||||
DowngradeOs,
|
||||
Registration,
|
||||
WanIpCheck,
|
||||
WelcomeModal,
|
||||
SsoButton,
|
||||
LogViewer,
|
||||
ThemeSwitcher,
|
||||
ApiKeyPage,
|
||||
DevModalTest,
|
||||
ApiKeyAuthorize,
|
||||
};
|
||||
|
||||
// Expose utility functions
|
||||
window.mountVueApp = mountVueApp;
|
||||
window.getMountedApp = getMountedApp;
|
||||
|
||||
// Create dynamic mount functions for each component
|
||||
componentMappings.forEach(({ component, selector, appId }) => {
|
||||
const componentName = appId.split('-').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join('');
|
||||
|
||||
(window as unknown as Record<string, unknown>)[`mount${componentName}`] = (customSelector?: string) => {
|
||||
return mountVueApp({
|
||||
component,
|
||||
selector: customSelector || selector,
|
||||
appId: `${appId}-${Date.now()}`,
|
||||
useShadowRoot: false,
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -96,19 +96,15 @@ const useDateTimeHelper = (
|
||||
formats.find(formatOption => formatOption.format === selectedFormat);
|
||||
|
||||
const dateFormat = findMatchingFormat(format?.date ?? dateFormatOptions[0].format, dateFormatOptions);
|
||||
console.debug('[dateFormat]', dateFormat);
|
||||
|
||||
let displayFormat = `${dateFormat?.display}`;
|
||||
console.debug('[displayFormat]', displayFormat);
|
||||
if (!hideMinutesSeconds) {
|
||||
const timeFormat = findMatchingFormat(format?.time ?? timeFormatOptions[0].format, timeFormatOptions);
|
||||
displayFormat = `${displayFormat} ${timeFormat?.display}`;
|
||||
console.debug('[displayFormat] with time', displayFormat);
|
||||
}
|
||||
|
||||
const formatDate = (date: number): string =>
|
||||
dayjs(date).format(displayFormat);
|
||||
console.debug('[formatDate]', formatDate(Date.now()));
|
||||
|
||||
/**
|
||||
* Original meat and potatos from:
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { useToast } from '@unraid/ui';
|
||||
|
||||
/**
|
||||
* Composable for clipboard operations with toast notifications
|
||||
*/
|
||||
export function useClipboardWithToast() {
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const toast = useToast();
|
||||
|
||||
/**
|
||||
* Copy text and show toast
|
||||
@@ -17,21 +15,48 @@ export function useClipboardWithToast() {
|
||||
text: string,
|
||||
successMessage: string = 'Copied to clipboard'
|
||||
): Promise<boolean> => {
|
||||
if (!isSupported.value) {
|
||||
console.warn('Clipboard API is not supported');
|
||||
toast.error('Clipboard not supported');
|
||||
return false;
|
||||
// Try modern Clipboard API first
|
||||
if (isSupported.value) {
|
||||
try {
|
||||
await copy(text);
|
||||
// Use global toast if available
|
||||
if (globalThis.toast) {
|
||||
globalThis.toast.success(successMessage);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to execCommand for HTTP contexts
|
||||
try {
|
||||
await copy(text);
|
||||
toast.success(successMessage);
|
||||
return true;
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
|
||||
if (success) {
|
||||
if (globalThis.toast) {
|
||||
globalThis.toast.success(successMessage);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
toast.error('Failed to copy to clipboard');
|
||||
return false;
|
||||
console.error('Fallback copy failed:', error);
|
||||
}
|
||||
|
||||
// Both methods failed
|
||||
if (globalThis.toast) {
|
||||
globalThis.toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,11 +4,30 @@ import { RetryLink } from '@apollo/client/link/retry/index.js';
|
||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
|
||||
import { getMainDefinition } from '@apollo/client/utilities/index.js';
|
||||
import { createClient } from 'graphql-ws';
|
||||
|
||||
import type { ErrorResponse } from '@apollo/client/link/error/index.js';
|
||||
import type { GraphQLFormattedError } from 'graphql';
|
||||
|
||||
import { createApolloCache } from './apollo-cache';
|
||||
import { WEBGUI_GRAPHQL } from './urls';
|
||||
|
||||
const httpEndpoint = WEBGUI_GRAPHQL;
|
||||
const wsEndpoint = new URL(WEBGUI_GRAPHQL.toString().replace('http', 'ws'));
|
||||
// Allow overriding the GraphQL endpoint for development/testing
|
||||
declare global {
|
||||
interface Window {
|
||||
GRAPHQL_ENDPOINT?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const getGraphQLEndpoint = () => {
|
||||
if (typeof window !== 'undefined' && window.GRAPHQL_ENDPOINT) {
|
||||
return new URL(window.GRAPHQL_ENDPOINT);
|
||||
}
|
||||
return WEBGUI_GRAPHQL;
|
||||
};
|
||||
|
||||
const httpEndpoint = getGraphQLEndpoint();
|
||||
const wsEndpoint = new URL(httpEndpoint.toString());
|
||||
wsEndpoint.protocol = httpEndpoint.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const DEV_MODE = (globalThis as unknown as { __DEV__: boolean }).__DEV__ ?? false;
|
||||
|
||||
const headers = {
|
||||
@@ -28,17 +47,15 @@ const wsLink = new GraphQLWsLink(
|
||||
})
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errorLink = onError(({ graphQLErrors, networkError }: any) => {
|
||||
const errorLink = onError(({ graphQLErrors, networkError }: ErrorResponse) => {
|
||||
if (graphQLErrors) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
graphQLErrors.map((error: any) => {
|
||||
graphQLErrors.forEach((error: GraphQLFormattedError) => {
|
||||
console.error('[GraphQL error]', error);
|
||||
const errorMsg = error.error?.message ?? error.message;
|
||||
const errorMsg =
|
||||
(error as GraphQLFormattedError & { error?: { message?: string } }).error?.message ?? error.message;
|
||||
if (errorMsg?.includes('offline')) {
|
||||
// @todo restart the api, but make sure not to trigger infinite loop
|
||||
}
|
||||
return error.message;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,9 +63,8 @@ const errorLink = onError(({ graphQLErrors, networkError }: any) => {
|
||||
console.error(`[Network error]: ${networkError}`);
|
||||
const msg = networkError.message ? networkError.message : networkError;
|
||||
if (typeof msg === 'string' && msg.includes('Unexpected token < in JSON at position 0')) {
|
||||
return 'Unraid API • CORS Error';
|
||||
console.error('Unraid API • CORS Error');
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,7 +85,7 @@ const retryLink = new RetryLink({
|
||||
// Disable Apollo Client if not in DEV Mode and server state says unraid-api is not running
|
||||
const disableQueryLink = new ApolloLink((operation, forward) => {
|
||||
if (!DEV_MODE && operation.getContext().serverState?.unraidApi?.status === 'offline') {
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
return forward(operation);
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ function formatRouteName(name: string | symbol | undefined) {
|
||||
<template>
|
||||
<div class="text-black bg-white dark:text-white dark:bg-black">
|
||||
<ClientOnly>
|
||||
<div class="bg-white dark:bg-zinc-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="bg-white dark:bg-zinc-800 border-b border-muted">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 p-3 md:p-4">
|
||||
<nav class="flex flex-wrap items-center gap-2">
|
||||
<template v-for="route in routes" :key="route.path">
|
||||
@@ -42,7 +42,7 @@ function formatRouteName(name: string | symbol | undefined) {
|
||||
<Badge
|
||||
:variant="router.currentRoute.value.path === route.path ? 'orange' : 'gray'"
|
||||
size="xs"
|
||||
class="cursor-pointer"
|
||||
class="cursor-pointer header-nav-badge hover:brightness-90 hover:bg-transparent [&.bg-gray-200]:hover:bg-gray-200 [&.bg-orange]:hover:bg-orange"
|
||||
>
|
||||
{{ formatRouteName(route.name) }}
|
||||
</Badge>
|
||||
@@ -52,7 +52,7 @@ function formatRouteName(name: string | symbol | undefined) {
|
||||
<ModalsCe />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-3 p-3 md:p-4 bg-gray-50 dark:bg-zinc-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-3 p-3 md:p-4 bg-gray-50 dark:bg-zinc-900 border-b border-muted">
|
||||
<DummyServerSwitcher />
|
||||
<ColorSwitcherCe />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'path';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import removeConsole from 'vite-plugin-remove-console';
|
||||
|
||||
import type { PluginOption, UserConfig } from 'vite';
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
/**
|
||||
* Used to avoid redeclaring variables in the webgui codebase.
|
||||
@@ -34,15 +34,13 @@ console.log(dropConsole ? 'WARN: Console logs are disabled' : 'INFO: Console log
|
||||
|
||||
const assetsDir = path.join(__dirname, '../api/dev/webGui/');
|
||||
|
||||
/**
|
||||
* Create a tag configuration
|
||||
*/
|
||||
const createWebComponentTag = (name: string, path: string, appContext: string) => ({
|
||||
async: false,
|
||||
name,
|
||||
path,
|
||||
appContext
|
||||
});
|
||||
// REMOVED: No longer needed with standalone mount approach
|
||||
// const createWebComponentTag = (name: string, path: string, appContext: string) => ({
|
||||
// async: false,
|
||||
// name,
|
||||
// path,
|
||||
// appContext
|
||||
// });
|
||||
|
||||
/**
|
||||
* Shared terser options for consistent minification
|
||||
@@ -118,51 +116,27 @@ const sharedDefine = {
|
||||
__VUE_PROD_DEVTOOLS__: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply shared Vite configuration to a config object
|
||||
*/
|
||||
const applySharedViteConfig = (config: UserConfig, includeJQueryIsolation = false) => {
|
||||
if (!config.plugins) config.plugins = [];
|
||||
if (!config.define) config.define = {};
|
||||
if (!config.build) config.build = {};
|
||||
|
||||
// Add shared plugins
|
||||
config.plugins.push(...getSharedPlugins(includeJQueryIsolation));
|
||||
|
||||
// Merge define values
|
||||
Object.assign(config.define, sharedDefine);
|
||||
|
||||
// Apply build configuration
|
||||
config.build.minify = 'terser';
|
||||
config.build.terserOptions = sharedTerserOptions;
|
||||
|
||||
return config;
|
||||
};
|
||||
// REMOVED: No longer needed with standalone mount approach
|
||||
// const applySharedViteConfig = (config: UserConfig, includeJQueryIsolation = false) => {
|
||||
// if (!config.plugins) config.plugins = [];
|
||||
// if (!config.define) config.define = {};
|
||||
// if (!config.build) config.build = {};
|
||||
//
|
||||
// // Add shared plugins
|
||||
// config.plugins.push(...getSharedPlugins(includeJQueryIsolation));
|
||||
//
|
||||
// // Merge define values
|
||||
// Object.assign(config.define, sharedDefine);
|
||||
//
|
||||
// // Apply build configuration
|
||||
// config.build.minify = 'terser';
|
||||
// config.build.terserOptions = sharedTerserOptions;
|
||||
//
|
||||
// return config;
|
||||
// };
|
||||
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
nitro: {
|
||||
publicAssets: [
|
||||
{
|
||||
baseURL: '/webGui/',
|
||||
dir: assetsDir,
|
||||
},
|
||||
],
|
||||
devProxy: {
|
||||
'/graphql': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: false,
|
||||
// Important: preserve the host header
|
||||
headers: {
|
||||
'X-Forwarded-Host': 'localhost:3000',
|
||||
'X-Forwarded-Proto': 'http',
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
devServer: {
|
||||
port: 3000,
|
||||
},
|
||||
@@ -173,7 +147,7 @@ export default defineNuxtConfig({
|
||||
enabled: process.env.NODE_ENV === 'development',
|
||||
},
|
||||
|
||||
modules: ['@vueuse/nuxt', '@pinia/nuxt', 'nuxt-custom-elements', '@nuxt/eslint', '@nuxt/ui'],
|
||||
modules: ['@vueuse/nuxt', '@pinia/nuxt', '@nuxt/eslint', '@nuxt/ui'],
|
||||
|
||||
ui: {
|
||||
theme: {
|
||||
@@ -205,47 +179,33 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
|
||||
customElements: {
|
||||
analyzer: process.env.NODE_ENV !== 'test',
|
||||
entries: [
|
||||
// @ts-expect-error The nuxt-custom-elements module types don't perfectly match our configuration object structure.
|
||||
// The custom elements configuration requires specific properties and methods that may not align with the
|
||||
// module's TypeScript definitions, particularly around the viteExtend function and tag configuration format.
|
||||
{
|
||||
name: 'UnraidComponents',
|
||||
viteExtend(config: UserConfig) {
|
||||
const sharedConfig = applySharedViteConfig(config, true);
|
||||
|
||||
// Optimize CSS while keeping it inlined for functionality
|
||||
if (!sharedConfig.css) sharedConfig.css = {};
|
||||
sharedConfig.css.devSourcemap = process.env.NODE_ENV === 'development';
|
||||
|
||||
return sharedConfig;
|
||||
},
|
||||
tags: [
|
||||
createWebComponentTag('UnraidAuth', '@/components/Auth.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidConnectSettings', '@/components/ConnectSettings/ConnectSettings.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidDownloadApiLogs', '@/components/DownloadApiLogs.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidHeaderOsVersion', '@/components/HeaderOsVersion.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidModals', '@/components/Modals.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidUserProfile', '@/components/UserProfile.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidUpdateOs', '@/components/UpdateOs.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidDowngradeOs', '@/components/DowngradeOs.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidRegistration', '@/components/Registration.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidWanIpCheck', '@/components/WanIpCheck.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidWelcomeModal', '@/components/Activation/WelcomeModal.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidSsoButton', '@/components/SsoButton.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidLogViewer', '@/components/Logs/LogViewer.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidThemeSwitcher', '@/components/ThemeSwitcher.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidApiKeyManager', '@/components/ApiKeyPage.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidDevModalTest', '@/components/DevModalTest.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
createWebComponentTag('UnraidApiKeyAuthorize', '@/components/ApiKeyAuthorize.ce', '@/components/Wrapper/web-component-plugins'),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
compatibilityDate: '2024-12-05',
|
||||
|
||||
ssr: false,
|
||||
|
||||
// Configure for static generation
|
||||
nitro: {
|
||||
preset: 'static',
|
||||
publicAssets: [
|
||||
{
|
||||
baseURL: '/webGui/',
|
||||
dir: assetsDir,
|
||||
},
|
||||
],
|
||||
devProxy: {
|
||||
'/graphql': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: false,
|
||||
// Important: preserve the host header
|
||||
headers: {
|
||||
'X-Forwarded-Host': 'localhost:3000',
|
||||
'X-Forwarded-Proto': 'http',
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,17 +11,20 @@
|
||||
"serve": "NODE_ENV=production PORT=${PORT:-4321} node .output/server/index.mjs",
|
||||
"// Build": "",
|
||||
"prebuild:dev": "pnpm predev",
|
||||
"build:dev": "nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run deploy-to-unraid:dev",
|
||||
"build:dev": "pnpm run build && pnpm run deploy-to-unraid:dev",
|
||||
"build:webgui": "pnpm run type-check && nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run copy-to-webgui-repo",
|
||||
"build": "NODE_ENV=production nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run validate:css",
|
||||
"build": "NODE_ENV=production nuxi build --dotenv .env.production && pnpm run build:standalone && pnpm run manifest-ts && pnpm run validate:css",
|
||||
"build:standalone": "vite build --config vite.standalone.config.ts && pnpm run manifest-standalone",
|
||||
"prebuild:watch": "pnpm predev",
|
||||
"build:watch": "nuxi build --dotenv .env.production --watch && pnpm run manifest-ts",
|
||||
"generate": "nuxt generate",
|
||||
"manifest-ts": "node ./scripts/add-timestamp-webcomponent-manifest.js",
|
||||
"manifest-standalone": "node ./scripts/add-timestamp-standalone-manifest.js",
|
||||
"validate:css": "node ./scripts/validate-custom-elements-css.js",
|
||||
"// Deployment": "",
|
||||
"unraid:deploy": "pnpm build:dev",
|
||||
"deploy-to-unraid:dev": "./scripts/deploy-dev.sh",
|
||||
"clean-unraid": "./scripts/clean-unraid.sh",
|
||||
"copy-to-webgui-repo": "./scripts/copy-to-webgui-repo.sh",
|
||||
"// Code Quality": "",
|
||||
"lint": "eslint .",
|
||||
@@ -35,6 +38,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ci": "vitest run",
|
||||
"test:standalone": "pnpm run build:standalone && vite --config vite.test.config.ts",
|
||||
"// Nuxt": "",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
@@ -71,7 +75,6 @@
|
||||
"happy-dom": "18.0.1",
|
||||
"lodash-es": "4.17.21",
|
||||
"nuxt": "3.18.1",
|
||||
"nuxt-custom-elements": "2.0.0-beta.32",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"shadcn-nuxt": "2.2.0",
|
||||
|
||||
@@ -122,7 +122,7 @@ watch(
|
||||
|
||||
<h3 class="text-lg font-semibold font-mono">ConnectSettingsCe</h3>
|
||||
<ConnectSettingsCe />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
|
||||
<!-- <h3 class="text-lg font-semibold font-mono">
|
||||
DownloadApiLogsCe
|
||||
@@ -141,19 +141,19 @@ watch(
|
||||
<hr class="border-black dark:border-white"> -->
|
||||
<h3 class="text-lg font-semibold font-mono">UpdateOsCe</h3>
|
||||
<UpdateOsCe />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">DowngraadeOsCe</h3>
|
||||
<DowngradeOsCe :restore-release-date="'2022-10-10'" :restore-version="'6.11.2'" />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">RegistrationCe</h3>
|
||||
<RegistrationCe />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">ModalsCe</h3>
|
||||
<ModalsCe />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">WelcomeModalCe</h3>
|
||||
<WelcomeModalCe />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">Test Callback Builder</h3>
|
||||
<div class="flex flex-col justify-end gap-2">
|
||||
<p>
|
||||
@@ -172,7 +172,7 @@ watch(
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h2 class="text-xl font-semibold font-mono">Nuxt UI Button - Primary Color Test</h2>
|
||||
<div class="flex gap-4 items-center">
|
||||
<UButton color="primary" variant="solid">Primary Solid</UButton>
|
||||
@@ -183,7 +183,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-background">
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h2 class="text-xl font-semibold font-mono">Brand Button Component</h2>
|
||||
<template v-for="variant in variants" :key="variant">
|
||||
<BrandButton
|
||||
@@ -196,12 +196,12 @@ watch(
|
||||
</template>
|
||||
</div>
|
||||
<div class="bg-background">
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h2 class="text-xl font-semibold font-mono">SSO Button Component</h2>
|
||||
<SsoButtonCe />
|
||||
</div>
|
||||
<div class="bg-background">
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h2 class="text-xl font-semibold font-mono">Log Viewer Component</h2>
|
||||
<LogViewerCe />
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@ const executeCliCommand = async () => {
|
||||
<pre class="bg-muted p-3 rounded text-xs overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">{{ JSON.stringify(debugData, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4">
|
||||
<div class="border-t border-muted pt-4">
|
||||
<h3 class="font-semibold mb-2 text-sm">JWT/OIDC Token Validation Tool:</h3>
|
||||
<p class="text-xs text-muted-foreground mb-3">Enter a JWT or OIDC session token to validate it using the CLI command</p>
|
||||
|
||||
|
||||
@@ -27,38 +27,38 @@ const { serverState } = storeToRefs(serverStore);
|
||||
</div>
|
||||
<unraid-user-profile :server="JSON.stringify(serverState)" />
|
||||
</header>
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
|
||||
<h3 class="text-lg font-semibold font-mono">ConnectSettingsCe</h3>
|
||||
<ConnectSettingsCe />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">DownloadApiLogsCe</h3>
|
||||
<unraid-download-api-logs />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">AuthCe</h3>
|
||||
<unraid-auth />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">WanIpCheckCe</h3>
|
||||
<unraid-wan-ip-check php-wan-ip="47.184.85.45" />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">HeaderOsVersion</h3>
|
||||
<unraid-header-os-version />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">UpdateOsCe</h3>
|
||||
<unraid-update-os />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">DowngradeOsCe</h3>
|
||||
<unraid-downgrade-os restore-release-date="2022-10-10" restore-version="6.11.2" />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">RegistrationCe</h3>
|
||||
<unraid-registration />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">ModalsCe</h3>
|
||||
<!-- uncomment to test modals <unraid-modals />-->
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">SSOSignInButtonCe</h3>
|
||||
<unraid-sso-button />
|
||||
<hr class="border-black dark:border-white" >
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">ApiKeyManagerCe</h3>
|
||||
<unraid-api-key-manager />
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ const showWelcomeModal = () => {
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<WelcomeModalCe ref="welcomeModalRef" />
|
||||
<ModalsCe />
|
||||
<div class="mt-4 p-4 border rounded bg-gray-100 dark:bg-gray-800">
|
||||
<div class="mt-4 p-4 border border-muted rounded bg-gray-100 dark:bg-gray-800">
|
||||
<h3 class="text-lg font-semibold mb-2">Activation Modal Debug Info:</h3>
|
||||
<p>Should Show Modal (`showActivationModal`): {{ isVisible }}</p>
|
||||
<ul class="list-disc list-inside ml-4">
|
||||
|
||||
1
web/public/images/UN-logotype-gradient.svg
Normal file
1
web/public/images/UN-logotype-gradient.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 222.36 39.04"><defs><linearGradient id="header-logo" x1="47.53" y1="79.1" x2="170.71" y2="-44.08" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e32929"/><stop offset="1" stop-color="#ff8d30"/></linearGradient></defs><title>unraid.net</title><path d="M146.7,29.47H135l-3,9h-6.49L138.93,0h8l13.41,38.49h-7.09L142.62,6.93l-5.83,16.88h8ZM29.69,0V25.4c0,8.91-5.77,13.64-14.9,13.64S0,34.31,0,25.4V0H6.54V25.4c0,5.17,3.19,7.92,8.25,7.92s8.36-2.75,8.36-7.92V0ZM50.86,12v26.5H44.31V0h6.11l17,26.5V0H74V38.49H67.9ZM171.29,0h6.54V38.49h-6.54Zm51.07,24.69c0,9-5.88,13.8-15.17,13.8H192.67V0H207.3c9.18,0,15.06,4.78,15.06,13.8ZM215.82,13.8c0-5.28-3.3-8.14-8.52-8.14h-8.08V32.77h8c5.33,0,8.63-2.8,8.63-8.08ZM108.31,23.92c4.34-1.6,6.93-5.28,6.93-11.55C115.24,3.68,110.18,0,102.48,0H88.84V38.49h6.55V5.66h6.87c3.8,0,6.21,1.82,6.21,6.71s-2.41,6.76-6.21,6.76H98.88l9.21,19.36h7.53Z" fill="url(#header-logo)"/></svg>
|
||||
|
After Width: | Height: | Size: 1008 B |
35
web/scripts/add-timestamp-standalone-manifest.js
Normal file
35
web/scripts/add-timestamp-standalone-manifest.js
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const distPath = '.nuxt/standalone-apps';
|
||||
const manifestPath = path.join(distPath, 'standalone.manifest.json');
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(distPath)) {
|
||||
console.warn(`Directory ${distPath} does not exist. Skipping manifest generation.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Get all JS files in the dist directory
|
||||
const files = fs.readdirSync(distPath);
|
||||
const manifest = {};
|
||||
|
||||
files.forEach(file => {
|
||||
if (file.endsWith('.js') || file.endsWith('.css')) {
|
||||
const key = file.replace(/\.(js|css)$/, '.$1');
|
||||
manifest[key] = {
|
||||
file: file,
|
||||
src: file,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Add timestamp
|
||||
manifest.ts = Date.now();
|
||||
|
||||
// Write manifest
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
console.log('Standalone apps manifest created:', manifestPath);
|
||||
@@ -2,6 +2,13 @@ const fs = require('fs');
|
||||
|
||||
// Read the JSON file
|
||||
const filePath = '../web/.nuxt/nuxt-custom-elements/dist/unraid-components/manifest.json';
|
||||
|
||||
// Check if file exists (web components are now disabled in favor of standalone)
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log('Web components manifest not found (using standalone mount instead)');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const jsonData = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
|
||||
// Add timestamp (ts) to the JSON data
|
||||
|
||||
134
web/scripts/clean-unraid.sh
Executable file
134
web/scripts/clean-unraid.sh
Executable file
@@ -0,0 +1,134 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the server name is provided
|
||||
if [[ -z "$1" ]]; then
|
||||
echo "Error: SSH server name is required."
|
||||
echo "Usage: $0 <server_name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set server name from command-line argument
|
||||
server_name="$1"
|
||||
|
||||
echo "Cleaning deployed files from Unraid server: $server_name"
|
||||
|
||||
exit_code=0
|
||||
|
||||
# Remove standalone apps directory
|
||||
echo "Removing standalone apps directory..."
|
||||
ssh root@"${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
|
||||
standalone_exit_code=$?
|
||||
if [ $standalone_exit_code -ne 0 ]; then
|
||||
echo "Warning: Failed to remove standalone apps directory"
|
||||
exit_code=$standalone_exit_code
|
||||
fi
|
||||
|
||||
# Clean up auth-request.php file
|
||||
clean_auth_request() {
|
||||
local server_name="$1"
|
||||
echo "Cleaning auth-request.php file..."
|
||||
|
||||
# SSH into server and clean auth-request.php
|
||||
ssh "root@${server_name}" bash -s << 'EOF'
|
||||
AUTH_REQUEST_FILE='/usr/local/emhttp/auth-request.php'
|
||||
|
||||
if [ ! -f "$AUTH_REQUEST_FILE" ]; then
|
||||
echo "Auth request file not found: $AUTH_REQUEST_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if grep -q '\$arrWhitelist' "$AUTH_REQUEST_FILE"; then
|
||||
# Create a timestamped backup
|
||||
TIMESTAMP=$(date +%s)
|
||||
BACKUP_FILE="${AUTH_REQUEST_FILE}.bak.clean.${TIMESTAMP}"
|
||||
TEMP_FILE="${AUTH_REQUEST_FILE}.tmp.clean"
|
||||
|
||||
# Create backup
|
||||
cp "$AUTH_REQUEST_FILE" "$BACKUP_FILE" || {
|
||||
echo "Failed to create backup of $AUTH_REQUEST_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Clean up any existing temp file
|
||||
rm -f "$TEMP_FILE"
|
||||
|
||||
# Remove all unraid-components entries from the whitelist array
|
||||
awk '
|
||||
BEGIN { in_array = 0 }
|
||||
/\$arrWhitelist\s*=\s*\[/ {
|
||||
in_array = 1
|
||||
print $0
|
||||
next
|
||||
}
|
||||
in_array && /^\s*\]/ {
|
||||
in_array = 0
|
||||
print $0
|
||||
next
|
||||
}
|
||||
!in_array || !/\/plugins\/dynamix\.my\.servers\/unraid-components\/.*\.(m?js|css)/ {
|
||||
print $0
|
||||
}
|
||||
' "$AUTH_REQUEST_FILE" > "$TEMP_FILE"
|
||||
|
||||
# Check if processing succeeded and temp file is non-empty
|
||||
if [ $? -ne 0 ] || [ ! -s "$TEMP_FILE" ]; then
|
||||
echo "Failed to process $AUTH_REQUEST_FILE" >&2
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify the temp file has the expected content
|
||||
if ! grep -q '\$arrWhitelist' "$TEMP_FILE" 2>/dev/null; then
|
||||
echo "Generated file does not contain \$arrWhitelist array" >&2
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Atomically replace the original file
|
||||
mv "$TEMP_FILE" "$AUTH_REQUEST_FILE" || {
|
||||
echo "Failed to update $AUTH_REQUEST_FILE" >&2
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Cleaned $AUTH_REQUEST_FILE (backup: $BACKUP_FILE)"
|
||||
else
|
||||
echo "\$arrWhitelist array not found in $AUTH_REQUEST_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
EOF
|
||||
}
|
||||
|
||||
clean_auth_request "$server_name"
|
||||
auth_request_exit_code=$?
|
||||
|
||||
# If auth request cleanup failed, update exit_code
|
||||
if [ $auth_request_exit_code -ne 0 ]; then
|
||||
echo "Warning: Failed to clean auth-request.php"
|
||||
exit_code=$auth_request_exit_code
|
||||
fi
|
||||
|
||||
# Remove the parent unraid-components directory if it's empty
|
||||
echo "Cleaning up empty directories..."
|
||||
ssh root@"${server_name}" "rmdir /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/ 2>/dev/null || true"
|
||||
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "Successfully cleaned all deployed files from $server_name"
|
||||
|
||||
# Play success sound based on the operating system
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS
|
||||
afplay /System/Library/Sounds/Purr.aiff 2>/dev/null || true
|
||||
elif [[ "$OSTYPE" == "linux-gnu" ]]; then
|
||||
# Linux
|
||||
paplay /usr/share/sounds/freedesktop/stereo/bell.oga 2>/dev/null || true
|
||||
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then
|
||||
# Windows
|
||||
powershell.exe -c "(New-Object Media.SoundPlayer 'C:\Windows\Media\chimes.wav').PlaySync()" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
echo "Some cleanup operations failed (exit code: $exit_code)"
|
||||
fi
|
||||
|
||||
# Exit with the final exit code
|
||||
exit $exit_code
|
||||
@@ -10,77 +10,133 @@ fi
|
||||
# Set server name from command-line argument
|
||||
server_name="$1"
|
||||
|
||||
# Source directory path
|
||||
source_directory=".nuxt/nuxt-custom-elements/dist/unraid-components/"
|
||||
# Source directory paths
|
||||
standalone_directory=".nuxt/standalone-apps/"
|
||||
|
||||
if [ ! -d "$source_directory" ]; then
|
||||
echo "The web components directory does not exist."
|
||||
# Check what we have to deploy
|
||||
has_standalone=false
|
||||
|
||||
if [ -d "$standalone_directory" ]; then
|
||||
has_standalone=true
|
||||
fi
|
||||
|
||||
# Exit if standalone directory doesn't exist
|
||||
if [ "$has_standalone" = false ]; then
|
||||
echo "Error: Standalone apps directory does not exist."
|
||||
echo "Please run 'pnpm build' or 'pnpm build:standalone' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Replace the value inside the rsync command with the user's input
|
||||
# Delete existing web components in the target directory
|
||||
ssh "root@${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt/*"
|
||||
exit_code=0
|
||||
|
||||
rsync_command="rsync -avz -e ssh $source_directory root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt"
|
||||
|
||||
echo "Executing the following command:"
|
||||
echo "$rsync_command"
|
||||
|
||||
# Execute the rsync command and capture the exit code
|
||||
eval "$rsync_command"
|
||||
exit_code=$?
|
||||
# Deploy standalone apps if they exist
|
||||
if [ "$has_standalone" = true ]; then
|
||||
echo "Deploying standalone apps..."
|
||||
# Ensure remote directory exists
|
||||
ssh root@"${server_name}" "mkdir -p /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
|
||||
# Run rsync with proper quoting
|
||||
rsync -avz --delete -e "ssh" "$standalone_directory" "root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
|
||||
standalone_exit_code=$?
|
||||
# If standalone rsync failed, update exit_code
|
||||
if [ $standalone_exit_code -ne 0 ]; then
|
||||
exit_code=$standalone_exit_code
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update the auth-request.php file to include the new web component JS
|
||||
update_auth_request() {
|
||||
local server_name="$1"
|
||||
# SSH into server and update auth-request.php
|
||||
ssh "root@${server_name}" "
|
||||
ssh "root@${server_name}" bash -s << 'EOF'
|
||||
AUTH_REQUEST_FILE='/usr/local/emhttp/auth-request.php'
|
||||
WEB_COMPS_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt/_nuxt/'
|
||||
UNRAID_COMPS_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/'
|
||||
|
||||
# Find JS files and modify paths
|
||||
mapfile -t JS_FILES < <(find \"\$WEB_COMPS_DIR\" -type f -name \"*.js\" | sed 's|/usr/local/emhttp||' | sort -u)
|
||||
# Find ALL JS/MJS/CSS files under unraid-components
|
||||
if [ -d "$UNRAID_COMPS_DIR" ]; then
|
||||
mapfile -t FILES_TO_ADD < <(find "$UNRAID_COMPS_DIR" -type f \( -name "*.js" -o -name "*.mjs" -o -name "*.css" \) | sed 's|/usr/local/emhttp||' | sort -u)
|
||||
else
|
||||
echo "Unraid components directory not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILES_TO_ADD+=(\"\${JS_FILES[@]}\")
|
||||
|
||||
if grep -q '\$arrWhitelist' \"\$AUTH_REQUEST_FILE\"; then
|
||||
if grep -q '\$arrWhitelist' "$AUTH_REQUEST_FILE"; then
|
||||
# Create a timestamped backup
|
||||
TIMESTAMP=$(date +%s)
|
||||
BACKUP_FILE="${AUTH_REQUEST_FILE}.bak.${TIMESTAMP}"
|
||||
TEMP_FILE="${AUTH_REQUEST_FILE}.tmp.new"
|
||||
|
||||
# Create backup
|
||||
cp "$AUTH_REQUEST_FILE" "$BACKUP_FILE" || {
|
||||
echo "Failed to create backup of $AUTH_REQUEST_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Clean up any existing temp file
|
||||
rm -f "$TEMP_FILE"
|
||||
|
||||
# Process the file through both stages using a pipeline
|
||||
# First remove existing web component entries, then add new ones
|
||||
awk '
|
||||
BEGIN { in_array = 0 }
|
||||
/\\\$arrWhitelist\s*=\s*\[/ {
|
||||
/\$arrWhitelist\s*=\s*\[/ {
|
||||
in_array = 1
|
||||
print \$0
|
||||
print $0
|
||||
next
|
||||
}
|
||||
in_array && /^\s*\]/ {
|
||||
in_array = 0
|
||||
print \$0
|
||||
print $0
|
||||
next
|
||||
}
|
||||
!in_array || !/\/plugins\/dynamix\.my\.servers\/unraid-components\/nuxt\/_nuxt\/unraid-components\.client-/ {
|
||||
print \$0
|
||||
!in_array || !/\/plugins\/dynamix\.my\.servers\/unraid-components\/.*\.(m?js|css)/ {
|
||||
print $0
|
||||
}
|
||||
' \"\$AUTH_REQUEST_FILE\" > \"\${AUTH_REQUEST_FILE}.tmp\"
|
||||
|
||||
# Now add new entries right after the opening bracket
|
||||
awk -v files_to_add=\"\$(printf '%s\n' \"\${FILES_TO_ADD[@]}\" | sort -u | awk '{printf \" \\\x27%s\\\x27,\n\", \$0}')\" '
|
||||
/\\\$arrWhitelist\s*=\s*\[/ {
|
||||
print \$0
|
||||
' "$AUTH_REQUEST_FILE" | \
|
||||
awk -v files_to_add="$(printf '%s\n' "${FILES_TO_ADD[@]}" | sed "s/'/\\\\'/g" | sort -u | awk '{printf " \047%s\047,\n", $0}')" '
|
||||
/\$arrWhitelist\s*=\s*\[/ {
|
||||
print $0
|
||||
print files_to_add
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' \"\${AUTH_REQUEST_FILE}.tmp\" > \"\${AUTH_REQUEST_FILE}\"
|
||||
|
||||
rm \"\${AUTH_REQUEST_FILE}.tmp\"
|
||||
echo \"Updated \$AUTH_REQUEST_FILE with new web component JS files\"
|
||||
' > "$TEMP_FILE"
|
||||
|
||||
# Check pipeline succeeded and temp file is non-empty
|
||||
if [ ${PIPESTATUS[0]} -ne 0 ] || [ ${PIPESTATUS[1]} -ne 0 ] || [ ! -s "$TEMP_FILE" ]; then
|
||||
echo "Failed to process $AUTH_REQUEST_FILE" >&2
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify the temp file has the expected content
|
||||
if ! grep -q '\$arrWhitelist' "$TEMP_FILE" 2>/dev/null; then
|
||||
echo "Generated file does not contain \$arrWhitelist array" >&2
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Atomically replace the original file
|
||||
mv "$TEMP_FILE" "$AUTH_REQUEST_FILE" || {
|
||||
echo "Failed to update $AUTH_REQUEST_FILE" >&2
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Updated $AUTH_REQUEST_FILE with new web component JS files (backup: $BACKUP_FILE)"
|
||||
else
|
||||
echo \"\\\$arrWhitelist array not found in \$AUTH_REQUEST_FILE\"
|
||||
echo "\$arrWhitelist array not found in $AUTH_REQUEST_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
"
|
||||
EOF
|
||||
}
|
||||
|
||||
update_auth_request "$server_name"
|
||||
auth_request_exit_code=$?
|
||||
|
||||
# If auth request update failed, update exit_code
|
||||
if [ $auth_request_exit_code -ne 0 ]; then
|
||||
exit_code=$auth_request_exit_code
|
||||
fi
|
||||
|
||||
# Play built-in sound based on the operating system
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
@@ -94,5 +150,5 @@ elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then
|
||||
powershell.exe -c "(New-Object Media.SoundPlayer 'C:\Windows\Media\Windows Default.wav').PlaySync()"
|
||||
fi
|
||||
|
||||
# Exit with the rsync command's exit code
|
||||
# Exit with the final exit code (non-zero if any command failed)
|
||||
exit $exit_code
|
||||
@@ -29,16 +29,26 @@ function findJSFiles(dir, jsFiles = []) {
|
||||
* Validates that Tailwind CSS styles are properly inlined in the JavaScript bundle
|
||||
*/
|
||||
function validateCustomElementsCSS() {
|
||||
console.log('🔍 Validating custom elements JS bundle includes inlined Tailwind styles...');
|
||||
console.log('🔍 Validating JS bundle includes inlined Tailwind styles...');
|
||||
|
||||
try {
|
||||
// Find the custom elements JS files
|
||||
const customElementsDir = '.nuxt/nuxt-custom-elements/dist';
|
||||
const jsFiles = findJSFiles(customElementsDir);
|
||||
|
||||
// Check standalone apps first (new approach)
|
||||
const standaloneDir = '.nuxt/standalone-apps';
|
||||
let jsFiles = findJSFiles(standaloneDir);
|
||||
let usingStandalone = true;
|
||||
|
||||
// Fallback to custom elements if standalone doesn't exist
|
||||
if (jsFiles.length === 0) {
|
||||
throw new Error('No custom elements JS files found in ' + customElementsDir);
|
||||
const customElementsDir = '.nuxt/nuxt-custom-elements/dist';
|
||||
jsFiles = findJSFiles(customElementsDir);
|
||||
usingStandalone = false;
|
||||
|
||||
if (jsFiles.length === 0) {
|
||||
throw new Error('No JS files found in standalone apps or custom elements dist');
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📦 Using ${usingStandalone ? 'standalone apps' : 'custom elements'} bundle`);
|
||||
|
||||
// Find the largest JS file (likely the main bundle with inlined CSS)
|
||||
const jsFile = jsFiles.reduce((largest, current) => {
|
||||
@@ -52,40 +62,41 @@ function validateCustomElementsCSS() {
|
||||
const jsContent = fs.readFileSync(jsFile, 'utf8');
|
||||
|
||||
// Define required Tailwind indicators (looking for inlined CSS in JS)
|
||||
// Updated patterns to work with minified CSS (no spaces)
|
||||
const requiredIndicators = [
|
||||
{
|
||||
name: 'Tailwind utility classes (inline)',
|
||||
pattern: /\.flex\s*\{[^}]*display:\s*flex/,
|
||||
pattern: /\.flex\s*\{[^}]*display:\s*flex|\.flex{display:flex/,
|
||||
description: 'Basic Tailwind utility classes inlined'
|
||||
},
|
||||
{
|
||||
name: 'Tailwind margin utilities (inline)',
|
||||
pattern: /\.m-\d+\s*\{[^}]*margin:/,
|
||||
pattern: /\.m-\d+\s*\{[^}]*margin:|\.m-\d+{[^}]*margin:/,
|
||||
description: 'Tailwind margin utilities inlined'
|
||||
},
|
||||
{
|
||||
name: 'Tailwind padding utilities (inline)',
|
||||
pattern: /\.p-\d+\s*\{[^}]*padding:/,
|
||||
pattern: /\.p-\d+\s*\{[^}]*padding:|\.p-\d+{[^}]*padding:/,
|
||||
description: 'Tailwind padding utilities inlined'
|
||||
},
|
||||
{
|
||||
name: 'Tailwind color utilities (inline)',
|
||||
pattern: /\.text-\w+\s*\{[^}]*color:/,
|
||||
pattern: /\.text-\w+\s*\{[^}]*color:|\.text-\w+{[^}]*color:/,
|
||||
description: 'Tailwind text color utilities inlined'
|
||||
},
|
||||
{
|
||||
name: 'Tailwind background utilities (inline)',
|
||||
pattern: /\.bg-\w+\s*\{[^}]*background/,
|
||||
pattern: /\.bg-\w+\s*\{[^}]*background|\.bg-\w+{[^}]*background/,
|
||||
description: 'Tailwind background utilities inlined'
|
||||
},
|
||||
{
|
||||
name: 'CSS custom properties',
|
||||
pattern: /--[\w-]+:\s*[^;]+;/,
|
||||
pattern: /--[\w-]+:\s*[^;]+;|--[\w-]+:[^;]+;/,
|
||||
description: 'CSS custom properties (variables)'
|
||||
},
|
||||
{
|
||||
name: 'Responsive breakpoints',
|
||||
pattern: /@media\s*\([^)]*min-width/,
|
||||
pattern: /@media\s*\([^)]*min-width|@media\([^)]*min-width/,
|
||||
description: 'Responsive media queries'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue';
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import type { ApiKeyFragment } from '~/composables/gql/graphql.js';
|
||||
@@ -23,19 +23,25 @@ export const useApiKeyStore = defineStore('apiKey', () => {
|
||||
|
||||
function showModal(key: ApiKeyFragment | null = null) {
|
||||
editingKey.value = key;
|
||||
modalVisible.value = true;
|
||||
// Reset authorization mode if editing
|
||||
if (key) {
|
||||
isAuthorizationMode.value = false;
|
||||
authorizationData.value = null;
|
||||
}
|
||||
// Use nextTick to ensure DOM updates are complete before showing modal
|
||||
nextTick(() => {
|
||||
modalVisible.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
modalVisible.value = false;
|
||||
editingKey.value = null;
|
||||
isAuthorizationMode.value = false;
|
||||
authorizationData.value = null;
|
||||
// Clean up state after modal closes
|
||||
nextTick(() => {
|
||||
editingKey.value = null;
|
||||
isAuthorizationMode.value = false;
|
||||
authorizationData.value = null;
|
||||
});
|
||||
}
|
||||
|
||||
function setAuthorizationMode(
|
||||
|
||||
@@ -117,15 +117,29 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
// overwrite with hex colors set in webGUI @ /Settings/DisplaySettings
|
||||
if (theme.value.textColor) {
|
||||
customTheme['--header-text-primary'] = theme.value.textColor;
|
||||
// Also set the Tailwind v4 color variable for utility classes
|
||||
customTheme['--color-header-text-primary'] = theme.value.textColor;
|
||||
}
|
||||
if (theme.value.metaColor) {
|
||||
customTheme['--header-text-secondary'] = theme.value.metaColor;
|
||||
// Also set the Tailwind v4 color variable for utility classes
|
||||
customTheme['--color-header-text-secondary'] = theme.value.metaColor;
|
||||
}
|
||||
|
||||
if (theme.value.bgColor) {
|
||||
customTheme['--header-background-color'] = theme.value.bgColor;
|
||||
// Also set the Tailwind v4 color variable for utility classes
|
||||
customTheme['--color-header-background'] = theme.value.bgColor;
|
||||
customTheme['--header-gradient-start'] = hexToRgba(theme.value.bgColor, 0);
|
||||
customTheme['--header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7);
|
||||
// Also set the Tailwind v4 color variables for gradient utility classes
|
||||
customTheme['--color-header-gradient-start'] = hexToRgba(theme.value.bgColor, 0);
|
||||
customTheme['--color-header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7);
|
||||
}
|
||||
|
||||
// Set ui-border-muted if it exists in the theme
|
||||
if (customTheme['--ui-border-muted']) {
|
||||
// The value is already set from the defaultColors theme
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
327
web/test-standalone.html
Normal file
327
web/test-standalone.html
Normal file
@@ -0,0 +1,327 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Standalone Vue Apps Test Page</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.test-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
color: #666;
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
.status.loading {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.mount-target {
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 4px;
|
||||
min-height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
.mount-target::before {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
background: white;
|
||||
padding: 0 5px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.debug-info {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.multiple-mounts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.test-button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.test-button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.test-button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Teleport target for dropdowns and modals -->
|
||||
<div id="teleports"></div>
|
||||
|
||||
<!-- Mount point for Modals component -->
|
||||
<unraid-modals></unraid-modals>
|
||||
|
||||
<div class="container">
|
||||
<h1>🧪 Standalone Vue Apps Test Page</h1>
|
||||
<div id="status" class="status loading">Loading...</div>
|
||||
|
||||
<!-- Test Section 1: Single Mount -->
|
||||
<div class="test-section">
|
||||
<h2>Test 1: Single Component Mount</h2>
|
||||
<p>Testing single instance of HeaderOsVersion component</p>
|
||||
<div class="mount-target" data-label="HeaderOsVersion Mount">
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Section 2: Multiple Mounts -->
|
||||
<div class="test-section">
|
||||
<h2>Test 2: Multiple Component Mounts (Shared Pinia Store)</h2>
|
||||
<p>Testing that multiple instances share the same Pinia store</p>
|
||||
<div class="multiple-mounts">
|
||||
<div class="mount-target" data-label="Instance 1">
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
<div class="mount-target" data-label="Instance 2">
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
<div class="mount-target" data-label="Instance 3">
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Section 3: Dynamic Mount -->
|
||||
<div class="test-section">
|
||||
<h2>Test 3: Dynamic Component Creation</h2>
|
||||
<p>Test dynamically adding components after page load</p>
|
||||
<button class="test-button" id="addComponent">Add New Component</button>
|
||||
<button class="test-button" id="removeComponent">Remove Last Component</button>
|
||||
<button class="test-button" id="remountAll">Remount All</button>
|
||||
<div id="dynamicContainer" style="margin-top: 20px;">
|
||||
<!-- Dynamic components will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Section 4: Modal Testing -->
|
||||
<div class="test-section">
|
||||
<h2>Test 4: Modal Components</h2>
|
||||
<p>Test modal functionality</p>
|
||||
<button class="test-button" onclick="testTrialModal()">Open Trial Modal</button>
|
||||
<button class="test-button" onclick="testUpdateModal()">Open Update Modal</button>
|
||||
<button class="test-button" onclick="testApiKeyModal()">Open API Key Modal</button>
|
||||
<div style="margin-top: 10px;">
|
||||
<small>Note: Modals require proper store state to display</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Info -->
|
||||
<div class="test-section">
|
||||
<h2>Debug Information</h2>
|
||||
<div class="debug-info" id="debugInfo">
|
||||
Waiting for initialization...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mock configurations for local testing -->
|
||||
<script>
|
||||
// Set GraphQL endpoint directly to API server
|
||||
// Change this to match your API server port
|
||||
window.GRAPHQL_ENDPOINT = 'http://localhost:3001/graphql';
|
||||
|
||||
// Mock webGui path for images
|
||||
window.__WEBGUI_PATH__ = '';
|
||||
|
||||
// Add some debug logging
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const status = document.getElementById('status');
|
||||
const debugInfo = document.getElementById('debugInfo');
|
||||
|
||||
// Log when scripts are loaded
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeName === 'SCRIPT') {
|
||||
console.log('Script loaded:', node.src || 'inline');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.head, { childList: true });
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
// Check for Vue app mounting
|
||||
let checkInterval = setInterval(() => {
|
||||
const mountedElements = document.querySelectorAll('unraid-header-os-version');
|
||||
let mountedCount = 0;
|
||||
|
||||
mountedElements.forEach(el => {
|
||||
if (el.innerHTML.trim() !== '') {
|
||||
mountedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (mountedCount > 0) {
|
||||
status.className = 'status success';
|
||||
status.textContent = `✅ Successfully mounted ${mountedCount} component(s)`;
|
||||
|
||||
// Update debug info
|
||||
debugInfo.textContent = `
|
||||
Components Found: ${mountedElements.length}
|
||||
Components Mounted: ${mountedCount}
|
||||
Vue Apps: ${window.mountedApps ? Object.keys(window.mountedApps).length : 0}
|
||||
Pinia Store: ${window.globalPinia ? 'Initialized' : 'Not found'}
|
||||
GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
|
||||
`.trim();
|
||||
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
if (status.className === 'status loading') {
|
||||
status.className = 'status error';
|
||||
status.textContent = '❌ Failed to mount components (timeout)';
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
// Dynamic component controls
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let dynamicCount = 0;
|
||||
const dynamicContainer = document.getElementById('dynamicContainer');
|
||||
|
||||
document.getElementById('addComponent').addEventListener('click', () => {
|
||||
dynamicCount++;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mount-target';
|
||||
wrapper.setAttribute('data-label', `Dynamic Instance ${dynamicCount}`);
|
||||
wrapper.style.marginBottom = '10px';
|
||||
wrapper.innerHTML = '<unraid-header-os-version></unraid-header-os-version>';
|
||||
dynamicContainer.appendChild(wrapper);
|
||||
|
||||
// Trigger mount if app is already loaded
|
||||
if (window.mountVueApp) {
|
||||
window.mountVueApp({
|
||||
component: window.HeaderOsVersion,
|
||||
selector: 'unraid-header-os-version',
|
||||
appId: `dynamic-${dynamicCount}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('removeComponent').addEventListener('click', () => {
|
||||
const lastChild = dynamicContainer.lastElementChild;
|
||||
if (lastChild) {
|
||||
dynamicContainer.removeChild(lastChild);
|
||||
dynamicCount = Math.max(0, dynamicCount - 1);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('remountAll').addEventListener('click', () => {
|
||||
// This would require the mount function to be exposed globally
|
||||
console.log('Remounting all components...');
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
// Modal test functions
|
||||
window.testTrialModal = function() {
|
||||
console.log('Testing trial modal...');
|
||||
if (window.globalPinia) {
|
||||
const trialStore = window.globalPinia._s.get('trial');
|
||||
if (trialStore) {
|
||||
trialStore.trialModalVisible = true;
|
||||
console.log('Trial modal triggered');
|
||||
} else {
|
||||
console.error('Trial store not found');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.testUpdateModal = function() {
|
||||
console.log('Testing update modal...');
|
||||
if (window.globalPinia) {
|
||||
const updateStore = window.globalPinia._s.get('updateOs');
|
||||
if (updateStore) {
|
||||
updateStore.updateOsModalVisible = true;
|
||||
console.log('Update modal triggered');
|
||||
} else {
|
||||
console.error('Update store not found');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.testApiKeyModal = function() {
|
||||
console.log('Testing API key modal...');
|
||||
if (window.globalPinia) {
|
||||
const apiKeyStore = window.globalPinia._s.get('apiKey');
|
||||
if (apiKeyStore) {
|
||||
apiKeyStore.showCreateModal = true;
|
||||
console.log('API key modal triggered');
|
||||
} else {
|
||||
console.error('API key store not found');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Load the standalone app -->
|
||||
<script type="module" src=".nuxt/standalone-apps/standalone-apps.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -36,12 +36,19 @@ export const defaultDark: ThemeVariables = {
|
||||
'--destructive-foreground': '0 0% 98%',
|
||||
'--ring': '0 0% 83.1%',
|
||||
'--radius': '0.5rem',
|
||||
'--custom-font-size': '10px',
|
||||
'--ui-border-muted': 'hsl(0 0% 20%)',
|
||||
'--header-text-primary': '#1c1c1c',
|
||||
'--header-text-secondary': '#999999',
|
||||
'--header-background-color': '#f2f2f2',
|
||||
'--header-gradient-start': 'rgba(0, 0, 0, 0)',
|
||||
'--header-gradient-end': 'var(--header-background-color)',
|
||||
'--banner-gradient': null,
|
||||
'--color-header-text-primary': '#1c1c1c',
|
||||
'--color-header-text-secondary': '#999999',
|
||||
'--color-header-background': '#f2f2f2',
|
||||
'--color-header-gradient-start': 'rgba(0, 0, 0, 0)',
|
||||
'--color-header-gradient-end': '#f2f2f2',
|
||||
...legacyColors,
|
||||
} as const satisfies ThemeVariables;
|
||||
|
||||
@@ -66,12 +73,19 @@ export const defaultLight: ThemeVariables = {
|
||||
'--destructive-foreground': '0 0% 98%',
|
||||
'--ring': '0 0% 3.9%',
|
||||
'--radius': '0.5rem',
|
||||
'--custom-font-size': '10px',
|
||||
'--ui-border-muted': 'hsl(0 0% 89.8%)',
|
||||
'--header-text-primary': '#f2f2f2',
|
||||
'--header-text-secondary': '#999999',
|
||||
'--header-background-color': '#1c1b1b',
|
||||
'--header-gradient-start': 'rgba(0, 0, 0, 0)',
|
||||
'--header-gradient-end': 'var(--header-background-color)',
|
||||
'--banner-gradient': null,
|
||||
'--color-header-text-primary': '#f2f2f2',
|
||||
'--color-header-text-secondary': '#999999',
|
||||
'--color-header-background': '#1c1b1b',
|
||||
'--color-header-gradient-start': 'rgba(0, 0, 0, 0)',
|
||||
'--color-header-gradient-end': '#1c1b1b',
|
||||
...legacyColors,
|
||||
} as const satisfies ThemeVariables;
|
||||
|
||||
@@ -94,11 +108,21 @@ export const defaultColors: Record<string, ThemeVariables> = {
|
||||
'--header-text-primary': '#39587f',
|
||||
'--header-text-secondary': '#606e7f',
|
||||
'--header-background-color': '#1c1b1b',
|
||||
'--color-header-text-primary': '#39587f',
|
||||
'--color-header-text-secondary': '#606e7f',
|
||||
'--color-header-background': '#1c1b1b',
|
||||
'--color-header-gradient-start': 'rgba(28, 27, 27, 0)',
|
||||
'--color-header-gradient-end': '#1c1b1b',
|
||||
},
|
||||
azure: {
|
||||
...defaultDark,
|
||||
...defaultLight,
|
||||
'--header-text-primary': '#39587f',
|
||||
'--header-text-secondary': '#606e7f',
|
||||
'--header-background-color': '#f2f2f2',
|
||||
'--color-header-text-primary': '#39587f',
|
||||
'--color-header-text-secondary': '#606e7f',
|
||||
'--color-header-background': '#f2f2f2',
|
||||
'--color-header-gradient-start': 'rgba(242, 242, 242, 0)',
|
||||
'--color-header-gradient-end': '#f2f2f2',
|
||||
},
|
||||
} as const satisfies Record<string, ThemeVariables>;
|
||||
|
||||
8
web/themes/types.d.ts
vendored
8
web/themes/types.d.ts
vendored
@@ -35,6 +35,14 @@ type BaseThemeVariables = {
|
||||
'--header-gradient-start': string;
|
||||
'--header-gradient-end': string;
|
||||
'--banner-gradient': string | null;
|
||||
// Tailwind v4 color variables for utility classes
|
||||
'--color-header-text-primary'?: string;
|
||||
'--color-header-text-secondary'?: string;
|
||||
'--color-header-background'?: string;
|
||||
'--color-header-gradient-start'?: string;
|
||||
'--color-header-gradient-end'?: string;
|
||||
'--custom-font-size'?: string;
|
||||
'--ui-border-muted'?: string;
|
||||
};
|
||||
|
||||
type LegacyThemeVariables = {
|
||||
|
||||
98
web/vite.standalone.config.ts
Normal file
98
web/vite.standalone.config.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import path, { resolve } from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Read CSS content at build time
|
||||
const getCssContent = () => {
|
||||
const directories = [
|
||||
'.nuxt/dist/client/_nuxt',
|
||||
'.output/public/_nuxt'
|
||||
];
|
||||
|
||||
for (const dir of directories) {
|
||||
const fullDir = path.resolve(__dirname, dir);
|
||||
if (fs.existsSync(fullDir)) {
|
||||
// Find entry.*.css files dynamically
|
||||
const files = fs.readdirSync(fullDir);
|
||||
const entryFile = files.find(f => f.startsWith('entry.') && f.endsWith('.css'));
|
||||
|
||||
if (entryFile) {
|
||||
const fullPath = path.join(fullDir, entryFile);
|
||||
console.log(`Reading CSS from: ${fullPath}`);
|
||||
return fs.readFileSync(fullPath, 'utf-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to source asset
|
||||
const fallback = path.resolve(__dirname, 'assets/main.css');
|
||||
if (fs.existsSync(fallback)) {
|
||||
console.warn('Nuxt CSS not found; falling back to assets/main.css');
|
||||
return fs.readFileSync(fallback, 'utf-8');
|
||||
}
|
||||
|
||||
console.warn('No CSS file found, using empty string');
|
||||
return '';
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
{
|
||||
name: 'inject-css-content',
|
||||
transform(code, id) {
|
||||
// Replace CSS import with actual content
|
||||
if (id.includes('vue-mount-app')) {
|
||||
const cssContent = getCssContent();
|
||||
const replacement = `const tailwindStyles = ${JSON.stringify(cssContent)};`;
|
||||
|
||||
// Replace the import statement
|
||||
code = code.replace(
|
||||
/import tailwindStyles from ['"]~\/assets\/main\.css\?inline['"];?/,
|
||||
replacement
|
||||
);
|
||||
|
||||
return code;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': resolve(__dirname, './'),
|
||||
'@': resolve(__dirname, './'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '.nuxt/standalone-apps',
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'components/standalone-mount.ts'),
|
||||
name: 'UnraidStandaloneApps',
|
||||
fileName: 'standalone-apps',
|
||||
formats: ['es'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [],
|
||||
output: {
|
||||
format: 'es',
|
||||
entryFileNames: 'standalone-apps.js',
|
||||
chunkFileNames: '[name]-[hash].js',
|
||||
assetFileNames: '[name]-[hash][extname]',
|
||||
inlineDynamicImports: false,
|
||||
},
|
||||
},
|
||||
cssCodeSplit: false,
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
mangle: {
|
||||
toplevel: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
|
||||
},
|
||||
});
|
||||
26
web/vite.test.config.ts
Normal file
26
web/vite.test.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
root: '.',
|
||||
server: {
|
||||
port: 5173,
|
||||
open: '/test-standalone.html',
|
||||
cors: true,
|
||||
fs: {
|
||||
strict: false,
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': resolve(__dirname, './'),
|
||||
'@': resolve(__dirname, './'),
|
||||
'vue': 'vue/dist/vue.esm-bundler.js',
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['vue', 'pinia', '@vue/apollo-composable'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user