Files
chaos-api/web/default/src/components/notification-popover.tsx
T
t0ng7u 583da45296 refactor(ui): Improve usage log filter responsiveness and mobile UX
Refactor the usage log filter toolbar into a shared reusable component for common, drawing, and task logs. Optimize desktop filters with a responsive grid, move secondary filters into a mobile drawer, standardize filter typography, remove redundant filter icons, and add the missing i18n translations for the new drawer description.
2026-05-25 05:35:44 +08:00

355 lines
9.4 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { TFunction } from 'i18next'
import { Bell, Megaphone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getAnnouncementColorClass } from '@/lib/colors'
import { formatDateTimeObject } from '@/lib/time'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Markdown } from '@/components/ui/markdown'
import {
Popover,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface AnnouncementItem {
type?: string
content?: string
extra?: string
publishDate?: string | Date
}
interface NotificationPopoverProps {
open: boolean
onOpenChange: (open: boolean) => void
unreadCount: number
activeTab: 'notice' | 'announcements'
onTabChange: (tab: 'notice' | 'announcements') => void
notice: string
announcements: AnnouncementItem[]
loading: boolean
className?: string
}
/**
* Get relative time string from a date
*/
function getRelativeTime(publishDate: string | Date, t: TFunction): string {
if (!publishDate) return ''
const now = new Date()
const pubDate = new Date(publishDate)
// If invalid date, return original string
if (isNaN(pubDate.getTime()))
return typeof publishDate === 'string' ? publishDate : ''
const diffMs = now.getTime() - pubDate.getTime()
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
const diffWeeks = Math.floor(diffDays / 7)
const diffMonths = Math.floor(diffDays / 30)
const diffYears = Math.floor(diffDays / 365)
// If future time, show specific date
if (diffMs < 0) return formatDateTimeObject(pubDate)
// Return relative time based on difference
if (diffSeconds < 60) return t('Just now')
if (diffMinutes < 60)
return diffMinutes === 1
? t('1 minute ago')
: t('{{count}} minutes ago', { count: diffMinutes })
if (diffHours < 24)
return diffHours === 1
? t('1 hour ago')
: t('{{count}} hours ago', { count: diffHours })
if (diffDays < 7)
return diffDays === 1
? t('1 day ago')
: t('{{count}} days ago', { count: diffDays })
if (diffWeeks < 4)
return diffWeeks === 1
? t('1 week ago')
: t('{{count}} weeks ago', { count: diffWeeks })
if (diffMonths < 12)
return diffMonths === 1
? t('1 month ago')
: t('{{count}} months ago', { count: diffMonths })
if (diffYears < 2) return t('1 year ago')
// Over 2 years, show specific date
return formatDateTimeObject(pubDate)
}
/**
* Announcement status dot indicator
*/
function AnnouncementDot({ type }: { type?: string }) {
return (
<span
className={cn(
'mt-1.5 inline-block size-2 shrink-0 rounded-full',
getAnnouncementColorClass(type)
)}
/>
)
}
/**
* Empty state component
*/
function EmptyState({
icon,
title,
description,
}: {
icon: React.ReactNode
title: string
description?: string
}) {
return (
<Empty className='min-h-48 border-0 p-4'>
<EmptyHeader>
<EmptyMedia variant='icon'>{icon}</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
{description ? (
<EmptyDescription>{description}</EmptyDescription>
) : null}
</EmptyHeader>
</Empty>
)
}
/**
* Notice tab content
*/
function NoticeContent({
notice,
loading,
t,
}: {
notice: string
loading: boolean
t: TFunction
}) {
if (loading) {
return (
<EmptyState
icon={<Bell />}
title={t('Loading...')}
description={t('Latest platform updates and notices')}
/>
)
}
if (!notice) {
return (
<EmptyState icon={<Bell />} title={t('No announcements at this time')} />
)
}
return (
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
<Markdown>{notice}</Markdown>
</ScrollArea>
)
}
/**
* Announcements tab content
*/
function AnnouncementsContent({
announcements,
loading,
t,
}: {
announcements: AnnouncementItem[]
loading: boolean
t: TFunction
}) {
if (loading) {
return (
<EmptyState
icon={<Megaphone />}
title={t('Loading...')}
description={t('Latest platform updates and notices')}
/>
)
}
if (announcements.length === 0) {
return (
<EmptyState icon={<Megaphone />} title={t('No system announcements')} />
)
}
return (
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
<div className='flex flex-col'>
{announcements.map((item, idx) => {
const publishDate = item.publishDate
? new Date(item.publishDate)
: null
const relativeTime = publishDate
? getRelativeTime(publishDate, t)
: ''
const absoluteTime = publishDate
? formatDateTimeObject(publishDate)
: ''
return (
<div key={idx}>
<div className='py-3'>
<div className='flex items-start gap-3'>
<AnnouncementDot type={item.type} />
<div className='flex min-w-0 flex-1 flex-col gap-2'>
<div className='text-sm'>
<Markdown>{item.content || ''}</Markdown>
</div>
{item.extra ? (
<div className='text-muted-foreground text-xs'>
<Markdown>{item.extra}</Markdown>
</div>
) : null}
{absoluteTime ? (
<div className='text-muted-foreground text-xs'>
{relativeTime ? `${relativeTime}` : null}
{absoluteTime}
</div>
) : null}
</div>
</div>
</div>
{idx < announcements.length - 1 ? <Separator /> : null}
</div>
)
})}
</div>
</ScrollArea>
)
}
/**
* Notification popover with Notice and Announcements tabs
*/
export function NotificationPopover({
open,
onOpenChange,
unreadCount,
activeTab,
onTabChange,
notice,
announcements,
loading,
className,
}: NotificationPopoverProps) {
const { t } = useTranslation()
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
render={
<Button
variant='ghost'
size='icon'
className={cn('relative size-9', className)}
aria-label={t('Notifications')}
/>
}
>
<Bell className='size-[1.2rem]' />
{unreadCount > 0 ? (
<Badge
variant='destructive'
className='absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-semibold tabular-nums'
>
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
) : null}
</PopoverTrigger>
<PopoverContent
align='end'
sideOffset={8}
className='w-[min(26rem,calc(100vw-1rem))] gap-3 p-3'
>
<PopoverHeader className='gap-1 px-1'>
<PopoverTitle>{t('System Announcements')}</PopoverTitle>
<p className='text-muted-foreground text-xs'>
{t('Latest platform updates and notices')}
</p>
</PopoverHeader>
<Tabs
value={activeTab}
onValueChange={onTabChange as (value: string) => void}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='notice' className='gap-1.5'>
<Bell className='size-3.5' />
{t('Notice')}
</TabsTrigger>
<TabsTrigger value='announcements' className='gap-1.5'>
<Megaphone className='size-3.5' />
{t('Timeline')}
</TabsTrigger>
</TabsList>
<TabsContent value='notice' className='mt-2'>
<NoticeContent notice={notice} loading={loading} t={t} />
</TabsContent>
<TabsContent value='announcements' className='mt-2'>
<AnnouncementsContent
announcements={announcements}
loading={loading}
t={t}
/>
</TabsContent>
</Tabs>
<div className='flex justify-end'>
<Button size='sm' onClick={() => onOpenChange(false)}>
{t('Close')}
</Button>
</div>
</PopoverContent>
</Popover>
)
}