mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
feat: responsive notifications
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
import { useMutation } from '@vue/apollo-composable';
|
||||
import type { NotificationFragmentFragment } from '~/composables/gql/graphql';
|
||||
import { NotificationType } from '~/composables/gql/graphql';
|
||||
import { format } from 'date-fns';
|
||||
import { enGB, enUS } from 'date-fns/locale';
|
||||
import {
|
||||
archiveNotification as archiveMutation,
|
||||
deleteNotification as deleteMutation,
|
||||
@@ -63,13 +65,26 @@ const deleteNotification = reactive(
|
||||
const mutationError = computed(() => {
|
||||
return archive.error?.message ?? deleteNotification.error?.message;
|
||||
});
|
||||
|
||||
const reformattedTimestamp = computed(() => {
|
||||
const userLocale = navigator.language ?? 'en-US'; // Get the user's browser language (e.g., 'en-US', 'fr-FR')
|
||||
|
||||
const reformattedDate = new Intl.DateTimeFormat(userLocale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12:
|
||||
['AM', 'PM'].includes(props.formattedTimestamp ?? 'AM')
|
||||
}).format(new Date(props.timestamp ?? new Date()));
|
||||
return reformattedDate;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- fixed width hack ensures alignment with other elements regardless of scrollbar presence or width -->
|
||||
<div class="group/item relative py-5 flex flex-col gap-2 text-base w-[487px]">
|
||||
<header class="w-full flex flex-row items-baseline justify-between gap-2 -translate-y-1">
|
||||
<h3 class="tracking-normal flex flex-row items-baseline gap-2 uppercase font-bold">
|
||||
<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">
|
||||
<h3 class="tracking-normal flex flex-row items-baseline gap-2 uppercase font-bold overflow-x-hidden">
|
||||
<!-- the `translate` compensates for extra space added by the `svg` element when rendered -->
|
||||
<component
|
||||
:is="icon.component"
|
||||
@@ -77,11 +92,14 @@ const mutationError = computed(() => {
|
||||
class="size-5 shrink-0 translate-y-1"
|
||||
:class="icon.color"
|
||||
/>
|
||||
<span>{{ title }}</span>
|
||||
<span class="truncate flex-1" :title="title">{{ title }}</span>
|
||||
</h3>
|
||||
|
||||
<div class="shrink-0 flex flex-row items-baseline justify-end gap-2 mt-1">
|
||||
<p class="text-gray-500 text-sm">{{ formattedTimestamp }}</p>
|
||||
<div
|
||||
class="shrink-0 flex flex-row items-baseline justify-end gap-2 mt-1"
|
||||
:title="formattedTimestamp ?? reformattedTimestamp"
|
||||
>
|
||||
<p class="text-gray-500 text-sm">{{ reformattedTimestamp }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -89,7 +107,7 @@ const mutationError = computed(() => {
|
||||
{{ subject }}
|
||||
</h4>
|
||||
|
||||
<div class="w-full flex flex-row items-center justify-between gap-2">
|
||||
<div class="flex flex-row items-center justify-between gap-2">
|
||||
<div class="" v-html="descriptionMarkup" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,11 +67,10 @@ async function onLoadMore() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The horizontal padding here adjusts for the scrollbar offset -->
|
||||
<div
|
||||
v-if="notifications?.length > 0"
|
||||
v-infinite-scroll="[onLoadMore, { canLoadMore: () => canLoadMore }]"
|
||||
class="divide-y divide-gray-200 overflow-y-auto h-full pl-7"
|
||||
class="divide-y divide-gray-200 px-7 flex flex-col overflow-y-scroll flex-1 min-h-0"
|
||||
>
|
||||
<NotificationsItem
|
||||
v-for="notification in notifications"
|
||||
@@ -81,6 +80,9 @@ async function onLoadMore() {
|
||||
<div v-if="loading" class="py-5 grid place-content-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-if="!canLoadMore" class="py-5 grid place-content-center text-gray-500">
|
||||
You've reached the end...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoadingError v-else :loading="loading" :error="offlineError ?? error" @retry="refetch">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/shadcn/button';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/shadcn/sheet';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import { Button } from '@/components/shadcn/button';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- false positive :(
|
||||
import { Importance, NotificationType } from '~/composables/gql/graphql';
|
||||
import {
|
||||
@@ -47,86 +47,80 @@ const overview = computed(() => {
|
||||
<span class="sr-only">Notifications</span>
|
||||
<NotificationsIndicator :overview="overview" />
|
||||
</SheetTrigger>
|
||||
|
||||
<!-- We remove the horizontal padding from the container to keep the NotificationList's scrollbar in the right place -->
|
||||
<SheetContent
|
||||
:to="teleportTarget"
|
||||
class="w-full max-w-[100vw] sm:max-w-[540px] h-screen px-0"
|
||||
class="w-full max-w-[100vw] sm:max-w-[540px] max-h-screen h-screen min-h-screen px-0 flex flex-col gap-5 pb-0"
|
||||
>
|
||||
<div class="flex flex-col h-full gap-5">
|
||||
<SheetHeader class="ml-1 px-6 flex items-baseline gap-1">
|
||||
<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>
|
||||
<div class="relative flex flex-col h-full w-full">
|
||||
<SheetHeader class="ml-1 px-6 items-baseline gap-1">
|
||||
<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">
|
||||
<div class="flex flex-row justify-between items-center flex-wrap gap-5 px-6">
|
||||
<TabsList class="flex">
|
||||
<TabsTrigger value="unread">
|
||||
Unread <span v-if="overview">({{ overview.unread.total }})</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="archived">
|
||||
Archived <span v-if="overview">({{ overview.archive.total }})</span>
|
||||
</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"
|
||||
>
|
||||
Archive All
|
||||
</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"
|
||||
>
|
||||
Delete All
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<!-- min-h-0 prevents the flex container from expanding beyond its containing bounds. -->
|
||||
<!-- this is necessary because flex items have a default min-height: auto, -->
|
||||
<!-- which means they won't shrink below the height of their content, even if you use flex-1 or other flex properties. -->
|
||||
<Tabs default-value="unread" class="flex-1 flex flex-col min-h-0" activation-mode="manual">
|
||||
<div class="flex flex-row justify-between items-center flex-wrap gap-5 px-6">
|
||||
<TabsList class="ml-[1px]">
|
||||
<TabsTrigger value="unread">
|
||||
Unread <span v-if="overview">({{ overview.unread.total }})</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="archived">
|
||||
Archived <span v-if="overview">({{ overview.archive.total }})</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="unread">
|
||||
<Button
|
||||
:disabled="loadingArchiveAll"
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="text-foreground hover:text-destructive transition-none"
|
||||
@click="confirmAndArchiveAll"
|
||||
>
|
||||
Archive All
|
||||
</Button>
|
||||
</TabsContent>
|
||||
<TabsContent value="archived">
|
||||
<Button
|
||||
:disabled="loadingDeleteAll"
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="text-foreground hover:text-destructive transition-none"
|
||||
@click="confirmAndDeleteArchives"
|
||||
>
|
||||
Delete All
|
||||
</Button>
|
||||
</TabsContent>
|
||||
<Select
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
importance = val === 'all' ? undefined : (val as Importance);
|
||||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger class="h-auto">
|
||||
<SelectValue class="text-gray-400 leading-6" placeholder="Filter By" />
|
||||
</SelectTrigger>
|
||||
<SelectContent :to="teleportTarget">
|
||||
<SelectGroup>
|
||||
<SelectLabel>Notification Types</SelectLabel>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem :value="Importance.Alert"> Alert </SelectItem>
|
||||
<SelectItem :value="Importance.Info">Info</SelectItem>
|
||||
<SelectItem :value="Importance.Warning">Warning</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
importance = val === 'all' ? undefined : (val as Importance);
|
||||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger class="h-auto">
|
||||
<SelectValue class="text-gray-400 leading-6" placeholder="Filter By" />
|
||||
</SelectTrigger>
|
||||
<SelectContent :to="teleportTarget">
|
||||
<SelectGroup>
|
||||
<SelectLabel>Notification Types</SelectLabel>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem :value="Importance.Alert"> Alert </SelectItem>
|
||||
<SelectItem :value="Importance.Info">Info</SelectItem>
|
||||
<SelectItem :value="Importance.Warning">Warning</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<TabsContent value="unread" class="flex-col flex-1 min-h-0">
|
||||
<NotificationsList :importance="importance" :type="NotificationType.Unread" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="unread" class="flex-1 min-h-0 mt-5">
|
||||
<NotificationsList :importance="importance" :type="NotificationType.Unread" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="archived" class="flex-1 min-h-0 mt-5">
|
||||
<NotificationsList :importance="importance" :type="NotificationType.Archive" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="archived" class="flex-col flex-1 min-h-0">
|
||||
<NotificationsList :importance="importance" :type="NotificationType.Archive" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -5,10 +5,22 @@ const props = defineProps<DialogRootProps & { class?: string }>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
const initialViewport = ref(document.querySelector('meta[name="viewport"]')?.getAttribute('content') ?? 'width=1300');
|
||||
const openListener = (opened: boolean) => {
|
||||
/**
|
||||
* Update meta tags for the root page when oepned
|
||||
*/
|
||||
|
||||
if (opened) {
|
||||
document.querySelector('meta[name="viewport"]')?.setAttribute('content', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0')
|
||||
} else {
|
||||
document.querySelector('meta[name="viewport"]')?.setAttribute('content', initialViewport.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="forwarded">
|
||||
<DialogRoot v-bind="forwarded" @update:open="openListener">
|
||||
<slot />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
@@ -35,6 +35,11 @@ const delegatedProps = computed(() => {
|
||||
return delegated;
|
||||
});
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
|
||||
watch(forwarded.value, (newval) => {
|
||||
console.log(newval);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import { TabsContent, type TabsContentProps } from 'radix-vue'
|
||||
import { cn } from '~/components/shadcn/utils'
|
||||
import { cn } from '~/components/shadcn/utils';
|
||||
import { TabsContent, type TabsContentProps } from 'radix-vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
const { class: _, ...delegated } = props;
|
||||
return delegated;
|
||||
});
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsContent
|
||||
:class="cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', props.class)"
|
||||
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
class="data-[state=active]:flex data-[state=inactive]:hidden"
|
||||
>
|
||||
<slot />
|
||||
</TabsContent>
|
||||
|
||||
6109
web/package-lock.json
generated
6109
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -31,60 +31,62 @@
|
||||
"@graphql-codegen/client-preset": "^4.5.1",
|
||||
"@graphql-codegen/introspection": "^4.0.3",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
|
||||
"@nuxt/devtools": "^1.3.1",
|
||||
"@nuxt/eslint": "^0.3.12",
|
||||
"@nuxtjs/tailwindcss": "^6.12.1",
|
||||
"@nuxt/devtools": "^1.6.4",
|
||||
"@nuxt/eslint": "^0.7.3",
|
||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||
"@rollup/plugin-strip": "^3.0.4",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/eslint-config-prettier": "^6.11.3",
|
||||
"@types/node": "^18",
|
||||
"@types/node": "^22",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@vue/apollo-util": "^4.0.0-beta.6",
|
||||
"@vueuse/core": "^10.11.1",
|
||||
"@vueuse/nuxt": "^10.9.0",
|
||||
"@vueuse/core": "^12.0.0",
|
||||
"@vueuse/nuxt": "^12.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nuxt": "^3.11.2",
|
||||
"nuxt": "^3.14.1592",
|
||||
"nuxt-custom-elements": "2.0.0-beta.18",
|
||||
"prettier": "3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||
"shadcn-nuxt": "^0.10.4",
|
||||
"prettier": "3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"shadcn-nuxt": "^0.11.3",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"terser": "^5.31.0",
|
||||
"terser": "^5.37.0",
|
||||
"vite-plugin-remove-console": "^2.2.0",
|
||||
"vitest": "^2.1.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.10.4",
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@apollo/client": "^3.12.3",
|
||||
"@floating-ui/dom": "^1.6.12",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"@floating-ui/vue": "^1.1.5",
|
||||
"@headlessui/vue": "^1.7.22",
|
||||
"@heroicons/vue": "^2.1.3",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@vue/apollo-composable": "^4.0.2",
|
||||
"@vueuse/components": "^10.9.0",
|
||||
"@vueuse/integrations": "^10.9.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@pinia/nuxt": "^0.9.0",
|
||||
"@vue/apollo-composable": "^4.2.1",
|
||||
"@vueuse/components": "^12.0.0",
|
||||
"@vueuse/integrations": "^12.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"focus-trap": "^7.5.4",
|
||||
"graphql": "^16.8.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"focus-trap": "^7.6.2",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"hex-to-rgba": "^2.0.1",
|
||||
"isomorphic-dompurify": "^2.16.0",
|
||||
"lucide-vue-next": "^0.445.0",
|
||||
"marked": "^12.0.2",
|
||||
"marked-base-url": "^1.1.3",
|
||||
"radix-vue": "^1.9.6",
|
||||
"semver": "^7.6.2",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"vue-i18n": "^9.14.1",
|
||||
"wretch": "^2.8.1"
|
||||
"isomorphic-dompurify": "^2.19.0",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"marked": "^15.0.3",
|
||||
"marked-base-url": "^1.1.6",
|
||||
"radix-vue": "^1.9.11",
|
||||
"semver": "^7.6.3",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"vue-i18n": "^10.0.5",
|
||||
"wretch": "^2.11.0"
|
||||
},
|
||||
"overrides": {
|
||||
"vue": "latest",
|
||||
|
||||
@@ -13,6 +13,13 @@ onBeforeMount(() => {
|
||||
registerEntry('UnraidComponents');
|
||||
});
|
||||
|
||||
useHead({
|
||||
meta: [
|
||||
{ name: 'viewport',
|
||||
content: 'width=1300', }
|
||||
]
|
||||
})
|
||||
|
||||
const valueToMakeCallback = ref<SendPayloads | undefined>();
|
||||
const callbackDestination = ref<string>('');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user