✨ 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.
This commit is contained in:
+11
-5
@@ -53,6 +53,12 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFooterClassName,
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { useSidebar } from './ui/sidebar'
|
||||
|
||||
const Item = RadioPrimitive.Root
|
||||
@@ -88,14 +94,14 @@ export function ConfigDrawer() {
|
||||
>
|
||||
<Palette className='size-[1.2rem]' aria-hidden='true' />
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex w-full flex-col sm:max-w-md'>
|
||||
<SheetHeader className='pb-0 text-start'>
|
||||
<SheetContent className={sideDrawerContentClassName('sm:max-w-md')}>
|
||||
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||
<SheetTitle>{t('Theme Settings')}</SheetTitle>
|
||||
<SheetDescription id='config-drawer-description'>
|
||||
{t('Adjust the appearance and layout to suit your preferences.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='space-y-6 overflow-y-auto px-4'>
|
||||
<div className={sideDrawerFormClassName()}>
|
||||
<ThemeConfig />
|
||||
<PresetConfig />
|
||||
<RadiusConfig />
|
||||
@@ -105,7 +111,7 @@ export function ConfigDrawer() {
|
||||
<ContentLayoutConfig />
|
||||
<DirConfig />
|
||||
</div>
|
||||
<SheetFooter className='gap-2'>
|
||||
<SheetFooter className={sideDrawerFooterClassName('grid-cols-1')}>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleReset}
|
||||
@@ -302,7 +308,7 @@ const RADIUS_OPTIONS: {
|
||||
// CSS border-radius value used to render the visual preview corner.
|
||||
preview: string
|
||||
}[] = [
|
||||
{ value: 'default', label: 'Auto', preview: '999px' },
|
||||
{ value: 'default', label: 'Auto', preview: '1rem' },
|
||||
{ value: 'none', label: '0', preview: '0' },
|
||||
{ value: 'sm', label: '0.3', preview: '0.3rem' },
|
||||
{ value: 'md', label: '0.5', preview: '0.5rem' },
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
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 { createElement, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const sideDrawerContentClassName = (className?: string) =>
|
||||
cn(
|
||||
'bg-background text-foreground flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 shadow-none',
|
||||
className
|
||||
)
|
||||
|
||||
export const sideDrawerHeaderClassName = (className?: string) =>
|
||||
cn(
|
||||
'border-border/70 bg-background/95 border-b px-4 py-3 text-start backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:px-6 sm:py-4',
|
||||
className
|
||||
)
|
||||
|
||||
export const sideDrawerFormClassName = (className?: string) =>
|
||||
cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6 sm:py-5',
|
||||
className
|
||||
)
|
||||
|
||||
export const sideDrawerFooterClassName = (className?: string) =>
|
||||
cn(
|
||||
'border-border/70 bg-background/95 grid grid-cols-2 gap-2 border-t px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4',
|
||||
className
|
||||
)
|
||||
|
||||
export const sideDrawerSectionClassName = (className?: string) =>
|
||||
cn(
|
||||
'border-border/60 flex flex-col gap-4 border-b pb-6 last:border-b-0 last:pb-0',
|
||||
className
|
||||
)
|
||||
|
||||
export const sideDrawerSwitchItemClassName = (className?: string) =>
|
||||
cn(
|
||||
'border-border/60 flex min-h-16 flex-row items-center justify-between gap-3 border-y py-3',
|
||||
className
|
||||
)
|
||||
|
||||
export function SideDrawerSection(props: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return createElement(
|
||||
'section',
|
||||
{ className: sideDrawerSectionClassName(props.className) },
|
||||
props.children
|
||||
)
|
||||
}
|
||||
|
||||
export function SideDrawerSectionHeader(props: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return createElement(
|
||||
'div',
|
||||
{ className: cn('flex items-start gap-3', props.className) },
|
||||
props.icon
|
||||
? createElement(
|
||||
'span',
|
||||
{
|
||||
className:
|
||||
'bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md',
|
||||
},
|
||||
props.icon
|
||||
)
|
||||
: null,
|
||||
createElement(
|
||||
'div',
|
||||
{ className: 'min-w-0 flex-1' },
|
||||
createElement(
|
||||
'h3',
|
||||
{ className: 'text-sm leading-none font-semibold tracking-tight' },
|
||||
props.title
|
||||
),
|
||||
props.description
|
||||
? createElement(
|
||||
'p',
|
||||
{ className: 'text-muted-foreground mt-1 text-xs leading-5' },
|
||||
props.description
|
||||
)
|
||||
: null
|
||||
)
|
||||
)
|
||||
}
|
||||
+4
-5
@@ -31,12 +31,12 @@ type GroupBadgeProps = Omit<
|
||||
|
||||
function getGroupRatioClassName(ratio: number): string {
|
||||
if (ratio > 1) {
|
||||
return 'border-warning/25 bg-warning/10 text-warning'
|
||||
return 'bg-warning/10 text-warning'
|
||||
}
|
||||
if (ratio < 1) {
|
||||
return 'border-info/25 bg-info/10 text-info'
|
||||
return 'bg-info/10 text-info'
|
||||
}
|
||||
return 'border-border bg-muted text-muted-foreground'
|
||||
return 'bg-muted text-muted-foreground'
|
||||
}
|
||||
|
||||
function getGroupLabel(params: {
|
||||
@@ -94,11 +94,10 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
{badge}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
|
||||
'inline-flex h-6 items-center rounded-full px-2 font-mono text-sm leading-none font-medium tabular-nums',
|
||||
getGroupRatioClassName(ratio)
|
||||
)}
|
||||
>
|
||||
<span className='size-1 rounded-full bg-current opacity-60' />
|
||||
<span>{ratio}x</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
+9
-18
@@ -20,8 +20,7 @@ import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||
import { ConfigDrawer } from '@/components/config-drawer'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationButton } from '@/components/notification-button'
|
||||
import { NotificationDialog } from '@/components/notification-dialog'
|
||||
import { NotificationPopover } from '@/components/notification-popover'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { Search } from '@/components/search'
|
||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
@@ -128,9 +127,15 @@ export function AppHeader({
|
||||
)}
|
||||
{showSearch && <Search />}
|
||||
{showNotifications && (
|
||||
<NotificationButton
|
||||
<NotificationPopover
|
||||
open={notifications.popoverOpen}
|
||||
onOpenChange={notifications.setPopoverOpen}
|
||||
unreadCount={notifications.unreadCount}
|
||||
onClick={() => notifications.openDialog()}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
/>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
@@ -139,20 +144,6 @@ export function AppHeader({
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
{/* Notification Dialog */}
|
||||
{showNotifications && (
|
||||
<NotificationDialog
|
||||
open={notifications.dialogOpen}
|
||||
onOpenChange={notifications.setDialogOpen}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
onCloseToday={notifications.closeToday}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,8 +35,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationButton } from '@/components/notification-button'
|
||||
import { NotificationDialog } from '@/components/notification-dialog'
|
||||
import { NotificationPopover } from '@/components/notification-popover'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { ThemeSwitch } from '@/components/theme-switch'
|
||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
@@ -271,9 +270,15 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
{showLanguageSwitcher && <LanguageSwitcher />}
|
||||
{showThemeSwitch && <ThemeSwitch />}
|
||||
{showNotifications && (
|
||||
<NotificationButton
|
||||
<NotificationPopover
|
||||
open={notifications.popoverOpen}
|
||||
onOpenChange={notifications.setPopoverOpen}
|
||||
unreadCount={notifications.unreadCount}
|
||||
onClick={() => notifications.openDialog()}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -445,20 +450,6 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Notification Dialog */}
|
||||
{showNotifications && (
|
||||
<NotificationDialog
|
||||
open={notifications.dialogOpen}
|
||||
onOpenChange={notifications.setDialogOpen}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
onCloseToday={notifications.closeToday}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
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 { Bell } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface NotificationButtonProps {
|
||||
unreadCount: number
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification bell button with unread badge
|
||||
* Displays in the app header next to theme switch and profile dropdown
|
||||
*/
|
||||
export function NotificationButton({
|
||||
unreadCount,
|
||||
onClick,
|
||||
className,
|
||||
}: NotificationButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='relative'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={onClick}
|
||||
className={cn('h-9 w-9', className)}
|
||||
aria-label={t('Notifications')}
|
||||
>
|
||||
<Bell className='size-[1.2rem]' />
|
||||
</Button>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+114
-50
@@ -22,15 +22,23 @@ 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
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'
|
||||
@@ -42,15 +50,16 @@ interface AnnouncementItem {
|
||||
publishDate?: string | Date
|
||||
}
|
||||
|
||||
interface NotificationDialogProps {
|
||||
interface NotificationPopoverProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
unreadCount: number
|
||||
activeTab: 'notice' | 'announcements'
|
||||
onTabChange: (tab: 'notice' | 'announcements') => void
|
||||
notice: string
|
||||
announcements: AnnouncementItem[]
|
||||
loading: boolean
|
||||
onCloseToday: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +122,7 @@ function AnnouncementDot({ type }: { type?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 inline-block h-2 w-2 shrink-0 rounded-full',
|
||||
'mt-1.5 inline-block size-2 shrink-0 rounded-full',
|
||||
getAnnouncementColorClass(type)
|
||||
)}
|
||||
/>
|
||||
@@ -123,11 +132,25 @@ function AnnouncementDot({ type }: { type?: string }) {
|
||||
/**
|
||||
* Empty state component
|
||||
*/
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>{message}</p>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,15 +167,23 @@ function NoticeContent({
|
||||
t: TFunction
|
||||
}) {
|
||||
if (loading) {
|
||||
return <EmptyState message={t('Loading...')} />
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Bell />}
|
||||
title={t('Loading...')}
|
||||
description={t('Latest platform updates and notices')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!notice) {
|
||||
return <EmptyState message={t('No announcements at this time')} />
|
||||
return (
|
||||
<EmptyState icon={<Bell />} title={t('No announcements at this time')} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className='h-[50vh] pr-4'>
|
||||
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
|
||||
<Markdown>{notice}</Markdown>
|
||||
</ScrollArea>
|
||||
)
|
||||
@@ -171,16 +202,24 @@ function AnnouncementsContent({
|
||||
t: TFunction
|
||||
}) {
|
||||
if (loading) {
|
||||
return <EmptyState message={t('Loading...')} />
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Megaphone />}
|
||||
title={t('Loading...')}
|
||||
description={t('Latest platform updates and notices')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (announcements.length === 0) {
|
||||
return <EmptyState message={t('No system announcements')} />
|
||||
return (
|
||||
<EmptyState icon={<Megaphone />} title={t('No system announcements')} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className='h-[50vh] pr-4'>
|
||||
<div className='space-y-0'>
|
||||
<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)
|
||||
@@ -197,30 +236,27 @@ function AnnouncementsContent({
|
||||
<div className='py-3'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<AnnouncementDot type={item.type} />
|
||||
<div className='min-w-0 flex-1 space-y-2'>
|
||||
{/* Content */}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-2'>
|
||||
<div className='text-sm'>
|
||||
<Markdown>{item.content || ''}</Markdown>
|
||||
</div>
|
||||
|
||||
{/* Extra info */}
|
||||
{item.extra && (
|
||||
{item.extra ? (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
<Markdown>{item.extra}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Time */}
|
||||
{absoluteTime && (
|
||||
{absoluteTime ? (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{relativeTime && `${relativeTime} • `}
|
||||
{relativeTime ? `${relativeTime} • ` : null}
|
||||
{absoluteTime}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{idx < announcements.length - 1 && <Separator />}
|
||||
{idx < announcements.length - 1 ? <Separator /> : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -230,25 +266,54 @@ function AnnouncementsContent({
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification dialog with Notice and Announcements tabs
|
||||
* Notification popover with Notice and Announcements tabs
|
||||
*/
|
||||
export function NotificationDialog({
|
||||
export function NotificationPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
unreadCount,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
notice,
|
||||
announcements,
|
||||
loading,
|
||||
onCloseToday,
|
||||
}: NotificationDialogProps) {
|
||||
className,
|
||||
}: NotificationPopoverProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('System Announcements')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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}
|
||||
@@ -256,20 +321,20 @@ export function NotificationDialog({
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='notice' className='gap-1.5'>
|
||||
<Bell className='h-3.5 w-3.5' />
|
||||
<Bell className='size-3.5' />
|
||||
{t('Notice')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='announcements' className='gap-1.5'>
|
||||
<Megaphone className='h-3.5 w-3.5' />
|
||||
<Megaphone className='size-3.5' />
|
||||
{t('Timeline')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='notice' className='mt-4'>
|
||||
<TabsContent value='notice' className='mt-2'>
|
||||
<NoticeContent notice={notice} loading={loading} t={t} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='announcements' className='mt-4'>
|
||||
<TabsContent value='announcements' className='mt-2'>
|
||||
<AnnouncementsContent
|
||||
announcements={announcements}
|
||||
loading={loading}
|
||||
@@ -278,13 +343,12 @@ export function NotificationDialog({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className='gap-2'>
|
||||
<Button variant='outline' onClick={onCloseToday}>
|
||||
{t('Close Today')}
|
||||
<div className='flex justify-end'>
|
||||
<Button size='sm' onClick={() => onOpenChange(false)}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t('Close')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
+91
-23
@@ -74,9 +74,33 @@ export const textColorMap = {
|
||||
export type StatusVariant = keyof typeof dotColorMap
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'text-xs gap-1.5',
|
||||
md: 'text-xs gap-1.5',
|
||||
lg: 'text-sm gap-2',
|
||||
sm: 'h-6 gap-1 px-2 text-sm leading-none',
|
||||
md: 'h-6 gap-1 px-2 text-sm leading-none',
|
||||
lg: 'h-7 gap-1.5 px-2.5 text-sm leading-none',
|
||||
} as const
|
||||
|
||||
const badgeSurfaceMap = {
|
||||
success: 'bg-success/10 text-success',
|
||||
warning: 'bg-warning/10 text-warning',
|
||||
danger: 'bg-destructive/10 text-destructive',
|
||||
info: 'bg-info/10 text-info',
|
||||
neutral: 'bg-muted text-muted-foreground',
|
||||
purple: 'bg-chart-4/10 text-chart-4',
|
||||
amber: 'bg-warning/10 text-warning',
|
||||
blue: 'bg-chart-1/10 text-chart-1',
|
||||
cyan: 'bg-chart-2/10 text-chart-2',
|
||||
green: 'bg-success/10 text-success',
|
||||
grey: 'bg-muted text-muted-foreground',
|
||||
indigo: 'bg-chart-1/10 text-chart-1',
|
||||
'light-blue': 'bg-info/10 text-info',
|
||||
'light-green': 'bg-success/10 text-success',
|
||||
lime: 'bg-chart-3/10 text-chart-3',
|
||||
orange: 'bg-warning/10 text-warning',
|
||||
pink: 'bg-chart-5/10 text-chart-5',
|
||||
red: 'bg-destructive/10 text-destructive',
|
||||
teal: 'bg-chart-2/10 text-chart-2',
|
||||
violet: 'bg-chart-4/10 text-chart-4',
|
||||
yellow: 'bg-warning/10 text-warning',
|
||||
} as const
|
||||
|
||||
export interface StatusBadgeProps extends Omit<
|
||||
@@ -87,7 +111,7 @@ export interface StatusBadgeProps extends Omit<
|
||||
children?: React.ReactNode
|
||||
icon?: LucideIcon
|
||||
pulse?: boolean
|
||||
/** When false, hides the leading dot */
|
||||
/** Kept for compatibility. Badges no longer render leading dots. */
|
||||
showDot?: boolean
|
||||
variant?: StatusVariant | null
|
||||
size?: 'sm' | 'md' | 'lg' | null
|
||||
@@ -103,7 +127,7 @@ export function StatusBadge({
|
||||
variant,
|
||||
size = 'sm',
|
||||
pulse = false,
|
||||
showDot = true,
|
||||
showDot = false,
|
||||
copyable = true,
|
||||
copyText,
|
||||
autoColor,
|
||||
@@ -112,6 +136,7 @@ export function StatusBadge({
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
void showDot
|
||||
|
||||
const computedVariant: StatusVariant = autoColor
|
||||
? (stringToColor(autoColor) as StatusVariant)
|
||||
@@ -131,58 +156,101 @@ export function StatusBadge({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex w-fit shrink-0 items-center font-medium whitespace-nowrap',
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center rounded-full font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
sizeMap[size ?? 'sm'],
|
||||
textColorMap[computedVariant],
|
||||
badgeSurfaceMap[computedVariant],
|
||||
pulse && 'animate-pulse',
|
||||
copyable &&
|
||||
'cursor-pointer transition-opacity hover:opacity-70 active:scale-95',
|
||||
'cursor-copy hover:brightness-95 active:scale-95 dark:hover:brightness-110',
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
title={copyable ? `Click to copy: ${copyText || label || ''}` : undefined}
|
||||
{...props}
|
||||
>
|
||||
{showDot && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block size-1.5 shrink-0 rounded-full',
|
||||
dotColorMap[computedVariant]
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
{Icon && <Icon className='size-3 shrink-0' />}
|
||||
{Icon && <Icon className='size-3.5 shrink-0' />}
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export interface StatusBadgeListProps<T> extends Omit<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
'children'
|
||||
> {
|
||||
empty?: React.ReactNode
|
||||
getKey?: (item: T, index: number) => React.Key
|
||||
items: T[]
|
||||
max?: number
|
||||
moreLabel?: (remaining: number) => string
|
||||
renderItem: (item: T, index: number) => React.ReactNode
|
||||
}
|
||||
|
||||
export function StatusBadgeList<T>(props: StatusBadgeListProps<T>) {
|
||||
const {
|
||||
className,
|
||||
empty = <span className='text-muted-foreground text-xs'>-</span>,
|
||||
getKey,
|
||||
items,
|
||||
max = 2,
|
||||
moreLabel,
|
||||
renderItem,
|
||||
...domProps
|
||||
} = props
|
||||
|
||||
if (items.length === 0) {
|
||||
return empty
|
||||
}
|
||||
|
||||
const displayed = items.slice(0, max)
|
||||
const remaining = items.length - max
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-full items-center gap-1 overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...domProps}
|
||||
>
|
||||
{displayed.map((item, index) => (
|
||||
<React.Fragment key={getKey?.(item, index) ?? index}>
|
||||
{renderItem(item, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<StatusBadge
|
||||
label={moreLabel?.(remaining) ?? `+${remaining}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='shrink-0'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const statusPresets = {
|
||||
active: {
|
||||
variant: 'success' as const,
|
||||
label: 'Active',
|
||||
showDot: true,
|
||||
},
|
||||
inactive: {
|
||||
variant: 'neutral' as const,
|
||||
label: 'Inactive',
|
||||
showDot: true,
|
||||
},
|
||||
invited: {
|
||||
variant: 'info' as const,
|
||||
label: 'Invited',
|
||||
showDot: true,
|
||||
},
|
||||
suspended: {
|
||||
variant: 'danger' as const,
|
||||
label: 'Suspended',
|
||||
showDot: true,
|
||||
},
|
||||
pending: {
|
||||
variant: 'warning' as const,
|
||||
label: 'Pending',
|
||||
showDot: true,
|
||||
pulse: true,
|
||||
},
|
||||
} as const
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
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 { cn } from '@/lib/utils'
|
||||
|
||||
type TableIdProps = {
|
||||
className?: string
|
||||
value: number | string
|
||||
}
|
||||
|
||||
export function TableId(props: TableIdProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground inline-block font-mono tabular-nums',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -73,7 +73,7 @@ function DrawerContent({
|
||||
<DrawerPrimitive.Content
|
||||
data-slot='drawer-content'
|
||||
className={cn(
|
||||
'group/drawer-content bg-popover text-popover-foreground fixed z-50 flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
'group/drawer-content bg-background text-foreground fixed z-50 flex h-auto flex-col overflow-hidden text-sm shadow-none data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ function SheetContent({
|
||||
data-slot='sheet-content'
|
||||
data-side={side}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0',
|
||||
'bg-background text-foreground fixed z-50 flex flex-col gap-4 overflow-hidden bg-clip-padding text-sm shadow-none transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-ending-style:translate-x-[2.5rem] data-starting-style:translate-x-[2.5rem] sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
|
||||
+23
-9
@@ -26,15 +26,15 @@ import {
|
||||
Loading03Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme()
|
||||
const Toaster = (props: ToasterProps) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
theme={resolvedTheme}
|
||||
className='toaster group'
|
||||
icons={{
|
||||
success: (
|
||||
@@ -78,14 +78,28 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--success-bg':
|
||||
'color-mix(in oklch, var(--success) 16%, var(--popover))',
|
||||
'--success-border':
|
||||
'color-mix(in oklch, var(--success) 35%, var(--border))',
|
||||
'--success-text': 'var(--success)',
|
||||
'--info-bg': 'color-mix(in oklch, var(--info) 16%, var(--popover))',
|
||||
'--info-border':
|
||||
'color-mix(in oklch, var(--info) 35%, var(--border))',
|
||||
'--info-text': 'var(--info)',
|
||||
'--warning-bg':
|
||||
'color-mix(in oklch, var(--warning) 18%, var(--popover))',
|
||||
'--warning-border':
|
||||
'color-mix(in oklch, var(--warning) 38%, var(--border))',
|
||||
'--warning-text': 'var(--warning)',
|
||||
'--error-bg':
|
||||
'color-mix(in oklch, var(--destructive) 16%, var(--popover))',
|
||||
'--error-border':
|
||||
'color-mix(in oklch, var(--destructive) 35%, var(--border))',
|
||||
'--error-text': 'var(--destructive)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: 'cn-toast',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
+5
-2
@@ -25,11 +25,14 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='table-container'
|
||||
className='relative w-full overflow-x-auto'
|
||||
className='relative w-full overflow-x-auto overflow-y-hidden'
|
||||
>
|
||||
<table
|
||||
data-slot='table'
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
className={cn(
|
||||
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user