refactor: update notification components to use UIcon and UButton for improved consistency

- replaced Heroicons components with UIcon for better integration
- refactored Sidebar.vue to utilize USlideover and UButton for a cleaner UI
- removed unused imports and styles in main.css for better maintainability

NOTES:
- had to change main.css variables for it to work properly. Need to make sure this doesn't ruin other people's code.
- still needs to be further refactored to align with existing ui variables
This commit is contained in:
Ajit Mehrotra
2025-11-25 16:39:34 -05:00
parent 4492cf484c
commit 51d7b05858
4 changed files with 192 additions and 171 deletions
+2 -2
View File
@@ -52,8 +52,8 @@
font: inherit;
min-width: revert;
text-transform: revert;
background: revert;
color: revert;
/* background: revert; */
/* color: revert; */
}
/* Reset text inputs so Nuxt UI field styles render correctly */
@@ -1,11 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import { BellIcon, ExclamationTriangleIcon, ShieldExclamationIcon } from '@heroicons/vue/24/solid';
import { cn } from '@unraid/ui';
import type { OverviewQuery } from '~/composables/gql/graphql';
import type { Component } from 'vue';
import { NotificationImportance as Importance } from '~/composables/gql/graphql';
@@ -27,16 +25,16 @@ const indicatorLevel = computed(() => {
}
});
const icon = computed<{ component: Component; color: string } | null>(() => {
const icon = computed<{ name: string; color: string } | null>(() => {
switch (indicatorLevel.value) {
case Importance.WARNING:
return {
component: ExclamationTriangleIcon,
name: 'i-heroicons-exclamation-triangle-20-solid',
color: 'text-yellow-500 translate-y-0.5',
};
case Importance.ALERT:
return {
component: ShieldExclamationIcon,
name: 'i-heroicons-shield-exclamation-20-solid',
color: 'text-unraid-red',
};
}
@@ -46,14 +44,14 @@ const icon = computed<{ component: Component; color: string } | null>(() => {
<template>
<div class="relative flex items-center justify-center">
<BellIcon class="text-header-text-primary h-6 w-6" />
<UIcon name="i-heroicons-bell-20-solid" class="text-header-text-primary h-6 w-6" />
<div
v-if="!seen && indicatorLevel === 'UNREAD'"
class="border-muted bg-unraid-green absolute top-0 right-0 size-2.5 rounded-full border"
/>
<component
:is="icon.component"
<UIcon
v-else-if="!seen && icon && indicatorLevel"
:name="icon.name"
:class="cn('absolute -top-1 -right-1 size-4 rounded-full', icon.color)"
/>
</div>
+23 -35
View File
@@ -4,19 +4,9 @@ import { useI18n } from 'vue-i18n';
import { useMutation } from '@vue/apollo-composable';
import { computedAsync } from '@vueuse/core';
import {
ArchiveBoxIcon,
CheckBadgeIcon,
ExclamationTriangleIcon,
LinkIcon,
ShieldExclamationIcon,
TrashIcon,
} from '@heroicons/vue/24/solid';
import { Button } from '@unraid/ui';
import { Markdown } from '@/helpers/markdown';
import type { NotificationFragmentFragment } from '~/composables/gql/graphql';
import type { Component } from 'vue';
import {
archiveNotification as archiveMutation,
@@ -37,21 +27,21 @@ const descriptionMarkup = computedAsync(async () => {
}
}, '');
const icon = computed<{ component: Component; color: string } | null>(() => {
const icon = computed<{ name: string; color: string } | null>(() => {
switch (props.importance) {
case 'INFO':
return {
component: CheckBadgeIcon,
name: 'i-heroicons-check-badge-20-solid',
color: 'text-unraid-green',
};
case 'WARNING':
return {
component: ExclamationTriangleIcon,
name: 'i-heroicons-exclamation-triangle-20-solid',
color: 'text-yellow-accent',
};
case 'ALERT':
return {
component: ShieldExclamationIcon,
name: 'i-heroicons-shield-exclamation-20-solid',
color: 'text-unraid-red',
};
}
@@ -97,12 +87,7 @@ const reformattedTimestamp = computed<string>(() => {
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
:is="icon.component"
v-if="icon"
class="size-5 shrink-0 translate-y-1"
:class="icon.color"
/>
<UIcon v-if="icon" :name="icon.name" class="size-5 shrink-0 translate-y-1" :class="icon.color" />
<span class="flex-1 truncate" :title="title">{{ title }}</span>
</h3>
@@ -125,30 +110,33 @@ const reformattedTimestamp = computed<string>(() => {
<p v-if="mutationError" class="text-red-600">{{ t('common.error') }}: {{ mutationError }}</p>
<div class="flex items-baseline justify-end gap-4">
<a
<UButton
v-if="link"
:href="link"
class="text-primary inline-flex items-center justify-center text-sm font-medium hover:underline focus:underline"
:to="link"
variant="link"
class="text-primary inline-flex items-center justify-center p-0 text-sm font-medium hover:underline focus:underline"
icon="i-heroicons-link-20-solid"
>
<LinkIcon class="mr-2 size-4" />
<span class="text-sm">{{ t('notifications.item.viewLink') }}</span>
</a>
<Button
</UButton>
<UButton
v-if="type === NotificationType.UNREAD"
:disabled="archive.loading"
@click="() => archive.mutate({ id: props.id })"
:loading="archive.loading"
icon="i-heroicons-archive-box-20-solid"
class="!bg-none"
@click="() => void archive.mutate({ id: props.id })"
>
<ArchiveBoxIcon class="mr-2 size-4" />
<span class="text-sm">{{ t('notifications.item.archive') }}</span>
</Button>
<Button
</UButton>
<UButton
v-if="type === NotificationType.ARCHIVE"
:disabled="deleteNotification.loading"
@click="() => deleteNotification.mutate({ id: props.id, type: props.type })"
:loading="deleteNotification.loading"
icon="i-heroicons-trash-20-solid"
class="!bg-none"
@click="() => void deleteNotification.mutate({ id: props.id, type: props.type })"
>
<TrashIcon class="mr-2 size-4" />
<span class="text-sm">{{ t('notifications.item.delete') }}</span>
</Button>
</UButton>
</div>
</div>
</template>
+161 -126
View File
@@ -3,24 +3,6 @@ import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
import {
Button,
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@unraid/ui';
import { Settings } from 'lucide-vue-next';
import ConfirmDialog from '~/components/ConfirmDialog.vue';
import {
archiveAllNotifications,
@@ -122,6 +104,10 @@ onNotificationAdded(({ data }) => {
);
});
const openSettings = () => {
window.location.assign('/Settings/Notifications');
};
const overview = computed(() => {
if (!result.value) {
return;
@@ -139,122 +125,171 @@ const readArchivedCount = computed(() => {
const prepareToViewNotifications = () => {
void recalculateOverview();
};
const isOpen = ref(false);
const activeTab = ref<'unread' | 'archived'>('unread');
const tabs = computed(() => [
{
id: 'unread',
label: t('notifications.sidebar.unreadTab'),
count: overview.value?.unread.total,
},
{
id: 'archived',
label: t('notifications.sidebar.archivedTab'),
count: readArchivedCount.value,
},
]);
</script>
<!-- totally scuffed but we use: !bg-transparent, !bg-none, hover:text-current to override conflicting webgui/api styles -->
<template>
<Sheet>
<SheetTrigger as-child>
<Button variant="header" size="header" @click="prepareToViewNotifications">
<span class="sr-only">{{ t('notifications.sidebar.openButtonSr') }}</span>
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
</Button>
</SheetTrigger>
<SheetContent
side="right"
class="flex h-screen max-h-screen min-h-screen w-full max-w-screen flex-col gap-5 px-0 pb-0 sm:max-w-[540px]"
<div>
<UButton
variant="ghost"
color="neutral"
class="!bg-transparent"
@click="
() => {
isOpen = true;
prepareToViewNotifications();
}
"
>
<div class="relative flex h-full w-full flex-col">
<SheetHeader class="ml-1 items-baseline gap-1 px-3 pb-2">
<SheetTitle class="text-2xl">{{ t('notifications.sidebar.title') }}</SheetTitle>
</SheetHeader>
<Tabs
default-value="unread"
class="flex min-h-0 flex-1 flex-col"
:aria-label="t('notifications.sidebar.statusTabsAria')"
>
<div class="flex flex-row flex-wrap items-center justify-between gap-3 px-3">
<TabsList class="flex" :aria-label="t('notifications.sidebar.statusTabsListAria')">
<TabsTrigger value="unread" as-child>
<Button variant="ghost" size="sm" class="inline-flex items-center gap-1 px-3 py-1">
<span>{{ t('notifications.sidebar.unreadTab') }}</span>
<span v-if="overview" class="font-normal">({{ overview.unread.total }})</span>
</Button>
</TabsTrigger>
<TabsTrigger value="archived" as-child>
<Button variant="ghost" size="sm" class="inline-flex items-center gap-1 px-3 py-1">
<span>{{ t('notifications.sidebar.archivedTab') }}</span>
<span v-if="overview" class="font-normal">({{ readArchivedCount }})</span>
</Button>
</TabsTrigger>
</TabsList>
<TabsContent value="unread" class="flex-col items-end">
<Button
:disabled="loadingArchiveAll"
variant="link"
size="sm"
class="text-foreground hover:text-destructive transition-none"
@click="confirmAndArchiveAll"
>
{{ t('notifications.sidebar.archiveAllAction') }}
</Button>
</TabsContent>
<TabsContent value="archived" class="flex-col items-end">
<Button
:disabled="loadingDeleteAll"
variant="link"
size="sm"
class="text-foreground hover:text-destructive transition-none"
@click="confirmAndDeleteArchives"
>
{{ t('notifications.sidebar.deleteAllAction') }}
</Button>
</TabsContent>
</div>
<span class="sr-only">{{ t('notifications.sidebar.openButtonSr') }}</span>
<NotificationsIndicator :overview="overview" :seen="haveSeenNotifications" />
</UButton>
<div class="mt-3 flex items-start justify-between gap-3 px-3">
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div
class="border-border/60 bg-muted/60 flex flex-wrap items-center gap-1 rounded-xl border p-1"
role="group"
>
<Button
v-for="option in filterOptions"
:key="option.value ?? 'all'"
variant="ghost"
size="sm"
class="h-8 rounded-lg border border-transparent px-3 text-xs font-medium transition-colors"
:class="
importance === option.value
? 'border-border bg-background text-foreground'
: 'text-muted-foreground hover:border-border/60 hover:bg-muted/40 hover:text-foreground'
"
:aria-pressed="importance === option.value"
@click="importance = option.value"
<USlideover
v-model:open="isOpen"
side="right"
:title="t('notifications.sidebar.title')"
:close="{
color: 'neutral',
variant: 'ghost',
class: 'rounded-md !bg-none hover:text-current',
}"
:ui="{
content: 'w-screen max-w-screen sm:max-w-[540px]',
title: 'text-3xl font-normal',
}"
>
<template #body>
<div class="flex h-full flex-col">
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Controls Area -->
<div class="flex flex-col gap-3 px-0 py-3">
<!-- Tabs & Action Button Row -->
<div class="flex items-center justify-between gap-3">
<!-- Custom Pill Tabs -->
<div class="dark:bg-muted flex shrink-0 gap-1 rounded-lg bg-gray-100 p-2">
<UButton
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id as 'unread' | 'archived'"
:color="activeTab === tab.id ? 'primary' : 'neutral'"
:variant="activeTab === tab.id ? 'solid' : 'ghost'"
size="sm"
class="!bg-none transition-colors"
:class="[
activeTab === tab.id
? 'text-white'
: 'text-gray-500 hover:bg-transparent hover:text-gray-700 dark:text-gray-400 dark:hover:bg-transparent dark:hover:text-gray-200',
]"
>
<span>{{ tab.label }}</span>
<span v-if="tab.count !== undefined" class="opacity-90">({{ tab.count }})</span>
</UButton>
</div>
<!-- Action Button -->
<UButton
v-if="activeTab === 'unread'"
:disabled="loadingArchiveAll"
variant="link"
color="neutral"
class="hover:text-primary h-auto !bg-none p-0 font-normal hover:underline"
@click="confirmAndArchiveAll"
>
{{ option.label }}
</Button>
{{ t('notifications.sidebar.archiveAllAction') }}
</UButton>
<UButton
v-else
:disabled="loadingDeleteAll"
variant="link"
color="neutral"
class="text-foreground hover:text-destructive h-auto !bg-none p-0 font-normal transition-colors hover:underline"
@click="confirmAndDeleteArchives"
>
{{ t('notifications.sidebar.deleteAllAction') }}
</UButton>
</div>
<!-- Filters & Settings Row -->
<div class="flex items-center justify-between gap-3">
<!-- Filter Button Group -->
<div
class="dark:bg-muted flex items-center gap-1 overflow-x-auto rounded-lg bg-gray-100 p-1"
>
<UButton
v-for="option in filterOptions"
:key="option.value ?? 'all'"
@click="importance = option.value"
color="neutral"
variant="ghost"
size="xs"
class="!bg-none whitespace-nowrap transition-colors"
:class="[
importance === option.value
? 'dark:bg-accented bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 hover:bg-white hover:text-gray-900 dark:text-white dark:ring-gray-600 dark:hover:bg-gray-700 dark:hover:text-white'
: 'text-muted-foreground hover:text-foreground hover:bg-transparent hover:ring-1 hover:ring-gray-300 dark:hover:ring-gray-600',
]"
>
{{ option.label }}
</UButton>
</div>
<!-- Settings Icon -->
<UTooltip
:delay-duration="0"
:content="{
align: 'center',
side: 'top',
sideOffset: 8,
}"
:text="t('notifications.sidebar.editSettingsTooltip')"
>
<UButton
variant="ghost"
color="neutral"
icon="i-heroicons-cog-6-tooth-20-solid"
class="h-8 w-8 !bg-none hover:text-current"
@click="openSettings"
/>
</UTooltip>
</div>
</div>
<div class="shrink-0">
<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>{{ t('notifications.sidebar.editSettingsTooltip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Notifications List Content -->
<div class="flex flex-1 flex-col overflow-hidden">
<NotificationsList
v-if="activeTab === 'unread'"
:importance="importance"
:type="NotificationType.UNREAD"
class="flex-1"
/>
<NotificationsList
v-else
:importance="importance"
:type="NotificationType.ARCHIVE"
class="flex-1"
/>
</div>
</div>
<TabsContent value="unread" class="min-h-0 flex-1 flex-col">
<NotificationsList :importance="importance" :type="NotificationType.UNREAD" />
</TabsContent>
<TabsContent value="archived" class="min-h-0 flex-1 flex-col">
<NotificationsList :importance="importance" :type="NotificationType.ARCHIVE" />
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
<!-- Global Confirm Dialog -->
<ConfirmDialog />
</div>
</template>
</USlideover>
<!-- Global Confirm Dialog -->
<ConfirmDialog />
</div>
</template>