mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
refactor(ui): replace vue-sonner toasts with nuxtui toasts
> [!Note] This stubs the unraid-ui/src/components/common/toast. Initially created a shim to convert vue-sonnner toasts to nuxtui. However, since there weren't that many, I just did a clean replacement. - replace router link with window.location.assign The `UButton` component attempts to inject the Vue Router instance when the `:to` prop is used. In the standalone component environment (where the router is not installed), this caused a "TypeError: inject(...) is undefined" crash when rendering notifications with links. This change replaces the `:to` prop with a standard `@click` handler that uses `window.location.assign`, ensuring navigation works correctly without requiring the router context.
This commit is contained in:
8
unraid-ui/src/global.d.ts
vendored
8
unraid-ui/src/global.d.ts
vendored
@@ -1,8 +0,0 @@
|
||||
/* eslint-disable no-var */
|
||||
declare global {
|
||||
/** loaded by Toaster.vue */
|
||||
var toast: (typeof import('vue-sonner'))['toast'];
|
||||
}
|
||||
|
||||
// an export or import statement is required to make this file a module
|
||||
export {};
|
||||
@@ -32,10 +32,18 @@ export default {
|
||||
right: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
toaster: {
|
||||
position: 'bottom-right' as const,
|
||||
expand: true,
|
||||
duration: 5000,
|
||||
|
||||
//css theming/style-overrides for the toast component
|
||||
// https://ui.nuxt.com/docs/components/toast#theme
|
||||
toast: {},
|
||||
|
||||
// Also, for toasts, BUT this is imported in the Root UApp in mount-engine.ts
|
||||
// https://ui.nuxt.com/docs/components/toast#examples
|
||||
toaster: {
|
||||
position: 'top-center' as const,
|
||||
// expand: false, --> buggy
|
||||
duration: 5000,
|
||||
max: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
|
||||
|
||||
import { AlertTriangle, Octagon } from 'lucide-vue-next';
|
||||
import { useToast } from '@nuxt/ui/composables/useToast.js';
|
||||
|
||||
import type { FragmentType } from '~/composables/gql';
|
||||
import type {
|
||||
@@ -23,6 +22,8 @@ import {
|
||||
import { useFragment } from '~/composables/gql';
|
||||
import { NotificationImportance } from '~/composables/gql/graphql';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const { result, loading, error, refetch } = useQuery<
|
||||
WarningAndAlertNotificationsQuery,
|
||||
WarningAndAlertNotificationsQueryVariables
|
||||
@@ -88,24 +89,24 @@ const formatTimestamp = (notification: NotificationFragmentFragment) => {
|
||||
|
||||
const importanceMeta: Record<
|
||||
NotificationImportance,
|
||||
{ label: string; badge: string; icon: typeof AlertTriangle; accent: string }
|
||||
{ label: string; badge: string; icon: string; accent: string }
|
||||
> = {
|
||||
[NotificationImportance.ALERT]: {
|
||||
label: 'Alert',
|
||||
badge: 'bg-red-100 text-red-700 border border-red-300',
|
||||
icon: Octagon,
|
||||
icon: 'i-lucide-octagon',
|
||||
accent: 'text-red-600',
|
||||
},
|
||||
[NotificationImportance.WARNING]: {
|
||||
label: 'Warning',
|
||||
badge: 'bg-amber-100 text-amber-700 border border-amber-300',
|
||||
icon: AlertTriangle,
|
||||
icon: 'i-lucide-alert-triangle',
|
||||
accent: 'text-amber-600',
|
||||
},
|
||||
[NotificationImportance.INFO]: {
|
||||
label: 'Info',
|
||||
badge: 'bg-blue-100 text-blue-700 border border-blue-300',
|
||||
icon: AlertTriangle,
|
||||
icon: 'i-lucide-alert-triangle',
|
||||
accent: 'text-blue-600',
|
||||
},
|
||||
};
|
||||
@@ -156,30 +157,32 @@ onNotificationAdded(({ data }) => {
|
||||
|
||||
void refetch();
|
||||
|
||||
if (!globalThis.toast) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (notification.timestamp) {
|
||||
// Trigger the global toast in tandem with the subscription update.
|
||||
const funcMapping: Record<
|
||||
NotificationImportance,
|
||||
(typeof globalThis)['toast']['info' | 'error' | 'warning']
|
||||
'error' | 'warning' | 'info' | 'primary' | 'secondary' | 'success' | 'neutral'
|
||||
> = {
|
||||
[NotificationImportance.ALERT]: globalThis.toast.error,
|
||||
[NotificationImportance.WARNING]: globalThis.toast.warning,
|
||||
[NotificationImportance.INFO]: globalThis.toast.info,
|
||||
[NotificationImportance.ALERT]: 'error',
|
||||
[NotificationImportance.WARNING]: 'warning',
|
||||
[NotificationImportance.INFO]: 'success',
|
||||
};
|
||||
const toast = funcMapping[notification.importance];
|
||||
const color = funcMapping[notification.importance];
|
||||
const createOpener = () => ({
|
||||
label: 'Open',
|
||||
onClick: () => notification.link && window.open(notification.link, '_blank', 'noopener'),
|
||||
onClick: () => {
|
||||
if (notification.link) {
|
||||
window.location.assign(notification.link);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
requestAnimationFrame(() =>
|
||||
toast(notification.title, {
|
||||
toast.add({
|
||||
title: notification.title,
|
||||
description: notification.subject,
|
||||
action: notification.link ? createOpener() : undefined,
|
||||
color,
|
||||
actions: notification.link ? [createOpener()] : undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -190,7 +193,7 @@ onNotificationAdded(({ data }) => {
|
||||
<section class="flex flex-col gap-4 rounded-lg border border-amber-200 bg-white p-4 shadow-sm">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<AlertTriangle class="h-5 w-5 text-amber-600" aria-hidden="true" />
|
||||
<UIcon name="i-lucide-alert-triangle" class="h-5 w-5 text-amber-600" aria-hidden="true" />
|
||||
<h2 class="text-base font-semibold text-gray-900">Warnings & Alerts</h2>
|
||||
</div>
|
||||
<span v-if="!loading" class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
|
||||
@@ -211,7 +214,7 @@ onNotificationAdded(({ data }) => {
|
||||
<li v-for="{ notification, displayTimestamp, meta } in enrichedNotifications" :key="notification.id"
|
||||
class="grid gap-2 rounded-md border border-gray-200 p-3 transition hover:border-amber-300">
|
||||
<div class="flex items-start gap-3">
|
||||
<component :is="meta.icon" class="mt-0.5 h-5 w-5 flex-none" :class="meta.accent" aria-hidden="true" />
|
||||
<UIcon :name="meta.icon" class="mt-0.5 h-5 w-5 flex-none" :class="meta.accent" aria-hidden="true" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="meta.badge">
|
||||
|
||||
@@ -64,6 +64,12 @@ const mutationError = computed(() => {
|
||||
return archive.error?.message ?? deleteNotification.error?.message;
|
||||
});
|
||||
|
||||
const openLink = () => {
|
||||
if (props.link) {
|
||||
window.location.assign(props.link);
|
||||
}
|
||||
};
|
||||
|
||||
const reformattedTimestamp = computed<string>(() => {
|
||||
if (!props.timestamp) return '';
|
||||
const userLocale = navigator.language ?? 'en-US'; // Get the user's browser language (e.g., 'en-US', 'fr-FR')
|
||||
@@ -110,7 +116,13 @@ const reformattedTimestamp = computed<string>(() => {
|
||||
<p v-if="mutationError" class="text-destructive">{{ t('common.error') }}: {{ mutationError }}</p>
|
||||
|
||||
<div class="flex items-baseline justify-end gap-4">
|
||||
<UButton v-if="link" :to="link" variant="link" icon="i-heroicons-link-20-solid">
|
||||
<UButton
|
||||
v-if="link"
|
||||
variant="link"
|
||||
icon="i-heroicons-link-20-solid"
|
||||
color="neutral"
|
||||
@click="openLink"
|
||||
>
|
||||
{{ t('notifications.item.viewLink') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
|
||||
@@ -5,8 +5,6 @@ import { storeToRefs } from 'pinia';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { vInfiniteScroll } from '@vueuse/components';
|
||||
|
||||
import { CheckIcon } from '@heroicons/vue/24/solid';
|
||||
|
||||
import type { ApolloError } from '@apollo/client/errors';
|
||||
import type { NotificationImportance as Importance, NotificationType } from '~/composables/gql/graphql';
|
||||
import type { GraphQLError } from 'graphql';
|
||||
@@ -250,7 +248,7 @@ const displayErrorMessage = computed(() => {
|
||||
|
||||
<!-- Default (empty state) -->
|
||||
<div v-else class="contents">
|
||||
<CheckIcon class="text-unraid-green h-10 translate-y-3" />
|
||||
<UIcon name="i-heroicons-check-20-solid" class="text-unraid-green h-10 w-10 translate-y-3" />
|
||||
{{ noNotificationsMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
|
||||
import { useToast } from '@nuxt/ui/composables/useToast.js';
|
||||
|
||||
import ConfirmDialog from '~/components/ConfirmDialog.vue';
|
||||
import {
|
||||
@@ -22,6 +23,8 @@ import { useFragment } from '~/composables/gql';
|
||||
import { NotificationImportance as Importance, NotificationType } from '~/composables/gql/graphql';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const { mutate: archiveAll, loading: loadingArchiveAll } = useMutation(archiveAllNotifications);
|
||||
const { mutate: deleteArchives, loading: loadingDeleteAll } = useMutation(deleteArchivedNotifications);
|
||||
const { mutate: recalculateOverview } = useMutation(resetOverview);
|
||||
@@ -88,25 +91,27 @@ onNotificationAdded(({ data }) => {
|
||||
if (notif.timestamp) {
|
||||
latestNotificationTimestamp.value = notif.timestamp;
|
||||
}
|
||||
if (!globalThis.toast) {
|
||||
return;
|
||||
}
|
||||
|
||||
const funcMapping: Record<Importance, (typeof globalThis)['toast']['info' | 'error' | 'warning']> = {
|
||||
[Importance.ALERT]: globalThis.toast.error,
|
||||
[Importance.WARNING]: globalThis.toast.warning,
|
||||
[Importance.INFO]: globalThis.toast.info,
|
||||
const funcMapping: Record<
|
||||
Importance,
|
||||
'error' | 'warning' | 'info' | 'primary' | 'secondary' | 'success' | 'neutral'
|
||||
> = {
|
||||
[Importance.ALERT]: 'error',
|
||||
[Importance.WARNING]: 'warning',
|
||||
[Importance.INFO]: 'success',
|
||||
};
|
||||
const toast = funcMapping[notif.importance];
|
||||
const color = funcMapping[notif.importance];
|
||||
const createOpener = () => ({
|
||||
label: t('notifications.sidebar.toastOpen'),
|
||||
onClick: () => window.location.assign(notif.link as string),
|
||||
});
|
||||
|
||||
requestAnimationFrame(() =>
|
||||
toast(notif.title, {
|
||||
toast.add({
|
||||
title: notif.title,
|
||||
description: notif.subject,
|
||||
action: notif.link ? createOpener() : undefined,
|
||||
color,
|
||||
actions: notif.link ? [createOpener()] : undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Toaster } from '@unraid/ui';
|
||||
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
// Get dark mode from theme store
|
||||
const theme = computed(() => (themeStore.darkMode ? 'dark' : 'light'));
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
position?:
|
||||
| 'top-center'
|
||||
| 'top-right'
|
||||
| 'top-left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right'
|
||||
| 'bottom-left';
|
||||
}>(),
|
||||
{
|
||||
position: 'top-right',
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toaster rich-colors close-button :position="position" :theme="theme" />
|
||||
</template>
|
||||
@@ -134,11 +134,6 @@ export const componentMappings: ComponentMapping[] = [
|
||||
selector: 'unraid-color-switcher',
|
||||
appId: 'color-switcher',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('@/components/UnraidToaster.vue')),
|
||||
selector: ['unraid-toaster', 'uui-toaster'],
|
||||
appId: 'toaster',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('../UpdateOs/TestUpdateModal.standalone.vue')),
|
||||
selector: 'unraid-test-update-modal',
|
||||
|
||||
@@ -12,6 +12,8 @@ import { createI18nInstance, ensureLocale, getWindowLocale } from '~/helpers/i18
|
||||
// Import Pinia for use in Vue apps
|
||||
import { globalPinia } from '~/store/globalPinia';
|
||||
import { ensureUnapiScope, ensureUnapiScopeForSelectors, observeUnapiScope } from '~/utils/unapiScope';
|
||||
// Import the app config to pass runtime settings (like toaster position)
|
||||
import appConfig from '../../../app.config';
|
||||
|
||||
// Ensure Apollo client is singleton
|
||||
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
|
||||
@@ -244,6 +246,7 @@ export async function mountUnifiedApp() {
|
||||
UApp,
|
||||
{
|
||||
portal: portalTarget,
|
||||
toaster: appConfig.ui.toaster,
|
||||
},
|
||||
{
|
||||
default: () => h(component, props),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useClipboard } from '@vueuse/core';
|
||||
*/
|
||||
export function useClipboardWithToast() {
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const toast = useToast();
|
||||
|
||||
/**
|
||||
* Copy text and show toast
|
||||
@@ -19,10 +20,7 @@ export function useClipboardWithToast() {
|
||||
if (isSupported.value) {
|
||||
try {
|
||||
await copy(text);
|
||||
// Use global toast if available
|
||||
if (globalThis.toast) {
|
||||
globalThis.toast.success(successMessage);
|
||||
}
|
||||
toast.add({ title: successMessage, color: 'success' });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
@@ -43,9 +41,7 @@ export function useClipboardWithToast() {
|
||||
document.body.removeChild(textarea);
|
||||
|
||||
if (success) {
|
||||
if (globalThis.toast) {
|
||||
globalThis.toast.success(successMessage);
|
||||
}
|
||||
toast.add({ title: successMessage, color: 'success' });
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -53,9 +49,7 @@ export function useClipboardWithToast() {
|
||||
}
|
||||
|
||||
// Both methods failed
|
||||
if (globalThis.toast) {
|
||||
globalThis.toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
toast.add({ title: 'Failed to copy to clipboard', color: 'error' });
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user