refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ActionsProps = ComponentProps<'div'>
|
||||
|
||||
export const Actions = ({ className, children, ...props }: ActionsProps) => (
|
||||
<div className={cn('flex items-center gap-1', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type ActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Action = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
variant = 'ghost',
|
||||
size = 'sm',
|
||||
...props
|
||||
}: ActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground relative size-9 p-1.5',
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type='button'
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className='sr-only'>{label || tooltip}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button}></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { type LucideIcon, XIcon } from 'lucide-react'
|
||||
import type { ComponentProps, HTMLAttributes } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ArtifactProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const Artifact = ({ className, ...props }: ArtifactProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const ArtifactHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted/50 flex items-center justify-between border-b px-4 py-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ArtifactCloseProps = ComponentProps<typeof Button>
|
||||
|
||||
export const ArtifactClose = ({
|
||||
className,
|
||||
children,
|
||||
size = 'sm',
|
||||
variant = 'ghost',
|
||||
...props
|
||||
}: ArtifactCloseProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground size-8 p-0',
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type='button'
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon className='size-4' />}
|
||||
<span className='sr-only'>{t('Close')}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>
|
||||
|
||||
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
||||
<p
|
||||
className={cn('text-foreground text-sm font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>
|
||||
|
||||
export const ArtifactDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactDescriptionProps) => (
|
||||
<p className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||
)
|
||||
|
||||
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const ArtifactActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactActionsProps) => (
|
||||
<div className={cn('flex items-center gap-1', className)} {...props} />
|
||||
)
|
||||
|
||||
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string
|
||||
label?: string
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
export const ArtifactAction = ({
|
||||
tooltip,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
size = 'sm',
|
||||
variant = 'ghost',
|
||||
...props
|
||||
}: ArtifactActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground size-8 p-0',
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type='button'
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon ? <Icon className='size-4' /> : children}
|
||||
<span className='sr-only'>{label || tooltip}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button}></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const ArtifactContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactContentProps) => (
|
||||
<div className={cn('flex-1 overflow-auto p-4', className)} {...props} />
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import type { UIMessage } from 'ai'
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
type ReactElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type BranchContextType = {
|
||||
currentBranch: number
|
||||
totalBranches: number
|
||||
goToPrevious: () => void
|
||||
goToNext: () => void
|
||||
branches: ReactElement[]
|
||||
setBranches: (branches: ReactElement[]) => void
|
||||
}
|
||||
|
||||
const BranchContext = createContext<BranchContextType | null>(null)
|
||||
|
||||
const useBranch = () => {
|
||||
const context = useContext(BranchContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Branch components must be used within Branch')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number
|
||||
onBranchChange?: (branchIndex: number) => void
|
||||
}
|
||||
|
||||
export const Branch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: BranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch)
|
||||
const [branches, setBranches] = useState<ReactElement[]>([])
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch)
|
||||
onBranchChange?.(newBranch)
|
||||
}
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1
|
||||
handleBranchChange(newBranch)
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0
|
||||
handleBranchChange(newBranch)
|
||||
}
|
||||
|
||||
const contextValue: BranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
}
|
||||
|
||||
return (
|
||||
<BranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
</BranchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
|
||||
const { currentBranch, setBranches, branches } = useBranch()
|
||||
const childrenArray = useMemo(
|
||||
() => (Array.isArray(children) ? children : [children]),
|
||||
[children]
|
||||
)
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray)
|
||||
}
|
||||
}, [childrenArray, branches, setBranches])
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2 overflow-hidden [&>div]:pb-0',
|
||||
index === currentBranch ? 'block' : 'hidden'
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage['role']
|
||||
}
|
||||
|
||||
export const BranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: BranchSelectorProps) => {
|
||||
const { totalBranches } = useBranch()
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 self-end px-10',
|
||||
from === 'assistant' ? 'justify-start' : 'justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type BranchPreviousProps = ComponentProps<typeof Button>
|
||||
|
||||
export const BranchPrevious = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchPreviousProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { goToPrevious, totalBranches } = useBranch()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={t('Previous branch')}
|
||||
className={cn(
|
||||
'text-muted-foreground size-7 shrink-0 transition-colors',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type BranchNextProps = ComponentProps<typeof Button>
|
||||
|
||||
export const BranchNext = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchNextProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { goToNext, totalBranches } = useBranch()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={t('Next branch')}
|
||||
className={cn(
|
||||
'text-muted-foreground size-7 shrink-0 transition-colors',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentBranch, totalBranches } = useBranch()
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground text-xs font-medium tabular-nums',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} {t('of')} {totalBranches}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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 { Background, ReactFlow, type ReactFlowProps } from '@xyflow/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { Controls } from './controls'
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const Canvas = ({ children, ...props }: CanvasProps) => (
|
||||
<ReactFlow
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
fitView
|
||||
panOnDrag={false}
|
||||
panOnScroll
|
||||
selectionOnDrag={true}
|
||||
zoomOnDoubleClick={false}
|
||||
{...props}
|
||||
>
|
||||
<Background bgColor='var(--sidebar)' />
|
||||
<Controls />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BrainIcon,
|
||||
ChevronDownIcon,
|
||||
DotIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
memo,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { useControllableState } from '@/lib/use-controllable-state'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ChainOfThoughtContextValue = {
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const useChainOfThought = () => {
|
||||
const context = useContext(ChainOfThoughtContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'ChainOfThought components must be used within ChainOfThought'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type ChainOfThoughtProps = ComponentProps<'div'> & {
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const ChainOfThought = memo(
|
||||
({
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
})
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div
|
||||
className={cn('not-prose max-w-prose space-y-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
>
|
||||
|
||||
export const ChainOfThoughtHeader = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useChainOfThought()
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className='size-4' />
|
||||
<span className='flex-1 text-left'>
|
||||
{children ?? 'Chain of Thought'}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-4 transition-transform',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<'div'> & {
|
||||
icon?: LucideIcon
|
||||
label: string
|
||||
description?: string
|
||||
status?: 'complete' | 'active' | 'pending'
|
||||
}
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = 'complete',
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => {
|
||||
const statusStyles = {
|
||||
complete: 'text-muted-foreground',
|
||||
active: 'text-foreground',
|
||||
pending: 'text-muted-foreground/50',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-2 text-sm',
|
||||
statusStyles[status],
|
||||
'fade-in-0 slide-in-from-top-2 animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='relative mt-0.5'>
|
||||
<Icon className='size-4' />
|
||||
<div className='bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px' />
|
||||
</div>
|
||||
<div className='flex-1 space-y-2'>
|
||||
<div>{label}</div>
|
||||
{description && (
|
||||
<div className='text-muted-foreground text-xs'>{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<'div'>
|
||||
|
||||
export const ChainOfThoughtSearchResults = memo(
|
||||
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
|
||||
<div className={cn('flex items-center gap-2', className)} {...props} />
|
||||
)
|
||||
)
|
||||
|
||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>
|
||||
|
||||
export const ChainOfThoughtSearchResult = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||
<Badge
|
||||
className={cn('gap-1 px-2 py-0.5 text-xs font-normal', className)}
|
||||
variant='secondary'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
)
|
||||
|
||||
export type ChainOfThoughtContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>
|
||||
|
||||
export const ChainOfThoughtContent = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtContentProps) => {
|
||||
const { isOpen } = useChainOfThought()
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-2 space-y-3',
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-popover-foreground data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ChainOfThoughtImageProps = ComponentProps<'div'> & {
|
||||
caption?: string
|
||||
}
|
||||
|
||||
export const ChainOfThoughtImage = memo(
|
||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||
<div className={cn('mt-2 space-y-2', className)} {...props}>
|
||||
<div className='bg-muted relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg p-3'>
|
||||
{children}
|
||||
</div>
|
||||
{caption && <p className='text-muted-foreground text-xs'>{caption}</p>}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
ChainOfThought.displayName = 'ChainOfThought'
|
||||
ChainOfThoughtHeader.displayName = 'ChainOfThoughtHeader'
|
||||
ChainOfThoughtStep.displayName = 'ChainOfThoughtStep'
|
||||
ChainOfThoughtSearchResults.displayName = 'ChainOfThoughtSearchResults'
|
||||
ChainOfThoughtSearchResult.displayName = 'ChainOfThoughtSearchResult'
|
||||
ChainOfThoughtContent.displayName = 'ChainOfThoughtContent'
|
||||
ChainOfThoughtImage.displayName = 'ChainOfThoughtImage'
|
||||
@@ -0,0 +1,663 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
'use client'
|
||||
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { EditorState, type Extension } from '@codemirror/state'
|
||||
import { EditorView, lineNumbers } from '@codemirror/view'
|
||||
import { tags as highlightTags } from '@lezer/highlight'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type CSSProperties,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BundledLanguage } from 'shiki'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string
|
||||
collapsedLines?: number
|
||||
defaultCollapsed?: boolean
|
||||
enableCollapse?: boolean
|
||||
filename?: string
|
||||
language: BundledLanguage | string
|
||||
maxExpandedLines?: number
|
||||
/** @deprecated use collapsedLines for collapsed preview height. */
|
||||
maxCollapsedLines?: number
|
||||
showLineNumbers?: boolean
|
||||
showToolbar?: boolean
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
type CodeBlockEditorProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'onChange' | 'onKeyDown' | 'title'
|
||||
> & {
|
||||
actions?: ReactNode
|
||||
ariaLabel: string
|
||||
language: BundledLanguage | string
|
||||
onChange: (value: string) => void
|
||||
onKeyDown?: (event: globalThis.KeyboardEvent) => void
|
||||
rows?: number
|
||||
title?: ReactNode
|
||||
value: string
|
||||
}
|
||||
|
||||
type CodeMirrorCodeViewProps = {
|
||||
ariaLabel: string
|
||||
autoFocus?: boolean
|
||||
language: BundledLanguage | string
|
||||
onChange?: (value: string) => void
|
||||
onKeyDown?: (event: globalThis.KeyboardEvent) => void
|
||||
readOnly?: boolean
|
||||
rows?: number
|
||||
showLineNumbers?: boolean
|
||||
value: string
|
||||
}
|
||||
|
||||
type CodeBlockFrameProps = Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
|
||||
bodyClassName?: string
|
||||
bodyMaxHeight?: string
|
||||
bodyOverlay?: ReactNode
|
||||
children: ReactNode
|
||||
endActions?: ReactNode
|
||||
showToolbar?: boolean
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string
|
||||
language: string
|
||||
}
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: '',
|
||||
language: 'plaintext',
|
||||
})
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
||||
csharp: 'c#',
|
||||
golang: 'go',
|
||||
js: 'javascript',
|
||||
shell: 'bash',
|
||||
shellscript: 'bash',
|
||||
ts: 'typescript',
|
||||
}
|
||||
|
||||
const LANGUAGE_PATTERN = /^[a-z0-9][a-z0-9+#._-]{0,31}$/i
|
||||
const codeMirrorTheme = EditorView.theme({
|
||||
'&': {
|
||||
background: 'transparent',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: '13px',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--foreground)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
lineHeight: '1.5rem',
|
||||
minHeight: 'var(--code-editor-min-height)',
|
||||
minWidth: 'max-content',
|
||||
padding: '1rem 1rem 1rem 0',
|
||||
},
|
||||
'.cm-editor': {
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
},
|
||||
'.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
background: 'transparent',
|
||||
borderRight: '0',
|
||||
color: 'var(--muted-foreground)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5rem',
|
||||
padding: '1rem 1rem 1rem 0',
|
||||
},
|
||||
'.cm-gutters:empty': {
|
||||
display: 'none',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
minWidth: '2.5rem',
|
||||
padding: '0 1rem 0 0',
|
||||
textAlign: 'right',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'var(--font-mono)',
|
||||
lineHeight: '1.5rem',
|
||||
minHeight: 'var(--code-editor-min-height)',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
background:
|
||||
'color-mix(in oklch, var(--primary) 28%, transparent) !important',
|
||||
},
|
||||
})
|
||||
|
||||
const codeMirrorHighlightStyle = syntaxHighlighting(
|
||||
HighlightStyle.define([
|
||||
{ tag: highlightTags.heading, color: '#e06c75', fontWeight: '600' },
|
||||
{ tag: [highlightTags.strong, highlightTags.emphasis], color: '#d19a66' },
|
||||
{ tag: [highlightTags.link, highlightTags.url], color: '#61afef' },
|
||||
{
|
||||
tag: [highlightTags.monospace, highlightTags.contentSeparator],
|
||||
color: '#98c379',
|
||||
},
|
||||
{
|
||||
tag: [highlightTags.keyword, highlightTags.processingInstruction],
|
||||
color: '#c678dd',
|
||||
},
|
||||
{
|
||||
tag: [highlightTags.atom, highlightTags.bool, highlightTags.number],
|
||||
color: '#d19a66',
|
||||
},
|
||||
{ tag: [highlightTags.string, highlightTags.inserted], color: '#98c379' },
|
||||
{ tag: [highlightTags.deleted, highlightTags.invalid], color: '#e06c75' },
|
||||
{
|
||||
tag: [highlightTags.meta, highlightTags.comment],
|
||||
color: 'var(--muted-foreground)',
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
function getRequestedCodeLanguage(language?: string) {
|
||||
const normalized = language?.trim().toLowerCase() || 'plaintext'
|
||||
if (!LANGUAGE_PATTERN.test(normalized)) {
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
return LANGUAGE_ALIASES[normalized] ?? normalized
|
||||
}
|
||||
|
||||
function getCodeMirrorLanguageExtension(language: BundledLanguage | string) {
|
||||
const requestedLanguage = getRequestedCodeLanguage(language)
|
||||
if (
|
||||
requestedLanguage === 'markdown' ||
|
||||
requestedLanguage === 'md' ||
|
||||
requestedLanguage === 'mdx'
|
||||
) {
|
||||
return markdown()
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function getCodeLineCount(code: string) {
|
||||
if (!code) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return code.split('\n').length
|
||||
}
|
||||
|
||||
function getDownloadFilename(language: string, filename?: string) {
|
||||
if (filename) {
|
||||
return filename
|
||||
}
|
||||
|
||||
const extension = language === 'plaintext' ? 'txt' : language
|
||||
return `code.${extension}`
|
||||
}
|
||||
|
||||
function getCodeBlockHeight(lines: number) {
|
||||
return `${Math.max(4, lines) * 1.5 + 2}rem`
|
||||
}
|
||||
|
||||
function getCodeBlockMaxHeight(
|
||||
isCodeCollapsed: boolean,
|
||||
previewLines: number,
|
||||
maxExpandedLines?: number
|
||||
): string | undefined {
|
||||
if (isCodeCollapsed) {
|
||||
return getCodeBlockHeight(previewLines)
|
||||
}
|
||||
|
||||
if (maxExpandedLines) {
|
||||
return getCodeBlockHeight(maxExpandedLines)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getCodeMirrorExtensions(options: {
|
||||
language: BundledLanguage | string
|
||||
onKeyDown?: (event: globalThis.KeyboardEvent) => void
|
||||
readOnly: boolean
|
||||
showLineNumbers: boolean
|
||||
}): Extension[] {
|
||||
const extensions: Extension[] = [
|
||||
getCodeMirrorLanguageExtension(options.language),
|
||||
codeMirrorHighlightStyle,
|
||||
codeMirrorTheme,
|
||||
EditorState.tabSize.of(2),
|
||||
EditorState.readOnly.of(options.readOnly),
|
||||
EditorView.editable.of(!options.readOnly),
|
||||
]
|
||||
|
||||
if (options.showLineNumbers) {
|
||||
extensions.unshift(lineNumbers())
|
||||
}
|
||||
|
||||
if (options.onKeyDown) {
|
||||
extensions.push(
|
||||
EditorView.domEventHandlers({
|
||||
keydown(event) {
|
||||
options.onKeyDown?.(event)
|
||||
return event.defaultPrevented
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
function CodeMirrorCodeView({
|
||||
ariaLabel,
|
||||
autoFocus = false,
|
||||
language,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
readOnly = false,
|
||||
rows = 8,
|
||||
showLineNumbers = true,
|
||||
value,
|
||||
}: CodeMirrorCodeViewProps) {
|
||||
const editorHostRef = useRef<HTMLDivElement>(null)
|
||||
const editorViewRef = useRef<EditorView | null>(null)
|
||||
const initialValueRef = useRef(value)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const editorMinHeight = `${Math.max(4, rows) * 1.5 + 2}rem`
|
||||
const editorExtensions = useMemo(
|
||||
() =>
|
||||
getCodeMirrorExtensions({
|
||||
language,
|
||||
onKeyDown,
|
||||
readOnly,
|
||||
showLineNumbers,
|
||||
}),
|
||||
[language, onKeyDown, readOnly, showLineNumbers]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
useEffect(() => {
|
||||
const editorHost = editorHostRef.current
|
||||
if (!editorHost) {
|
||||
return
|
||||
}
|
||||
|
||||
const editorView = new EditorView({
|
||||
doc: initialValueRef.current,
|
||||
extensions: [
|
||||
...editorExtensions,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current?.(update.state.doc.toString())
|
||||
}
|
||||
}),
|
||||
],
|
||||
parent: editorHost,
|
||||
})
|
||||
editorViewRef.current = editorView
|
||||
if (autoFocus) {
|
||||
editorView.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
editorView.destroy()
|
||||
editorViewRef.current = null
|
||||
}
|
||||
}, [autoFocus, editorExtensions])
|
||||
|
||||
useEffect(() => {
|
||||
const editorView = editorViewRef.current
|
||||
if (!editorView) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentValue = editorView.state.doc.toString()
|
||||
if (currentValue === value) {
|
||||
return
|
||||
}
|
||||
|
||||
editorView.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editorView.state.doc.length,
|
||||
insert: value,
|
||||
},
|
||||
})
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
aria-readonly={readOnly}
|
||||
className='min-h-(--code-editor-min-height)'
|
||||
ref={editorHostRef}
|
||||
role='textbox'
|
||||
style={
|
||||
{
|
||||
'--code-editor-min-height': editorMinHeight,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const CodeBlockFrame = ({
|
||||
bodyClassName,
|
||||
bodyMaxHeight,
|
||||
bodyOverlay,
|
||||
children,
|
||||
className,
|
||||
endActions,
|
||||
showToolbar = false,
|
||||
title,
|
||||
...props
|
||||
}: CodeBlockFrameProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group/code-block bg-muted/20 text-foreground my-3 w-full max-w-full overflow-hidden rounded-lg border shadow-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showToolbar && (
|
||||
<div className='bg-muted/35 border-border/70 flex min-h-10 items-center gap-2 border-b px-2 py-1.5'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-muted-foreground truncate font-mono text-[11px] font-medium tracking-wide uppercase'>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
{endActions && (
|
||||
<div className='flex shrink-0 items-center gap-1'>{endActions}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='relative min-w-0'>
|
||||
<div
|
||||
className={cn(
|
||||
'code-block-scroll max-w-full overflow-auto transition-[max-height] duration-200 ease-out',
|
||||
bodyClassName
|
||||
)}
|
||||
style={{ maxHeight: bodyMaxHeight }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{bodyOverlay}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
collapsedLines = 12,
|
||||
defaultCollapsed,
|
||||
enableCollapse = true,
|
||||
filename,
|
||||
language,
|
||||
maxExpandedLines,
|
||||
maxCollapsedLines,
|
||||
showLineNumbers = false,
|
||||
showToolbar = false,
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed))
|
||||
const displayLanguage = getRequestedCodeLanguage(language)
|
||||
const lineCount = useMemo(() => getCodeLineCount(code), [code])
|
||||
const previewLines = maxCollapsedLines ?? collapsedLines
|
||||
const canCollapse = enableCollapse && lineCount > previewLines
|
||||
const isCodeCollapsed = canCollapse && isCollapsed
|
||||
const displayTitle = title ?? displayLanguage
|
||||
const bodyMaxHeight = getCodeBlockMaxHeight(
|
||||
isCodeCollapsed,
|
||||
previewLines,
|
||||
maxExpandedLines
|
||||
)
|
||||
|
||||
const downloadCode = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = getDownloadFilename(displayLanguage, filename)
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code, language: displayLanguage }}>
|
||||
<CodeBlockFrame
|
||||
bodyClassName='p-0'
|
||||
bodyMaxHeight={bodyMaxHeight}
|
||||
bodyOverlay={
|
||||
<>
|
||||
{isCodeCollapsed && (
|
||||
<div className='from-muted/20 to-background pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-b' />
|
||||
)}
|
||||
{!showToolbar && children && (
|
||||
<div className='absolute top-2 right-2 flex items-center gap-1'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
className={className}
|
||||
endActions={
|
||||
<>
|
||||
{canCollapse && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={isCodeCollapsed ? t('Expand') : t('Collapse')}
|
||||
className='size-8'
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
{isCodeCollapsed ? (
|
||||
<ChevronRightIcon className='size-4' />
|
||||
) : (
|
||||
<ChevronDownIcon className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>{isCodeCollapsed ? t('Expand') : t('Collapse')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showToolbar && children}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
className='size-8'
|
||||
onClick={downloadCode}
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
<DownloadIcon className='size-4' />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>{t('Download')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
showToolbar={showToolbar}
|
||||
title={displayTitle}
|
||||
{...props}
|
||||
>
|
||||
<CodeMirrorCodeView
|
||||
ariaLabel={
|
||||
typeof displayTitle === 'string' ? displayTitle : displayLanguage
|
||||
}
|
||||
language={language}
|
||||
readOnly
|
||||
rows={Math.min(Math.max(lineCount, 4), maxExpandedLines ?? lineCount)}
|
||||
showLineNumbers={showLineNumbers}
|
||||
value={code}
|
||||
/>
|
||||
</CodeBlockFrame>
|
||||
</CodeBlockContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const CodeBlockEditor = ({
|
||||
actions,
|
||||
ariaLabel,
|
||||
className,
|
||||
language,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
rows = 8,
|
||||
title,
|
||||
value,
|
||||
...props
|
||||
}: CodeBlockEditorProps) => {
|
||||
return (
|
||||
<CodeBlockFrame
|
||||
bodyClassName='p-0'
|
||||
className={className}
|
||||
endActions={actions}
|
||||
showToolbar
|
||||
title={title}
|
||||
{...props}
|
||||
>
|
||||
<CodeMirrorCodeView
|
||||
ariaLabel={ariaLabel}
|
||||
autoFocus
|
||||
language={language}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={rows}
|
||||
showLineNumbers
|
||||
value={value}
|
||||
/>
|
||||
</CodeBlockFrame>
|
||||
)
|
||||
}
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void
|
||||
onError?: (error: Error) => void
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const { code } = useContext(CodeBlockContext)
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error('Clipboard API not available'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setIsCopied(true)
|
||||
onCopy?.()
|
||||
setTimeout(() => setIsCopied(false), timeout)
|
||||
} catch (error) {
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
aria-label={isCopied ? t('Copied!') : t('Copy code')}
|
||||
className={cn('size-8 shrink-0', className)}
|
||||
onClick={copyToClipboard}
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent>
|
||||
<p>{isCopied ? t('Copied!') : t('Copy code')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import type { ToolUIPart } from 'ai'
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
} from 'react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Workaround for missing types in 'ai' package
|
||||
type ExtendedToolState =
|
||||
| ToolUIPart['state']
|
||||
| 'approval-requested'
|
||||
| 'approval-responded'
|
||||
| 'output-denied'
|
||||
type ExtendedToolApproval = { approved: boolean }
|
||||
|
||||
type ConfirmationContextValue = {
|
||||
approval: ExtendedToolApproval | undefined
|
||||
state: ExtendedToolState
|
||||
}
|
||||
|
||||
const ConfirmationContext = createContext<ConfirmationContextValue | null>(null)
|
||||
|
||||
const useConfirmation = () => {
|
||||
const context = useContext(ConfirmationContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Confirmation components must be used within Confirmation')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export type ConfirmationProps = ComponentProps<typeof Alert> & {
|
||||
approval?: ExtendedToolApproval
|
||||
state: ExtendedToolState
|
||||
}
|
||||
|
||||
export const Confirmation = ({
|
||||
className,
|
||||
approval,
|
||||
state,
|
||||
...props
|
||||
}: ConfirmationProps) => {
|
||||
if (!approval || state === 'input-streaming' || state === 'input-available') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationContext.Provider value={{ approval, state }}>
|
||||
<Alert className={cn('flex flex-col gap-2', className)} {...props} />
|
||||
</ConfirmationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>
|
||||
|
||||
export const ConfirmationTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationTitleProps) => (
|
||||
<AlertDescription className={cn('inline', className)} {...props} />
|
||||
)
|
||||
|
||||
export type ConfirmationRequestProps = {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
|
||||
const { state } = useConfirmation()
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== 'approval-requested') {
|
||||
return null
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export type ConfirmationAcceptedProps = {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const ConfirmationAccepted = ({
|
||||
children,
|
||||
}: ConfirmationAcceptedProps) => {
|
||||
const { approval, state } = useConfirmation()
|
||||
|
||||
// Only show when approved and in response states
|
||||
if (
|
||||
!approval?.approved ||
|
||||
(state !== 'approval-responded' &&
|
||||
state !== 'output-denied' &&
|
||||
state !== 'output-available')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export type ConfirmationRejectedProps = {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const ConfirmationRejected = ({
|
||||
children,
|
||||
}: ConfirmationRejectedProps) => {
|
||||
const { approval, state } = useConfirmation()
|
||||
|
||||
// Only show when rejected and in response states
|
||||
if (
|
||||
approval?.approved !== false ||
|
||||
(state !== 'approval-responded' &&
|
||||
state !== 'output-denied' &&
|
||||
state !== 'output-available')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export type ConfirmationActionsProps = ComponentProps<'div'>
|
||||
|
||||
export const ConfirmationActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationActionsProps) => {
|
||||
const { state } = useConfirmation()
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== 'approval-requested') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-end gap-2 self-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ConfirmationActionProps = ComponentProps<typeof Button>
|
||||
|
||||
export const ConfirmationAction = (props: ConfirmationActionProps) => (
|
||||
<Button className='h-8 px-3 text-sm' type='button' {...props} />
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
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 { ConnectionLineComponent } from '@xyflow/react'
|
||||
|
||||
const HALF = 0.5
|
||||
|
||||
export const Connection: ConnectionLineComponent = ({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}) => (
|
||||
<g>
|
||||
<path
|
||||
className='animated'
|
||||
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
||||
fill='none'
|
||||
stroke='var(--color-ring)'
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
fill='#fff'
|
||||
r={3}
|
||||
stroke='var(--color-ring)'
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
@@ -0,0 +1,440 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import type { LanguageModelUsage } from 'ai'
|
||||
import { type ComponentProps, createContext, useContext } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getUsage } from 'tokenlens'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PERCENT_MAX = 100
|
||||
const ICON_RADIUS = 10
|
||||
const ICON_VIEWBOX = 24
|
||||
const ICON_CENTER = 12
|
||||
const ICON_STROKE_WIDTH = 2
|
||||
|
||||
type ModelId = string
|
||||
|
||||
type ContextSchema = {
|
||||
usedTokens: number
|
||||
maxTokens: number
|
||||
usage?: LanguageModelUsage
|
||||
modelId?: ModelId
|
||||
}
|
||||
|
||||
const ContextContext = createContext<ContextSchema | null>(null)
|
||||
|
||||
const useContextValue = () => {
|
||||
const context = useContext(ContextContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Context components must be used within Context')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema
|
||||
|
||||
export const Context = ({
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
...props
|
||||
}: ContextProps) => (
|
||||
<ContextContext.Provider
|
||||
value={{
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
}}
|
||||
>
|
||||
<HoverCard {...props} />
|
||||
</ContextContext.Provider>
|
||||
)
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { t } = useTranslation()
|
||||
const { usedTokens, maxTokens } = useContextValue()
|
||||
const circumference = 2 * Math.PI * ICON_RADIUS
|
||||
const usedPercent = usedTokens / maxTokens
|
||||
const dashOffset = circumference * (1 - usedPercent)
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label={t('Model context usage')}
|
||||
height='20'
|
||||
role='img'
|
||||
style={{ color: 'currentcolor' }}
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
width='20'
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill='none'
|
||||
opacity='0.25'
|
||||
r={ICON_RADIUS}
|
||||
stroke='currentColor'
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill='none'
|
||||
opacity='0.7'
|
||||
r={ICON_RADIUS}
|
||||
stroke='currentColor'
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap='round'
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
style={{ transformOrigin: 'center', transform: 'rotate(-90deg)' }}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextTriggerProps = ComponentProps<typeof Button>
|
||||
|
||||
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue()
|
||||
const usedPercent = usedTokens / maxTokens
|
||||
const renderedPercent = new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent)
|
||||
|
||||
return (
|
||||
<HoverCardTrigger
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
render={
|
||||
<Button type='button' variant='ghost' {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
{renderedPercent}
|
||||
</span>
|
||||
<ContextIcon />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextContentProps = ComponentProps<typeof HoverCardContent>
|
||||
|
||||
export const ContextContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentProps) => (
|
||||
<HoverCardContent
|
||||
className={cn('min-w-60 divide-y overflow-hidden p-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ContextContentHeaderProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextContentHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentHeaderProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue()
|
||||
const usedPercent = usedTokens / maxTokens
|
||||
const displayPct = new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent)
|
||||
const used = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
}).format(usedTokens)
|
||||
const total = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
}).format(maxTokens)
|
||||
|
||||
return (
|
||||
<div className={cn('w-full space-y-2 p-3', className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className='flex items-center justify-between gap-3 text-xs'>
|
||||
<p>{displayPct}</p>
|
||||
<p className='text-muted-foreground font-mono'>
|
||||
{used} / {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Progress className='bg-muted' value={usedPercent * PERCENT_MAX} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextContentBodyProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextContentBody = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentBodyProps) => (
|
||||
<div className={cn('w-full p-3', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type ContextContentFooterProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextContentFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { modelId, usage } = useContextValue()
|
||||
const costUSD = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: {
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
},
|
||||
}).costUSD?.totalUSD
|
||||
: undefined
|
||||
const totalCost = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(costUSD ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-secondary flex w-full items-center justify-between gap-3 p-3 text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className='text-muted-foreground'>{t('Total cost')}</span>
|
||||
<span>{totalCost}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextInputUsageProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextInputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextInputUsageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { usage, modelId } = useContextValue()
|
||||
const inputTokens = usage?.inputTokens ?? 0
|
||||
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!inputTokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
const inputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: inputTokens, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined
|
||||
const inputCostText = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(inputCost ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between text-xs', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className='text-muted-foreground'>{t('Input')}</span>
|
||||
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextOutputUsageProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextOutputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextOutputUsageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { usage, modelId } = useContextValue()
|
||||
const outputTokens = usage?.outputTokens ?? 0
|
||||
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!outputTokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
const outputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: 0, output: outputTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined
|
||||
const outputCostText = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(outputCost ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between text-xs', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className='text-muted-foreground'>{t('Output')}</span>
|
||||
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextReasoningUsageProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextReasoningUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextReasoningUsageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { usage, modelId } = useContextValue()
|
||||
const reasoningTokens = usage?.outputTokenDetails.reasoningTokens ?? 0
|
||||
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!reasoningTokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
const reasoningCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { reasoningTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined
|
||||
const reasoningCostText = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(reasoningCost ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between text-xs', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className='text-muted-foreground'>{t('Reasoning')}</span>
|
||||
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextCacheUsageProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextCacheUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextCacheUsageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { usage, modelId } = useContextValue()
|
||||
const cacheTokens = usage?.inputTokenDetails.cacheReadTokens ?? 0
|
||||
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!cacheTokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined
|
||||
const cacheCostText = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(cacheCost ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between text-xs', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className='text-muted-foreground'>{t('Cache')}</span>
|
||||
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TokensWithCost = ({
|
||||
tokens,
|
||||
costText,
|
||||
}: {
|
||||
tokens?: number
|
||||
costText?: string
|
||||
}) => (
|
||||
<span>
|
||||
{tokens === undefined
|
||||
? '—'
|
||||
: new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
}).format(tokens)}
|
||||
{costText ? (
|
||||
<span className='text-muted-foreground ml-2'>• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
@@ -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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { Controls as ControlsPrimitive } from '@xyflow/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>
|
||||
|
||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||
<ControlsPrimitive
|
||||
className={cn(
|
||||
'bg-card gap-px overflow-hidden rounded-md border p-1 shadow-none!',
|
||||
'[&>button]:hover:bg-secondary! [&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent!',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { ArrowDownIcon } from 'lucide-react'
|
||||
import { type ComponentProps, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
|
||||
initial='smooth'
|
||||
resize='smooth'
|
||||
role='log'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content className={cn('p-4', className)} {...props} />
|
||||
)
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<'div'> & {
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => {
|
||||
const { t } = useTranslation()
|
||||
const resolvedTitle = title ?? t('No messages yet')
|
||||
const resolvedDescription =
|
||||
description ?? t('Start a conversation to see messages here')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className='text-muted-foreground'>{icon}</div>}
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-sm font-medium'>{resolvedTitle}</h3>
|
||||
{resolvedDescription && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{resolvedDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom()
|
||||
}, [scrollToBottom])
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
'absolute bottom-4 left-[50%] translate-x-[-50%]',
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='outline'
|
||||
aria-label='Scroll to bottom'
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className='size-4' aria-hidden='true' />
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
BaseEdge,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
getSimpleBezierPath,
|
||||
type InternalNode,
|
||||
type Node,
|
||||
Position,
|
||||
useInternalNode,
|
||||
} from '@xyflow/react'
|
||||
|
||||
const Temporary = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) => {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
className='stroke-ring stroke-1'
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeDasharray: '5, 5',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const getHandleCoordsByPosition = (
|
||||
node: InternalNode<Node>,
|
||||
handlePosition: Position
|
||||
) => {
|
||||
// Choose the handle type based on position - Left is for target, Right is for source
|
||||
const handleType = handlePosition === Position.Left ? 'target' : 'source'
|
||||
|
||||
const handle = node.internals.handleBounds?.[handleType]?.find(
|
||||
(h) => h.position === handlePosition
|
||||
)
|
||||
|
||||
if (!handle) {
|
||||
return [0, 0] as const
|
||||
}
|
||||
|
||||
let offsetX = handle.width / 2
|
||||
let offsetY = handle.height / 2
|
||||
|
||||
// this is a tiny detail to make the markerEnd of an edge visible.
|
||||
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
|
||||
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
|
||||
switch (handlePosition) {
|
||||
case Position.Left:
|
||||
offsetX = 0
|
||||
break
|
||||
case Position.Right:
|
||||
offsetX = handle.width
|
||||
break
|
||||
case Position.Top:
|
||||
offsetY = 0
|
||||
break
|
||||
case Position.Bottom:
|
||||
offsetY = handle.height
|
||||
break
|
||||
default:
|
||||
throw new Error(`Invalid handle position: ${handlePosition}`)
|
||||
}
|
||||
|
||||
const x = node.internals.positionAbsolute.x + handle.x + offsetX
|
||||
const y = node.internals.positionAbsolute.y + handle.y + offsetY
|
||||
|
||||
return [x, y] as const
|
||||
}
|
||||
|
||||
const getEdgeParams = (
|
||||
source: InternalNode<Node>,
|
||||
target: InternalNode<Node>
|
||||
) => {
|
||||
const sourcePos = Position.Right
|
||||
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos)
|
||||
const targetPos = Position.Left
|
||||
const [tx, ty] = getHandleCoordsByPosition(target, targetPos)
|
||||
|
||||
return {
|
||||
sx,
|
||||
sy,
|
||||
tx,
|
||||
ty,
|
||||
sourcePos,
|
||||
targetPos,
|
||||
}
|
||||
}
|
||||
|
||||
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
|
||||
const sourceNode = useInternalNode(source)
|
||||
const targetNode = useInternalNode(target)
|
||||
|
||||
if (!(sourceNode && targetNode)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode
|
||||
)
|
||||
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
sourcePosition: sourcePos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
targetPosition: targetPos,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
|
||||
<circle fill='var(--primary)' r='4'>
|
||||
<animateMotion dur='2s' path={edgePath} repeatCount='indefinite' />
|
||||
</circle>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Edge = {
|
||||
Temporary,
|
||||
Animated,
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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 { Experimental_GeneratedImage } from 'ai'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
'h-auto max-w-full overflow-hidden rounded-md',
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type InlineCitationProps = ComponentProps<'span'>
|
||||
|
||||
export const InlineCitation = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationProps) => (
|
||||
<span
|
||||
className={cn('group inline items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type InlineCitationTextProps = ComponentProps<'span'>
|
||||
|
||||
export const InlineCitationText = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationTextProps) => (
|
||||
<span
|
||||
className={cn('group-hover:bg-accent transition-colors', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard {...props} />
|
||||
)
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
export const InlineCitationCardTrigger = ({
|
||||
sources,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardTriggerProps) => (
|
||||
<HoverCardTrigger
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
render={
|
||||
<Badge className={cn('ml-1', className)} variant='secondary' {...props}>
|
||||
{sources[0] ? (
|
||||
<>
|
||||
{new URL(sources[0]).hostname}{' '}
|
||||
{sources.length > 1 && `+${sources.length - 1}`}
|
||||
</>
|
||||
) : (
|
||||
'unknown'
|
||||
)}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
export type InlineCitationCardBodyProps = ComponentProps<'div'>
|
||||
|
||||
export const InlineCitationCardBody = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardBodyProps) => (
|
||||
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
|
||||
)
|
||||
|
||||
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined)
|
||||
|
||||
const useCarouselApi = () => {
|
||||
const context = useContext(CarouselApiContext)
|
||||
return context
|
||||
}
|
||||
|
||||
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>
|
||||
|
||||
export const InlineCitationCarousel = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationCarouselProps) => {
|
||||
const [api, setApi] = useState<CarouselApi>()
|
||||
|
||||
return (
|
||||
<CarouselApiContext.Provider value={api}>
|
||||
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
|
||||
{children}
|
||||
</Carousel>
|
||||
</CarouselApiContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineCitationCarouselContentProps = ComponentProps<'div'>
|
||||
|
||||
export const InlineCitationCarouselContent = (
|
||||
props: InlineCitationCarouselContentProps
|
||||
) => <CarouselContent {...props} />
|
||||
|
||||
export type InlineCitationCarouselItemProps = ComponentProps<'div'>
|
||||
|
||||
export const InlineCitationCarouselItem = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselItemProps) => (
|
||||
<CarouselItem
|
||||
className={cn('w-full space-y-2 p-4 pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>
|
||||
|
||||
export const InlineCitationCarouselHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-secondary flex items-center justify-between gap-2 rounded-t-md p-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>
|
||||
|
||||
export const InlineCitationCarouselIndex = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselIndexProps) => {
|
||||
const api = useCarouselApi()
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCount(api.scrollSnapList().length)
|
||||
|
||||
setCurrent(api.selectedScrollSnap() + 1)
|
||||
|
||||
api.on('select', () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1)
|
||||
})
|
||||
}, [api])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground flex flex-1 items-center justify-end px-3 py-1 text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${current}/${count}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>
|
||||
|
||||
export const InlineCitationCarouselPrev = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselPrevProps) => {
|
||||
const { t } = useTranslation()
|
||||
const api = useCarouselApi()
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollPrev()
|
||||
}
|
||||
}, [api])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={t('Previous')}
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={handleClick}
|
||||
type='button'
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon className='text-muted-foreground size-4' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineCitationCarouselNextProps = ComponentProps<'button'>
|
||||
|
||||
export const InlineCitationCarouselNext = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselNextProps) => {
|
||||
const { t } = useTranslation()
|
||||
const api = useCarouselApi()
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollNext()
|
||||
}
|
||||
}, [api])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={t('Next')}
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={handleClick}
|
||||
type='button'
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon className='text-muted-foreground size-4' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineCitationSourceProps = ComponentProps<'div'> & {
|
||||
title?: string
|
||||
url?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const InlineCitationSource = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationSourceProps) => (
|
||||
<div className={cn('space-y-1', className)} {...props}>
|
||||
{title && (
|
||||
<h4 className='truncate text-sm leading-tight font-medium'>{title}</h4>
|
||||
)}
|
||||
{url && (
|
||||
<p className='text-muted-foreground truncate text-xs break-all'>{url}</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className='text-muted-foreground line-clamp-3 text-sm leading-relaxed'>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>
|
||||
|
||||
export const InlineCitationQuote = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationQuoteProps) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'border-muted text-muted-foreground border-l-2 pl-3 text-sm italic',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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 { HTMLAttributes } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type LoaderIconProps = {
|
||||
size?: number
|
||||
}
|
||||
|
||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin='round'
|
||||
style={{ color: 'currentcolor' }}
|
||||
viewBox='0 0 16 16'
|
||||
width={size}
|
||||
>
|
||||
<title>{t('Loader')}</title>
|
||||
<g clipPath='url(#clip0_2393_1490)'>
|
||||
<path d='M8 0V4' stroke='currentColor' strokeWidth='1.5' />
|
||||
<path
|
||||
d='M8 16V12'
|
||||
opacity='0.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M3.29773 1.52783L5.64887 4.7639'
|
||||
opacity='0.9'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M12.7023 1.52783L10.3511 4.7639'
|
||||
opacity='0.1'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M12.7023 14.472L10.3511 11.236'
|
||||
opacity='0.4'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M3.29773 14.472L5.64887 11.236'
|
||||
opacity='0.6'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M15.6085 5.52783L11.8043 6.7639'
|
||||
opacity='0.2'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M0.391602 10.472L4.19583 9.23598'
|
||||
opacity='0.7'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M15.6085 10.4722L11.8043 9.2361'
|
||||
opacity='0.3'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M0.391602 5.52783L4.19583 6.7639'
|
||||
opacity='0.8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_2393_1490'>
|
||||
<rect fill='white' height='16' width='16' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number
|
||||
}
|
||||
|
||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex animate-spin items-center justify-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LoaderIcon size={size} />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
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 { UIMessage } from 'ai'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import type { ComponentProps, HTMLAttributes } from 'react'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage['role']
|
||||
}
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full items-end justify-end gap-2',
|
||||
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const messageContentVariants = cva(
|
||||
'is-user:dark flex flex-col gap-2 overflow-hidden rounded-lg text-sm',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
contained: [
|
||||
'max-w-[100%] px-3 py-1',
|
||||
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
|
||||
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
|
||||
],
|
||||
flat: [
|
||||
'group-[.is-user]:max-w-[80%] group-[.is-user]:bg-secondary group-[.is-user]:px-3 group-[.is-user]:py-1 group-[.is-user]:text-foreground',
|
||||
'group-[.is-assistant]:text-foreground',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'contained',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof messageContentVariants>
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(messageContentVariants({ variant, className }))}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
|
||||
src: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export const MessageAvatar = ({
|
||||
src,
|
||||
name,
|
||||
className,
|
||||
...props
|
||||
}: MessageAvatarProps) => (
|
||||
<Avatar className={cn('ring-border size-8 ring-1', className)} {...props}>
|
||||
<AvatarImage alt='' className='mt-0 mb-0' src={src} />
|
||||
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
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 { Handle, Position } from '@xyflow/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type NodeProps = ComponentProps<typeof Card> & {
|
||||
handles: {
|
||||
target: boolean
|
||||
source: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const Node = ({ handles, className, ...props }: NodeProps) => (
|
||||
<Card
|
||||
className={cn(
|
||||
'node-container relative size-full h-auto w-sm gap-0 rounded-md p-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{handles.target && <Handle position={Position.Left} type='target' />}
|
||||
{handles.source && <Handle position={Position.Right} type='source' />}
|
||||
{props.children}
|
||||
</Card>
|
||||
)
|
||||
|
||||
export type NodeHeaderProps = ComponentProps<typeof CardHeader>
|
||||
|
||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn('bg-secondary gap-0.5 rounded-t-md border-b p-3!', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type NodeTitleProps = ComponentProps<typeof CardTitle>
|
||||
|
||||
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />
|
||||
|
||||
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>
|
||||
|
||||
export const NodeDescription = (props: NodeDescriptionProps) => (
|
||||
<CardDescription {...props} />
|
||||
)
|
||||
|
||||
export type NodeActionProps = ComponentProps<typeof CardAction>
|
||||
|
||||
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />
|
||||
|
||||
export type NodeContentProps = ComponentProps<typeof CardContent>
|
||||
|
||||
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
|
||||
<CardContent className={cn('p-3', className)} {...props} />
|
||||
)
|
||||
|
||||
export type NodeFooterProps = ComponentProps<typeof CardFooter>
|
||||
|
||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||
<CardFooter
|
||||
className={cn('bg-secondary rounded-b-md border-t p-3!', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,409 @@
|
||||
/*
|
||||
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 {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from 'lucide-react'
|
||||
import { type ComponentProps, createContext, useContext } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const providers = {
|
||||
github: {
|
||||
title: 'Open in GitHub',
|
||||
createUrl: (url: string) => url,
|
||||
icon: (
|
||||
<svg fill='currentColor' role='img' viewBox='0 0 24 24'>
|
||||
<title>GitHub</title>
|
||||
<path d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12' />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
scira: {
|
||||
title: 'Open in Scira',
|
||||
createUrl: (q: string) =>
|
||||
`https://scira.ai/?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill='none'
|
||||
height='934'
|
||||
viewBox='0 0 910 934'
|
||||
width='910'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<title>Scira AI</title>
|
||||
<path
|
||||
d='M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z'
|
||||
fill='currentColor'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='8'
|
||||
/>
|
||||
<path
|
||||
d='M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z'
|
||||
fill='currentColor'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='8'
|
||||
/>
|
||||
<path
|
||||
d='M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='20'
|
||||
/>
|
||||
<path
|
||||
d='M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='20'
|
||||
/>
|
||||
<path
|
||||
d='M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z'
|
||||
fill='currentColor'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='8'
|
||||
/>
|
||||
<path
|
||||
d='M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z'
|
||||
fill='currentColor'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='8'
|
||||
/>
|
||||
<path
|
||||
d='M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442'
|
||||
stroke='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='30'
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
chatgpt: {
|
||||
title: 'Open in ChatGPT',
|
||||
createUrl: (prompt: string) =>
|
||||
`https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: 'search',
|
||||
prompt,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill='currentColor'
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d='M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z' />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
claude: {
|
||||
title: 'Open in Claude',
|
||||
createUrl: (q: string) =>
|
||||
`https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill='currentColor'
|
||||
role='img'
|
||||
viewBox='0 0 12 12'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
clipRule='evenodd'
|
||||
d='M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
t3: {
|
||||
title: 'Open in T3 Chat',
|
||||
createUrl: (q: string) =>
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
},
|
||||
v0: {
|
||||
title: 'Open in v0',
|
||||
createUrl: (q: string) =>
|
||||
`https://v0.app?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill='currentColor'
|
||||
viewBox='0 0 147 70'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<title>v0</title>
|
||||
<path d='M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z' />
|
||||
<path d='M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z' />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
cursor: {
|
||||
title: 'Open in Cursor',
|
||||
createUrl: (text: string) => {
|
||||
const url = new URL('https://cursor.com/link/prompt')
|
||||
url.searchParams.set('text', text)
|
||||
return url.toString()
|
||||
},
|
||||
icon: (
|
||||
<svg
|
||||
version='1.1'
|
||||
viewBox='0 0 466.73 532.09'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path
|
||||
d='M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const OpenInContext = createContext<{ query: string } | undefined>(undefined)
|
||||
|
||||
const useOpenInContext = () => {
|
||||
const context = useContext(OpenInContext)
|
||||
if (!context) {
|
||||
throw new Error('OpenIn components must be used within an OpenIn provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
|
||||
query: string
|
||||
}
|
||||
|
||||
export const OpenIn = ({ query, ...props }: OpenInProps) => (
|
||||
<OpenInContext.Provider value={{ query }}>
|
||||
<DropdownMenu {...props} />
|
||||
</OpenInContext.Provider>
|
||||
)
|
||||
|
||||
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>
|
||||
|
||||
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
className={cn('w-[240px]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInItem = (props: OpenInItemProps) => (
|
||||
<DropdownMenuItem {...props} />
|
||||
)
|
||||
|
||||
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>
|
||||
|
||||
export const OpenInLabel = (props: OpenInLabelProps) => (
|
||||
<DropdownMenuLabel {...props} />
|
||||
)
|
||||
|
||||
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>
|
||||
|
||||
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
|
||||
<DropdownMenuSeparator {...props} />
|
||||
)
|
||||
|
||||
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>
|
||||
|
||||
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<DropdownMenuTrigger
|
||||
{...props}
|
||||
render={
|
||||
<Button type='button' variant='outline'>
|
||||
{children ?? (
|
||||
<>
|
||||
{t('Open in chat')}
|
||||
<ChevronDownIcon className='ml-2 size-4' />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
{...props}
|
||||
render={
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.chatgpt.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='shrink-0'>{providers.chatgpt.icon}</span>
|
||||
<span className='flex-1'>{providers.chatgpt.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInClaude = (props: OpenInClaudeProps) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
{...props}
|
||||
render={
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.claude.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='shrink-0'>{providers.claude.icon}</span>
|
||||
<span className='flex-1'>{providers.claude.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInT3 = (props: OpenInT3Props) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
{...props}
|
||||
render={
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.t3.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='shrink-0'>{providers.t3.icon}</span>
|
||||
<span className='flex-1'>{providers.t3.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInScira = (props: OpenInSciraProps) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
{...props}
|
||||
render={
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.scira.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='shrink-0'>{providers.scira.icon}</span>
|
||||
<span className='flex-1'>{providers.scira.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInv0 = (props: OpenInv0Props) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
{...props}
|
||||
render={
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.v0.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='shrink-0'>{providers.v0.icon}</span>
|
||||
<span className='flex-1'>{providers.v0.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInCursor = (props: OpenInCursorProps) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
{...props}
|
||||
render={
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.cursor.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='shrink-0'>{providers.cursor.icon}</span>
|
||||
<span className='flex-1'>{providers.cursor.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
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 { Panel as PanelPrimitive } from '@xyflow/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PanelProps = ComponentProps<typeof PanelPrimitive>
|
||||
|
||||
export const Panel = ({ className, ...props }: PanelProps) => (
|
||||
<PanelPrimitive
|
||||
className={cn(
|
||||
'bg-card m-4 overflow-hidden rounded-md border p-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { ChevronsUpDownIcon } from 'lucide-react'
|
||||
import { type ComponentProps, createContext, useContext } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { Shimmer } from './shimmer'
|
||||
|
||||
type PlanContextValue = {
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null)
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext)
|
||||
if (!context) {
|
||||
throw new Error('Plan components must be used within Plan')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => (
|
||||
<PlanContext.Provider value={{ isStreaming }}>
|
||||
<Collapsible
|
||||
data-slot='plan'
|
||||
{...props}
|
||||
render={<Card className={cn('shadow-none', className)} />}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
)
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn('flex items-start justify-between', className)}
|
||||
data-slot='plan-header'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
'children'
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan()
|
||||
|
||||
return (
|
||||
<CardTitle data-slot='plan-title' {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
)
|
||||
}
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
'children'
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan()
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn('text-balance', className)}
|
||||
data-slot='plan-description'
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
)
|
||||
}
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot='plan-action' {...props} />
|
||||
)
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent
|
||||
render={<CardContent data-slot='plan-content' {...props} />}
|
||||
></CollapsibleContent>
|
||||
)
|
||||
|
||||
export type PlanFooterProps = ComponentProps<'div'>
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot='plan-footer' {...props} />
|
||||
)
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
className={cn('size-8', className)}
|
||||
data-slot='plan-trigger'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className='size-4' />
|
||||
<span className='sr-only'>{t('Toggle plan')}</span>
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { ChevronDownIcon, PaperclipIcon } from 'lucide-react'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type QueueMessagePart = {
|
||||
type: string
|
||||
text?: string
|
||||
url?: string
|
||||
filename?: string
|
||||
mediaType?: string
|
||||
}
|
||||
|
||||
export type QueueMessage = {
|
||||
id: string
|
||||
parts: QueueMessagePart[]
|
||||
}
|
||||
|
||||
export type QueueTodo = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
status?: 'pending' | 'completed'
|
||||
}
|
||||
|
||||
export type QueueItemProps = ComponentProps<'li'>
|
||||
|
||||
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
'group hover:bg-muted flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemIndicatorProps = ComponentProps<'span'> & {
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export const QueueItemIndicator = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemIndicatorProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 inline-block size-2.5 rounded-full border',
|
||||
completed
|
||||
? 'border-muted-foreground/20 bg-muted-foreground/10'
|
||||
: 'border-muted-foreground/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemContentProps = ComponentProps<'span'> & {
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export const QueueItemContent = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemContentProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
'line-clamp-1 grow break-words',
|
||||
completed
|
||||
? 'text-muted-foreground/50 line-through'
|
||||
: 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemDescriptionProps = ComponentProps<'div'> & {
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export const QueueItemDescription = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemDescriptionProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'ml-6 text-xs',
|
||||
completed
|
||||
? 'text-muted-foreground/40 line-through'
|
||||
: 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemActionsProps = ComponentProps<'div'>
|
||||
|
||||
export const QueueItemActions = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionsProps) => (
|
||||
<div className={cn('flex gap-1', className)} {...props} />
|
||||
)
|
||||
|
||||
export type QueueItemActionProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
'variant' | 'size'
|
||||
>
|
||||
|
||||
export const QueueItemAction = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground size-auto rounded p-1 opacity-0 transition-opacity group-hover:opacity-100',
|
||||
className
|
||||
)}
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemAttachmentProps = ComponentProps<'div'>
|
||||
|
||||
export const QueueItemAttachment = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemAttachmentProps) => (
|
||||
<div className={cn('mt-1 flex flex-wrap gap-2', className)} {...props} />
|
||||
)
|
||||
|
||||
export type QueueItemImageProps = ComponentProps<'img'>
|
||||
|
||||
export const QueueItemImage = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemImageProps) => (
|
||||
<img
|
||||
alt=''
|
||||
className={cn('h-8 w-8 rounded border object-cover', className)}
|
||||
height={32}
|
||||
width={32}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemFileProps = ComponentProps<'span'>
|
||||
|
||||
export const QueueItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemFileProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
'bg-muted flex items-center gap-1 rounded border px-2 py-1 text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaperclipIcon size={12} />
|
||||
<span className='max-w-[100px] truncate'>{children}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
export type QueueListProps = ComponentProps<typeof ScrollArea>
|
||||
|
||||
export const QueueList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueListProps) => (
|
||||
<ScrollArea className={cn('mt-2 -mb-1', className)} {...props}>
|
||||
<div className='max-h-40 pr-4'>
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
|
||||
// QueueSection - collapsible section container
|
||||
export type QueueSectionProps = ComponentProps<typeof Collapsible>
|
||||
|
||||
export const QueueSection = ({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
...props
|
||||
}: QueueSectionProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
)
|
||||
|
||||
// QueueSectionTrigger - section header/trigger
|
||||
export type QueueSectionTriggerProps = ComponentProps<'button'>
|
||||
|
||||
export const QueueSectionTrigger = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={cn(
|
||||
'group bg-muted/40 text-muted-foreground hover:bg-muted h-auto w-full justify-between px-3 py-2 text-left',
|
||||
className
|
||||
)}
|
||||
type='button'
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
|
||||
// QueueSectionLabel - label content with icon and count
|
||||
export type QueueSectionLabelProps = ComponentProps<'span'> & {
|
||||
count?: number
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const QueueSectionLabel = ({
|
||||
count,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<span className={cn('flex items-center gap-2', className)} {...props}>
|
||||
<ChevronDownIcon className='size-4 -rotate-90 transition-transform group-data-[panel-open]:rotate-0' />
|
||||
{icon}
|
||||
<span>
|
||||
{count} {label}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
// QueueSectionContent - collapsible content area
|
||||
export type QueueSectionContentProps = ComponentProps<typeof CollapsibleContent>
|
||||
|
||||
export const QueueSectionContent = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionContentProps) => (
|
||||
<CollapsibleContent className={cn(className)} {...props} />
|
||||
)
|
||||
|
||||
export type QueueProps = ComponentProps<'div'>
|
||||
|
||||
export const Queue = ({ className, ...props }: QueueProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border bg-background flex flex-col gap-2 rounded-xl border px-3 pt-2 pb-2 shadow-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { BrainIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
memo,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { useControllableState } from '@/lib/use-controllable-state'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { Response } from './response'
|
||||
import { Shimmer } from './shimmer'
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
duration: number
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
|
||||
|
||||
const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext)
|
||||
if (!context) {
|
||||
throw new Error('Reasoning components must be used within Reasoning')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000
|
||||
const MS_IN_S = 1000
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
})
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: 0,
|
||||
})
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||
const [startTime, setStartTime] = useState<number | null>(null)
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
||||
setStartTime(null)
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration])
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setHasAutoClosed(true)
|
||||
}, AUTO_CLOSE_DELAY)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4', className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning()
|
||||
const { t } = useTranslation()
|
||||
const thinkingText = t('Thought for {{duration}} seconds', {
|
||||
duration: duration ?? 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground inline-grid w-fit max-w-full grid-cols-[0.875rem_minmax(0,auto)_0.875rem] items-center gap-1.5 text-sm leading-none transition-colors [&_p]:m-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className='grid size-3.5 place-items-center'>
|
||||
<BrainIcon className='size-3.5' />
|
||||
</span>
|
||||
<span className='min-w-0 truncate leading-none'>
|
||||
{isStreaming ? (
|
||||
<Shimmer duration={1}>{t('Thinking...')}</Shimmer>
|
||||
) : (
|
||||
thinkingText
|
||||
)}
|
||||
</span>
|
||||
<span className='grid size-3.5 place-items-center'>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200 ease-out',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'CollapsibleContent group/reasoning-content border-border/70 mt-2 ml-1.5 border-l pl-3 text-sm leading-5',
|
||||
'text-muted-foreground outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='transition-[opacity,transform] duration-200 ease-out group-data-[closed]/reasoning-content:-translate-y-1 group-data-[closed]/reasoning-content:opacity-0 group-data-[open]/reasoning-content:translate-y-0 group-data-[open]/reasoning-content:opacity-100 motion-reduce:transition-none'>
|
||||
<Response className='grid gap-1.5 [&_li]:my-0.5 [&_ol]:my-1.5 [&_p]:my-1.5 [&_p]:leading-5 [&_ul]:my-1.5'>
|
||||
{children}
|
||||
</Response>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
)
|
||||
|
||||
Reasoning.displayName = 'Reasoning'
|
||||
ReasoningTrigger.displayName = 'ReasoningTrigger'
|
||||
ReasoningContent.displayName = 'ReasoningContent'
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
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 { ReactNode } from 'react'
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import { isFootnoteNode } from './response-node-guards'
|
||||
import type { ParsedResponseContent } from './response-types'
|
||||
|
||||
const FENCE_START_PATTERN = /^(`{3,}|~{3,})([^\n]*)$/
|
||||
const FENCE_END_PATTERN = /^(`{3,}|~{3,})\s*$/
|
||||
const SECTION_HEADING_PATTERN = /^#{2,6}\s+\d+\.\s+/
|
||||
const MARKDOWN_EXAMPLE_LANGUAGES = new Set(['markdown', 'md', 'mdx'])
|
||||
|
||||
type MarkdownExampleFence = {
|
||||
contentLines: string[]
|
||||
fenceChar: string
|
||||
language: string
|
||||
nestedFence: boolean
|
||||
}
|
||||
|
||||
function getFenceRunLength(line: string, fenceChar: string): number {
|
||||
let length = 0
|
||||
|
||||
for (const char of line) {
|
||||
if (char !== fenceChar) {
|
||||
break
|
||||
}
|
||||
|
||||
length++
|
||||
}
|
||||
|
||||
return length
|
||||
}
|
||||
|
||||
function getMarkdownExampleFenceLength(block: MarkdownExampleFence): number {
|
||||
let maxFenceLength = 3
|
||||
|
||||
for (const line of block.contentLines) {
|
||||
if (!line.startsWith(block.fenceChar)) {
|
||||
continue
|
||||
}
|
||||
|
||||
maxFenceLength = Math.max(
|
||||
maxFenceLength,
|
||||
getFenceRunLength(line, block.fenceChar) + 1
|
||||
)
|
||||
}
|
||||
|
||||
return maxFenceLength
|
||||
}
|
||||
|
||||
function appendMarkdownExampleFence(
|
||||
output: string[],
|
||||
block: MarkdownExampleFence
|
||||
): void {
|
||||
const fence = block.fenceChar.repeat(getMarkdownExampleFenceLength(block))
|
||||
|
||||
output.push(`${fence}${block.language}`)
|
||||
output.push(...block.contentLines)
|
||||
output.push(fence)
|
||||
}
|
||||
|
||||
function normalizeMarkdownExampleFences(input: string): string {
|
||||
const lines = input.split('\n')
|
||||
const output: string[] = []
|
||||
let exampleFence: MarkdownExampleFence | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
if (!exampleFence) {
|
||||
const match = line.match(FENCE_START_PATTERN)
|
||||
|
||||
if (!match) {
|
||||
output.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const language = match[2].trim().toLowerCase()
|
||||
if (MARKDOWN_EXAMPLE_LANGUAGES.has(language)) {
|
||||
exampleFence = {
|
||||
contentLines: [],
|
||||
fenceChar: match[1][0],
|
||||
language,
|
||||
nestedFence: false,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
output.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!exampleFence.nestedFence && SECTION_HEADING_PATTERN.test(line)) {
|
||||
appendMarkdownExampleFence(output, exampleFence)
|
||||
output.push(line)
|
||||
exampleFence = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (exampleFence.nestedFence && FENCE_END_PATTERN.test(line)) {
|
||||
exampleFence.contentLines.push(line)
|
||||
exampleFence.nestedFence = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
line.startsWith(exampleFence.fenceChar.repeat(3)) &&
|
||||
!FENCE_END_PATTERN.test(line)
|
||||
) {
|
||||
exampleFence.contentLines.push(line)
|
||||
exampleFence.nestedFence = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (FENCE_END_PATTERN.test(line)) {
|
||||
appendMarkdownExampleFence(output, exampleFence)
|
||||
exampleFence = null
|
||||
continue
|
||||
}
|
||||
|
||||
exampleFence.contentLines.push(line)
|
||||
}
|
||||
|
||||
if (exampleFence) {
|
||||
appendMarkdownExampleFence(output, exampleFence)
|
||||
}
|
||||
|
||||
return output.join('\n')
|
||||
}
|
||||
|
||||
export function stripCustomTags(input: unknown): string {
|
||||
if (typeof input !== 'string') {
|
||||
return String(input ?? '')
|
||||
}
|
||||
|
||||
return input
|
||||
.replaceAll(
|
||||
/<\/?(conversation|conversationcontent|reasoning|reasoningcontent|reasoningtrigger|sources|sourcescontent|sourcestrigger|branch|branchmessages|branchnext|branchpage|branchprevious|branchselector|message|messagecontent)\b[^>]*>/gi,
|
||||
''
|
||||
)
|
||||
.replaceAll(/<\/?think\b[^>]*>/gi, '')
|
||||
}
|
||||
|
||||
export function getMarkdownContent(children: ReactNode): string {
|
||||
if (Array.isArray(children)) {
|
||||
return normalizeMarkdownExampleFences(stripCustomTags(children.join('')))
|
||||
}
|
||||
|
||||
return normalizeMarkdownExampleFences(stripCustomTags(children))
|
||||
}
|
||||
|
||||
export function getNodeKey(node: ParsedNode, index: number): string {
|
||||
const raw = typeof node.raw === 'string' ? node.raw : ''
|
||||
return `${node.type}-${index}-${raw.slice(0, 24)}`
|
||||
}
|
||||
|
||||
export function parseResponseContent(
|
||||
nodes: ParsedNode[]
|
||||
): ParsedResponseContent {
|
||||
const footnotes: ParsedResponseContent['footnotes'] = []
|
||||
const bodyNodes: ParsedNode[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (isFootnoteNode(node)) {
|
||||
footnotes.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
bodyNodes.push(node)
|
||||
}
|
||||
|
||||
return { bodyNodes, footnotes }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
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 {
|
||||
BlockquoteNode,
|
||||
CodeBlockNode,
|
||||
DefinitionListNode,
|
||||
FootnoteNode,
|
||||
HeadingNode,
|
||||
HtmlBlockNode,
|
||||
ImageNode,
|
||||
LinkNode,
|
||||
ListNode,
|
||||
MathBlockNode,
|
||||
MathInlineNode,
|
||||
ParsedNode,
|
||||
TableNode,
|
||||
TextNode,
|
||||
} from 'stream-markdown-parser'
|
||||
|
||||
export function hasParsedChildren(
|
||||
node: ParsedNode
|
||||
): node is ParsedNode & { children: ParsedNode[] } {
|
||||
return 'children' in node && Array.isArray(node.children)
|
||||
}
|
||||
|
||||
export function isTextNode(node: ParsedNode): node is TextNode {
|
||||
return node.type === 'text' && 'content' in node
|
||||
}
|
||||
|
||||
export function isHeadingNode(node: ParsedNode): node is HeadingNode {
|
||||
return node.type === 'heading' && 'level' in node && hasParsedChildren(node)
|
||||
}
|
||||
|
||||
export function isListNode(node: ParsedNode): node is ListNode {
|
||||
return node.type === 'list' && 'items' in node && Array.isArray(node.items)
|
||||
}
|
||||
|
||||
export function isCodeBlockNode(node: ParsedNode): node is CodeBlockNode {
|
||||
return node.type === 'code_block' && 'code' in node && 'language' in node
|
||||
}
|
||||
|
||||
export function isLinkNode(node: ParsedNode): node is LinkNode {
|
||||
return node.type === 'link' && 'href' in node && hasParsedChildren(node)
|
||||
}
|
||||
|
||||
export function isImageNode(node: ParsedNode): node is ImageNode {
|
||||
return node.type === 'image' && 'src' in node && 'alt' in node
|
||||
}
|
||||
|
||||
export function isBlockquoteNode(node: ParsedNode): node is BlockquoteNode {
|
||||
return node.type === 'blockquote' && hasParsedChildren(node)
|
||||
}
|
||||
|
||||
export function isTableNode(node: ParsedNode): node is TableNode {
|
||||
return node.type === 'table' && 'header' in node && 'rows' in node
|
||||
}
|
||||
|
||||
export function isDefinitionListNode(
|
||||
node: ParsedNode
|
||||
): node is DefinitionListNode {
|
||||
return (
|
||||
node.type === 'definition_list' &&
|
||||
'items' in node &&
|
||||
Array.isArray(node.items)
|
||||
)
|
||||
}
|
||||
|
||||
export function isMathBlockNode(node: ParsedNode): node is MathBlockNode {
|
||||
return node.type === 'math_block' && 'content' in node
|
||||
}
|
||||
|
||||
export function isMathInlineNode(node: ParsedNode): node is MathInlineNode {
|
||||
return node.type === 'math_inline' && 'content' in node
|
||||
}
|
||||
|
||||
export function isFootnoteNode(node: ParsedNode): node is FootnoteNode {
|
||||
return node.type === 'footnote' && 'id' in node && hasParsedChildren(node)
|
||||
}
|
||||
|
||||
export function isHtmlBlockNode(node: ParsedNode): node is HtmlBlockNode {
|
||||
return node.type === 'html_block' && 'tag' in node
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
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 { t } from 'i18next'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { BlockquoteNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { hasParsedChildren } from './response-node-guards'
|
||||
import type {
|
||||
AlertConfig,
|
||||
AlertKind,
|
||||
BlockRendererOptions,
|
||||
} from './response-types'
|
||||
|
||||
const alertConfig = {
|
||||
note: {
|
||||
label: 'Note',
|
||||
className:
|
||||
'border-blue-500/40 bg-blue-500/8 text-blue-950 dark:text-blue-100',
|
||||
markerClassName: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
tip: {
|
||||
label: 'Tip',
|
||||
className:
|
||||
'border-emerald-500/40 bg-emerald-500/8 text-emerald-950 dark:text-emerald-100',
|
||||
markerClassName: 'text-emerald-600 dark:text-emerald-300',
|
||||
},
|
||||
important: {
|
||||
label: 'Important',
|
||||
className:
|
||||
'border-violet-500/40 bg-violet-500/8 text-violet-950 dark:text-violet-100',
|
||||
markerClassName: 'text-violet-600 dark:text-violet-300',
|
||||
},
|
||||
warning: {
|
||||
label: 'Warning',
|
||||
className:
|
||||
'border-amber-500/40 bg-amber-500/8 text-amber-950 dark:text-amber-100',
|
||||
markerClassName: 'text-amber-600 dark:text-amber-300',
|
||||
},
|
||||
caution: {
|
||||
label: 'Caution',
|
||||
className: 'border-red-500/40 bg-red-500/8 text-red-950 dark:text-red-100',
|
||||
markerClassName: 'text-red-600 dark:text-red-300',
|
||||
},
|
||||
} satisfies Record<AlertKind, AlertConfig>
|
||||
|
||||
function getAlertKind(node: BlockquoteNode): AlertKind | null {
|
||||
const firstChild = node.children[0]
|
||||
if (!firstChild || firstChild.type !== 'paragraph') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasParsedChildren(firstChild)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const firstInline = firstChild.children[0]
|
||||
if (!firstInline || firstInline.type !== 'text') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!('content' in firstInline) || typeof firstInline.content !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const markerPattern = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?/i
|
||||
const match = firstInline.content.match(markerPattern)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return match[1].toLowerCase() as AlertKind
|
||||
}
|
||||
|
||||
function getAlertChildren(node: BlockquoteNode, kind: AlertKind): ParsedNode[] {
|
||||
const firstChild = node.children[0]
|
||||
if (!firstChild || firstChild.type !== 'paragraph') {
|
||||
return node.children
|
||||
}
|
||||
|
||||
if (!hasParsedChildren(firstChild)) {
|
||||
return node.children
|
||||
}
|
||||
|
||||
const firstInline = firstChild.children[0]
|
||||
if (!firstInline || firstInline.type !== 'text') {
|
||||
return node.children
|
||||
}
|
||||
|
||||
if (!('content' in firstInline) || typeof firstInline.content !== 'string') {
|
||||
return node.children
|
||||
}
|
||||
|
||||
const marker = `[!${kind.toUpperCase()}]`
|
||||
const content = firstInline.content.replace(marker, '').replace(/^\s*\n?/, '')
|
||||
const nextParagraph = {
|
||||
...firstChild,
|
||||
children: [
|
||||
{ ...firstInline, content, raw: content },
|
||||
...firstChild.children.slice(1),
|
||||
],
|
||||
}
|
||||
|
||||
if (!content && nextParagraph.children.length === 1) {
|
||||
return node.children.slice(1)
|
||||
}
|
||||
|
||||
return [nextParagraph, ...node.children.slice(1)]
|
||||
}
|
||||
|
||||
export function renderBlockquote(
|
||||
node: BlockquoteNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
const alertKind = getAlertKind(node)
|
||||
if (alertKind) {
|
||||
const config = alertConfig[alertKind]
|
||||
const alertChildren = getAlertChildren(node, alertKind)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'my-4 rounded-lg border px-4 py-3 text-sm',
|
||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
config.className
|
||||
)}
|
||||
key={key}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 text-xs font-semibold tracking-wide uppercase',
|
||||
config.markerClassName
|
||||
)}
|
||||
>
|
||||
{t(config.label)}
|
||||
</div>
|
||||
{options.renderChildren(alertChildren)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<blockquote
|
||||
className='border-border text-muted-foreground my-4 border-l-2 pl-4'
|
||||
key={key}
|
||||
>
|
||||
{options.renderChildren(node.children)}
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
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 { ReactNode } from 'react'
|
||||
import type {
|
||||
CodeBlockNode,
|
||||
DefinitionItemNode,
|
||||
DefinitionListNode,
|
||||
HeadingNode,
|
||||
ListNode,
|
||||
MathBlockNode,
|
||||
MathInlineNode,
|
||||
} from 'stream-markdown-parser'
|
||||
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getNodeKey } from './response-content'
|
||||
import type { BlockRendererOptions } from './response-types'
|
||||
|
||||
const headingClasses = {
|
||||
1: 'mt-6 mb-3 text-xl font-semibold tracking-normal',
|
||||
2: 'mt-6 mb-3 text-lg font-semibold tracking-normal',
|
||||
3: 'mt-5 mb-2 text-base font-semibold tracking-normal',
|
||||
4: 'mt-5 mb-2 text-sm font-semibold tracking-normal',
|
||||
5: 'text-muted-foreground mt-4 mb-2 text-sm font-semibold tracking-normal',
|
||||
6: 'text-muted-foreground mt-4 mb-2 text-xs font-semibold tracking-normal uppercase',
|
||||
} satisfies Record<1 | 2 | 3 | 4 | 5 | 6, string>
|
||||
|
||||
export function renderHeading(
|
||||
node: HeadingNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
const headingLevel = Math.min(Math.max(node.level, 1), 6) as
|
||||
| 1
|
||||
| 2
|
||||
| 3
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
const className = headingClasses[headingLevel]
|
||||
const children = options.renderChildren(node.children)
|
||||
|
||||
if (headingLevel === 1) {
|
||||
return (
|
||||
<h1 className={className} key={key}>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
if (headingLevel === 2) {
|
||||
return (
|
||||
<h2 className={className} key={key}>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
if (headingLevel === 3) {
|
||||
return (
|
||||
<h3 className={className} key={key}>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
if (headingLevel === 4) {
|
||||
return (
|
||||
<h4 className={className} key={key}>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
}
|
||||
|
||||
if (headingLevel === 5) {
|
||||
return (
|
||||
<h5 className={className} key={key}>
|
||||
{children}
|
||||
</h5>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<h6 className={className} key={key}>
|
||||
{children}
|
||||
</h6>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderList(
|
||||
node: ListNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
const className = cn(
|
||||
'my-3 list-outside space-y-1.5 pl-5',
|
||||
node.ordered ? 'list-decimal' : 'list-disc'
|
||||
)
|
||||
const items = node.items.map((item, index) => (
|
||||
<li
|
||||
className='marker:text-muted-foreground pl-1 leading-7'
|
||||
key={getNodeKey(item, index)}
|
||||
>
|
||||
{options.renderChildren(item.children)}
|
||||
</li>
|
||||
))
|
||||
|
||||
if (node.ordered) {
|
||||
return (
|
||||
<ol className={className} key={key} start={node.start}>
|
||||
{items}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={className} key={key}>
|
||||
{items}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderCodeBlock(node: CodeBlockNode, key: string): ReactNode {
|
||||
const language = node.language || 'plaintext'
|
||||
const lineCount = node.code.split('\n').length
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
collapsedLines={14}
|
||||
code={node.code}
|
||||
defaultCollapsed={lineCount > 14}
|
||||
key={key}
|
||||
language={language}
|
||||
maxExpandedLines={44}
|
||||
showLineNumbers
|
||||
showToolbar
|
||||
title={language}
|
||||
>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlock>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderDefinitionList(
|
||||
node: DefinitionListNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
return (
|
||||
<dl className='my-4 space-y-3' key={key}>
|
||||
{node.items.map((item, index) =>
|
||||
renderDefinitionItem(item, index, options)
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefinitionItem(
|
||||
node: DefinitionItemNode,
|
||||
index: number,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
return (
|
||||
<div key={`definition-${index}`}>
|
||||
<dt className='font-semibold'>{options.renderChildren(node.term)}</dt>
|
||||
<dd className='text-muted-foreground mt-1 pl-4'>
|
||||
{options.renderChildren(node.definition)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderMathBlock(node: MathBlockNode, key: string): ReactNode {
|
||||
return (
|
||||
<pre
|
||||
className='border-border bg-muted/40 my-4 overflow-x-auto rounded-lg border p-4 font-mono text-sm'
|
||||
key={key}
|
||||
>
|
||||
{node.content}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderMathInline(node: MathInlineNode, key: string): ReactNode {
|
||||
return (
|
||||
<code
|
||||
className='bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]'
|
||||
key={key}
|
||||
>
|
||||
{node.content}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
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 { t } from 'i18next'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HtmlBlockNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import { hasParsedChildren, isHtmlBlockNode } from './response-node-guards'
|
||||
import type { BlockRendererOptions } from './response-types'
|
||||
|
||||
export function renderDetails(
|
||||
node: HtmlBlockNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
const children = Array.isArray(node.children) ? node.children : []
|
||||
const summaryNode = children.find(isSummaryHtmlNode)
|
||||
const contentNodes = children.filter((child) => child !== summaryNode)
|
||||
const summary = getDetailsSummary(summaryNode, options)
|
||||
|
||||
return (
|
||||
<details
|
||||
className='border-border/70 my-4 rounded-lg border px-4 py-3'
|
||||
key={key}
|
||||
>
|
||||
<summary className='text-foreground cursor-pointer text-sm font-semibold'>
|
||||
{summary}
|
||||
</summary>
|
||||
<div className='border-border/70 mt-3 border-l pl-4 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0'>
|
||||
{options.renderChildren(contentNodes)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function isSummaryHtmlNode(node: ParsedNode): node is HtmlBlockNode {
|
||||
return isHtmlBlockNode(node) && node.tag === 'summary'
|
||||
}
|
||||
|
||||
function getDetailsSummary(
|
||||
node: HtmlBlockNode | undefined,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
if (!node || !hasParsedChildren(node)) {
|
||||
return t('Details')
|
||||
}
|
||||
|
||||
return options.renderChildren(node.children)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
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 { t } from 'i18next'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FootnoteNode } from 'stream-markdown-parser'
|
||||
|
||||
import type { BlockRendererOptions } from './response-types'
|
||||
|
||||
export function renderFootnotes(
|
||||
footnotes: FootnoteNode[],
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
if (footnotes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='border-border/70 text-muted-foreground mt-6 border-t pt-3 text-sm'>
|
||||
<ol className='list-decimal space-y-2 pl-5'>
|
||||
{footnotes.map((footnote) => (
|
||||
<li id={`footnote-${footnote.id}`} key={footnote.id}>
|
||||
<div className='inline [&>*:first-child]:mt-0 [&>*:last-child]:mb-0'>
|
||||
{options.renderChildren(footnote.children)}
|
||||
</div>
|
||||
<a
|
||||
aria-label={t('Back to footnote {{id}} reference', {
|
||||
id: footnote.id,
|
||||
})}
|
||||
className='text-primary ml-2 underline-offset-2 hover:underline'
|
||||
href={`#footnote-ref-${footnote.id}`}
|
||||
>
|
||||
{t('Back')}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
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 { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { sanitizeImageSrc, type ImageNode } from 'stream-markdown-parser'
|
||||
|
||||
type ResponseImageProps = {
|
||||
node: ImageNode
|
||||
}
|
||||
|
||||
export function ResponseImage(props: ResponseImageProps) {
|
||||
const { t } = useTranslation()
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const src = sanitizeImageSrc(props.node.src)
|
||||
|
||||
if (!src || hasError) {
|
||||
return (
|
||||
<span className='border-border/70 text-muted-foreground my-4 inline-flex rounded-md border px-3 py-2 text-xs italic'>
|
||||
{props.node.alt || t('Image not available')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={props.node.alt}
|
||||
className='border-border/70 my-4 block h-auto max-h-96 max-w-full rounded-lg border object-contain'
|
||||
loading='lazy'
|
||||
onError={() => setHasError(true)}
|
||||
src={src}
|
||||
title={props.node.title ?? undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
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 { ReactNode } from 'react'
|
||||
import {
|
||||
shouldOpenLinkInNewTab,
|
||||
type ImageNode,
|
||||
type LinkNode,
|
||||
type TextNode,
|
||||
} from 'stream-markdown-parser'
|
||||
|
||||
import { ResponseImage } from './response-renderer-image'
|
||||
import type { RenderChildren } from './response-types'
|
||||
|
||||
export function renderTextNode(node: TextNode): ReactNode {
|
||||
return node.content
|
||||
}
|
||||
|
||||
export function renderLink(
|
||||
node: LinkNode,
|
||||
key: string,
|
||||
renderChildren: RenderChildren
|
||||
): ReactNode {
|
||||
const opensInNewTab = shouldOpenLinkInNewTab(node.href)
|
||||
const rel = opensInNewTab ? 'noreferrer noopener' : undefined
|
||||
const target = opensInNewTab ? '_blank' : undefined
|
||||
|
||||
return (
|
||||
<a
|
||||
className='text-primary underline-offset-4 hover:underline'
|
||||
href={node.href}
|
||||
key={key}
|
||||
rel={rel}
|
||||
target={target}
|
||||
title={node.title ?? undefined}
|
||||
>
|
||||
{renderChildren(node.children)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderImage(node: ImageNode, key: string): ReactNode {
|
||||
return <ResponseImage key={key} node={node} />
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
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 { ReactNode } from 'react'
|
||||
import type { TableCellNode, TableNode } from 'stream-markdown-parser'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getNodeKey } from './response-content'
|
||||
import type { BlockRendererOptions } from './response-types'
|
||||
|
||||
function getTableCellAlignClass(
|
||||
align: TableCellNode['align'] | undefined
|
||||
): string {
|
||||
if (align === 'right') {
|
||||
return 'text-right'
|
||||
}
|
||||
|
||||
if (align === 'center') {
|
||||
return 'text-center'
|
||||
}
|
||||
|
||||
return 'text-left'
|
||||
}
|
||||
|
||||
function renderTableCell(
|
||||
node: TableCellNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
const alignClass = getTableCellAlignClass(node.align)
|
||||
|
||||
if (node.header) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'text-muted-foreground px-3 py-2 text-xs font-semibold whitespace-nowrap',
|
||||
alignClass
|
||||
)}
|
||||
key={key}
|
||||
>
|
||||
{options.renderChildren(node.children)}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<td className={cn('px-3 py-2 align-top', alignClass)} key={key}>
|
||||
{options.renderChildren(node.children)}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderTable(
|
||||
node: TableNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className='border-border/70 my-4 w-full overflow-x-auto rounded-lg border'
|
||||
key={key}
|
||||
>
|
||||
<table className='my-0 w-full min-w-max border-separate border-spacing-0 text-sm'>
|
||||
<thead className='bg-muted/60'>
|
||||
<tr className='border-border/70'>
|
||||
{node.header.cells.map((cell, index) =>
|
||||
renderTableCell(cell, getNodeKey(cell, index), options)
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-border/70 divide-y'>
|
||||
{node.rows.map((row, rowIndex) => (
|
||||
<tr className='border-border/70' key={getNodeKey(row, rowIndex)}>
|
||||
{row.cells.map((cell, cellIndex) =>
|
||||
renderTableCell(cell, getNodeKey(cell, cellIndex), options)
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
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 { ReactNode } from 'react'
|
||||
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import { getNodeKey } from './response-content'
|
||||
import {
|
||||
hasParsedChildren,
|
||||
isBlockquoteNode,
|
||||
isCodeBlockNode,
|
||||
isDefinitionListNode,
|
||||
isHeadingNode,
|
||||
isHtmlBlockNode,
|
||||
isImageNode,
|
||||
isLinkNode,
|
||||
isListNode,
|
||||
isMathBlockNode,
|
||||
isMathInlineNode,
|
||||
isTableNode,
|
||||
isTextNode,
|
||||
} from './response-node-guards'
|
||||
import { renderBlockquote } from './response-renderer-alert'
|
||||
import {
|
||||
renderCodeBlock,
|
||||
renderDefinitionList,
|
||||
renderHeading,
|
||||
renderList,
|
||||
renderMathBlock,
|
||||
renderMathInline,
|
||||
} from './response-renderer-blocks'
|
||||
import { renderDetails } from './response-renderer-details'
|
||||
import { renderFootnotes as renderFootnotesBlock } from './response-renderer-footnotes'
|
||||
import {
|
||||
renderImage,
|
||||
renderLink,
|
||||
renderTextNode,
|
||||
} from './response-renderer-inline'
|
||||
import { renderTable } from './response-renderer-table'
|
||||
|
||||
export function renderChildren(nodes: ParsedNode[]): ReactNode {
|
||||
return nodes.map((node, index) => renderNode(node, getNodeKey(node, index)))
|
||||
}
|
||||
|
||||
export function renderFootnotes(footnotes: FootnoteNode[]): ReactNode {
|
||||
return renderFootnotesBlock(footnotes, { renderChildren })
|
||||
}
|
||||
|
||||
function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
if (isTextNode(node)) {
|
||||
return renderTextNode(node)
|
||||
}
|
||||
|
||||
if (isHeadingNode(node)) {
|
||||
return renderHeading(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (node.type === 'paragraph' && hasParsedChildren(node)) {
|
||||
return (
|
||||
<p className='my-3 leading-7' key={key}>
|
||||
{renderChildren(node.children)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'inline' && hasParsedChildren(node)) {
|
||||
return <span key={key}>{renderChildren(node.children)}</span>
|
||||
}
|
||||
|
||||
if (isListNode(node)) {
|
||||
return renderList(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (isCodeBlockNode(node)) {
|
||||
return renderCodeBlock(node, key)
|
||||
}
|
||||
|
||||
if (node.type === 'inline_code' && 'code' in node) {
|
||||
return (
|
||||
<code
|
||||
className='bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]'
|
||||
key={key}
|
||||
>
|
||||
{String(node.code)}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLinkNode(node)) {
|
||||
return renderLink(node, key, renderChildren)
|
||||
}
|
||||
|
||||
if (isImageNode(node)) {
|
||||
return renderImage(node, key)
|
||||
}
|
||||
|
||||
if (isBlockquoteNode(node)) {
|
||||
return renderBlockquote(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (isTableNode(node)) {
|
||||
return renderTable(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (isDefinitionListNode(node)) {
|
||||
return renderDefinitionList(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (node.type === 'strong' && hasParsedChildren(node)) {
|
||||
return (
|
||||
<strong className='text-foreground font-semibold' key={key}>
|
||||
{renderChildren(node.children)}
|
||||
</strong>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'emphasis' && hasParsedChildren(node)) {
|
||||
return <em key={key}>{renderChildren(node.children)}</em>
|
||||
}
|
||||
|
||||
if (node.type === 'strikethrough' && hasParsedChildren(node)) {
|
||||
return <del key={key}>{renderChildren(node.children)}</del>
|
||||
}
|
||||
|
||||
if (node.type === 'highlight' && hasParsedChildren(node)) {
|
||||
return <mark key={key}>{renderChildren(node.children)}</mark>
|
||||
}
|
||||
|
||||
if (node.type === 'insert' && hasParsedChildren(node)) {
|
||||
return <ins key={key}>{renderChildren(node.children)}</ins>
|
||||
}
|
||||
|
||||
if (node.type === 'subscript' && hasParsedChildren(node)) {
|
||||
return <sub key={key}>{renderChildren(node.children)}</sub>
|
||||
}
|
||||
|
||||
if (node.type === 'superscript' && hasParsedChildren(node)) {
|
||||
return <sup key={key}>{renderChildren(node.children)}</sup>
|
||||
}
|
||||
|
||||
if (
|
||||
(node.type === 'checkbox' || node.type === 'checkbox_input') &&
|
||||
'checked' in node
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
checked={Boolean(node.checked)}
|
||||
className='accent-primary mr-2 size-4 align-[-0.15em]'
|
||||
disabled
|
||||
key={key}
|
||||
readOnly
|
||||
type='checkbox'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'hardbreak') {
|
||||
return <br key={key} />
|
||||
}
|
||||
|
||||
if (node.type === 'thematic_break') {
|
||||
return <hr className='border-border/70 my-6' key={key} />
|
||||
}
|
||||
|
||||
if (isMathBlockNode(node)) {
|
||||
return renderMathBlock(node, key)
|
||||
}
|
||||
|
||||
if (isMathInlineNode(node)) {
|
||||
return renderMathInline(node, key)
|
||||
}
|
||||
|
||||
if (node.type === 'footnote_reference' && 'id' in node) {
|
||||
return (
|
||||
<sup className='text-primary mx-0.5 text-xs' key={key}>
|
||||
<a
|
||||
className='underline-offset-2 hover:underline'
|
||||
href={`#footnote-${String(node.id)}`}
|
||||
id={`footnote-ref-${String(node.id)}`}
|
||||
>
|
||||
[{String(node.id)}]
|
||||
</a>
|
||||
</sup>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'footnote_anchor') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isHtmlBlockNode(node) && node.tag === 'details') {
|
||||
return renderDetails(node, key, { renderChildren })
|
||||
}
|
||||
|
||||
if (node.type === 'html_block' && 'content' in node) {
|
||||
return <span key={key}>{String(node.content)}</span>
|
||||
}
|
||||
|
||||
if (node.type === 'html_inline' && 'content' in node) {
|
||||
return <span key={key}>{String(node.content)}</span>
|
||||
}
|
||||
|
||||
if (hasParsedChildren(node)) {
|
||||
return <span key={key}>{renderChildren(node.children)}</span>
|
||||
}
|
||||
|
||||
if ('content' in node && typeof node.content === 'string') {
|
||||
return <span key={key}>{node.content}</span>
|
||||
}
|
||||
|
||||
return <span key={key}>{node.raw}</span>
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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 { ReactNode } from 'react'
|
||||
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
export type ResponseProps = {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
final?: boolean
|
||||
}
|
||||
|
||||
export type AlertKind = 'note' | 'tip' | 'important' | 'warning' | 'caution'
|
||||
|
||||
export type AlertConfig = {
|
||||
label: string
|
||||
className: string
|
||||
markerClassName: string
|
||||
}
|
||||
|
||||
export type ParsedResponseContent = {
|
||||
bodyNodes: ParsedNode[]
|
||||
footnotes: FootnoteNode[]
|
||||
}
|
||||
|
||||
export type RenderChildren = (nodes: ParsedNode[]) => ReactNode
|
||||
|
||||
export type BlockRendererOptions = {
|
||||
renderChildren: RenderChildren
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getMarkdownContent, parseResponseContent } from './response-content'
|
||||
import { renderChildren, renderFootnotes } from './response-renderer'
|
||||
import type { ResponseProps } from './response-types'
|
||||
|
||||
const markdown = getMarkdown('new-api-response')
|
||||
const MAX_PARSED_MARKDOWN_CHARS = 20_000
|
||||
|
||||
export const Response = memo((props: ResponseProps) => {
|
||||
const content = getMarkdownContent(props.children)
|
||||
const shouldParseMarkdown = content.length <= MAX_PARSED_MARKDOWN_CHARS
|
||||
const nodes = useMemo(() => {
|
||||
if (!shouldParseMarkdown) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parseMarkdownToStructure(content, markdown, {
|
||||
final: props.final ?? true,
|
||||
validateLink: markdown.options.validateLink,
|
||||
})
|
||||
}, [content, props.final, shouldParseMarkdown])
|
||||
const parsedContent = useMemo(() => parseResponseContent(nodes), [nodes])
|
||||
const renderedContent =
|
||||
parsedContent.bodyNodes.length > 0
|
||||
? renderChildren(parsedContent.bodyNodes)
|
||||
: content
|
||||
const footnotes = renderFootnotes(parsedContent.footnotes)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'size-full min-w-0 text-pretty [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{renderedContent}
|
||||
{footnotes}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Response.displayName = 'Response'
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { type CSSProperties, type ElementType, memo, useMemo } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string
|
||||
as?: ElementType
|
||||
className?: string
|
||||
duration?: number
|
||||
spread?: number
|
||||
}
|
||||
|
||||
const MotionP = motion.p
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
)
|
||||
|
||||
return (
|
||||
<MotionP
|
||||
animate={{ backgroundPosition: '0% center' }}
|
||||
className={cn(
|
||||
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent',
|
||||
'[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))]',
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: '100% center' }}
|
||||
style={
|
||||
{
|
||||
'--spread': `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
'var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))',
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: 'linear',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionP>
|
||||
)
|
||||
}
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent)
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { BookIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SourcesProps = ComponentProps<'div'>
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn('not-prose text-primary mb-4 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number
|
||||
}
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className='font-medium'>
|
||||
{t('Used')} {count} {t('sources')}
|
||||
</p>
|
||||
<ChevronDownIcon className='h-4 w-4' />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'border-border/70 mt-3 ml-2 flex w-fit flex-col gap-2 border-l pl-4',
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type SourceProps = ComponentProps<'a'>
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={href}
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className='h-4 w-4' />
|
||||
<span className='block font-medium'>{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>
|
||||
|
||||
export const Suggestions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SuggestionsProps) => (
|
||||
<ScrollArea className='w-full overflow-x-auto whitespace-nowrap' {...props}>
|
||||
<div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
<ScrollBar className='hidden' orientation='horizontal' />
|
||||
</ScrollArea>
|
||||
)
|
||||
|
||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
|
||||
suggestion: string
|
||||
onClick?: (suggestion: string) => void
|
||||
}
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onClick,
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
children,
|
||||
...props
|
||||
}: SuggestionProps) => {
|
||||
const handleClick = () => {
|
||||
onClick?.(suggestion)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('cursor-pointer px-4', className)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type='button'
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children || suggestion}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { ChevronDownIcon, SearchIcon } from 'lucide-react'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<'div'>
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-secondary text-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type TaskItemProps = ComponentProps<'div'>
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn('text-muted-foreground text-sm', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
)
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string
|
||||
}
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn('group', className)}
|
||||
{...props}
|
||||
render={
|
||||
<div className='text-muted-foreground hover:text-foreground flex w-full cursor-pointer items-center gap-2 text-sm transition-colors'>
|
||||
{children ?? (
|
||||
<>
|
||||
<SearchIcon className='size-4' />
|
||||
<p className='text-sm'>{title}</p>
|
||||
<ChevronDownIcon className='size-4 transition-transform group-data-[panel-open]:rotate-180' />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-popover-foreground data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='border-muted mt-4 space-y-2 border-l-2 pl-4'>
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import type { ToolUIPart } from 'ai'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from 'lucide-react'
|
||||
import { type ComponentProps, isValidElement, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { CodeBlock } from './code-block'
|
||||
|
||||
// Workaround for missing types in 'ai' package
|
||||
type ExtendedToolState =
|
||||
| ToolUIPart['state']
|
||||
| 'approval-requested'
|
||||
| 'approval-responded'
|
||||
| 'output-denied'
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4 w-full rounded-md border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string
|
||||
type: ToolUIPart['type']
|
||||
state: ExtendedToolState
|
||||
className?: string
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: ExtendedToolState) => {
|
||||
const labels: Record<ExtendedToolState, string> = {
|
||||
'input-streaming': 'Pending',
|
||||
'input-available': 'Running',
|
||||
'approval-requested': 'Awaiting Approval',
|
||||
'approval-responded': 'Responded',
|
||||
'output-available': 'Completed',
|
||||
'output-error': 'Error',
|
||||
'output-denied': 'Denied',
|
||||
}
|
||||
|
||||
const icons: Record<ExtendedToolState, ReactNode> = {
|
||||
'input-streaming': <CircleIcon className='size-4' />,
|
||||
'input-available': <ClockIcon className='size-4 animate-pulse' />,
|
||||
'approval-requested': <ClockIcon className='text-warning size-4' />,
|
||||
'approval-responded': <CheckCircleIcon className='text-info size-4' />,
|
||||
'output-available': <CheckCircleIcon className='text-success size-4' />,
|
||||
'output-error': <XCircleIcon className='text-destructive size-4' />,
|
||||
'output-denied': <XCircleIcon className='text-warning size-4' />,
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className='gap-1.5 text-xs' variant='secondary'>
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'group flex w-full items-center justify-between gap-4 p-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<WrenchIcon className='text-muted-foreground size-4' />
|
||||
<span className='text-sm font-medium'>
|
||||
{title ?? type.split('-').slice(1).join('-')}
|
||||
</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className='text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180' />
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-popover-foreground data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ToolInputProps = ComponentProps<'div'> & {
|
||||
input: ToolUIPart['input']
|
||||
}
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
|
||||
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
|
||||
{t('Parameters')}
|
||||
</h4>
|
||||
<div className='bg-muted/50 rounded-md'>
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language='json' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ToolOutputProps = ComponentProps<'div'> & {
|
||||
output: ToolUIPart['output']
|
||||
errorText: ToolUIPart['errorText']
|
||||
}
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>
|
||||
|
||||
if (typeof output === 'object' && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language='json' />
|
||||
)
|
||||
} else if (typeof output === 'string') {
|
||||
Output = <CodeBlock code={output} language='json' />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2 p-4', className)} {...props}>
|
||||
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
|
||||
{errorText ? 'Error' : 'Result'}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
|
||||
errorText
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
: 'bg-muted/50 text-foreground'
|
||||
)}
|
||||
>
|
||||
{errorText && <div>{errorText}</div>}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
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 { NodeToolbar, Position } from '@xyflow/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ToolbarProps = ComponentProps<typeof NodeToolbar>
|
||||
|
||||
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
|
||||
<NodeToolbar
|
||||
className={cn(
|
||||
'bg-background flex items-center gap-1 rounded-sm border p-1.5',
|
||||
className
|
||||
)}
|
||||
position={Position.Bottom}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type WebPreviewContextValue = {
|
||||
url: string
|
||||
setUrl: (url: string) => void
|
||||
consoleOpen: boolean
|
||||
setConsoleOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null)
|
||||
|
||||
const useWebPreview = () => {
|
||||
const context = useContext(WebPreviewContext)
|
||||
if (!context) {
|
||||
throw new Error('WebPreview components must be used within a WebPreview')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type WebPreviewProps = ComponentProps<'div'> & {
|
||||
defaultUrl?: string
|
||||
onUrlChange?: (url: string) => void
|
||||
}
|
||||
|
||||
export const WebPreview = ({
|
||||
className,
|
||||
children,
|
||||
defaultUrl = '',
|
||||
onUrlChange,
|
||||
...props
|
||||
}: WebPreviewProps) => {
|
||||
const [url, setUrl] = useState(defaultUrl)
|
||||
const [consoleOpen, setConsoleOpen] = useState(false)
|
||||
|
||||
const handleUrlChange = (newUrl: string) => {
|
||||
setUrl(newUrl)
|
||||
onUrlChange?.(newUrl)
|
||||
}
|
||||
|
||||
const contextValue: WebPreviewContextValue = {
|
||||
url,
|
||||
setUrl: handleUrlChange,
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
}
|
||||
|
||||
return (
|
||||
<WebPreviewContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card flex size-full flex-col rounded-lg border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WebPreviewContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type WebPreviewNavigationProps = ComponentProps<'div'>
|
||||
|
||||
export const WebPreviewNavigation = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationProps) => (
|
||||
<div
|
||||
className={cn('flex items-center gap-1 border-b p-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export const WebPreviewNavigationButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationButtonProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
className='hover:text-foreground h-8 w-8 p-0'
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>
|
||||
|
||||
export const WebPreviewUrl = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: WebPreviewUrlProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { url, setUrl } = useWebPreview()
|
||||
const [inputValue, setInputValue] = useState(url)
|
||||
|
||||
// Sync input value with context URL when it changes externally
|
||||
useEffect(() => {
|
||||
setInputValue(url)
|
||||
}, [url])
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value)
|
||||
onChange?.(event)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
const target = event.target as HTMLInputElement
|
||||
setUrl(target.value)
|
||||
}
|
||||
onKeyDown?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className='h-8 flex-1 text-sm'
|
||||
onChange={onChange ?? handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('Enter URL...')}
|
||||
value={value ?? inputValue}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
|
||||
loading?: ReactNode
|
||||
}
|
||||
|
||||
export const WebPreviewBody = ({
|
||||
className,
|
||||
loading,
|
||||
src,
|
||||
...props
|
||||
}: WebPreviewBodyProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { url } = useWebPreview()
|
||||
|
||||
return (
|
||||
<div className='flex-1'>
|
||||
<iframe
|
||||
className={cn('size-full', className)}
|
||||
sandbox='allow-scripts allow-same-origin allow-forms allow-popups allow-presentation'
|
||||
src={(src ?? url) || undefined}
|
||||
title={t('Preview')}
|
||||
{...props}
|
||||
/>
|
||||
{loading}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
|
||||
logs?: Array<{
|
||||
level: 'log' | 'warn' | 'error'
|
||||
message: string
|
||||
timestamp: Date
|
||||
}>
|
||||
}
|
||||
|
||||
export const WebPreviewConsole = ({
|
||||
className,
|
||||
logs = [],
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewConsoleProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { consoleOpen, setConsoleOpen } = useWebPreview()
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn('bg-muted/50 border-t font-mono text-sm', className)}
|
||||
onOpenChange={setConsoleOpen}
|
||||
open={consoleOpen}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
className='hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium'
|
||||
variant='ghost'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t('Console')}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
consoleOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'px-4 pb-4',
|
||||
'data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-closed:animate-out data-open:animate-in outline-none'
|
||||
)}
|
||||
>
|
||||
<div className='max-h-48 space-y-1 overflow-y-auto'>
|
||||
{logs.length === 0 ? (
|
||||
<p className='text-muted-foreground'>{t('No console output')}</p>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs',
|
||||
log.level === 'error' && 'text-destructive',
|
||||
log.level === 'warn' && 'text-warning',
|
||||
log.level === 'log' && 'text-foreground'
|
||||
)}
|
||||
key={`${log.timestamp.getTime()}-${index}`}
|
||||
>
|
||||
<span className='text-muted-foreground'>
|
||||
{dayjs(log.timestamp).format('HH:mm:ss')}
|
||||
</span>{' '}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
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 { useRef, useEffect, type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AnimateInViewProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
delay?: number
|
||||
threshold?: number
|
||||
animation?: 'fade-up' | 'fade-in' | 'scale-in' | 'fade-left' | 'fade-right'
|
||||
once?: boolean
|
||||
as?: 'div' | 'section' | 'li' | 'span'
|
||||
}
|
||||
|
||||
export function AnimateInView(props: AnimateInViewProps) {
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
delay = 0,
|
||||
threshold = 0.15,
|
||||
animation = 'fade-up',
|
||||
once = true,
|
||||
} = props
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
if (mq.matches) {
|
||||
el.classList.remove('opacity-0')
|
||||
el.classList.add(`landing-animate-${animation}`)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
el.classList.remove('opacity-0')
|
||||
el.classList.add(`landing-animate-${animation}`)
|
||||
if (once) observer.unobserve(el)
|
||||
} else if (!once) {
|
||||
el.classList.add('opacity-0')
|
||||
el.classList.remove(`landing-animate-${animation}`)
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin: '0px 0px -40px 0px' }
|
||||
)
|
||||
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [threshold, once, animation])
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={ref as never}
|
||||
className={cn(
|
||||
'opacity-0 will-change-[transform,opacity]',
|
||||
props.className
|
||||
)}
|
||||
style={{ animationDelay: delay ? `${delay}ms` : undefined }}
|
||||
>
|
||||
{props.children}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
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 { UseQueryResult } from '@tanstack/react-query'
|
||||
import { AutoSkeleton } from 'auto-skeleton-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ErrorState } from '@/components/error-state'
|
||||
import { useThemeRadiusPx } from '@/lib/theme-radius'
|
||||
|
||||
interface ContentSkeletonProps {
|
||||
loading: boolean
|
||||
children: ReactNode
|
||||
borderRadius?: number
|
||||
minTextHeight?: number
|
||||
maxDepth?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ContentSkeleton(props: ContentSkeletonProps) {
|
||||
const themeRadius = useThemeRadiusPx()
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<AutoSkeleton
|
||||
loading={props.loading}
|
||||
config={{
|
||||
animation: 'none',
|
||||
baseColor: 'var(--skeleton-base)',
|
||||
highlightColor: 'var(--skeleton-highlight)',
|
||||
borderRadius: props.borderRadius ?? themeRadius,
|
||||
minTextHeight: props.minTextHeight ?? 14,
|
||||
maxDepth: props.maxDepth ?? 10,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</AutoSkeleton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface QuerySkeletonProps {
|
||||
query: UseQueryResult<unknown, unknown>
|
||||
children: ReactNode
|
||||
className?: string
|
||||
errorTitle?: string
|
||||
errorDescription?: string
|
||||
}
|
||||
|
||||
export function QuerySkeleton(props: QuerySkeletonProps) {
|
||||
if (props.query.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title={props.errorTitle}
|
||||
description={props.errorDescription}
|
||||
onRetry={() => props.query.refetch()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentSkeleton
|
||||
loading={props.query.isLoading}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</ContentSkeleton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
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 { Telescope } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function ComingSoon() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='h-svh'>
|
||||
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
|
||||
<Telescope size={72} />
|
||||
<h1 className='text-4xl leading-tight font-bold'>
|
||||
{t('Coming Soon!')}
|
||||
</h1>
|
||||
<p className='text-muted-foreground text-center'>
|
||||
{t('This page has not been created yet.')} <br />
|
||||
{t('Stay tuned though!')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
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 { useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { useSidebarData } from '@/hooks/use-sidebar-data'
|
||||
|
||||
import { getNavGroupsForPath } from './layout/lib/sidebar-view-registry'
|
||||
import { ScrollArea } from './ui/scroll-area'
|
||||
|
||||
export function CommandMenu() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { setTheme } = useTheme()
|
||||
const { open, setOpen } = useSearch()
|
||||
const { pathname } = useLocation()
|
||||
const sidebarData = useSidebarData()
|
||||
|
||||
// Use the active nested sidebar view's nav groups when one matches
|
||||
// the current URL; otherwise fall back to the root navigation.
|
||||
const navGroups = getNavGroupsForPath(pathname, t) ?? sidebarData.navGroups
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
setOpen(false)
|
||||
command()
|
||||
},
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<CommandDialog modal open={open} onOpenChange={setOpen}>
|
||||
<Command>
|
||||
<CommandInput placeholder={t('Type a command or search...')} />
|
||||
<CommandList>
|
||||
<ScrollArea className='h-72 pe-1'>
|
||||
<CommandEmpty>{t('No results found.')}</CommandEmpty>
|
||||
{navGroups.map((group) => (
|
||||
<CommandGroup key={group.id || group.title} heading={group.title}>
|
||||
{group.items.map((navItem, i) => {
|
||||
if (navItem.url)
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${navItem.url}-${i}`}
|
||||
value={navItem.title}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: navItem.url }))
|
||||
}}
|
||||
>
|
||||
<div className='flex size-4 items-center justify-center'>
|
||||
<ArrowRight className='text-muted-foreground/80 size-2' />
|
||||
</div>
|
||||
{navItem.title}
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
return navItem.items?.map((subItem, i) => (
|
||||
<CommandItem
|
||||
key={`${navItem.title}-${subItem.url}-${i}`}
|
||||
value={`${navItem.title}-${subItem.url}`}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: subItem.url }))
|
||||
}}
|
||||
>
|
||||
<div className='flex size-4 items-center justify-center'>
|
||||
<ArrowRight className='text-muted-foreground/80 size-2' />
|
||||
</div>
|
||||
{navItem.title} <ChevronRight /> {subItem.title}
|
||||
</CommandItem>
|
||||
))
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading='Theme'>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
|
||||
<Sun /> <span>{t('Light')}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
|
||||
<Moon className='scale-90' />
|
||||
<span>{t('Dark')}</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => runCommand(() => setTheme('system'))}
|
||||
>
|
||||
<Laptop />
|
||||
<span>{t('System')}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
/*
|
||||
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 { Radio as RadioPrimitive } from '@base-ui/react/radio'
|
||||
import { RadioGroup as Radio } from '@base-ui/react/radio-group'
|
||||
import { CircleCheck, Palette, RotateCcw } from 'lucide-react'
|
||||
import type { SVGProps } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { IconDir } from '@/assets/custom/icon-dir'
|
||||
import { IconLayoutCompact } from '@/assets/custom/icon-layout-compact'
|
||||
import { IconLayoutDefault } from '@/assets/custom/icon-layout-default'
|
||||
import { IconLayoutFull } from '@/assets/custom/icon-layout-full'
|
||||
import { IconSidebarFloating } from '@/assets/custom/icon-sidebar-floating'
|
||||
import { IconSidebarInset } from '@/assets/custom/icon-sidebar-inset'
|
||||
import { IconSidebarSidebar } from '@/assets/custom/icon-sidebar-sidebar'
|
||||
import { IconThemeDark } from '@/assets/custom/icon-theme-dark'
|
||||
import { IconThemeLight } from '@/assets/custom/icon-theme-light'
|
||||
import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFooterClassName,
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { useDirection } from '@/context/direction-provider'
|
||||
import { type Collapsible, useLayout } from '@/context/layout-provider'
|
||||
import { useThemeCustomization } from '@/context/theme-customization-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import {
|
||||
type ContentLayout,
|
||||
THEME_PRESETS,
|
||||
type ThemeFont,
|
||||
type ThemePreset,
|
||||
type ThemeRadius,
|
||||
type ThemeScale,
|
||||
} from '@/lib/theme-customization'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useSidebar } from './ui/sidebar'
|
||||
|
||||
const Item = RadioPrimitive.Root
|
||||
|
||||
export function ConfigDrawer() {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen } = useSidebar()
|
||||
const { resetDir } = useDirection()
|
||||
const { resetTheme } = useTheme()
|
||||
const { resetLayout } = useLayout()
|
||||
const { resetCustomization } = useThemeCustomization()
|
||||
|
||||
const handleReset = () => {
|
||||
setOpen(true)
|
||||
resetDir()
|
||||
resetTheme()
|
||||
resetLayout()
|
||||
resetCustomization()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger
|
||||
render={
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
aria-label={t('Open theme settings')}
|
||||
aria-describedby='config-drawer-description'
|
||||
className='max-md:hidden'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Palette className='size-[1.2rem]' aria-hidden='true' />
|
||||
</SheetTrigger>
|
||||
<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={sideDrawerFormClassName()}>
|
||||
<ThemeConfig />
|
||||
<PresetConfig />
|
||||
<FontConfig />
|
||||
<RadiusConfig />
|
||||
<ScaleConfig />
|
||||
<SidebarConfig />
|
||||
<LayoutConfig />
|
||||
<ContentLayoutConfig />
|
||||
<DirConfig />
|
||||
</div>
|
||||
<SheetFooter className={sideDrawerFooterClassName('grid-cols-1')}>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleReset}
|
||||
aria-label={t('Reset all settings to default values')}
|
||||
>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle(props: {
|
||||
title: string
|
||||
showReset?: boolean
|
||||
onReset?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground mb-2 flex items-center gap-2 text-sm font-semibold',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.title}
|
||||
{props.showReset && props.onReset && (
|
||||
<Button
|
||||
size='icon'
|
||||
variant='secondary'
|
||||
className='size-4'
|
||||
onClick={props.onReset}
|
||||
aria-label='Reset'
|
||||
>
|
||||
<RotateCcw className='size-3' aria-hidden='true' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem(props: {
|
||||
item: {
|
||||
value: string
|
||||
label: string
|
||||
icon: (props: SVGProps<SVGSVGElement>) => React.ReactElement
|
||||
}
|
||||
isTheme?: boolean
|
||||
}) {
|
||||
const isTheme = props.isTheme ?? false
|
||||
return (
|
||||
<Item
|
||||
value={props.item.value}
|
||||
className={cn('group outline-none', 'transition duration-200 ease-in')}
|
||||
aria-label={`Select ${props.item.label.toLowerCase()}`}
|
||||
aria-describedby={`${props.item.value}-description`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'ring-border relative rounded-md ring-[1px]',
|
||||
'group-data-checked:ring-primary group-data-checked:shadow-2xl',
|
||||
'group-focus-visible:ring-2'
|
||||
)}
|
||||
role='img'
|
||||
aria-hidden='false'
|
||||
aria-label={`${props.item.label} option preview`}
|
||||
>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'fill-primary size-6 stroke-white',
|
||||
'group-data-unchecked:hidden',
|
||||
'absolute top-0 right-0 translate-x-1/2 -translate-y-1/2'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<props.item.icon
|
||||
className={cn(
|
||||
!isTheme &&
|
||||
'stroke-primary fill-primary group-data-unchecked:stroke-muted-foreground group-data-unchecked:fill-muted-foreground'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className='mt-1 text-xs'
|
||||
id={`${props.item.value}-description`}
|
||||
aria-live='polite'
|
||||
>
|
||||
{props.item.label}
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaultTheme, theme, setTheme } = useTheme()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title={t('Theme')}
|
||||
showReset={theme !== defaultTheme}
|
||||
onReset={() => setTheme(defaultTheme)}
|
||||
/>
|
||||
<Radio
|
||||
value={theme}
|
||||
onValueChange={setTheme}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label={t('Select theme preference')}
|
||||
aria-describedby='theme-description'
|
||||
>
|
||||
{[
|
||||
{ value: 'system', label: t('System'), icon: IconThemeSystem },
|
||||
{ value: 'light', label: t('Light'), icon: IconThemeLight },
|
||||
{ value: 'dark', label: t('Dark'), icon: IconThemeDark },
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} isTheme />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='theme-description' className='sr-only'>
|
||||
{t('Choose between system preference, light mode, or dark mode')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaults, customization, setPreset } = useThemeCustomization()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title={t('Color preset')}
|
||||
showReset={customization.preset !== defaults.preset}
|
||||
onReset={() => setPreset(defaults.preset)}
|
||||
/>
|
||||
<Radio
|
||||
value={customization.preset}
|
||||
onValueChange={(v) => setPreset(v as ThemePreset)}
|
||||
className='grid w-full grid-cols-4 gap-3'
|
||||
aria-label={t('Select color preset')}
|
||||
>
|
||||
{THEME_PRESETS.map((preset) => (
|
||||
<Item
|
||||
key={preset.value}
|
||||
value={preset.value}
|
||||
className='group flex flex-col items-stretch outline-none'
|
||||
aria-label={t(`preset.${preset.value}`)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'ring-border relative h-12 rounded-md ring-[1px] transition',
|
||||
'group-data-checked:ring-primary group-data-checked:shadow-md',
|
||||
'group-focus-visible:ring-2',
|
||||
'group-hover:ring-primary/60'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0 rounded-md'
|
||||
style={{
|
||||
background:
|
||||
preset.value === 'default'
|
||||
? 'linear-gradient(135deg, oklch(0.68 0.2 25) 0%, oklch(0.8 0.17 85) 25%, oklch(0.72 0.18 155) 50%, oklch(0.66 0.19 245) 75%, oklch(0.68 0.2 315) 100%)'
|
||||
: `linear-gradient(135deg, ${preset.swatches[0]} 0%, ${preset.swatches[1] ?? preset.swatches[0]} 100%)`,
|
||||
}}
|
||||
/>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'fill-primary absolute top-0 right-0 z-10 size-5 translate-x-1/2 -translate-y-1/2 stroke-white',
|
||||
'group-data-unchecked:hidden'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-1.5 truncate text-center text-xs'>
|
||||
{t(`preset.${preset.value}`)}
|
||||
</div>
|
||||
</Item>
|
||||
))}
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Font options shown in the theme drawer.
|
||||
*
|
||||
* Each option renders a live "Aa" preview in the font it represents.
|
||||
* `Auto` deliberately leaves `fontFamily` undefined so the preview inherits
|
||||
* the currently active body font — that way the user sees what `Auto` will
|
||||
* actually look like for the active preset (Anthropic → serif glyphs,
|
||||
* everything else → sans glyphs) without us having to duplicate the
|
||||
* preset-default mapping in the UI.
|
||||
*/
|
||||
const FONT_OPTIONS: {
|
||||
value: ThemeFont
|
||||
label: string
|
||||
// CSS font-family applied to the "Aa" preview. `undefined` = inherit
|
||||
// from the current theme (used by the `default` option).
|
||||
preview?: string
|
||||
}[] = [
|
||||
{ value: 'default', label: 'Auto', preview: undefined },
|
||||
{ value: 'sans', label: 'Sans', preview: 'var(--font-sans)' },
|
||||
{ value: 'serif', label: 'Serif', preview: 'var(--font-serif)' },
|
||||
]
|
||||
|
||||
function FontConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaults, customization, setFont } = useThemeCustomization()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title={t('Font')}
|
||||
showReset={customization.font !== defaults.font}
|
||||
onReset={() => setFont(defaults.font)}
|
||||
/>
|
||||
<Radio
|
||||
value={customization.font}
|
||||
onValueChange={(v) => setFont(v as ThemeFont)}
|
||||
className='grid w-full grid-cols-3 gap-4'
|
||||
aria-label={t('Select body font')}
|
||||
>
|
||||
{FONT_OPTIONS.map((option) => (
|
||||
<Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='group flex flex-col items-stretch outline-none'
|
||||
aria-label={
|
||||
option.value === 'default' ? t('System default') : option.label
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'ring-border relative h-12 rounded-md ring-[1px] transition',
|
||||
'group-data-checked:ring-primary group-data-checked:shadow-md',
|
||||
'group-focus-visible:ring-2',
|
||||
'group-hover:ring-primary/60'
|
||||
)}
|
||||
>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'fill-primary absolute top-0 right-0 z-10 size-5 translate-x-1/2 -translate-y-1/2 stroke-white',
|
||||
'group-data-unchecked:hidden'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='text-foreground absolute inset-0 flex items-center justify-center text-lg leading-none font-medium'
|
||||
style={
|
||||
option.preview
|
||||
? { fontFamily: option.preview }
|
||||
: // `font: inherit` defers to the active theme so the
|
||||
// "Auto" tile previews what the resolved font will be.
|
||||
{ font: 'inherit', fontSize: '1.125rem' }
|
||||
}
|
||||
>
|
||||
Aa
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-1.5 text-center text-xs'>{option.label}</div>
|
||||
</Item>
|
||||
))}
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RADIUS_OPTIONS: {
|
||||
value: ThemeRadius
|
||||
label: string
|
||||
// CSS border-radius value used to render the visual preview corner.
|
||||
preview: string
|
||||
}[] = [
|
||||
{ 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' },
|
||||
{ value: 'lg', label: '0.75', preview: '0.75rem' },
|
||||
{ value: 'xl', label: '1.0', preview: '1rem' },
|
||||
]
|
||||
|
||||
function RadiusConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaults, customization, setRadius } = useThemeCustomization()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title={t('Border radius')}
|
||||
showReset={customization.radius !== defaults.radius}
|
||||
onReset={() => setRadius(defaults.radius)}
|
||||
/>
|
||||
<Radio
|
||||
value={customization.radius}
|
||||
onValueChange={(v) => setRadius(v as ThemeRadius)}
|
||||
className='grid w-full grid-cols-6 gap-2'
|
||||
aria-label={t('Select border radius')}
|
||||
>
|
||||
{RADIUS_OPTIONS.map((option) => (
|
||||
<Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='group flex flex-col items-stretch outline-none'
|
||||
aria-label={
|
||||
option.value === 'default' ? t('System default') : option.label
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'ring-border relative h-12 rounded-md ring-[1px] transition',
|
||||
'group-data-checked:ring-primary group-data-checked:shadow-md',
|
||||
'group-focus-visible:ring-2',
|
||||
'group-hover:ring-primary/60'
|
||||
)}
|
||||
>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'fill-primary absolute top-0 right-0 z-10 size-5 translate-x-1/2 -translate-y-1/2 stroke-white',
|
||||
'group-data-unchecked:hidden'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='border-foreground/70 absolute top-2.5 left-2.5 size-3.5 border-t-[1.5px] border-l-[1.5px]'
|
||||
style={{ borderTopLeftRadius: option.preview }}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-1.5 text-center text-xs'>{option.label}</div>
|
||||
</Item>
|
||||
))}
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual preview rows for the density preset. Each row's height represents
|
||||
* the relative line-height density (compact = tight rows, comfortable = wide).
|
||||
*/
|
||||
function ScalePreview(props: { rows: number; rowGap: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-2.5 flex flex-col justify-center'
|
||||
style={{ gap: props.rowGap }}
|
||||
>
|
||||
{Array.from({ length: props.rows }, (_, index) => 85 - index * 10).map(
|
||||
(width) => (
|
||||
<span
|
||||
key={width}
|
||||
className='bg-foreground/60 block h-[2px] rounded-full'
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScaleConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaults, customization, setScale } = useThemeCustomization()
|
||||
const scaleOptions: {
|
||||
value: ThemeScale
|
||||
label: string
|
||||
rows: number
|
||||
rowGap: string
|
||||
}[] = [
|
||||
{ value: 'sm', label: t('Compact'), rows: 4, rowGap: '3px' },
|
||||
{ value: 'default', label: t('Default'), rows: 3, rowGap: '6px' },
|
||||
{ value: 'lg', label: t('Comfortable'), rows: 2, rowGap: '10px' },
|
||||
{ value: 'xl', label: t('Super Large'), rows: 1, rowGap: '14px' },
|
||||
]
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title={t('Density')}
|
||||
showReset={customization.scale !== defaults.scale}
|
||||
onReset={() => setScale(defaults.scale)}
|
||||
/>
|
||||
<Radio
|
||||
value={customization.scale}
|
||||
onValueChange={(v) => setScale(v as ThemeScale)}
|
||||
className='grid w-full grid-cols-4 gap-3'
|
||||
aria-label={t('Select interface density')}
|
||||
>
|
||||
{scaleOptions.map((option) => (
|
||||
<Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='group flex flex-col items-stretch outline-none'
|
||||
aria-label={option.label}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'ring-border relative h-12 rounded-md ring-[1px] transition',
|
||||
'group-data-checked:ring-primary group-data-checked:shadow-md',
|
||||
'group-focus-visible:ring-2',
|
||||
'group-hover:ring-primary/60'
|
||||
)}
|
||||
>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'fill-primary absolute top-0 right-0 z-10 size-5 translate-x-1/2 -translate-y-1/2 stroke-white',
|
||||
'group-data-unchecked:hidden'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<ScalePreview rows={option.rows} rowGap={option.rowGap} />
|
||||
</div>
|
||||
<div className='mt-1.5 truncate text-center text-xs'>
|
||||
{option.label}
|
||||
</div>
|
||||
</Item>
|
||||
))}
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaultVariant, variant, setVariant } = useLayout()
|
||||
return (
|
||||
<div className='max-md:hidden'>
|
||||
<SectionTitle
|
||||
title={t('Sidebar')}
|
||||
showReset={defaultVariant !== variant}
|
||||
onReset={() => setVariant(defaultVariant)}
|
||||
/>
|
||||
<Radio
|
||||
value={variant}
|
||||
onValueChange={setVariant}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label={t('Select sidebar style')}
|
||||
aria-describedby='sidebar-description'
|
||||
>
|
||||
{[
|
||||
{ value: 'inset', label: t('Inset'), icon: IconSidebarInset },
|
||||
{
|
||||
value: 'floating',
|
||||
label: t('Floating'),
|
||||
icon: IconSidebarFloating,
|
||||
},
|
||||
{ value: 'sidebar', label: t('Sidebar'), icon: IconSidebarSidebar },
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='sidebar-description' className='sr-only'>
|
||||
{t('Choose between inset, floating, or standard sidebar layout')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { open, setOpen } = useSidebar()
|
||||
const { defaultCollapsible, collapsible, setCollapsible } = useLayout()
|
||||
|
||||
const radioState = open ? 'default' : collapsible
|
||||
|
||||
return (
|
||||
<div className='max-md:hidden'>
|
||||
<SectionTitle
|
||||
title={t('Layout')}
|
||||
showReset={radioState !== 'default'}
|
||||
onReset={() => {
|
||||
setOpen(true)
|
||||
setCollapsible(defaultCollapsible)
|
||||
}}
|
||||
/>
|
||||
<Radio
|
||||
value={radioState}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'default') {
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
setCollapsible(v as Collapsible)
|
||||
}}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label={t('Select layout style')}
|
||||
aria-describedby='layout-description'
|
||||
>
|
||||
{[
|
||||
{ value: 'default', label: t('Default'), icon: IconLayoutDefault },
|
||||
{ value: 'icon', label: t('Compact'), icon: IconLayoutCompact },
|
||||
{
|
||||
value: 'offcanvas',
|
||||
label: t('Full layout'),
|
||||
icon: IconLayoutFull,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='layout-description' className='sr-only'>
|
||||
{t(
|
||||
'Choose between default expanded, compact icon-only, or full layout mode'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentLayoutConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaults, customization, setContentLayout } = useThemeCustomization()
|
||||
return (
|
||||
<div className='max-md:hidden'>
|
||||
<SectionTitle
|
||||
title={t('Content width')}
|
||||
showReset={customization.contentLayout !== defaults.contentLayout}
|
||||
onReset={() => setContentLayout(defaults.contentLayout)}
|
||||
/>
|
||||
<Radio
|
||||
value={customization.contentLayout}
|
||||
onValueChange={(v) => setContentLayout(v as ContentLayout)}
|
||||
className='grid w-full grid-cols-2 gap-4'
|
||||
aria-label={t('Select content width')}
|
||||
>
|
||||
{[
|
||||
{ value: 'full', label: t('Full width') },
|
||||
{ value: 'centered', label: t('Centered') },
|
||||
].map((option) => (
|
||||
<Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='group flex flex-col items-stretch outline-none'
|
||||
aria-label={option.label}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'ring-border relative h-12 rounded-md ring-[1px] transition',
|
||||
'group-data-checked:ring-primary group-data-checked:shadow-md',
|
||||
'group-focus-visible:ring-2',
|
||||
'group-hover:ring-primary/60'
|
||||
)}
|
||||
>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'fill-primary absolute top-0 right-0 z-10 size-5 translate-x-1/2 -translate-y-1/2 stroke-white',
|
||||
'group-data-unchecked:hidden'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<ContentLayoutPreview centered={option.value === 'centered'} />
|
||||
</div>
|
||||
<div className='mt-1.5 truncate text-center text-xs'>
|
||||
{option.label}
|
||||
</div>
|
||||
</Item>
|
||||
))}
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini "page" mock used as the visual preview for content-width options.
|
||||
* `full` fills horizontally, `centered` clamps the body to a narrow column.
|
||||
*/
|
||||
function ContentLayoutPreview(props: { centered: boolean }) {
|
||||
return (
|
||||
<div aria-hidden='true' className='absolute inset-2 flex flex-col gap-1.5'>
|
||||
<span className='bg-foreground/40 block h-1.5 w-full rounded-sm' />
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 flex-col gap-1',
|
||||
props.centered ? 'mx-auto w-1/2' : 'w-full'
|
||||
)}
|
||||
>
|
||||
<span className='bg-foreground/60 block h-[2px] w-full rounded-full' />
|
||||
<span className='bg-foreground/60 block h-[2px] w-3/4 rounded-full' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DirConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaultDir, dir, setDir } = useDirection()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title={t('Direction')}
|
||||
showReset={defaultDir !== dir}
|
||||
onReset={() => setDir(defaultDir)}
|
||||
/>
|
||||
<Radio
|
||||
value={dir}
|
||||
onValueChange={setDir}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label={t('Select site direction')}
|
||||
aria-describedby='direction-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'ltr',
|
||||
label: t('Left to Right'),
|
||||
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||
<IconDir dir='ltr' {...props} />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'rtl',
|
||||
label: t('Right to Left'),
|
||||
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||
<IconDir dir='rtl' {...props} />
|
||||
),
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='direction-description' className='sr-only'>
|
||||
{t('Choose between left-to-right or right-to-left site direction')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: React.ReactNode
|
||||
disabled?: boolean
|
||||
desc: React.JSX.Element | string
|
||||
cancelBtnText?: string
|
||||
confirmText?: React.ReactNode
|
||||
destructive?: boolean
|
||||
handleConfirm: () => void
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
title,
|
||||
desc,
|
||||
children,
|
||||
className,
|
||||
confirmText,
|
||||
cancelBtnText,
|
||||
destructive,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
handleConfirm,
|
||||
...actions
|
||||
} = props
|
||||
return (
|
||||
<AlertDialog {...actions}>
|
||||
<AlertDialogContent className={cn(className && className)}>
|
||||
<AlertDialogHeader className='text-start'>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription render={<div />}>
|
||||
{desc}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{children}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
{cancelBtnText ?? t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
{confirmText ?? t('Continue')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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 { Check, Copy } from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
variant?: 'ghost' | 'outline' | 'default' | 'secondary' | 'destructive'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
tooltip?: string
|
||||
successTooltip?: string
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
iconClassName,
|
||||
variant = 'ghost',
|
||||
size = 'icon',
|
||||
tooltip,
|
||||
successTooltip,
|
||||
'aria-label': ariaLabel,
|
||||
}: CopyButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
|
||||
const isCopied = copiedText === value
|
||||
const resolvedTooltip = tooltip ?? t('Copy to clipboard')
|
||||
const resolvedSuccessTooltip = successTooltip ?? t('Copied!')
|
||||
const resolvedAriaLabel = ariaLabel ?? resolvedTooltip
|
||||
const copiedAriaLabel = t('Copied')
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={() => copyToClipboard(value)}
|
||||
aria-label={isCopied ? copiedAriaLabel : resolvedAriaLabel}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className={cn('text-success', iconClassName)} />
|
||||
) : (
|
||||
<Copy className={cn(iconClassName)} />
|
||||
)}
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip || successTooltip) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button}></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{isCopied ? resolvedSuccessTooltip : resolvedTooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Data Table Components
|
||||
|
||||
This package keeps a stable public API through `index.ts`; feature code should
|
||||
continue importing from `@/components/data-table`.
|
||||
|
||||
- `core/`: TanStack table rendering primitives, headers, rows, pagination,
|
||||
loading, empty states, and pinned-column behavior.
|
||||
- `layout/`: responsive page-level composition that combines toolbar, desktop
|
||||
table, mobile list, bulk actions, and pagination placement.
|
||||
- `toolbar/`: filter/search/view-option controls and selection action toolbar.
|
||||
- `static/`: lightweight table rendering for local/static arrays that do not
|
||||
need TanStack state.
|
||||
- `hooks/`: table state and filter hooks.
|
||||
|
||||
Keep feature-specific columns, actions, and dialogs inside their feature
|
||||
folders. Shared table code belongs here only when it is reusable across more
|
||||
than one feature.
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
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 * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type BadgeCellProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function BadgeCell({ className, ...props }: BadgeCellProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot='badge-cell'
|
||||
className={cn(
|
||||
'-ml-1.5 flex max-w-full min-w-0 items-center gap-1 overflow-hidden [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:min-w-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
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 * as React from 'react'
|
||||
|
||||
import { StatusBadgeList } from '@/components/status-badge'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface BadgeListCellProps {
|
||||
items: React.ReactNode[]
|
||||
max?: number
|
||||
tooltipClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Table cell renderer for a list of badges with overflow tooltip.
|
||||
* Displays up to `max` badges inline; remaining items appear in a tooltip.
|
||||
* Applies -ml-1.5 to compensate for badge px-1.5 and align with column header.
|
||||
*/
|
||||
export function BadgeListCell({
|
||||
items,
|
||||
max = 2,
|
||||
tooltipClassName,
|
||||
}: BadgeListCellProps) {
|
||||
if (items.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const showTooltip = items.length > max
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className='-ml-1.5 max-w-full' />}>
|
||||
<StatusBadgeList
|
||||
items={items}
|
||||
max={max}
|
||||
renderItem={(item) => item}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className={
|
||||
tooltipClassName ??
|
||||
'border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
}
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{items}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
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 Column } from '@tanstack/react-table'
|
||||
import {
|
||||
ArrowDown as ArrowDownIcon,
|
||||
ArrowUp as ArrowUpIcon,
|
||||
ChevronsUpDown as CaretSortIcon,
|
||||
EyeOff as EyeNoneIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DataTableColumnHeaderProps<TData, TValue> =
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
column: Column<TData, TValue>
|
||||
title: React.ReactNode
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
const { t } = useTranslation()
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='data-popup-open:bg-accent -ms-3 h-8'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className='ms-2 h-4 w-4' />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className='ms-2 h-4 w-4' />
|
||||
) : (
|
||||
<CaretSortIcon className='ms-2 h-4 w-4' />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className='text-muted-foreground/70 size-3.5' />
|
||||
{t('Asc')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='text-muted-foreground/70 size-3.5' />
|
||||
{t('Desc')}
|
||||
</DropdownMenuItem>
|
||||
{column.getCanHide() && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className='text-muted-foreground/70 size-3.5' />
|
||||
{t('Hide')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
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'
|
||||
|
||||
import type { DataTableColumnClassName, DataTablePinnedColumn } from './types'
|
||||
|
||||
export function getResolvedColumnClassName(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
): DataTableColumnClassName {
|
||||
return getResolvedColumnClassNameFromMap(
|
||||
getColumnClassName,
|
||||
getPinnedColumnMap(pinnedColumns)
|
||||
)
|
||||
}
|
||||
|
||||
export function getResolvedColumnClassNameFromMap(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumnById?: Map<string, DataTablePinnedColumn>
|
||||
): DataTableColumnClassName {
|
||||
return (columnId, kind) => {
|
||||
const customClassName = getColumnClassName?.(columnId, kind)
|
||||
const pinnedColumn = pinnedColumnById?.get(columnId)
|
||||
|
||||
if (!pinnedColumn) {
|
||||
return customClassName
|
||||
}
|
||||
|
||||
return cn(customClassName, getPinnedColumnClassName(pinnedColumn, kind))
|
||||
}
|
||||
}
|
||||
|
||||
export function getPinnedColumnMap(pinnedColumns?: DataTablePinnedColumn[]) {
|
||||
if (!pinnedColumns?.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return new Map(pinnedColumns.map((column) => [column.columnId, column]))
|
||||
}
|
||||
|
||||
function getPinnedColumnClassName(
|
||||
pinnedColumn: DataTablePinnedColumn,
|
||||
kind: 'header' | 'cell'
|
||||
) {
|
||||
const edgeClassName =
|
||||
pinnedColumn.side === 'left'
|
||||
? 'shadow-[8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
: 'shadow-[-8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
|
||||
return cn(
|
||||
'sticky whitespace-nowrap',
|
||||
pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
|
||||
edgeClassName,
|
||||
kind === 'header'
|
||||
? '[background-color:var(--table-header-bg,var(--table-header))] group-hover:[background-color:var(--table-header-hover)] z-30'
|
||||
: 'bg-background z-10 group-hover:[background-color:color-mix(in_oklch,var(--muted)_50%,var(--background))] group-data-[state=selected]:bg-muted',
|
||||
pinnedColumn.className,
|
||||
kind === 'header'
|
||||
? pinnedColumn.headerClassName
|
||||
: pinnedColumn.cellClassName
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
export function isContentSizedColumn(columnId: string): boolean {
|
||||
return columnId === 'actions'
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
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 { Table as TanstackTable } from '@tanstack/react-table'
|
||||
|
||||
import { isContentSizedColumn } from './content-sized-columns'
|
||||
|
||||
export function DataTableColgroup<TData>({
|
||||
table,
|
||||
}: {
|
||||
table: TanstackTable<TData>
|
||||
}) {
|
||||
const columns = table.getVisibleLeafColumns()
|
||||
const sizedColumns = columns.filter(
|
||||
(column) => !isContentSizedColumn(column.id)
|
||||
)
|
||||
const totalSize = sizedColumns.reduce((sum, col) => sum + col.getSize(), 0)
|
||||
|
||||
return (
|
||||
<colgroup>
|
||||
{columns.map((column) => {
|
||||
const width = getColumnWidth(
|
||||
table,
|
||||
column.id,
|
||||
column.getSize(),
|
||||
totalSize
|
||||
)
|
||||
|
||||
return <col key={column.id} style={{ width }} />
|
||||
})}
|
||||
</colgroup>
|
||||
)
|
||||
}
|
||||
|
||||
function getColumnWidth<TData>(
|
||||
table: TanstackTable<TData>,
|
||||
columnId: string,
|
||||
columnSize: number,
|
||||
totalSize: number
|
||||
) {
|
||||
if (isContentSizedColumn(columnId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (table.options.enableColumnResizing === true) {
|
||||
return `${columnSize}px`
|
||||
}
|
||||
|
||||
if (totalSize <= 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return `${(columnSize / totalSize) * 100}%`
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
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 {
|
||||
flexRender,
|
||||
type Header,
|
||||
type Table as TanstackTable,
|
||||
} from '@tanstack/react-table'
|
||||
import type { KeyboardEvent, MouseEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { DataTableColumnHeader } from './column-header'
|
||||
import { isContentSizedColumn } from './content-sized-columns'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableHeaderProps<TData> = {
|
||||
table: TanstackTable<TData>
|
||||
applyHeaderSize?: boolean
|
||||
className?: string
|
||||
rowClassName?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
}
|
||||
|
||||
export function DataTableHeader<TData>({
|
||||
table,
|
||||
applyHeaderSize,
|
||||
className,
|
||||
rowClassName,
|
||||
getColumnClassName,
|
||||
}: DataTableHeaderProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<TableHeader className={className}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className={rowClassName}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
data-column-id={header.column.id}
|
||||
className={cn(
|
||||
'relative',
|
||||
getColumnClassName?.(header.column.id, 'header')
|
||||
)}
|
||||
style={getHeaderSizeStyle(header, applyHeaderSize)}
|
||||
>
|
||||
{renderHeaderContent(header)}
|
||||
{shouldRenderColumnResizer(table, header) && (
|
||||
<div
|
||||
role='separator'
|
||||
aria-orientation='vertical'
|
||||
aria-label={t('Resize column')}
|
||||
data-column-resizer
|
||||
tabIndex={0}
|
||||
onDoubleClick={(event) =>
|
||||
handleColumnAutoSize(event, table, header)
|
||||
}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
onKeyDown={(event) =>
|
||||
handleColumnResizeKeyDown(event, table, header)
|
||||
}
|
||||
className={cn(
|
||||
'absolute top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none',
|
||||
'after:bg-border hover:after:bg-primary after:absolute after:top-2 after:right-0 after:h-[calc(100%-1rem)] after:w-px after:transition-colors',
|
||||
header.column.getIsResizing() && 'after:bg-primary'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
function handleColumnResizeKeyDown<TData>(
|
||||
event: KeyboardEvent<HTMLDivElement>,
|
||||
table: TanstackTable<TData>,
|
||||
header: Header<TData, unknown>
|
||||
) {
|
||||
const step = event.shiftKey ? 50 : 10
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
resizeColumnByKeyboard(table, header, -step)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
resizeColumnByKeyboard(table, header, step)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
autoSizeColumn(event.currentTarget, table, header)
|
||||
}
|
||||
}
|
||||
|
||||
function resizeColumnByKeyboard<TData>(
|
||||
table: TanstackTable<TData>,
|
||||
header: Header<TData, unknown>,
|
||||
delta: number
|
||||
) {
|
||||
table.setColumnSizing((previous) => ({
|
||||
...previous,
|
||||
[header.column.id]: getClampedColumnSize(
|
||||
header,
|
||||
header.column.getSize() + delta
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function handleColumnAutoSize<TData>(
|
||||
event: MouseEvent<HTMLDivElement>,
|
||||
table: TanstackTable<TData>,
|
||||
header: Header<TData, unknown>
|
||||
) {
|
||||
event.preventDefault()
|
||||
autoSizeColumn(event.currentTarget, table, header)
|
||||
}
|
||||
|
||||
function autoSizeColumn<TData>(
|
||||
resizerElement: HTMLElement,
|
||||
table: TanstackTable<TData>,
|
||||
header: Header<TData, unknown>
|
||||
) {
|
||||
const measuredSize = measureColumnContentWidth(
|
||||
resizerElement,
|
||||
header.column.id
|
||||
)
|
||||
|
||||
if (measuredSize === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
table.setColumnSizing((previous) => ({
|
||||
...previous,
|
||||
[header.column.id]: getClampedColumnSize(header, measuredSize),
|
||||
}))
|
||||
}
|
||||
|
||||
function getClampedColumnSize<TData>(
|
||||
header: Header<TData, unknown>,
|
||||
nextSize: number
|
||||
) {
|
||||
const { minSize, maxSize } = header.column.columnDef
|
||||
|
||||
if (typeof minSize === 'number' && nextSize < minSize) {
|
||||
return minSize
|
||||
}
|
||||
|
||||
if (typeof maxSize === 'number' && nextSize > maxSize) {
|
||||
return maxSize
|
||||
}
|
||||
|
||||
return nextSize
|
||||
}
|
||||
|
||||
function measureColumnContentWidth(
|
||||
resizerElement: HTMLElement,
|
||||
columnId: string
|
||||
) {
|
||||
const tableElement = resizerElement.closest('table')
|
||||
if (!tableElement) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cells = tableElement.querySelectorAll<HTMLElement>(
|
||||
getColumnElementSelector(columnId)
|
||||
)
|
||||
if (cells.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const measuredWidth = [...cells].reduce(
|
||||
(maxWidth, cell) => Math.max(maxWidth, measureElementWidth(cell)),
|
||||
0
|
||||
)
|
||||
|
||||
return measuredWidth > 0 ? Math.ceil(measuredWidth) : undefined
|
||||
}
|
||||
|
||||
function measureElementWidth(element: HTMLElement) {
|
||||
const clone = element.cloneNode(true) as HTMLElement
|
||||
|
||||
clone.querySelectorAll('[data-column-resizer]').forEach((resizer) => {
|
||||
resizer.remove()
|
||||
})
|
||||
|
||||
clone.style.position = 'absolute'
|
||||
clone.style.visibility = 'hidden'
|
||||
clone.style.pointerEvents = 'none'
|
||||
clone.style.left = '-10000px'
|
||||
clone.style.top = '0'
|
||||
clone.style.width = 'max-content'
|
||||
clone.style.minWidth = '0'
|
||||
clone.style.maxWidth = 'none'
|
||||
clone.style.height = 'auto'
|
||||
clone.style.whiteSpace = 'nowrap'
|
||||
|
||||
document.body.append(clone)
|
||||
const width = clone.scrollWidth
|
||||
clone.remove()
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
function getColumnElementSelector(columnId: string) {
|
||||
const escapedColumnId =
|
||||
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||||
? CSS.escape(columnId)
|
||||
: columnId.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
|
||||
|
||||
return `[data-column-id="${escapedColumnId}"]`
|
||||
}
|
||||
|
||||
function shouldRenderColumnResizer<TData>(
|
||||
table: TanstackTable<TData>,
|
||||
header: Header<TData, unknown>
|
||||
) {
|
||||
return (
|
||||
table.options.enableColumnResizing === true &&
|
||||
!header.isPlaceholder &&
|
||||
header.column.getCanResize() &&
|
||||
!isContentSizedColumn(header.column.id)
|
||||
)
|
||||
}
|
||||
|
||||
function getHeaderSizeStyle<TData>(
|
||||
header: Header<TData, unknown>,
|
||||
applyHeaderSize: boolean | undefined
|
||||
) {
|
||||
if (!applyHeaderSize || isContentSizedColumn(header.column.id)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { width: header.getSize() }
|
||||
}
|
||||
|
||||
function renderHeaderContent<TData>(header: Header<TData, unknown>) {
|
||||
if (header.isPlaceholder) return null
|
||||
const { header: headerDef, meta } = header.column.columnDef
|
||||
// A string header means the user wrote e.g. `header: t('Name')` — auto-render
|
||||
// with DataTableColumnHeader so sorting works without boilerplate.
|
||||
// A function (including TanStack's default accessor-key fallback) is passed
|
||||
// through as-is. meta.label is kept as a fallback for legacy columns.
|
||||
if (typeof headerDef === 'string') {
|
||||
return <DataTableColumnHeader column={header.column} title={headerDef} />
|
||||
}
|
||||
if (meta?.label) {
|
||||
return <DataTableColumnHeader column={header.column} title={meta.label} />
|
||||
}
|
||||
return flexRender(headerDef, header.getContext())
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
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 {
|
||||
flexRender,
|
||||
type Cell,
|
||||
type Row,
|
||||
type Table as TanstackTable,
|
||||
} from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { TruncatedCell } from './truncated-cell'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableRowProps<TData> = {
|
||||
row: Row<TData>
|
||||
className?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
cellRenderColumns?: TanstackTable<TData>['options']['columns']
|
||||
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
|
||||
|
||||
type DataTableRowInnerProps<TData> = DataTableRowProps<TData> & {
|
||||
isSelected: boolean
|
||||
/**
|
||||
* Stable signature of currently visible leaf columns for this row.
|
||||
* Captured outside the memo comparator so visibility toggles re-render
|
||||
* even when the TanStack row object reference stays the same.
|
||||
*/
|
||||
visibleColumnIds: string
|
||||
}
|
||||
|
||||
function DataTableRowInner<TData>({
|
||||
row,
|
||||
isSelected,
|
||||
className,
|
||||
getColumnClassName,
|
||||
cellRenderColumns,
|
||||
visibleColumnIds,
|
||||
...rowProps
|
||||
}: DataTableRowInnerProps<TData>) {
|
||||
// Destructured only to keep them out of `rowProps` (not valid DOM attrs)
|
||||
// and to feed the memo comparator below; intentionally unused here.
|
||||
void cellRenderColumns
|
||||
void visibleColumnIds
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className={className}
|
||||
{...rowProps}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const renderedCell = renderCellContent(cell)
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
data-column-id={cell.column.id}
|
||||
className={cn(
|
||||
'max-w-full min-w-0',
|
||||
renderedCell.isPrimitive && 'overflow-hidden',
|
||||
getColumnClassName?.(cell.column.id, 'cell')
|
||||
)}
|
||||
>
|
||||
{renderedCell.content}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
const MemoizedDataTableRow = React.memo(DataTableRowInner, (prev, next) => {
|
||||
// Do not read row.getIsSelected() / row.getVisibleCells() inside the
|
||||
// comparator: TanStack row objects keep a stable reference while selection
|
||||
// and columnVisibility mutate on the table instance. Reading them here would
|
||||
// compare identical live values and miss those updates. Both are lifted to
|
||||
// explicit props, captured per render in DataTableRow.
|
||||
//
|
||||
// Column cell renderers (and getColumnClassName) can close over external
|
||||
// state while the row stays stable, so column definitions and the class
|
||||
// resolver are part of the render identity and must be compared too.
|
||||
return (
|
||||
prev.row === next.row &&
|
||||
prev.className === next.className &&
|
||||
prev.isSelected === next.isSelected &&
|
||||
prev.visibleColumnIds === next.visibleColumnIds &&
|
||||
prev.getColumnClassName === next.getColumnClassName &&
|
||||
prev.cellRenderColumns === next.cellRenderColumns
|
||||
)
|
||||
}) as typeof DataTableRowInner
|
||||
|
||||
export function DataTableRow<TData>(props: DataTableRowProps<TData>) {
|
||||
const visibleColumnIds = props.row
|
||||
.getVisibleCells()
|
||||
.map((cell) => cell.column.id)
|
||||
.join('\0')
|
||||
|
||||
return (
|
||||
<MemoizedDataTableRow
|
||||
{...props}
|
||||
isSelected={props.row.getIsSelected()}
|
||||
visibleColumnIds={visibleColumnIds}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCellContent<TData>(cell: Cell<TData, unknown>) {
|
||||
const content = flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
const textContent = getPrimitiveTextContent(content)
|
||||
|
||||
if (!textContent) {
|
||||
return { content, isPrimitive: false }
|
||||
}
|
||||
|
||||
return {
|
||||
content: (
|
||||
<TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
|
||||
),
|
||||
isPrimitive: true,
|
||||
}
|
||||
}
|
||||
|
||||
function getPrimitiveTextContent(content: React.ReactNode): string | null {
|
||||
if (typeof content === 'string' || typeof content === 'number') {
|
||||
return String(content)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
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 { Row, Table as TanstackTable } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
getPinnedColumnMap,
|
||||
getResolvedColumnClassNameFromMap,
|
||||
} from './column-pinning'
|
||||
import { DataTableColgroup } from './data-table-colgroup'
|
||||
import { DataTableHeader } from './data-table-header'
|
||||
import { DataTableRow } from './data-table-row'
|
||||
import { TableEmpty } from './table-empty'
|
||||
import { getTableSizeStyle } from './table-sizing'
|
||||
import { TableSkeleton } from './table-skeleton'
|
||||
import type {
|
||||
DataTableColumnClassName,
|
||||
DataTablePinnedColumn,
|
||||
DataTableViewProps,
|
||||
} from './types'
|
||||
|
||||
export type {
|
||||
DataTableColumnClassName,
|
||||
DataTablePinnedColumn,
|
||||
DataTableRenderRowHelpers,
|
||||
DataTableViewProps,
|
||||
} from './types'
|
||||
export { DataTableRow } from './data-table-row'
|
||||
export { DataTableRowActionMenu } from './row-action-menu'
|
||||
|
||||
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
|
||||
const rows = props.rows ?? props.table.getRowModel().rows
|
||||
const colSpan = React.useMemo(
|
||||
() => props.table.getVisibleLeafColumns().length,
|
||||
[props.table]
|
||||
)
|
||||
const columnClassName = useResolvedColumnClassName(
|
||||
props.table,
|
||||
props.getColumnClassName,
|
||||
props.pinnedColumns
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border',
|
||||
props.containerClassName
|
||||
)}
|
||||
{...props.containerProps}
|
||||
>
|
||||
{props.splitHeader ? (
|
||||
<SplitHeaderTableView
|
||||
props={props}
|
||||
rows={rows}
|
||||
colSpan={colSpan}
|
||||
getColumnClassName={columnClassName}
|
||||
/>
|
||||
) : (
|
||||
<UnifiedTableView
|
||||
props={props}
|
||||
rows={rows}
|
||||
colSpan={colSpan}
|
||||
getColumnClassName={columnClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UnifiedTableView<TData>({
|
||||
props,
|
||||
rows,
|
||||
colSpan,
|
||||
getColumnClassName,
|
||||
}: {
|
||||
props: DataTableViewProps<TData>
|
||||
rows: Row<TData>[]
|
||||
colSpan: number
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
}) {
|
||||
const tableSizing = getTableSizing(props)
|
||||
|
||||
return (
|
||||
<div className={props.tableContainerClassName}>
|
||||
<Table className={props.tableClassName} style={tableSizing.style}>
|
||||
{tableSizing.colgroup}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={props.tableHeaderClassName}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SplitHeaderTableView<TData>({
|
||||
props,
|
||||
rows,
|
||||
colSpan,
|
||||
getColumnClassName,
|
||||
}: {
|
||||
props: DataTableViewProps<TData>
|
||||
rows: Row<TData>[]
|
||||
colSpan: number
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
}) {
|
||||
const tableSizing = getTableSizing(props)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-0 flex-col',
|
||||
props.tableContainerClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 flex-1 overflow-auto',
|
||||
'**:data-[slot=table-header]:[--table-header-bg:var(--table-header)]',
|
||||
'**:data-[slot=table-header]:bg-(--table-header-bg)',
|
||||
props.splitHeaderScrollClassName,
|
||||
props.bodyContainerClassName
|
||||
)}
|
||||
>
|
||||
<table
|
||||
data-slot='table'
|
||||
className={cn(
|
||||
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
|
||||
props.tableClassName
|
||||
)}
|
||||
style={tableSizing.style}
|
||||
>
|
||||
{tableSizing.colgroup}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={cn('sticky top-0 z-10', props.tableHeaderClassName)}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useResolvedColumnClassName<TData>(
|
||||
table: TanstackTable<TData>,
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
) {
|
||||
const allPinnedColumns = React.useMemo(() => {
|
||||
const metaPinnedColumns = getMetaPinnedColumns(table)
|
||||
return mergePinnedColumns(pinnedColumns, metaPinnedColumns)
|
||||
}, [table, pinnedColumns])
|
||||
|
||||
const pinnedColumnById = React.useMemo(
|
||||
() => getPinnedColumnMap(allPinnedColumns),
|
||||
[allPinnedColumns]
|
||||
)
|
||||
|
||||
return React.useMemo(
|
||||
() =>
|
||||
getResolvedColumnClassNameFromMap(getColumnClassName, pinnedColumnById),
|
||||
[getColumnClassName, pinnedColumnById]
|
||||
)
|
||||
}
|
||||
|
||||
function getMetaPinnedColumns<TData>(
|
||||
table: TanstackTable<TData>
|
||||
): DataTablePinnedColumn[] {
|
||||
return table.getAllColumns().flatMap((column) => {
|
||||
const side = column.columnDef.meta?.pinned
|
||||
if (!side) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ columnId: column.id, side }]
|
||||
})
|
||||
}
|
||||
|
||||
function mergePinnedColumns(
|
||||
explicitPinnedColumns: DataTablePinnedColumn[] | undefined,
|
||||
metaPinnedColumns: DataTablePinnedColumn[]
|
||||
): DataTablePinnedColumn[] | undefined {
|
||||
if (!metaPinnedColumns.length) {
|
||||
return explicitPinnedColumns
|
||||
}
|
||||
|
||||
if (!explicitPinnedColumns?.length) {
|
||||
return metaPinnedColumns
|
||||
}
|
||||
|
||||
const explicitColumnIds = new Set(
|
||||
explicitPinnedColumns.map((column) => column.columnId)
|
||||
)
|
||||
|
||||
return [
|
||||
...explicitPinnedColumns,
|
||||
...metaPinnedColumns.filter(
|
||||
(column) => !explicitColumnIds.has(column.columnId)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function getTableSizing<TData>(props: DataTableViewProps<TData>): {
|
||||
colgroup?: React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
} {
|
||||
if (props.colgroup) {
|
||||
return { colgroup: props.colgroup }
|
||||
}
|
||||
|
||||
if (!props.splitHeader && !props.applyHeaderSize) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
colgroup: <DataTableColgroup table={props.table} />,
|
||||
style: getTableSizeStyle(props.table),
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableBody<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
rows: Row<TData>[],
|
||||
colSpan: number,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
return (
|
||||
<TableBody className={props.tableBodyClassName}>
|
||||
{renderTableBodyContent(props, rows, colSpan, getColumnClassName)}
|
||||
</TableBody>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTableBodyContent<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
rows: Row<TData>[],
|
||||
colSpan: number,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
if (props.isLoading) {
|
||||
return (
|
||||
<TableSkeleton
|
||||
table={props.table}
|
||||
keyPrefix={props.skeletonKeyPrefix}
|
||||
rowHeight={props.skeletonRowHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return renderEmptyState(props, colSpan)
|
||||
}
|
||||
|
||||
return rows.map((row) =>
|
||||
props.renderRow
|
||||
? props.renderRow(row, {
|
||||
getCellClassName: (columnId, className) =>
|
||||
cn(getColumnClassName(columnId, 'cell'), className),
|
||||
})
|
||||
: renderDefaultRow(props, row, getColumnClassName)
|
||||
)
|
||||
}
|
||||
|
||||
function renderEmptyState<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
colSpan: number
|
||||
) {
|
||||
if (props.emptyContent) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className={props.emptyCellClassName}>
|
||||
{props.emptyContent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableEmpty
|
||||
colSpan={colSpan}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
icon={props.emptyIcon}
|
||||
>
|
||||
{props.emptyAction}
|
||||
</TableEmpty>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefaultRow<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
row: Row<TData>,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
return (
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))}
|
||||
getColumnClassName={getColumnClassName}
|
||||
cellRenderColumns={props.table.options.columns}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
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 Table } from '@tanstack/react-table'
|
||||
import {
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
ChevronsLeft as DoubleArrowLeftIcon,
|
||||
ChevronsRight as DoubleArrowRightIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn, getPageNumbers } from '@/lib/utils'
|
||||
|
||||
type DataTablePaginationProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const
|
||||
const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({
|
||||
value: `${pageSize}`,
|
||||
label: pageSize,
|
||||
}))
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const pagination = table.getState().pagination
|
||||
const currentPage = pagination.pageIndex + 1
|
||||
const pageSize = pagination.pageSize
|
||||
const totalPages = table.getPageCount()
|
||||
const totalRows = table.getRowCount()
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'@container/pagination flex min-w-0 items-center justify-end overflow-clip'
|
||||
)}
|
||||
style={{ overflowClipMargin: 1 }}
|
||||
>
|
||||
<div className='flex min-w-0 shrink-0 items-center gap-2 @xl/pagination:gap-3'>
|
||||
<div className='flex shrink-0 items-baseline gap-1.5 text-xs font-medium whitespace-nowrap sm:text-sm'>
|
||||
<span className='text-muted-foreground/80'>{t('Total:')}</span>
|
||||
<span className='text-foreground tabular-nums'>
|
||||
{totalRows.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-1.5 @lg/pagination:gap-2'>
|
||||
<p className='text-muted-foreground/80 hidden text-sm font-medium whitespace-nowrap @2xl/pagination:block'>
|
||||
{t('Rows per page')}
|
||||
</p>
|
||||
<Select
|
||||
items={PAGE_SIZE_SELECT_ITEMS}
|
||||
value={`${pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='text-foreground h-8 w-[64px] font-medium tabular-nums sm:w-[70px]'>
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top' alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{PAGE_SIZE_OPTIONS.map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 shrink-0 items-center gap-1 @lg/pagination:gap-1.5 @xl/pagination:gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to first page')}</span>
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to previous page')}</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<div key={`${pageNumber}-${index}`} className='flex items-center'>
|
||||
{pageNumber === '...' ? (
|
||||
<span className='text-muted-foreground/60 px-0.5 text-sm @lg/pagination:px-1'>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-8 min-w-8 px-2 tabular-nums',
|
||||
currentPage === pageNumber
|
||||
? 'font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
|
||||
>
|
||||
<span className='sr-only'>
|
||||
{t('Go to page {{page}}', { page: pageNumber })}
|
||||
</span>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to next page')}</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to last page')}</span>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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 { MoreHorizontal } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DataTableRowActionMenuProps = {
|
||||
children: React.ReactNode
|
||||
ariaLabel: string
|
||||
contentClassName?: string
|
||||
modal?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function DataTableRowActionMenu(props: DataTableRowActionMenuProps) {
|
||||
return (
|
||||
<DropdownMenu modal={props.modal} onOpenChange={props.onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='data-popup-open:bg-muted'
|
||||
aria-label={props.ariaLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal aria-hidden='true' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className={cn('w-48', props.contentClassName)}
|
||||
>
|
||||
{props.children}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
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 { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { TableRow, TableCell } from '@/components/ui/table'
|
||||
|
||||
interface TableEmptyProps {
|
||||
/**
|
||||
* Number of columns to span
|
||||
*/
|
||||
colSpan: number
|
||||
/**
|
||||
* Custom title for empty state
|
||||
* @default 'No Data'
|
||||
*/
|
||||
title?: string
|
||||
/**
|
||||
* Custom description for empty state
|
||||
* @default 'No records found. Try adjusting your filters.'
|
||||
*/
|
||||
description?: string
|
||||
/**
|
||||
* Custom icon component
|
||||
* @default Database icon
|
||||
*/
|
||||
icon?: React.ReactNode
|
||||
/**
|
||||
* Additional content to display (e.g., buttons)
|
||||
*/
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic table empty state component
|
||||
* Displays a centered empty state message when table has no data
|
||||
*/
|
||||
export function TableEmpty({
|
||||
colSpan,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
}: TableEmptyProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedTitle = title ?? t('No Data')
|
||||
const resolvedDescription =
|
||||
description ?? t('No records found. Try adjusting your filters.')
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className='h-[400px] p-0'>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
{icon || <Database className='size-6' />}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{children}
|
||||
</Empty>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -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 type { Table as TanstackTable } from '@tanstack/react-table'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { isContentSizedColumn } from './content-sized-columns'
|
||||
|
||||
export function getTableSizeStyle<TData>(
|
||||
table: TanstackTable<TData>
|
||||
): React.CSSProperties {
|
||||
const width = table
|
||||
.getVisibleLeafColumns()
|
||||
.filter((column) => !isContentSizedColumn(column.id))
|
||||
.reduce((total, column) => total + column.getSize(), 0)
|
||||
|
||||
return {
|
||||
minWidth: `max(100%, ${width}px)`,
|
||||
tableLayout: 'auto',
|
||||
width: '100%',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
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 { Table } from '@tanstack/react-table'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TableRow, TableCell } from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SKELETON_WIDTHS = [
|
||||
'75%',
|
||||
'60%',
|
||||
'85%',
|
||||
'50%',
|
||||
'70%',
|
||||
'90%',
|
||||
'55%',
|
||||
'80%',
|
||||
'65%',
|
||||
'45%',
|
||||
]
|
||||
|
||||
interface TableSkeletonProps<TData> {
|
||||
table: Table<TData>
|
||||
rowCount?: number
|
||||
rowHeight?: string
|
||||
keyPrefix?: string
|
||||
}
|
||||
|
||||
export function TableSkeleton<TData>({
|
||||
table,
|
||||
rowCount,
|
||||
rowHeight = 'h-[52px]',
|
||||
keyPrefix = 'skeleton',
|
||||
}: TableSkeletonProps<TData>) {
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
const finalRowCount =
|
||||
rowCount ?? Math.min(table.getState().pagination?.pageSize || 20, 20)
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: finalRowCount }, (_, rowIndex) => (
|
||||
<TableRow
|
||||
key={`${keyPrefix}-${rowIndex}`}
|
||||
className={cn(rowHeight, 'border-b')}
|
||||
>
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
const isSelectColumn = column.id === 'select'
|
||||
const widthIndex =
|
||||
(rowIndex * visibleColumns.length + colIndex) %
|
||||
SKELETON_WIDTHS.length
|
||||
|
||||
return (
|
||||
<TableCell key={column.id} className='py-3'>
|
||||
<Skeleton
|
||||
className={cn(
|
||||
'h-4 rounded-sm',
|
||||
isSelectColumn ? 'size-4' : undefined
|
||||
)}
|
||||
style={
|
||||
isSelectColumn
|
||||
? undefined
|
||||
: { width: SKELETON_WIDTHS[widthIndex] }
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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 * as React from 'react'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type TruncatedCellProps = {
|
||||
children: React.ReactNode
|
||||
cellClassName?: string
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
tooltipClassName?: string
|
||||
tooltipContent?: React.ReactNode
|
||||
}
|
||||
|
||||
export function TruncatedCell({
|
||||
children,
|
||||
cellClassName,
|
||||
className,
|
||||
contentClassName,
|
||||
side = 'top',
|
||||
tooltipClassName,
|
||||
tooltipContent,
|
||||
}: TruncatedCellProps) {
|
||||
const content = tooltipContent ?? getTextContent(children)
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'block max-w-full min-w-0 truncate',
|
||||
cellClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div
|
||||
className={cn(
|
||||
'block max-w-full min-w-0 truncate',
|
||||
cellClassName,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={cn('truncate', contentClassName)}>{children}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
className={cn('max-w-xs break-all', tooltipClassName)}
|
||||
>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function getTextContent(node: React.ReactNode): string {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
||||
if (Array.isArray(node)) return node.map(getTextContent).join('')
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
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 { Row, Table as TanstackTable } from '@tanstack/react-table'
|
||||
import type * as React from 'react'
|
||||
|
||||
export type DataTableColumnClassName = (
|
||||
columnId: string,
|
||||
kind: 'header' | 'cell'
|
||||
) => string | undefined
|
||||
|
||||
export type DataTablePinnedColumn = {
|
||||
columnId: string
|
||||
side: 'left' | 'right'
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
}
|
||||
|
||||
export type DataTableRenderRowHelpers = {
|
||||
getCellClassName: (columnId: string, className?: string) => string | undefined
|
||||
}
|
||||
|
||||
export type DataTableViewProps<TData> = {
|
||||
table: TanstackTable<TData>
|
||||
isLoading?: boolean
|
||||
rows?: Row<TData>[]
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyIcon?: React.ReactNode
|
||||
emptyAction?: React.ReactNode
|
||||
emptyContent?: React.ReactNode
|
||||
emptyCellClassName?: string
|
||||
skeletonKeyPrefix?: string
|
||||
skeletonRowHeight?: string
|
||||
renderRow?: (
|
||||
row: Row<TData>,
|
||||
helpers: DataTableRenderRowHelpers
|
||||
) => React.ReactNode
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
applyHeaderSize?: boolean
|
||||
tableClassName?: string
|
||||
tableHeaderClassName?: string
|
||||
tableHeaderRowClassName?: string
|
||||
tableBodyClassName?: string
|
||||
tableBodyRowClassName?: string
|
||||
splitHeader?: boolean
|
||||
splitHeaderScrollClassName?: string
|
||||
bodyContainerClassName?: string
|
||||
containerClassName?: string
|
||||
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
|
||||
tableContainerClassName?: string
|
||||
colgroup?: React.ReactNode
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
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 * as React from 'react'
|
||||
|
||||
export const DATA_TABLE_VIEW_MODES = {
|
||||
TABLE: 'table',
|
||||
CARD: 'card',
|
||||
} as const
|
||||
|
||||
export type DataTableViewMode =
|
||||
(typeof DATA_TABLE_VIEW_MODES)[keyof typeof DATA_TABLE_VIEW_MODES]
|
||||
|
||||
function isViewMode(value: unknown): value is DataTableViewMode {
|
||||
return (
|
||||
value === DATA_TABLE_VIEW_MODES.TABLE ||
|
||||
value === DATA_TABLE_VIEW_MODES.CARD
|
||||
)
|
||||
}
|
||||
|
||||
function readViewMode(
|
||||
storageKey: string | undefined,
|
||||
fallback: DataTableViewMode
|
||||
): DataTableViewMode {
|
||||
if (!storageKey || typeof window === 'undefined') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
return isViewMode(raw) ? raw : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
type UseDataTableViewModeOptions = {
|
||||
/**
|
||||
* localStorage key for persisting the selected view mode. When omitted the
|
||||
* selection lives only in memory (resets on reload).
|
||||
*/
|
||||
storageKey?: string
|
||||
/** Initial mode used when nothing is persisted. Defaults to `'table'`. */
|
||||
defaultMode?: DataTableViewMode
|
||||
}
|
||||
|
||||
/**
|
||||
* View-mode (table vs. card) state with optional per-table localStorage
|
||||
* persistence. Mirrors the SSR/try-catch guarded approach used for column
|
||||
* visibility persistence in {@link useDataTable}.
|
||||
*/
|
||||
export function useDataTableViewMode(
|
||||
options: UseDataTableViewModeOptions = {}
|
||||
): [DataTableViewMode, (mode: DataTableViewMode) => void] {
|
||||
const defaultMode = options.defaultMode ?? DATA_TABLE_VIEW_MODES.TABLE
|
||||
const storageKey = options.storageKey
|
||||
|
||||
const [viewMode, setViewModeState] = React.useState<DataTableViewMode>(() =>
|
||||
readViewMode(storageKey, defaultMode)
|
||||
)
|
||||
|
||||
// Re-hydrate when the storage key changes (e.g. switching tables).
|
||||
const hydratedStorageKeyRef = React.useRef(storageKey)
|
||||
React.useEffect(() => {
|
||||
if (storageKey === hydratedStorageKeyRef.current) {
|
||||
return
|
||||
}
|
||||
hydratedStorageKeyRef.current = storageKey
|
||||
setViewModeState(readViewMode(storageKey, defaultMode))
|
||||
}, [storageKey, defaultMode])
|
||||
|
||||
const setViewMode = React.useCallback(
|
||||
(mode: DataTableViewMode) => {
|
||||
setViewModeState(mode)
|
||||
if (!storageKey || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, mode)
|
||||
} catch {
|
||||
// Storage can be unavailable in private mode; controls still work.
|
||||
}
|
||||
},
|
||||
[storageKey]
|
||||
)
|
||||
|
||||
return [viewMode, setViewMode]
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
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 ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type ColumnSizingState,
|
||||
type ExpandedState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type TableOptions,
|
||||
type Updater,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
type DataTableFeatureOptions<TData> = Pick<
|
||||
TableOptions<TData>,
|
||||
| 'enableRowSelection'
|
||||
| 'getRowId'
|
||||
| 'getSubRows'
|
||||
| 'globalFilterFn'
|
||||
| 'autoResetPageIndex'
|
||||
| 'manualFiltering'
|
||||
| 'manualPagination'
|
||||
| 'manualSorting'
|
||||
| 'enableSorting'
|
||||
| 'enableColumnResizing'
|
||||
>
|
||||
|
||||
type DataTableStateOptions = {
|
||||
initialSorting?: SortingState
|
||||
sorting?: SortingState
|
||||
onSortingChange?: OnChangeFn<SortingState>
|
||||
initialColumnVisibility?: VisibilityState
|
||||
columnVisibilityStorageKey?: string | false
|
||||
columnVisibility?: VisibilityState
|
||||
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
|
||||
initialColumnSizing?: ColumnSizingState
|
||||
columnSizingStorageKey?: string | false
|
||||
columnSizing?: ColumnSizingState
|
||||
onColumnSizingChange?: OnChangeFn<ColumnSizingState>
|
||||
initialRowSelection?: RowSelectionState
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>
|
||||
initialExpanded?: ExpandedState
|
||||
expanded?: ExpandedState
|
||||
onExpandedChange?: OnChangeFn<ExpandedState>
|
||||
columnFilters?: ColumnFiltersState
|
||||
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
|
||||
globalFilter?: string
|
||||
onGlobalFilterChange?: OnChangeFn<string>
|
||||
initialPagination?: PaginationState
|
||||
pagination?: PaginationState
|
||||
onPaginationChange?: OnChangeFn<PaginationState>
|
||||
}
|
||||
|
||||
type DataTableRowModelOptions = {
|
||||
withFilteredRowModel?: boolean
|
||||
withPaginationRowModel?: boolean
|
||||
withSortedRowModel?: boolean
|
||||
withFacetedRowModel?: boolean
|
||||
withExpandedRowModel?: boolean
|
||||
}
|
||||
|
||||
type UseDataTableOptions<TData> = DataTableFeatureOptions<TData> &
|
||||
DataTableStateOptions &
|
||||
DataTableRowModelOptions & {
|
||||
data: TData[]
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
totalCount?: number
|
||||
pageCount?: number
|
||||
ensurePageInRange?: (pageCount: number) => void
|
||||
}
|
||||
|
||||
type ColumnSizingBounds = Record<
|
||||
string,
|
||||
{
|
||||
minSize?: number
|
||||
maxSize?: number
|
||||
}
|
||||
>
|
||||
|
||||
type ColumnWithSizing<TData> = ColumnDef<TData, unknown> & {
|
||||
accessorKey?: string | number
|
||||
columns?: ColumnDef<TData, unknown>[]
|
||||
}
|
||||
|
||||
const COLUMN_SIZING_PERSIST_DELAY_MS = 250
|
||||
|
||||
function resolveUpdater<TValue>(
|
||||
updater: Updater<TValue>,
|
||||
previous: TValue
|
||||
): TValue {
|
||||
return typeof updater === 'function'
|
||||
? (updater as (old: TValue) => TValue)(previous)
|
||||
: updater
|
||||
}
|
||||
|
||||
function useControllableTableState<TValue>(
|
||||
controlledValue: TValue | undefined,
|
||||
defaultValue: TValue,
|
||||
onChange: OnChangeFn<TValue> | undefined
|
||||
): [TValue, OnChangeFn<TValue>] {
|
||||
const [uncontrolledValue, setUncontrolledValue] =
|
||||
React.useState<TValue>(defaultValue)
|
||||
|
||||
const value = controlledValue ?? uncontrolledValue
|
||||
|
||||
const setValue = React.useCallback<OnChangeFn<TValue>>(
|
||||
(updater) => {
|
||||
if (controlledValue === undefined) {
|
||||
setUncontrolledValue((previous) => resolveUpdater(updater, previous))
|
||||
}
|
||||
onChange?.(updater)
|
||||
},
|
||||
[controlledValue, onChange]
|
||||
)
|
||||
|
||||
return [value, setValue]
|
||||
}
|
||||
|
||||
function readColumnVisibility(storageKey: string | undefined): VisibilityState {
|
||||
if (!storageKey || typeof window === 'undefined') return {}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return {}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.entries(parsed).reduce<VisibilityState>(
|
||||
(visibility, [key, value]) => {
|
||||
if (typeof value === 'boolean') {
|
||||
visibility[key] = value
|
||||
}
|
||||
return visibility
|
||||
},
|
||||
{}
|
||||
)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function getColumnId<TData>(column: ColumnDef<TData, unknown>) {
|
||||
const columnWithSizing = column as ColumnWithSizing<TData>
|
||||
|
||||
if (typeof columnWithSizing.id === 'string') {
|
||||
return columnWithSizing.id
|
||||
}
|
||||
|
||||
if (typeof columnWithSizing.accessorKey === 'string') {
|
||||
return columnWithSizing.accessorKey.replaceAll('.', '_')
|
||||
}
|
||||
|
||||
if (typeof columnWithSizing.accessorKey === 'number') {
|
||||
return String(columnWithSizing.accessorKey)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildColumnSizingBounds<TData>(
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
): ColumnSizingBounds {
|
||||
return columns.reduce<ColumnSizingBounds>((bounds, column) => {
|
||||
const columnWithSizing = column as ColumnWithSizing<TData>
|
||||
const columnId = getColumnId(column)
|
||||
|
||||
if (columnId) {
|
||||
const minSize =
|
||||
typeof columnWithSizing.minSize === 'number' &&
|
||||
Number.isFinite(columnWithSizing.minSize)
|
||||
? columnWithSizing.minSize
|
||||
: undefined
|
||||
const maxSize =
|
||||
typeof columnWithSizing.maxSize === 'number' &&
|
||||
Number.isFinite(columnWithSizing.maxSize)
|
||||
? columnWithSizing.maxSize
|
||||
: undefined
|
||||
|
||||
if (minSize !== undefined || maxSize !== undefined) {
|
||||
bounds[columnId] = { minSize, maxSize }
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(columnWithSizing.columns)) {
|
||||
Object.assign(bounds, buildColumnSizingBounds(columnWithSizing.columns))
|
||||
}
|
||||
|
||||
return bounds
|
||||
}, {})
|
||||
}
|
||||
|
||||
function getBoundedColumnSize(
|
||||
columnId: string,
|
||||
value: unknown,
|
||||
bounds: ColumnSizingBounds
|
||||
) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const columnBounds = bounds[columnId]
|
||||
let size = value
|
||||
|
||||
if (columnBounds?.minSize !== undefined && size < columnBounds.minSize) {
|
||||
size = columnBounds.minSize
|
||||
}
|
||||
|
||||
if (columnBounds?.maxSize !== undefined && size > columnBounds.maxSize) {
|
||||
size = columnBounds.maxSize
|
||||
}
|
||||
|
||||
return size > 0 ? size : undefined
|
||||
}
|
||||
|
||||
function readColumnSizing(
|
||||
storageKey: string | undefined,
|
||||
bounds: ColumnSizingBounds
|
||||
): ColumnSizingState {
|
||||
if (!storageKey || typeof window === 'undefined') return {}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return {}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.entries(parsed).reduce<ColumnSizingState>(
|
||||
(sizing, [key, value]) => {
|
||||
const boundedSize = getBoundedColumnSize(key, value, bounds)
|
||||
|
||||
if (boundedSize !== undefined) {
|
||||
sizing[key] = boundedSize
|
||||
}
|
||||
return sizing
|
||||
},
|
||||
{}
|
||||
)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
totalCount,
|
||||
pageCount: explicitPageCount,
|
||||
ensurePageInRange,
|
||||
manualFiltering,
|
||||
manualPagination,
|
||||
manualSorting,
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
initialColumnSizing = {},
|
||||
initialRowSelection = {},
|
||||
initialExpanded = {},
|
||||
initialPagination = { pageIndex: 0, pageSize: 20 },
|
||||
withFilteredRowModel = !manualFiltering,
|
||||
withPaginationRowModel = !manualPagination,
|
||||
withSortedRowModel = !manualSorting && !manualPagination,
|
||||
withFacetedRowModel = !manualFiltering,
|
||||
withExpandedRowModel = false,
|
||||
} = options
|
||||
|
||||
const columnVisibilityStorageKey =
|
||||
typeof options.columnVisibilityStorageKey === 'string'
|
||||
? options.columnVisibilityStorageKey
|
||||
: undefined
|
||||
const columnSizingStorageKey =
|
||||
typeof options.columnSizingStorageKey === 'string'
|
||||
? options.columnSizingStorageKey
|
||||
: undefined
|
||||
const resolvedInitialColumnVisibility = React.useMemo(
|
||||
() => ({
|
||||
...initialColumnVisibility,
|
||||
...readColumnVisibility(columnVisibilityStorageKey),
|
||||
}),
|
||||
[columnVisibilityStorageKey, initialColumnVisibility]
|
||||
)
|
||||
const columnSizingBounds = React.useMemo(
|
||||
() => buildColumnSizingBounds(columns),
|
||||
[columns]
|
||||
)
|
||||
const resolvedInitialColumnSizing = React.useMemo(
|
||||
() => ({
|
||||
...initialColumnSizing,
|
||||
...readColumnSizing(columnSizingStorageKey, columnSizingBounds),
|
||||
}),
|
||||
[columnSizingBounds, columnSizingStorageKey, initialColumnSizing]
|
||||
)
|
||||
|
||||
const [sorting, onSortingChange] = useControllableTableState(
|
||||
options.sorting,
|
||||
initialSorting,
|
||||
options.onSortingChange
|
||||
)
|
||||
const [columnVisibility, onColumnVisibilityChange] =
|
||||
useControllableTableState(
|
||||
options.columnVisibility,
|
||||
resolvedInitialColumnVisibility,
|
||||
options.onColumnVisibilityChange
|
||||
)
|
||||
const [columnSizing, onColumnSizingChange] = useControllableTableState(
|
||||
options.columnSizing,
|
||||
resolvedInitialColumnSizing,
|
||||
options.onColumnSizingChange
|
||||
)
|
||||
const hydratedColumnVisibilityStorageKeyRef = React.useRef(
|
||||
columnVisibilityStorageKey
|
||||
)
|
||||
const hydratedColumnSizingStorageKeyRef = React.useRef(columnSizingStorageKey)
|
||||
const skipNextColumnVisibilityPersistRef = React.useRef(false)
|
||||
const skipNextColumnSizingPersistRef = React.useRef(false)
|
||||
const columnSizingPersistTimerRef = React.useRef<number | undefined>(
|
||||
undefined
|
||||
)
|
||||
const [rowSelection, onRowSelectionChange] = useControllableTableState(
|
||||
options.rowSelection,
|
||||
initialRowSelection,
|
||||
options.onRowSelectionChange
|
||||
)
|
||||
const [expanded, onExpandedChange] = useControllableTableState(
|
||||
options.expanded,
|
||||
initialExpanded,
|
||||
options.onExpandedChange
|
||||
)
|
||||
const [pagination, onPaginationChange] = useControllableTableState(
|
||||
options.pagination,
|
||||
initialPagination,
|
||||
options.onPaginationChange
|
||||
)
|
||||
|
||||
const resolvedPageCount =
|
||||
explicitPageCount ??
|
||||
(totalCount !== undefined
|
||||
? Math.ceil(totalCount / pagination.pageSize)
|
||||
: undefined)
|
||||
const resolvedEnableSorting =
|
||||
options.enableSorting ??
|
||||
(!manualPagination ||
|
||||
Boolean(options.sorting) ||
|
||||
Boolean(options.onSortingChange))
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
rowCount: totalCount,
|
||||
pageCount: resolvedPageCount,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
rowSelection,
|
||||
expanded,
|
||||
columnFilters: options.columnFilters,
|
||||
globalFilter: options.globalFilter,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: options.enableRowSelection,
|
||||
enableSorting: resolvedEnableSorting,
|
||||
getRowId: options.getRowId,
|
||||
getSubRows: options.getSubRows,
|
||||
globalFilterFn: options.globalFilterFn,
|
||||
autoResetPageIndex: options.autoResetPageIndex,
|
||||
manualFiltering,
|
||||
manualPagination,
|
||||
manualSorting,
|
||||
enableColumnResizing: options.enableColumnResizing,
|
||||
columnResizeMode: 'onChange',
|
||||
onSortingChange,
|
||||
onColumnVisibilityChange,
|
||||
onColumnSizingChange,
|
||||
onRowSelectionChange,
|
||||
onExpandedChange,
|
||||
onColumnFiltersChange: options.onColumnFiltersChange,
|
||||
onGlobalFilterChange: options.onGlobalFilterChange,
|
||||
onPaginationChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: withFilteredRowModel
|
||||
? getFilteredRowModel()
|
||||
: undefined,
|
||||
getPaginationRowModel: withPaginationRowModel
|
||||
? getPaginationRowModel()
|
||||
: undefined,
|
||||
getSortedRowModel: withSortedRowModel ? getSortedRowModel() : undefined,
|
||||
getFacetedRowModel: withFacetedRowModel ? getFacetedRowModel() : undefined,
|
||||
getFacetedUniqueValues: withFacetedRowModel
|
||||
? getFacetedUniqueValues()
|
||||
: undefined,
|
||||
getExpandedRowModel: withExpandedRowModel
|
||||
? getExpandedRowModel()
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const actualPageCount = table.getPageCount()
|
||||
React.useEffect(() => {
|
||||
ensurePageInRange?.(actualPageCount)
|
||||
}, [actualPageCount, ensurePageInRange])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
options.columnVisibility !== undefined ||
|
||||
columnVisibilityStorageKey ===
|
||||
hydratedColumnVisibilityStorageKeyRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
hydratedColumnVisibilityStorageKeyRef.current = columnVisibilityStorageKey
|
||||
skipNextColumnVisibilityPersistRef.current = true
|
||||
onColumnVisibilityChange(() => resolvedInitialColumnVisibility)
|
||||
}, [
|
||||
columnVisibilityStorageKey,
|
||||
onColumnVisibilityChange,
|
||||
options.columnVisibility,
|
||||
resolvedInitialColumnVisibility,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
options.columnSizing !== undefined ||
|
||||
columnSizingStorageKey === hydratedColumnSizingStorageKeyRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
hydratedColumnSizingStorageKeyRef.current = columnSizingStorageKey
|
||||
skipNextColumnSizingPersistRef.current = true
|
||||
onColumnSizingChange(() => resolvedInitialColumnSizing)
|
||||
}, [
|
||||
columnSizingStorageKey,
|
||||
onColumnSizingChange,
|
||||
options.columnSizing,
|
||||
resolvedInitialColumnSizing,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!columnVisibilityStorageKey || typeof window === 'undefined') return
|
||||
|
||||
if (skipNextColumnVisibilityPersistRef.current) {
|
||||
skipNextColumnVisibilityPersistRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
columnVisibilityStorageKey,
|
||||
JSON.stringify(columnVisibility)
|
||||
)
|
||||
} catch {
|
||||
// Storage can be unavailable in private mode; table controls still work.
|
||||
}
|
||||
}, [columnVisibility, columnVisibilityStorageKey])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!columnSizingStorageKey || typeof window === 'undefined') return
|
||||
|
||||
if (skipNextColumnSizingPersistRef.current) {
|
||||
skipNextColumnSizingPersistRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (columnSizingPersistTimerRef.current !== undefined) {
|
||||
window.clearTimeout(columnSizingPersistTimerRef.current)
|
||||
}
|
||||
|
||||
columnSizingPersistTimerRef.current = window.setTimeout(() => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
columnSizingStorageKey,
|
||||
JSON.stringify(columnSizing)
|
||||
)
|
||||
} catch {
|
||||
// Storage can be unavailable in private mode; table controls still work.
|
||||
} finally {
|
||||
columnSizingPersistTimerRef.current = undefined
|
||||
}
|
||||
}, COLUMN_SIZING_PERSIST_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
if (columnSizingPersistTimerRef.current !== undefined) {
|
||||
window.clearTimeout(columnSizingPersistTimerRef.current)
|
||||
columnSizingPersistTimerRef.current = undefined
|
||||
}
|
||||
}
|
||||
}, [columnSizing, columnSizingStorageKey])
|
||||
|
||||
return {
|
||||
table,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
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 { ColumnFiltersState, OnChangeFn } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
type UseDebouncedColumnFilterOptions = {
|
||||
columnFilters: ColumnFiltersState
|
||||
columnId: string
|
||||
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export function useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId,
|
||||
onColumnFiltersChange,
|
||||
delay = 500,
|
||||
}: UseDebouncedColumnFilterOptions) {
|
||||
const value =
|
||||
(columnFilters.find((filter) => filter.id === columnId)?.value as
|
||||
| string
|
||||
| undefined) ?? ''
|
||||
const [inputValue, setInputValue] = React.useState(value)
|
||||
const [pendingValue, setPendingValue] = React.useState(value)
|
||||
const isComposingRef = React.useRef(false)
|
||||
const debouncedValue = useDebounce(pendingValue, delay)
|
||||
const onColumnFiltersChangeRef = React.useRef(onColumnFiltersChange)
|
||||
onColumnFiltersChangeRef.current = onColumnFiltersChange
|
||||
|
||||
React.useEffect(() => {
|
||||
// Keep the input aligned when URL state changes outside the local field.
|
||||
if (!isComposingRef.current) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setInputValue(value)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPendingValue(value)
|
||||
}, [value])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (debouncedValue === value) return
|
||||
|
||||
onColumnFiltersChangeRef.current((previous) => {
|
||||
const filters = previous.filter((filter) => filter.id !== columnId)
|
||||
return debouncedValue
|
||||
? [...filters, { id: columnId, value: debouncedValue }]
|
||||
: filters
|
||||
})
|
||||
}, [columnId, debouncedValue, value])
|
||||
|
||||
const updateInputValue = React.useCallback((nextValue: string) => {
|
||||
setInputValue(nextValue)
|
||||
|
||||
if (!isComposingRef.current) {
|
||||
setPendingValue(nextValue)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateInputValue(event.target.value)
|
||||
},
|
||||
[updateInputValue]
|
||||
)
|
||||
|
||||
const handleCompositionStart = React.useCallback(() => {
|
||||
isComposingRef.current = true
|
||||
}, [])
|
||||
|
||||
const handleCompositionEnd = React.useCallback(
|
||||
(event: React.CompositionEvent<HTMLInputElement>) => {
|
||||
isComposingRef.current = false
|
||||
const nextValue = event.currentTarget.value
|
||||
setInputValue(nextValue)
|
||||
setPendingValue(nextValue)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const resetInput = React.useCallback(() => {
|
||||
isComposingRef.current = false
|
||||
setInputValue('')
|
||||
setPendingValue('')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
value,
|
||||
inputValue,
|
||||
setInputValue: updateInputValue,
|
||||
onChange: handleChange,
|
||||
onCompositionStart: handleCompositionStart,
|
||||
onCompositionEnd: handleCompositionEnd,
|
||||
resetInput,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
export { DataTablePagination } from './core/pagination'
|
||||
export { DataTableColumnHeader } from './core/column-header'
|
||||
export { BadgeCell } from './core/badge-cell'
|
||||
export { BadgeListCell } from './core/badge-list-cell'
|
||||
export { TruncatedCell } from './core/truncated-cell'
|
||||
export { DataTableViewOptions } from './toolbar/view-options'
|
||||
export { DataTableToolbar } from './toolbar/toolbar'
|
||||
export { DataTableBulkActions } from './toolbar/bulk-actions'
|
||||
export {
|
||||
StaticDataTable,
|
||||
type StaticDataTableColumn,
|
||||
} from './static/static-data-table'
|
||||
export { StaticRowActions } from './static/static-row-actions'
|
||||
export { staticDataTableClassNames } from './static/static-data-table-classnames'
|
||||
export {
|
||||
DataTableRow,
|
||||
DataTableRowActionMenu,
|
||||
DataTableView,
|
||||
type DataTableColumnClassName,
|
||||
type DataTablePinnedColumn,
|
||||
type DataTableRenderRowHelpers,
|
||||
} from './core/data-table-view'
|
||||
export { MobileCardList } from './layout/mobile-card-list'
|
||||
export {
|
||||
DataTableCardGrid,
|
||||
type DataTableCardGridProps,
|
||||
type DataTableCardHelpers,
|
||||
} from './layout/card-grid'
|
||||
export { CardRowContent } from './layout/card-row-content'
|
||||
export { tableHasCompactMeta } from './layout/card-cell-utils'
|
||||
export {
|
||||
DataTablePage,
|
||||
type DataTablePageProps,
|
||||
} from './layout/data-table-page'
|
||||
export {
|
||||
DataTableViewModeToggle,
|
||||
type DataTableViewModeToggleProps,
|
||||
} from './toolbar/view-mode-toggle'
|
||||
export { useDataTable } from './hooks/use-data-table'
|
||||
export {
|
||||
useDataTableViewMode,
|
||||
DATA_TABLE_VIEW_MODES,
|
||||
type DataTableViewMode,
|
||||
} from './hooks/use-data-table-view-mode'
|
||||
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
|
||||
|
||||
export const DISABLED_ROW_DESKTOP =
|
||||
'[--data-table-card-bg:var(--table-disabled)] hover:[--data-table-card-bg:var(--table-disabled-hover)] data-[state=selected]:![--data-table-card-bg:var(--table-disabled)] data-[state=selected]:hover:![--data-table-card-bg:var(--table-disabled-hover)] [background-color:var(--table-disabled)] hover:[background-color:var(--table-disabled-hover)] [&>td:first-child]:[border-left-color:var(--table-disabled-border)] [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
|
||||
|
||||
export const DISABLED_ROW_MOBILE =
|
||||
'[--data-table-card-bg:var(--table-disabled)] data-[state=selected]:![--data-table-card-bg:var(--table-disabled)] [background-color:var(--table-disabled)]'
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
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 { flexRender, type Cell, type Table } from '@tanstack/react-table'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Shared cell helpers for the column-meta-driven card content used by both the
|
||||
* mobile list and the desktop card grid. Kept separate from the card content
|
||||
* component so the module exports only non-component utilities.
|
||||
*/
|
||||
|
||||
export function getCellLabel<TData>(cell: Cell<TData, unknown>): string | null {
|
||||
const { header, meta } = cell.column.columnDef
|
||||
if (typeof header === 'string') {
|
||||
return header
|
||||
}
|
||||
if (meta?.label) {
|
||||
return meta.label
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function renderCellContent<TData>(
|
||||
cell: Cell<TData, unknown>
|
||||
): ReactNode {
|
||||
const cellRenderer = cell.column.columnDef.cell
|
||||
if (cellRenderer) {
|
||||
return flexRender(cellRenderer, cell.getContext())
|
||||
}
|
||||
return cell.getValue() as ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any visible column declares `mobileTitle`/`mobileBadge` meta. When
|
||||
* true the compact two-tier layout is used; otherwise the condensed
|
||||
* label:value fallback layout is used.
|
||||
*/
|
||||
export function tableHasCompactMeta<TData>(table: Table<TData>): boolean {
|
||||
return table.getVisibleLeafColumns().some((col) => {
|
||||
const meta = col.columnDef.meta
|
||||
return Boolean(meta?.mobileTitle || meta?.mobileBadge)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
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 { Row, Table } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
/*
|
||||
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 * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { tableHasCompactMeta } from './card-cell-utils'
|
||||
import { CardRowContent } from './card-row-content'
|
||||
|
||||
/** Helpers passed to a custom {@link DataTableCardGridProps.renderCard}. */
|
||||
export type DataTableCardHelpers = {
|
||||
/**
|
||||
* Whether the table declares compact card meta (`mobileTitle`/`mobileBadge`).
|
||||
* Provided so custom renderers can match the default layout decision.
|
||||
*/
|
||||
compact: boolean
|
||||
/**
|
||||
* Row selection state captured before entering memoized custom card renderers.
|
||||
*/
|
||||
isSelected: boolean
|
||||
}
|
||||
|
||||
export interface DataTableCardGridProps<TData> {
|
||||
table: Table<TData>
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyIcon?: React.ReactNode
|
||||
getRowKey?: (row: Row<TData>) => string | number
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
/**
|
||||
* Custom card renderer. When omitted, cards render generically from the
|
||||
* column definitions via {@link CardRowContent} (driven by column meta).
|
||||
*/
|
||||
renderCard?: (
|
||||
row: Row<TData>,
|
||||
helpers: DataTableCardHelpers
|
||||
) => React.ReactNode
|
||||
/**
|
||||
* Responsive grid className override. Defaults to a 1/2/3-column grid.
|
||||
*/
|
||||
gridClassName?: string
|
||||
/** Stable key prefix for skeleton cards. */
|
||||
skeletonKeyPrefix?: string
|
||||
}
|
||||
|
||||
const DEFAULT_GRID_CLASSNAME =
|
||||
'grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3'
|
||||
|
||||
function CardGridSkeleton(props: {
|
||||
gridClassName?: string
|
||||
keyPrefix?: string
|
||||
}) {
|
||||
const prefix = props.keyPrefix ?? 'card-skeleton'
|
||||
return (
|
||||
<div className={props.gridClassName ?? DEFAULT_GRID_CLASSNAME}>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={`${prefix}-${i}`}
|
||||
className='space-y-3 rounded-lg border bg-(--table-row) p-3'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-5 w-16 rounded-md' />
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-x-3 gap-y-1.5'>
|
||||
{[1, 2, 3, 4].map((j) => (
|
||||
<div key={j}>
|
||||
<Skeleton className='mb-1 h-2 w-8' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop card view for table data — a responsive grid of bordered cards.
|
||||
*
|
||||
* Renders the same per-row content as {@link MobileCardList} (via
|
||||
* {@link CardRowContent}) unless a custom `renderCard` is supplied. This keeps
|
||||
* the card view reusable across any table with zero per-feature work while
|
||||
* still allowing a bespoke card design when desired.
|
||||
*
|
||||
* The default generic card omits the `select` column. Custom `renderCard`
|
||||
* implementations can use `helpers.isSelected` to keep selection UI in sync.
|
||||
*/
|
||||
export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resolvedEmptyTitle = props.emptyTitle ?? t('No Data')
|
||||
const resolvedEmptyDescription =
|
||||
props.emptyDescription ?? t('No data available')
|
||||
|
||||
const visibleColumns = props.table.getVisibleLeafColumns()
|
||||
const compact = React.useMemo(
|
||||
() => tableHasCompactMeta(props.table),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[visibleColumns]
|
||||
)
|
||||
|
||||
if (props.isLoading) {
|
||||
return (
|
||||
<CardGridSkeleton
|
||||
gridClassName={props.gridClassName}
|
||||
keyPrefix={props.skeletonKeyPrefix}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const rows = props.table.getRowModel().rows
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className='rounded-lg border p-6'>
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
{props.emptyIcon ?? <Database className='size-6' />}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={props.gridClassName ?? DEFAULT_GRID_CLASSNAME}>
|
||||
{rows.map((row) => {
|
||||
const key = props.getRowKey ? props.getRowKey(row) : row.id
|
||||
const isSelected = row.getIsSelected()
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-slot='data-table-card'
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className={cn(
|
||||
'rounded-lg border bg-(--data-table-card-bg,var(--table-row)) px-3 py-2.5 transition-[background-color,border-color] duration-150 data-[state=selected]:[--data-table-card-bg:color-mix(in_oklch,var(--primary)_7%,var(--table-row))] data-[state=selected]:border-primary/40',
|
||||
props.getRowClassName?.(row)
|
||||
)}
|
||||
>
|
||||
{props.renderCard ? (
|
||||
props.renderCard(row, { compact, isSelected })
|
||||
) : (
|
||||
<CardRowContent row={row} compact={compact} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
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 { Cell, Row } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
|
||||
import { StatusBadgeTypeContext } from '@/components/status-badge'
|
||||
|
||||
import { getCellLabel, renderCellContent } from './card-cell-utils'
|
||||
|
||||
function orderCardCells<TData>(
|
||||
cells: Cell<TData, unknown>[]
|
||||
): Cell<TData, unknown>[] {
|
||||
return [...cells].sort((a, b) => {
|
||||
const aOrder = a.column.columnDef.meta?.mobileOrder
|
||||
const bOrder = b.column.columnDef.meta?.mobileOrder
|
||||
|
||||
if (aOrder == null && bOrder == null) return 0
|
||||
if (aOrder == null) return 1
|
||||
if (bOrder == null) return -1
|
||||
return aOrder - bOrder
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared, column-meta-driven card content rendering for TanStack rows.
|
||||
*
|
||||
* Both {@link MobileCardList} (mobile) and {@link DataTableCardGrid} (desktop
|
||||
* card view) render the same inner content; only the surrounding container
|
||||
* differs (single bordered list vs. responsive grid of cards). Keeping the
|
||||
* per-row content here guarantees the two stay visually consistent.
|
||||
*
|
||||
* Column meta extensions (see `card-cell-utils.ts`):
|
||||
* - `mobileTitle` — card header (left, larger text)
|
||||
* - `mobileBadge` — inline with title (right, e.g. status badge)
|
||||
* - `mobileHidden` — hidden in card content
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compact content — structured layout with title header + side-by-side fields.
|
||||
* Used when columns define mobileTitle or mobileBadge meta.
|
||||
*
|
||||
* Visual structure:
|
||||
* [Title content] [Badge]
|
||||
* [Field1 label] [Field2 label]
|
||||
* [Field1 value] [Field2 value]
|
||||
* [Actions ⋯]
|
||||
*/
|
||||
function CompactContent<TData>({ row }: { row: Row<TData> }) {
|
||||
const allCells = row
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
// Read each cell's meta once, then reuse for all categorisation checks.
|
||||
const cellMetas = React.useMemo(
|
||||
() => allCells.map((c) => c.column.columnDef.meta),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[allCells.map((c) => c.id).join(',')]
|
||||
)
|
||||
|
||||
const titleCell = allCells.find((_, i) => cellMetas[i]?.mobileTitle)
|
||||
const badgeCell = allCells.find((_, i) => cellMetas[i]?.mobileBadge)
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
const fieldCells = orderCardCells(
|
||||
allCells.filter(
|
||||
(c, i) =>
|
||||
c !== titleCell &&
|
||||
c !== badgeCell &&
|
||||
c !== actionsCell &&
|
||||
!cellMetas[i]?.mobileHidden
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Row 1: Title + Badge */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
{titleCell && (
|
||||
<div className='min-w-0 flex-1 text-sm font-medium [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:whitespace-normal'>
|
||||
{renderCellContent(titleCell)}
|
||||
</div>
|
||||
)}
|
||||
{badgeCell && (
|
||||
<div className='flex-none [&_[data-slot=status-badge]]:max-w-none'>
|
||||
{renderCellContent(badgeCell)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Key fields wrap into compact columns instead of squeezing */}
|
||||
{fieldCells.length > 0 && (
|
||||
<div className='mt-1.5 grid grid-cols-2 gap-x-3 gap-y-1.5'>
|
||||
{fieldCells.map((cell) => {
|
||||
const label = getCellLabel(cell)
|
||||
return (
|
||||
<div key={cell.id} className='min-w-0 flex-1 overflow-hidden'>
|
||||
{label && (
|
||||
<div className='text-muted-foreground mb-0.5 text-[10px] leading-none select-none'>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0 overflow-hidden text-xs [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{actionsCell && (
|
||||
<div className='mt-1 -mb-0.5 flex justify-end'>
|
||||
{renderCellContent(actionsCell)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback content — condensed label:value pairs for tables without
|
||||
* mobileTitle/mobileBadge. Still respects mobileHidden.
|
||||
*/
|
||||
function FallbackContent<TData>({ row }: { row: Row<TData> }) {
|
||||
const allCells = row
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
const cellMetas = React.useMemo(
|
||||
() => allCells.map((c) => c.column.columnDef.meta),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[allCells.map((c) => c.id).join(',')]
|
||||
)
|
||||
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
const contentCells = orderCardCells(
|
||||
allCells.filter(
|
||||
(c, i) => c.column.id !== 'actions' && !cellMetas[i]?.mobileHidden
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentCells.map((cell) => {
|
||||
const label = getCellLabel(cell)
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className='flex justify-end overflow-hidden [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'
|
||||
>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell)}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className='flex items-start justify-between gap-2 overflow-hidden'
|
||||
>
|
||||
<span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'>
|
||||
{label}
|
||||
</span>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'>
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{actionsCell && (
|
||||
<div className='-mb-0.5 flex justify-end pt-0.5'>
|
||||
{renderCellContent(actionsCell)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single row's card content, auto-selecting the compact or fallback
|
||||
* layout. Callers compute `compact` once per table (via `tableHasCompactMeta`)
|
||||
* and pass it down to avoid recomputation per row.
|
||||
*/
|
||||
export function CardRowContent<TData>(props: {
|
||||
row: Row<TData>
|
||||
compact: boolean
|
||||
}) {
|
||||
return props.compact ? (
|
||||
<CompactContent row={props.row} />
|
||||
) : (
|
||||
<FallbackContent row={props.row} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
/*
|
||||
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 {
|
||||
ColumnDef,
|
||||
Row,
|
||||
Table as TanstackTable,
|
||||
} from '@tanstack/react-table'
|
||||
/*
|
||||
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 * as React from 'react'
|
||||
|
||||
import { PageFooterPortal } from '@/components/layout/components/page-footer'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
DataTableView,
|
||||
type DataTableColumnClassName,
|
||||
type DataTablePinnedColumn,
|
||||
type DataTableRenderRowHelpers,
|
||||
} from '../core/data-table-view'
|
||||
import { DataTablePagination } from '../core/pagination'
|
||||
import {
|
||||
DATA_TABLE_VIEW_MODES,
|
||||
useDataTableViewMode,
|
||||
type DataTableViewMode,
|
||||
} from '../hooks/use-data-table-view-mode'
|
||||
import { DataTableToolbar } from '../toolbar/toolbar'
|
||||
import { DataTableViewModeToggle } from '../toolbar/view-mode-toggle'
|
||||
import { DataTableCardGrid } from './card-grid'
|
||||
import { MobileCardList } from './mobile-card-list'
|
||||
|
||||
/**
|
||||
* Pass-through configuration for the default {@link DataTableToolbar}.
|
||||
* Pass `toolbar` (ReactNode) instead to fully replace the default toolbar.
|
||||
*/
|
||||
export type DataTablePageToolbarProps<TData> = Omit<
|
||||
React.ComponentProps<typeof DataTableToolbar<TData>>,
|
||||
'table'
|
||||
>
|
||||
|
||||
export type DataTablePageProps<TData> = {
|
||||
/**
|
||||
* TanStack Table instance returned from `useReactTable`.
|
||||
*/
|
||||
table: TanstackTable<TData>
|
||||
|
||||
/**
|
||||
* Column definitions. Used for skeleton column count and empty-state colSpan.
|
||||
*/
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
|
||||
/**
|
||||
* Initial loading state — renders {@link TableSkeleton} or mobile skeleton.
|
||||
*/
|
||||
isLoading?: boolean
|
||||
|
||||
/**
|
||||
* Refetch / background loading — dims the table without removing rows.
|
||||
*/
|
||||
isFetching?: boolean
|
||||
|
||||
/**
|
||||
* Empty-state title (used for both desktop {@link TableEmpty} and mobile fallback).
|
||||
*/
|
||||
emptyTitle?: string
|
||||
|
||||
/**
|
||||
* Empty-state description.
|
||||
*/
|
||||
emptyDescription?: string
|
||||
|
||||
/**
|
||||
* Empty-state icon override (desktop only; mobile uses default Database icon).
|
||||
*/
|
||||
emptyIcon?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Empty-state extra content — e.g. a "Create" button below the message.
|
||||
*/
|
||||
emptyAction?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Custom toolbar node — fully replaces the default {@link DataTableToolbar}.
|
||||
* Useful for layouts like "primary buttons + toolbar" or feature-specific filter cards.
|
||||
* If provided, `toolbarProps` is ignored.
|
||||
*/
|
||||
toolbar?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Pass-through props for the default {@link DataTableToolbar}.
|
||||
* Ignored if `toolbar` is provided. Pass `null` to omit the toolbar entirely.
|
||||
*/
|
||||
toolbarProps?: DataTablePageToolbarProps<TData> | null
|
||||
|
||||
/**
|
||||
* Bulk action bar — typically a wrapped {@link DataTableBulkActions} component.
|
||||
* Rendered only on desktop (mobile selection is uncommon).
|
||||
*/
|
||||
bulkActions?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Custom mobile list node — fully replaces the default {@link MobileCardList}.
|
||||
*/
|
||||
mobile?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Pass-through props for the default {@link MobileCardList}.
|
||||
* Ignored if `mobile` is provided.
|
||||
*/
|
||||
mobileProps?: {
|
||||
getRowKey?: (row: Row<TData>) => string | number
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the mobile-specific layout entirely — always renders desktop table.
|
||||
* Useful for pages where the table is read-only and short.
|
||||
*/
|
||||
hideMobile?: boolean
|
||||
|
||||
/**
|
||||
* Row className resolver — applied to both desktop `TableRow` and mobile card.
|
||||
* Composes with the default `data-state="selected"` styling on desktop.
|
||||
* The `ctx.isMobile` flag is provided so consumers can return the
|
||||
* appropriate variant (e.g. `DISABLED_ROW_DESKTOP` vs `DISABLED_ROW_MOBILE`)
|
||||
* without having to re-call `useMediaQuery` themselves.
|
||||
*/
|
||||
getRowClassName?: (
|
||||
row: Row<TData>,
|
||||
ctx: { isMobile: boolean }
|
||||
) => string | undefined
|
||||
|
||||
/**
|
||||
* Custom desktop row renderer — replaces the default `<TableRow>`/`<TableCell>` mapping.
|
||||
* Use for expanded rows, aggregate rows, click-on-row navigation, etc.
|
||||
*/
|
||||
renderRow?: (
|
||||
row: Row<TData>,
|
||||
helpers: DataTableRenderRowHelpers
|
||||
) => React.ReactNode
|
||||
|
||||
/**
|
||||
* Desktop column className resolver. Use for semantic alignment/spacing only;
|
||||
* fixed-column behavior should be configured with `pinnedColumns`.
|
||||
*/
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
|
||||
/**
|
||||
* Fixed desktop columns. The shared table component owns sticky position,
|
||||
* layering, shadows, and row-state backgrounds.
|
||||
*/
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
|
||||
/**
|
||||
* Apply explicit column widths from `header.getSize()` to `<TableHead>`.
|
||||
* Enable this when your column definitions include `size` and you want it honored.
|
||||
* Off by default (TanStack Table assigns a default size of 150 to all columns
|
||||
* which would unintentionally constrain layouts that don't define sizes).
|
||||
*/
|
||||
applyHeaderSize?: boolean
|
||||
|
||||
/**
|
||||
* Optional skeleton key prefix for stable React keys across re-renders.
|
||||
*/
|
||||
skeletonKeyPrefix?: string
|
||||
|
||||
/**
|
||||
* Whether to render pagination. Defaults to `true`.
|
||||
*/
|
||||
showPagination?: boolean
|
||||
|
||||
/**
|
||||
* Render pagination via `PageFooterPortal` (sticks to page footer).
|
||||
* Defaults to `true`. Set `false` to render inline below the table.
|
||||
*/
|
||||
paginationInFooter?: boolean
|
||||
|
||||
/**
|
||||
* Extra content rendered between the table/mobile list and the pagination.
|
||||
* E.g. summary stats, helper text.
|
||||
*/
|
||||
afterTable?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Outer wrapper className (applied to the toolbar+table column).
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* Make the desktop table consume the available page height and scroll inside
|
||||
* the table body while keeping the header fixed. Defaults to `true`.
|
||||
*/
|
||||
fixedHeight?: boolean
|
||||
|
||||
/**
|
||||
* Desktop table container className (the bordered scroll wrapper).
|
||||
*/
|
||||
tableClassName?: string
|
||||
|
||||
/**
|
||||
* Desktop `<TableHeader>` className override.
|
||||
* Use for header color/spacing overrides. Fixed-height pages keep the header
|
||||
* outside the scrollable body automatically.
|
||||
*/
|
||||
tableHeaderClassName?: string
|
||||
|
||||
/**
|
||||
* Opt into the table/card view toggle. Defaults to `false`, so existing
|
||||
* pages render the table only and behave exactly as before. When enabled, a
|
||||
* {@link DataTableViewModeToggle} is injected into the default toolbar
|
||||
* (requires `toolbarProps`; ignored when a fully custom `toolbar` is used)
|
||||
* and the view switches between the table and a card grid on desktop and
|
||||
* mobile. Mobile card mode reuses the same card renderer in a single column.
|
||||
*/
|
||||
enableCardView?: boolean
|
||||
|
||||
/**
|
||||
* Controlled view mode. When provided, `onViewModeChange` should update it.
|
||||
* Leave unset to let the page manage view mode internally (optionally
|
||||
* persisted via `viewModeStorageKey`).
|
||||
*/
|
||||
viewMode?: DataTableViewMode
|
||||
|
||||
/**
|
||||
* Change handler for the controlled `viewMode`.
|
||||
*/
|
||||
onViewModeChange?: (mode: DataTableViewMode) => void
|
||||
|
||||
/**
|
||||
* localStorage key for persisting the (uncontrolled) view mode per table.
|
||||
* Ignored when `viewMode` is controlled.
|
||||
*/
|
||||
viewModeStorageKey?: string
|
||||
|
||||
/**
|
||||
* Initial (uncontrolled) view mode. When unset, defaults to `'card'` if
|
||||
* `enableCardView` is `true`, otherwise `'table'`. A persisted selection
|
||||
* (via `viewModeStorageKey`) always takes precedence over this default.
|
||||
*/
|
||||
defaultViewMode?: DataTableViewMode
|
||||
|
||||
/**
|
||||
* Custom card renderer for card view. When omitted, cards are generated
|
||||
* generically from the column definitions (driven by column meta).
|
||||
*/
|
||||
renderCard?: React.ComponentProps<
|
||||
typeof DataTableCardGrid<TData>
|
||||
>['renderCard']
|
||||
|
||||
/**
|
||||
* Responsive grid className override for the card view.
|
||||
*/
|
||||
cardGridClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified table page wrapper. Encapsulates the canonical structure used across
|
||||
* all list pages: toolbar → desktop table / mobile list → pagination, plus
|
||||
* loading/empty states and an opt-in bulk action bar.
|
||||
*
|
||||
* Most pages should be expressible as:
|
||||
* ```tsx
|
||||
* <DataTablePage
|
||||
* table={table}
|
||||
* columns={columns}
|
||||
* isLoading={isLoading}
|
||||
* isFetching={isFetching}
|
||||
* emptyTitle={t('No X Found')}
|
||||
* toolbarProps={{ searchPlaceholder: t('Filter...'), filters }}
|
||||
* bulkActions={<MyBulkActions table={table} />}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* For complex layouts (custom mobile, expanded rows, custom toolbar), use the
|
||||
* `toolbar` / `mobile` / `renderRow` slots instead of the `*Props` variants.
|
||||
*/
|
||||
export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
const showMobile = isMobile && !props.hideMobile
|
||||
|
||||
const [internalViewMode, setInternalViewMode] = useDataTableViewMode({
|
||||
storageKey: props.viewModeStorageKey,
|
||||
// When card view is enabled, prefer it as the default unless the consumer
|
||||
// explicitly opts into a different initial mode. A persisted choice (via
|
||||
// `viewModeStorageKey`) still takes precedence over this default.
|
||||
defaultMode:
|
||||
props.defaultViewMode ??
|
||||
(props.enableCardView ? DATA_TABLE_VIEW_MODES.CARD : undefined),
|
||||
})
|
||||
const viewMode = props.viewMode ?? internalViewMode
|
||||
const setViewMode = props.onViewModeChange ?? setInternalViewMode
|
||||
const cardViewActive = !!props.enableCardView
|
||||
|
||||
const viewToggle = cardViewActive ? (
|
||||
<DataTableViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||
) : undefined
|
||||
|
||||
const toolbarNode = renderToolbar(props, viewToggle)
|
||||
const mobileNode = renderMobile(props, showMobile, cardViewActive, viewMode)
|
||||
const desktopNode = renderDesktop(props, showMobile, cardViewActive, viewMode)
|
||||
const paginationNode = renderPagination(props)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
props.fixedHeight !== false
|
||||
? 'flex h-full min-h-0 flex-col gap-2.5 sm:gap-3'
|
||||
: 'space-y-2.5 sm:space-y-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{toolbarNode}
|
||||
{mobileNode}
|
||||
{desktopNode}
|
||||
{props.afterTable}
|
||||
</div>
|
||||
|
||||
{/* Bulk actions are typically a fixed-position toolbar; let the consumer
|
||||
handle its own visibility, we just gate it to non-mobile. */}
|
||||
{!showMobile && props.bulkActions}
|
||||
|
||||
{paginationNode}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderToolbar<TData>(
|
||||
props: DataTablePageProps<TData>,
|
||||
viewToggle: React.ReactNode
|
||||
): React.ReactNode {
|
||||
if (props.toolbar !== undefined) {
|
||||
// Fully custom toolbar: the consumer owns layout, including any toggle.
|
||||
return props.toolbar
|
||||
}
|
||||
if (props.toolbarProps === null) {
|
||||
return null
|
||||
}
|
||||
if (props.toolbarProps) {
|
||||
return (
|
||||
<DataTableToolbar
|
||||
table={props.table}
|
||||
{...props.toolbarProps}
|
||||
viewToggle={props.toolbarProps.viewToggle ?? viewToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function renderPagination<TData>(
|
||||
props: DataTablePageProps<TData>
|
||||
): React.ReactNode {
|
||||
if (props.showPagination === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const pagination = <DataTablePagination table={props.table} />
|
||||
|
||||
return props.paginationInFooter !== false ? (
|
||||
<PageFooterPortal>{pagination}</PageFooterPortal>
|
||||
) : (
|
||||
<div className='pt-2'>{pagination}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMobile<TData>(
|
||||
props: DataTablePageProps<TData>,
|
||||
showMobile: boolean,
|
||||
cardViewActive: boolean,
|
||||
viewMode: DataTableViewMode
|
||||
): React.ReactNode {
|
||||
if (!showMobile) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isFetchingOnly = props.isFetching && !props.isLoading
|
||||
const ownGetRowClassName = props.getRowClassName
|
||||
const mobileGetRowClassName =
|
||||
props.mobileProps?.getRowClassName ??
|
||||
(ownGetRowClassName
|
||||
? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true })
|
||||
: undefined)
|
||||
|
||||
let mobileContent = props.mobile
|
||||
if (mobileContent === undefined) {
|
||||
if (cardViewActive && viewMode === DATA_TABLE_VIEW_MODES.TABLE) {
|
||||
mobileContent = (
|
||||
<DataTableView
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
renderRow={props.renderRow}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
tableHeaderClassName={cn(
|
||||
'[background-color:var(--table-header)]',
|
||||
props.tableHeaderClassName
|
||||
)}
|
||||
getColumnClassName={props.getColumnClassName}
|
||||
pinnedColumns={props.pinnedColumns}
|
||||
containerClassName={cn(
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60',
|
||||
props.tableClassName
|
||||
)}
|
||||
getRowClassName={(row) =>
|
||||
props.getRowClassName?.(row, { isMobile: false })
|
||||
}
|
||||
/>
|
||||
)
|
||||
} else if (cardViewActive) {
|
||||
mobileContent = (
|
||||
<DataTableCardGrid
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
renderCard={props.renderCard}
|
||||
gridClassName={props.cardGridClassName ?? 'grid grid-cols-1 gap-3'}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
getRowKey={props.mobileProps?.getRowKey}
|
||||
getRowClassName={mobileGetRowClassName}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
mobileContent = (
|
||||
<MobileCardList
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
getRowKey={props.mobileProps?.getRowKey}
|
||||
getRowClassName={mobileGetRowClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
|
||||
}
|
||||
|
||||
function renderDesktop<TData>(
|
||||
props: DataTablePageProps<TData>,
|
||||
showMobile: boolean,
|
||||
cardViewActive: boolean,
|
||||
viewMode: DataTableViewMode
|
||||
): React.ReactNode {
|
||||
if (showMobile) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isFetchingOnly = props.isFetching && !props.isLoading
|
||||
const fixedHeight = props.fixedHeight !== false
|
||||
|
||||
if (cardViewActive && viewMode === DATA_TABLE_VIEW_MODES.CARD) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
fixedHeight && 'min-h-0 flex-1 overflow-y-auto',
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60'
|
||||
)}
|
||||
>
|
||||
<DataTableCardGrid
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
renderCard={props.renderCard}
|
||||
gridClassName={props.cardGridClassName}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
getRowClassName={(row) =>
|
||||
props.getRowClassName?.(row, { isMobile: false })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTableView
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
renderRow={props.renderRow}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
splitHeader={fixedHeight}
|
||||
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
|
||||
tableHeaderClassName={cn(
|
||||
fixedHeight && '[background-color:var(--table-header)]',
|
||||
props.tableHeaderClassName
|
||||
)}
|
||||
getColumnClassName={props.getColumnClassName}
|
||||
pinnedColumns={props.pinnedColumns}
|
||||
containerClassName={cn(
|
||||
fixedHeight && 'min-h-0 flex-1',
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60',
|
||||
props.tableClassName
|
||||
)}
|
||||
getRowClassName={(row) =>
|
||||
props.getRowClassName?.(row, { isMobile: false })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
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 { Row, Table } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
/*
|
||||
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 * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { tableHasCompactMeta } from './card-cell-utils'
|
||||
import { CardRowContent } from './card-row-content'
|
||||
|
||||
interface MobileCardListProps<TData> {
|
||||
table: Table<TData>
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
getRowKey?: (row: Row<TData>) => string | number
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className='px-3 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-5 w-16 rounded-md' />
|
||||
</div>
|
||||
<div className='mt-1.5 grid grid-cols-2 gap-2'>
|
||||
<div className='flex-1'>
|
||||
<Skeleton className='mb-1 h-2 w-8' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<Skeleton className='mb-1 h-2 w-8' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FallbackListSkeleton() {
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className='space-y-1.5 px-3 py-2.5'>
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div key={j} className='flex items-center justify-between'>
|
||||
<Skeleton className='h-2.5 w-16' />
|
||||
<Skeleton className='h-3.5 w-28' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile-optimized list view for table data.
|
||||
*
|
||||
* Renders rows inside a single bordered container with dividers —
|
||||
* a Vercel/Stripe-style list rather than individual cards.
|
||||
*
|
||||
* Per-row content is shared with the desktop card view via
|
||||
* {@link CardRowContent}; see `card-row-content.tsx` for the column-meta
|
||||
* extensions (`mobileTitle`, `mobileBadge`, `mobileHidden`).
|
||||
*/
|
||||
export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
const {
|
||||
table,
|
||||
isLoading = false,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
getRowKey,
|
||||
getRowClassName,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resolvedEmptyTitle = emptyTitle ?? t('No Data')
|
||||
const resolvedEmptyDescription = emptyDescription ?? t('No data available')
|
||||
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
const hasCompactMeta = React.useMemo(
|
||||
() => tableHasCompactMeta(table),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[visibleColumns]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return hasCompactMeta ? <ListSkeleton /> : <FallbackListSkeleton />
|
||||
}
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className='rounded-lg border p-6'>
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Database className='size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
{rows.map((row) => {
|
||||
const key = getRowKey ? getRowKey(row) : row.id
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'[background-color:var(--data-table-card-bg,var(--table-row))] px-3 py-2.5',
|
||||
getRowClassName?.(row)
|
||||
)}
|
||||
>
|
||||
<CardRowContent row={row} compact={hasCompactMeta} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
export const staticDataTableClassNames = {
|
||||
container: 'overflow-hidden rounded-md border',
|
||||
sectionContainer: 'border-border/60 rounded-lg',
|
||||
embeddedContainer: 'rounded-none border-0',
|
||||
compactTable: 'text-sm',
|
||||
compactHeaderRow: 'hover:bg-transparent',
|
||||
mutedHeaderRow:
|
||||
'[background-color:var(--table-header)] hover:[background-color:var(--table-header-hover)]',
|
||||
compactHeaderCell:
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
|
||||
compactHeaderCellRight:
|
||||
'text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase',
|
||||
compactCell: 'py-2.5',
|
||||
compactTopCell: 'py-2.5 align-top',
|
||||
compactTopNumericCell: 'py-2.5 text-right align-top font-mono',
|
||||
compactMutedCell: 'text-muted-foreground py-2.5',
|
||||
compactMutedCodeCell: 'text-muted-foreground py-2.5 font-mono',
|
||||
compactNumericCell: 'py-2.5 text-right font-mono',
|
||||
compactMutedNumericCell: 'text-muted-foreground py-2.5 text-right font-mono',
|
||||
topCell: 'py-2 align-top',
|
||||
topMutedCell: 'text-muted-foreground py-2 align-top',
|
||||
codeCell: 'font-mono text-sm',
|
||||
mutedCell: 'text-muted-foreground text-sm',
|
||||
mutedCodeCell: 'text-muted-foreground font-mono text-sm',
|
||||
topNumericCell: 'py-2 text-right font-mono',
|
||||
mediumCell: 'font-medium',
|
||||
actionHeaderCell: 'w-auto max-w-none text-right',
|
||||
actionCell: 'w-auto max-w-none text-right',
|
||||
} as const
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
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 * as React from 'react'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { TruncatedCell } from '../core/truncated-cell'
|
||||
import { staticDataTableClassNames } from './static-data-table-classnames'
|
||||
|
||||
type StaticDataTableBaseProps = {
|
||||
className?: string
|
||||
tableClassName?: string
|
||||
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
|
||||
tableProps?: Omit<
|
||||
React.ComponentProps<typeof Table>,
|
||||
'className' | 'children'
|
||||
>
|
||||
}
|
||||
|
||||
type StaticDataTableDataProps<TData = unknown> = StaticDataTableBaseProps & {
|
||||
columns: StaticDataTableColumn<TData>[]
|
||||
data: TData[]
|
||||
getRowKey?: (row: TData, index: number) => React.Key
|
||||
getRowClassName?: (row: TData, index: number) => string | undefined
|
||||
renderRow?: (row: TData, index: number) => React.ReactNode
|
||||
empty?: boolean
|
||||
emptyContent?: React.ReactNode
|
||||
emptyClassName?: string
|
||||
headerRowClassName?: string
|
||||
}
|
||||
|
||||
type StaticDataTableChildrenProps = StaticDataTableBaseProps & {
|
||||
children: React.ReactNode
|
||||
columns?: never
|
||||
data?: never
|
||||
}
|
||||
|
||||
type StaticDataTableProps<TData = unknown> =
|
||||
| StaticDataTableDataProps<TData>
|
||||
| StaticDataTableChildrenProps
|
||||
|
||||
export type StaticDataTableColumn<TData = unknown> = {
|
||||
id: string
|
||||
header: React.ReactNode
|
||||
className?: string
|
||||
cellClassName?: string | ((row: TData, index: number) => string | undefined)
|
||||
cell?: (row: TData, index: number) => React.ReactNode
|
||||
}
|
||||
|
||||
export function StaticDataTable<TData = unknown>(
|
||||
props: StaticDataTableProps<TData>
|
||||
) {
|
||||
const { className, tableClassName, containerProps, tableProps } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(staticDataTableClassNames.container, className)}
|
||||
{...containerProps}
|
||||
>
|
||||
<Table className={tableClassName} {...tableProps}>
|
||||
{props.columns !== undefined ? (
|
||||
<StaticDataTableWithColumns {...props} />
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDataTableWithColumns<TData>({
|
||||
columns,
|
||||
data,
|
||||
getRowKey,
|
||||
getRowClassName,
|
||||
renderRow,
|
||||
empty,
|
||||
emptyContent,
|
||||
emptyClassName,
|
||||
headerRowClassName,
|
||||
}: StaticDataTableDataProps<TData>) {
|
||||
const isEmpty = empty ?? (data !== undefined && data.length === 0)
|
||||
const bodyRows = data.map((row, index) => (
|
||||
<StaticDataTableRow
|
||||
key={getRowKey?.(row, index) ?? index}
|
||||
row={row}
|
||||
index={index}
|
||||
columns={columns}
|
||||
getRowClassName={getRowClassName}
|
||||
renderRow={renderRow}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableHeader>
|
||||
<TableRow className={headerRowClassName}>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.id} className={column.className}>
|
||||
{column.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isEmpty ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={columns.length}
|
||||
className={emptyClassName}
|
||||
>
|
||||
{emptyContent}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
bodyRows
|
||||
)}
|
||||
</TableBody>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type StaticDataTableRowProps<TData> = Required<
|
||||
Pick<StaticDataTableDataProps<TData>, 'columns'>
|
||||
> &
|
||||
Pick<StaticDataTableDataProps<TData>, 'getRowClassName' | 'renderRow'> & {
|
||||
row: TData
|
||||
index: number
|
||||
}
|
||||
|
||||
function StaticDataTableRow<TData>({
|
||||
row,
|
||||
index,
|
||||
columns,
|
||||
getRowClassName,
|
||||
renderRow,
|
||||
}: StaticDataTableRowProps<TData>) {
|
||||
if (renderRow) {
|
||||
return <>{renderRow(row, index)}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow className={getRowClassName?.(row, index)}>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className={cn(
|
||||
'max-w-full min-w-0 overflow-hidden',
|
||||
getStaticCellClassName(column, row, index)
|
||||
)}
|
||||
>
|
||||
{renderStaticCellContent(column, row, index)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function renderStaticCellContent<TData>(
|
||||
column: StaticDataTableColumn<TData>,
|
||||
row: TData,
|
||||
index: number
|
||||
) {
|
||||
const content = column.cell?.(row, index)
|
||||
const textContent = getPrimitiveTextContent(content)
|
||||
|
||||
if (!textContent) return content
|
||||
|
||||
return <TruncatedCell tooltipContent={textContent}>{content}</TruncatedCell>
|
||||
}
|
||||
|
||||
function getPrimitiveTextContent(content: React.ReactNode): string | null {
|
||||
if (typeof content === 'string' || typeof content === 'number') {
|
||||
return String(content)
|
||||
}
|
||||
|
||||
if (
|
||||
React.isValidElement<{ children?: React.ReactNode }>(content) &&
|
||||
(typeof content.props.children === 'string' ||
|
||||
typeof content.props.children === 'number')
|
||||
) {
|
||||
return String(content.props.children)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getStaticCellClassName<TData>(
|
||||
column: StaticDataTableColumn<TData>,
|
||||
row: TData,
|
||||
index: number
|
||||
) {
|
||||
return typeof column.cellClassName === 'function'
|
||||
? column.cellClassName(row, index)
|
||||
: column.cellClassName
|
||||
}
|
||||
|
||||
type StaticDataTableEmptyRowProps = {
|
||||
colSpan: number
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function StaticDataTableEmptyRow({
|
||||
colSpan,
|
||||
children,
|
||||
className,
|
||||
}: StaticDataTableEmptyRowProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={colSpan}
|
||||
className={cn('h-24 text-center', className)}
|
||||
>
|
||||
{children}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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 { Pencil, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
import { DataTableRowActionMenu } from '../core/row-action-menu'
|
||||
|
||||
type StaticRowActionsProps = {
|
||||
editLabel: string
|
||||
deleteLabel: string
|
||||
menuLabel: string
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
editDisabled?: boolean
|
||||
deleteDisabled?: boolean
|
||||
}
|
||||
|
||||
export function StaticRowActions(props: StaticRowActionsProps) {
|
||||
return (
|
||||
<div className='flex justify-end gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={props.onEdit}
|
||||
disabled={props.editDisabled}
|
||||
aria-label={props.editLabel}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<DataTableRowActionMenu ariaLabel={props.menuLabel}>
|
||||
<DropdownMenuItem
|
||||
onClick={props.onDelete}
|
||||
disabled={props.deleteDisabled}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{props.deleteLabel}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DataTableRowActionMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
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 Table } from '@tanstack/react-table'
|
||||
import { X } from 'lucide-react'
|
||||
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DataTableBulkActionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
entityName: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A modular toolbar for displaying bulk actions when table rows are selected.
|
||||
*
|
||||
* @template TData The type of data in the table.
|
||||
* @param {object} props The component props.
|
||||
* @param {Table<TData>} props.table The react-table instance.
|
||||
* @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
|
||||
* @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
|
||||
* @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
|
||||
*/
|
||||
export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
entityName,
|
||||
children,
|
||||
}: DataTableBulkActionsProps<TData>): React.ReactNode | null {
|
||||
const { t } = useTranslation()
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedCount = selectedRows.length
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const buttonsRef = useRef<NodeListOf<HTMLButtonElement> | null>(null)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
||||
useLayoutEffect(() => {
|
||||
buttonsRef.current = toolbarRef.current?.querySelectorAll('button') ?? null
|
||||
})
|
||||
|
||||
// Announce selection changes to screen readers
|
||||
useEffect(() => {
|
||||
if (selectedCount > 0) {
|
||||
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAnnouncement(message)
|
||||
|
||||
// Clear announcement after a delay
|
||||
const timer = setTimeout(() => setAnnouncement(''), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [selectedCount, entityName])
|
||||
|
||||
const handleClearSelection = () => {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const buttons = buttonsRef.current
|
||||
if (!buttons) return
|
||||
|
||||
const currentIndex = Array.from(buttons).findIndex(
|
||||
(button) => button === document.activeElement
|
||||
)
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault()
|
||||
const nextIndex = (currentIndex + 1) % buttons.length
|
||||
buttons[nextIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
event.preventDefault()
|
||||
const prevIndex =
|
||||
currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
|
||||
buttons[prevIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
buttons[0]?.focus()
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
buttons[buttons.length - 1]?.focus()
|
||||
break
|
||||
case 'Escape': {
|
||||
// Check if the Escape key came from a dropdown trigger or content
|
||||
// We can't check dropdown state because the menu closes before our handler runs.
|
||||
const target = event.target as HTMLElement
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
|
||||
// Check if the event target or currently focused element is a dropdown trigger
|
||||
const isFromDropdownTrigger =
|
||||
target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
|
||||
activeElement?.getAttribute('data-slot') ===
|
||||
'dropdown-menu-trigger' ||
|
||||
target?.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
|
||||
|
||||
// Check if the focused element is inside dropdown content (which is portaled)
|
||||
const isFromDropdownContent =
|
||||
activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
|
||||
target?.closest('[data-slot="dropdown-menu-content"]')
|
||||
|
||||
if (isFromDropdownTrigger || isFromDropdownContent) {
|
||||
// Escape was meant for the dropdown - don't clear selection
|
||||
return
|
||||
}
|
||||
|
||||
// Escape was meant for the toolbar - clear selection
|
||||
event.preventDefault()
|
||||
handleClearSelection()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Live region for screen reader announcements */}
|
||||
<div
|
||||
aria-live='polite'
|
||||
aria-atomic='true'
|
||||
className='sr-only'
|
||||
role='status'
|
||||
>
|
||||
{announcement}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
role='toolbar'
|
||||
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
|
||||
aria-describedby='bulk-actions-description'
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
|
||||
'transition-all delay-100 duration-300 ease-out hover:scale-105',
|
||||
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 shadow-xl',
|
||||
'rounded-xl border',
|
||||
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
|
||||
'flex items-center gap-x-2'
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleClearSelection}
|
||||
className='size-6'
|
||||
aria-label={t('Clear selection')}
|
||||
title={t('Clear selection (Escape)')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<X />
|
||||
<span className='sr-only'>{t('Clear selection')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Clear selection (Escape)')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator
|
||||
className='h-5'
|
||||
orientation='vertical'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
<div
|
||||
className='flex items-center gap-x-1 text-sm'
|
||||
id='bulk-actions-description'
|
||||
>
|
||||
<Badge
|
||||
variant='default'
|
||||
className='min-w-8 rounded-lg'
|
||||
aria-label={`${selectedCount} selected`}
|
||||
>
|
||||
{selectedCount}
|
||||
</Badge>{' '}
|
||||
<span className='hidden sm:inline'>
|
||||
{entityName}
|
||||
{selectedCount > 1 ? 's' : ''}
|
||||
</span>{' '}
|
||||
{t('selected')}
|
||||
</div>
|
||||
|
||||
<Separator
|
||||
className='h-5'
|
||||
orientation='vertical'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
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 Column } from '@tanstack/react-table'
|
||||
import { Check as CheckIcon, PlusCircle as PlusCircledIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DataTableFacetedFilterProps<TData, TValue> = {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
iconNode?: React.ReactNode
|
||||
count?: number
|
||||
}[]
|
||||
/** Enable single select mode (only one option can be selected at a time) */
|
||||
singleSelect?: boolean
|
||||
}
|
||||
|
||||
function DataTableFacetedFilterInner<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
singleSelect = false,
|
||||
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||
const { t } = useTranslation()
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const filterValue = column?.getFilterValue() as string[] | undefined
|
||||
const selectedValues = new Set(filterValue)
|
||||
|
||||
const handleOptionSelect = (optionValue: string) => {
|
||||
const nextSelectedValues = getNextSelectedValues(
|
||||
selectedValues,
|
||||
optionValue,
|
||||
singleSelect
|
||||
)
|
||||
|
||||
column?.setFilterValue(
|
||||
nextSelectedValues.length ? nextSelectedValues : undefined
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant='outline' size='sm' className='h-8 border-dashed' />
|
||||
}
|
||||
>
|
||||
<PlusCircledIcon className='size-4' />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation='vertical' className='mx-2 h-4' />
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal lg:hidden'
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className='hidden space-x-1 lg:flex'>
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{selectedValues.size} {t('selected')}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
key={option.value}
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{t(option.label)}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='max-w-[360px] min-w-[200px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('No results found.')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => handleOptionSelect(option.value)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'border-primary flex size-4 items-center justify-center rounded-sm border',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn('text-background h-4 w-4')} />
|
||||
</div>
|
||||
{option.iconNode ? (
|
||||
<span className='text-muted-foreground flex size-4 items-center justify-center'>
|
||||
{option.iconNode}
|
||||
</span>
|
||||
) : option.icon ? (
|
||||
<option.icon className='text-muted-foreground size-4' />
|
||||
) : null}
|
||||
<span
|
||||
className='min-w-0 flex-1 truncate'
|
||||
title={t(option.label)}
|
||||
>
|
||||
{t(option.label)}
|
||||
</span>
|
||||
{typeof option.count === 'number' ? (
|
||||
<span className='text-muted-foreground ms-auto flex h-4 min-w-4 items-center justify-center font-mono text-xs'>
|
||||
{option.count}
|
||||
</span>
|
||||
) : facets?.get(option.value) ? (
|
||||
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className='justify-center text-center'
|
||||
>
|
||||
{t('Clear filters')}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export const DataTableFacetedFilter = React.memo(
|
||||
DataTableFacetedFilterInner
|
||||
) as typeof DataTableFacetedFilterInner
|
||||
|
||||
function getNextSelectedValues(
|
||||
selectedValues: Set<string>,
|
||||
optionValue: string,
|
||||
singleSelect: boolean
|
||||
): string[] {
|
||||
if (singleSelect) {
|
||||
return selectedValues.has(optionValue) ? [] : [optionValue]
|
||||
}
|
||||
|
||||
const nextSelectedValues = new Set(selectedValues)
|
||||
if (nextSelectedValues.has(optionValue)) {
|
||||
nextSelectedValues.delete(optionValue)
|
||||
} else {
|
||||
nextSelectedValues.add(optionValue)
|
||||
}
|
||||
|
||||
return Array.from(nextSelectedValues)
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/*
|
||||
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 { Table } from '@tanstack/react-table'
|
||||
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { DataTableFacetedFilter } from './faceted-filter'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
|
||||
type FilterDef = {
|
||||
columnId: string
|
||||
title: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
iconNode?: React.ReactNode
|
||||
count?: number
|
||||
}[]
|
||||
singleSelect?: boolean
|
||||
}
|
||||
|
||||
type SearchDraft = {
|
||||
baseValue: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
/**
|
||||
* Placeholder for the default search input. Defaults to `t('Filter...')`.
|
||||
*/
|
||||
searchPlaceholder?: string
|
||||
/**
|
||||
* Delay committing the default search input. Defaults to immediate updates.
|
||||
*/
|
||||
searchDebounceMs?: number
|
||||
/**
|
||||
* Column id to filter on. When provided, the search input filters
|
||||
* a specific column. When omitted, the search input updates the
|
||||
* table's `globalFilter`.
|
||||
*/
|
||||
searchKey?: string
|
||||
/**
|
||||
* Column-level filter chips (faceted multi-select / single-select).
|
||||
*/
|
||||
filters?: FilterDef[]
|
||||
/**
|
||||
* Replaces the default search input entirely. Use when the primary
|
||||
* "search" is something custom — e.g. a date-time range picker.
|
||||
*/
|
||||
customSearch?: ReactNode
|
||||
/**
|
||||
* Extra inputs/selects displayed in the primary row alongside the
|
||||
* search input and filter chips.
|
||||
*/
|
||||
additionalSearch?: ReactNode
|
||||
/**
|
||||
* Whether non-table filters (e.g. `additionalSearch` or `expandable`
|
||||
* inputs) are currently active. Controls Reset button visibility
|
||||
* when no column filters are set.
|
||||
*/
|
||||
hasAdditionalFilters?: boolean
|
||||
/**
|
||||
* Callback invoked when the user clicks Reset.
|
||||
*/
|
||||
onReset?: () => void
|
||||
/**
|
||||
* Additional filter inputs hidden behind an Expand/Collapse toggle.
|
||||
* Inputs flow inline with the primary row when expanded.
|
||||
*/
|
||||
expandable?: ReactNode
|
||||
/**
|
||||
* When `expandable` is collapsed, highlights the toggle if any of
|
||||
* the expandable inputs currently hold a value.
|
||||
*/
|
||||
hasExpandedActiveFilters?: boolean
|
||||
/**
|
||||
* Custom action buttons rendered BEFORE the built-in
|
||||
* Reset / Search / View buttons.
|
||||
*/
|
||||
preActions?: ReactNode
|
||||
/**
|
||||
* Explicit "Search" / "Apply" callback. When provided the toolbar
|
||||
* shows a primary Search button. Filters are committed only on click
|
||||
* (form-mode workflow).
|
||||
*/
|
||||
onSearch?: () => void
|
||||
/**
|
||||
* Loading state for the explicit Search button.
|
||||
*/
|
||||
searchLoading?: boolean
|
||||
/**
|
||||
* Hide the View Options (column visibility) dropdown.
|
||||
*/
|
||||
hideViewOptions?: boolean
|
||||
/**
|
||||
* Optional view-mode toggle (e.g. table vs. card) rendered in the right
|
||||
* action cluster, before the View Options dropdown. Typically a
|
||||
* {@link DataTableViewModeToggle}. Omitted by default.
|
||||
*/
|
||||
viewToggle?: ReactNode
|
||||
/**
|
||||
* Content rendered on the LEFT side of the secondary action row. When
|
||||
* provided the toolbar splits into two visual rows:
|
||||
* Row 1: search inputs / filter chips …… Expand
|
||||
* Row 2: expanded filters
|
||||
* Row 3: leftActions …… Reset / Search / ViewOptions
|
||||
*/
|
||||
leftActions?: ReactNode
|
||||
/**
|
||||
* Outer wrapper className override.
|
||||
*/
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified data-table filter panel — Ant Design Pro inspired.
|
||||
*
|
||||
* Layout (single flex-wrap row):
|
||||
* - Filters (search input + additional inputs + filter chips + expandable
|
||||
* inputs) flow horizontally and wrap as needed.
|
||||
* - The action cluster (Reset / Search / View / Expand) hugs the right
|
||||
* edge via `ms-auto`. When filters fill a row, the cluster naturally
|
||||
* wraps to the next line — still right-aligned — matching the
|
||||
* collapsed/expanded states from the user's reference design.
|
||||
*
|
||||
* No background panel, no row separators — relies on whitespace and the
|
||||
* adjacent table border for visual hierarchy.
|
||||
*/
|
||||
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [isSearchComposing, setIsSearchComposing] = useState(false)
|
||||
|
||||
const filters = props.filters ?? []
|
||||
const hasExpandable = props.expandable != null
|
||||
const hasSearch = props.onSearch != null
|
||||
|
||||
const isFiltered =
|
||||
props.table.getState().columnFilters.length > 0 ||
|
||||
!!props.table.getState().globalFilter ||
|
||||
!!props.hasAdditionalFilters
|
||||
|
||||
const placeholder = props.searchPlaceholder ?? t('Filter...')
|
||||
const currentSearchValue = props.searchKey
|
||||
? ((props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
|
||||
'')
|
||||
: ((props.table.getState().globalFilter as string | undefined) ?? '')
|
||||
|
||||
const [searchDraft, setSearchDraft] = useState<SearchDraft | null>(null)
|
||||
const activeSearchDraft =
|
||||
searchDraft &&
|
||||
(isSearchComposing || searchDraft.baseValue === currentSearchValue)
|
||||
? searchDraft
|
||||
: null
|
||||
const searchValue = activeSearchDraft?.value ?? currentSearchValue
|
||||
const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0)
|
||||
const debouncedSearchValue = useDebounce(searchValue, searchDebounceMs)
|
||||
|
||||
const commitSearchValue = React.useCallback(
|
||||
(value: string) => {
|
||||
if (value === currentSearchValue) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.searchKey) {
|
||||
props.table.getColumn(props.searchKey)?.setFilterValue(value)
|
||||
return
|
||||
}
|
||||
|
||||
props.table.setGlobalFilter(value)
|
||||
},
|
||||
[currentSearchValue, props.searchKey, props.table]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
searchDebounceMs <= 0 ||
|
||||
isSearchComposing ||
|
||||
debouncedSearchValue !== searchValue
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
commitSearchValue(debouncedSearchValue)
|
||||
}, [
|
||||
commitSearchValue,
|
||||
debouncedSearchValue,
|
||||
isSearchComposing,
|
||||
searchDebounceMs,
|
||||
searchValue,
|
||||
])
|
||||
|
||||
const queueSearchValue = (value: string) => {
|
||||
if (searchDebounceMs <= 0) {
|
||||
commitSearchValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setSearchDraft({ baseValue: currentSearchValue, value })
|
||||
|
||||
if (!isSearchComposing) {
|
||||
queueSearchValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchCompositionStart = () => {
|
||||
setIsSearchComposing(true)
|
||||
}
|
||||
|
||||
const handleSearchCompositionEnd = (
|
||||
event: React.CompositionEvent<HTMLInputElement>
|
||||
) => {
|
||||
setIsSearchComposing(false)
|
||||
const value = event.currentTarget.value
|
||||
setSearchDraft({ baseValue: currentSearchValue, value })
|
||||
queueSearchValue(value)
|
||||
}
|
||||
|
||||
const searchInput = (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
onCompositionStart={handleSearchCompositionStart}
|
||||
onCompositionEnd={handleSearchCompositionEnd}
|
||||
className='w-full sm:w-[200px] lg:w-[240px]'
|
||||
/>
|
||||
)
|
||||
|
||||
const filterChips = React.useMemo(
|
||||
() =>
|
||||
filters.map((filter) => {
|
||||
const column = props.table.getColumn(filter.columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
key={filter.columnId}
|
||||
column={column}
|
||||
title={filter.title}
|
||||
options={filter.options}
|
||||
singleSelect={filter.singleSelect}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[props.filters, props.table]
|
||||
)
|
||||
|
||||
const handleReset = () => {
|
||||
setIsSearchComposing(false)
|
||||
setSearchDraft(null)
|
||||
props.table.resetColumnFilters()
|
||||
props.table.setGlobalFilter('')
|
||||
props.onReset?.()
|
||||
}
|
||||
|
||||
// Reset: outline text-only for form mode (always visible, disabled when
|
||||
// nothing to reset); ghost text + X for filter-as-you-type mode (only
|
||||
// visible when active filters exist).
|
||||
let resetButton: ReactNode = null
|
||||
if (hasSearch) {
|
||||
resetButton = (
|
||||
<Button variant='outline' onClick={handleReset} disabled={!isFiltered}>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)
|
||||
} else if (isFiltered) {
|
||||
resetButton = (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleReset}
|
||||
className='text-muted-foreground hover:text-foreground gap-1 px-2'
|
||||
>
|
||||
{t('Reset')}
|
||||
<Cross2Icon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const searchButton = hasSearch ? (
|
||||
<Button onClick={props.onSearch} disabled={props.searchLoading}>
|
||||
{props.searchLoading && <Loader2 className='animate-spin' />}
|
||||
{t('Search')}
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const viewOptionsNode = !props.hideViewOptions ? (
|
||||
<DataTableViewOptions table={props.table} />
|
||||
) : null
|
||||
|
||||
const viewToggleNode = props.viewToggle ?? null
|
||||
|
||||
const expandToggle = hasExpandable ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
aria-expanded={expanded}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground gap-1 px-2',
|
||||
props.hasExpandedActiveFilters &&
|
||||
!expanded &&
|
||||
'text-primary hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{expanded ? t('Collapse') : t('Expand')}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200',
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const hasLeftActions = props.leftActions != null
|
||||
|
||||
if (hasLeftActions) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', props.className)}>
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.customSearch !== undefined ? props.customSearch : searchInput}
|
||||
{props.additionalSearch}
|
||||
{filterChips}
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{expandToggle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && hasExpandable && (
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.expandable}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.leftActions}
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{props.preActions}
|
||||
{resetButton}
|
||||
{searchButton}
|
||||
{viewToggleNode}
|
||||
{viewOptionsNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-2 sm:gap-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.customSearch !== undefined ? props.customSearch : searchInput}
|
||||
{props.additionalSearch}
|
||||
{filterChips}
|
||||
{expanded && hasExpandable && props.expandable}
|
||||
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{props.preActions}
|
||||
{resetButton}
|
||||
{searchButton}
|
||||
{viewToggleNode}
|
||||
{viewOptionsNode}
|
||||
{expandToggle}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
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 { Grid2X2, Table2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
DATA_TABLE_VIEW_MODES,
|
||||
type DataTableViewMode,
|
||||
} from '../hooks/use-data-table-view-mode'
|
||||
|
||||
export type DataTableViewModeToggleProps = {
|
||||
value: DataTableViewMode
|
||||
onChange: (mode: DataTableViewMode) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
type Segment = {
|
||||
value: DataTableViewMode
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable icon segmented control for switching a data table between table and
|
||||
* card views. Shared, accessible version of the local control used by the
|
||||
* model square (`pricing-toolbar.tsx`).
|
||||
*/
|
||||
export function DataTableViewModeToggle(props: DataTableViewModeToggleProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const segments: Segment[] = [
|
||||
{
|
||||
value: DATA_TABLE_VIEW_MODES.CARD,
|
||||
icon: Grid2X2,
|
||||
tooltip: t('Card view'),
|
||||
},
|
||||
{
|
||||
value: DATA_TABLE_VIEW_MODES.TABLE,
|
||||
icon: Table2,
|
||||
tooltip: t('Table view'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
role='group'
|
||||
aria-label={t('View mode')}
|
||||
className={cn(
|
||||
'bg-muted/60 inline-flex h-8 items-center rounded-lg border p-0.5',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{segments.map((segment) => {
|
||||
const Icon = segment.icon
|
||||
const isActive = segment.value === props.value
|
||||
return (
|
||||
<Tooltip key={segment.value}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => props.onChange(segment.value)}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'inline-flex h-full w-7 items-center justify-center rounded-md text-xs font-medium transition-all',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className='size-3.5' />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side='bottom' className='text-xs'>
|
||||
{segment.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
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 Table } from '@tanstack/react-table'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type DataTableViewOptionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const hideableColumns = React.useMemo(
|
||||
() =>
|
||||
table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||
),
|
||||
[table]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
className='shrink-0'
|
||||
aria-label={t('View')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t('View')}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
|
||||
{hideableColumns.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className='capitalize'
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{typeof column.columnDef.header === 'string'
|
||||
? column.columnDef.header
|
||||
: (column.columnDef.meta?.label ?? column.id)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
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 { Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
|
||||
const calendarLocales = {
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
fr,
|
||||
ru,
|
||||
ja,
|
||||
vi,
|
||||
} as const
|
||||
|
||||
type DatePickerProps = {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
placeholder,
|
||||
}: DatePickerProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const placeholderText = placeholder ?? t('Pick a date')
|
||||
const calendarLocale =
|
||||
calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
data-empty={!selected}
|
||||
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected ? (
|
||||
dayjs(selected).format('YYYY-MM-DD')
|
||||
) : (
|
||||
<span>{placeholderText}</span>
|
||||
)}
|
||||
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
captionLayout='dropdown'
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
locale={calendarLocale}
|
||||
disabled={(date: Date) =>
|
||||
date > new Date() || date < new Date('1900-01-01')
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
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 { ChevronDownIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const calendarLocales = {
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
fr,
|
||||
ru,
|
||||
ja,
|
||||
vi,
|
||||
} as const
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value?: Date
|
||||
onChange?: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: DateTimePickerProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const placeholderText = placeholder ?? t('Select date')
|
||||
const calendarLocale =
|
||||
calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [date, setDate] = React.useState<Date | undefined>(value)
|
||||
const [month, setMonth] = React.useState<Date | undefined>(value)
|
||||
const [time, setTime] = React.useState<string>('00:00')
|
||||
|
||||
React.useEffect(() => {
|
||||
setDate(value)
|
||||
setMonth(value)
|
||||
if (value) {
|
||||
const hours = value.getHours().toString().padStart(2, '0')
|
||||
const minutes = value.getMinutes().toString().padStart(2, '0')
|
||||
setTime(`${hours}:${minutes}`)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
const [hours, minutes] = time.split(':').map(Number)
|
||||
const newDate = new Date(selectedDate)
|
||||
newDate.setHours(hours, minutes, 0, 0)
|
||||
setDate(newDate)
|
||||
setMonth(newDate)
|
||||
onChange?.(newDate)
|
||||
setOpen(false)
|
||||
} else {
|
||||
setDate(undefined)
|
||||
setMonth(undefined)
|
||||
onChange?.(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = e.target.value
|
||||
setTime(newTime)
|
||||
|
||||
if (date) {
|
||||
const [hours, minutes] = newTime.split(':').map(Number)
|
||||
const newDate = new Date(date)
|
||||
newDate.setHours(hours, minutes, 0, 0)
|
||||
setDate(newDate)
|
||||
onChange?.(newDate)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setDate(undefined)
|
||||
setMonth(undefined)
|
||||
setTime('00:00')
|
||||
onChange?.(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-2', className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'flex-1 justify-between font-normal',
|
||||
!date && 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{date ? dayjs(date).format('YYYY-MM-DD') : placeholderText}
|
||||
<ChevronDownIcon className='h-4 w-4 opacity-50' />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto overflow-hidden p-0' align='start'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={date}
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
captionLayout='dropdown'
|
||||
onSelect={handleDateSelect}
|
||||
locale={calendarLocale}
|
||||
startMonth={new Date(currentYear - 100, 0)}
|
||||
endMonth={new Date(currentYear + 100, 11)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Input
|
||||
type='time'
|
||||
value={time}
|
||||
onChange={handleTimeChange}
|
||||
className='w-32 appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none'
|
||||
disabled={!date}
|
||||
/>
|
||||
{date && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleClear}
|
||||
className='shrink-0'
|
||||
aria-label='Clear'
|
||||
>
|
||||
<span aria-hidden='true'>✕</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
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 * as React from 'react'
|
||||
|
||||
import {
|
||||
Dialog as DialogRoot,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DialogProps = React.ComponentProps<typeof DialogRoot> & {
|
||||
title: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
trigger?: React.ReactElement
|
||||
footer?: React.ReactNode
|
||||
contentHeight?: React.CSSProperties['height']
|
||||
contentClassName?: string
|
||||
headerClassName?: string
|
||||
titleClassName?: string
|
||||
descriptionClassName?: string
|
||||
bodyClassName?: string
|
||||
footerClassName?: string
|
||||
initialFocus?: boolean
|
||||
showCloseButton?: boolean
|
||||
}
|
||||
|
||||
const dialogContentMotionClassName =
|
||||
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 duration-100'
|
||||
|
||||
export function Dialog({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
trigger,
|
||||
footer,
|
||||
contentHeight = 'auto',
|
||||
contentClassName,
|
||||
headerClassName,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
bodyClassName,
|
||||
footerClassName,
|
||||
initialFocus,
|
||||
showCloseButton,
|
||||
...dialogProps
|
||||
}: DialogProps) {
|
||||
return (
|
||||
<DialogRoot {...dialogProps}>
|
||||
{trigger ? <DialogTrigger render={trigger} /> : null}
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'flex max-h-[calc(100vh-2rem)] w-full flex-col gap-4 overflow-hidden p-4 sm:max-w-2xl sm:p-6',
|
||||
contentClassName,
|
||||
dialogContentMotionClassName
|
||||
)}
|
||||
initialFocus={initialFocus}
|
||||
showCloseButton={showCloseButton}
|
||||
style={
|
||||
{
|
||||
'--dialog-content-height': contentHeight,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<DialogHeader
|
||||
className={cn('flex-shrink-0 text-start', headerClassName)}
|
||||
>
|
||||
<DialogTitle className={titleClassName}>{title}</DialogTitle>
|
||||
{description ? (
|
||||
<DialogDescription className={descriptionClassName}>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-1 min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain',
|
||||
'h-[var(--dialog-content-height)] max-h-[calc(100vh-14rem)]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 px-1 py-1',
|
||||
'[&_form]:overflow-x-visible',
|
||||
'[&_[data-slot=scroll-area-viewport]]:px-1 [&_[data-slot=scroll-area-viewport]]:py-1',
|
||||
bodyClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer ? (
|
||||
<DialogFooter
|
||||
className={cn(
|
||||
'flex-shrink-0 gap-2 sm:-mx-6 sm:-mb-6 sm:justify-end sm:p-6',
|
||||
footerClassName
|
||||
)}
|
||||
>
|
||||
{footer}
|
||||
</DialogFooter>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
@@ -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 { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge'
|
||||
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
|
||||
iconTone?: IconBadgeTone
|
||||
className?: string
|
||||
}) {
|
||||
return createElement(
|
||||
'div',
|
||||
{ className: cn('flex items-start gap-3', props.className) },
|
||||
props.icon
|
||||
? createElement(
|
||||
IconBadge,
|
||||
{ tone: props.iconTone, size: '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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
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 { Database, type LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { FadeIn } from '@/components/page-transition'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon
|
||||
title?: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
bordered?: boolean
|
||||
}
|
||||
|
||||
export function EmptyState(props: EmptyStateProps) {
|
||||
const { t } = useTranslation()
|
||||
const Icon = props.icon ?? Database
|
||||
|
||||
return (
|
||||
<FadeIn>
|
||||
<Empty
|
||||
className={cn(
|
||||
'min-h-[300px]',
|
||||
props.bordered && 'border',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Icon className='size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{props.title ?? t('No Data')}</EmptyTitle>
|
||||
{props.description != null && (
|
||||
<EmptyDescription>{props.description}</EmptyDescription>
|
||||
)}
|
||||
</EmptyHeader>
|
||||
{props.action != null && <EmptyContent>{props.action}</EmptyContent>}
|
||||
</Empty>
|
||||
</FadeIn>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
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 { AlertTriangle, type LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { FadeIn } from '@/components/page-transition'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ErrorStateProps {
|
||||
icon?: LucideIcon
|
||||
title?: string
|
||||
description?: string
|
||||
onRetry?: () => void
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ErrorState(props: ErrorStateProps) {
|
||||
const { t } = useTranslation()
|
||||
const Icon = props.icon ?? AlertTriangle
|
||||
|
||||
return (
|
||||
<FadeIn>
|
||||
<Empty className={cn('min-h-[300px]', props.className)}>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Icon className='text-destructive size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>
|
||||
{props.title ?? t('Oops! Something went wrong')}
|
||||
</EmptyTitle>
|
||||
{props.description != null && (
|
||||
<EmptyDescription>{props.description}</EmptyDescription>
|
||||
)}
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
{props.onRetry != null && (
|
||||
<Button variant='outline' size='sm' onClick={props.onRetry}>
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
)}
|
||||
{props.action}
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</FadeIn>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { StatusBadge, type StatusBadgeProps } from './status-badge'
|
||||
|
||||
type GroupBadgeProps = Omit<
|
||||
StatusBadgeProps,
|
||||
'autoColor' | 'label' | 'variant'
|
||||
> & {
|
||||
group?: string | null
|
||||
label?: string
|
||||
ratio?: number | null
|
||||
}
|
||||
|
||||
function getGroupRatioClassName(ratio: number): string {
|
||||
if (ratio > 1) {
|
||||
return 'bg-warning/10 text-warning'
|
||||
}
|
||||
if (ratio < 1) {
|
||||
return 'bg-info/10 text-info'
|
||||
}
|
||||
return 'bg-muted text-muted-foreground'
|
||||
}
|
||||
|
||||
function getGroupLabel(params: {
|
||||
labelOverride?: string
|
||||
groupName?: string
|
||||
isAutoGroup: boolean
|
||||
isEmptyGroup: boolean
|
||||
t: (key: string) => string
|
||||
}): string {
|
||||
if (params.labelOverride) return params.labelOverride
|
||||
if (params.isEmptyGroup) return params.t('User Group')
|
||||
if (params.isAutoGroup) return params.t('Auto')
|
||||
return params.groupName ?? ''
|
||||
}
|
||||
|
||||
export function GroupBadge(props: GroupBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
group,
|
||||
label: labelOverride,
|
||||
ratio,
|
||||
copyable = false,
|
||||
showDot,
|
||||
className,
|
||||
...badgeProps
|
||||
} = props
|
||||
const groupName = group?.trim()
|
||||
const isAutoGroup = groupName === 'auto'
|
||||
const isEmptyGroup = !groupName
|
||||
const isSpecialGroup = isAutoGroup || isEmptyGroup
|
||||
const label = getGroupLabel({
|
||||
labelOverride,
|
||||
groupName,
|
||||
isAutoGroup,
|
||||
isEmptyGroup,
|
||||
t,
|
||||
})
|
||||
|
||||
const badge = (
|
||||
<StatusBadge
|
||||
{...badgeProps}
|
||||
copyable={copyable}
|
||||
label={label}
|
||||
showDot={showDot ?? (isSpecialGroup ? false : undefined)}
|
||||
variant={isSpecialGroup ? 'neutral' : undefined}
|
||||
autoColor={isSpecialGroup ? undefined : groupName}
|
||||
className={cn('min-w-0 shrink overflow-hidden', className)}
|
||||
/>
|
||||
)
|
||||
|
||||
if (ratio == null) {
|
||||
return badge
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='inline-flex max-w-full min-w-0 items-center gap-2 text-xs'>
|
||||
<span className='max-w-full min-w-0 overflow-hidden'>{badge}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-5 shrink-0 items-center rounded-full px-1.5 font-mono text-xs leading-none font-medium tabular-nums',
|
||||
getGroupRatioClassName(ratio)
|
||||
)}
|
||||
>
|
||||
<span>{ratio}x</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
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 DOMPurify, { type Config } from 'dompurify'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type HtmlContentVariant = 'inline' | 'isolated'
|
||||
|
||||
interface HtmlContentProps {
|
||||
content: string
|
||||
className?: string
|
||||
variant?: HtmlContentVariant
|
||||
}
|
||||
|
||||
const isolatedContentSandbox =
|
||||
'allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation'
|
||||
|
||||
const isolatedContentBaseStyles = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
iframe {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
`
|
||||
|
||||
const isolatedSanitizeOptions = {
|
||||
ADD_ATTR: [
|
||||
'allowfullscreen',
|
||||
'autoplay',
|
||||
'class',
|
||||
'controls',
|
||||
'default',
|
||||
'id',
|
||||
'kind',
|
||||
'label',
|
||||
'loading',
|
||||
'loop',
|
||||
'muted',
|
||||
'playsinline',
|
||||
'poster',
|
||||
'preload',
|
||||
'referrerpolicy',
|
||||
'rel',
|
||||
'srclang',
|
||||
'style',
|
||||
'target',
|
||||
],
|
||||
ADD_TAGS: ['audio', 'iframe', 'picture', 'source', 'style', 'track', 'video'],
|
||||
FORBID_ATTR: ['srcdoc'],
|
||||
FORBID_TAGS: ['base', 'embed', 'link', 'meta', 'object', 'script'],
|
||||
FORCE_BODY: true,
|
||||
} satisfies Config
|
||||
|
||||
function hardenIsolatedHtml(html: string): string {
|
||||
if (typeof document === 'undefined') {
|
||||
return html
|
||||
}
|
||||
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
|
||||
template.content.querySelectorAll('a[target="_blank"]').forEach((link) => {
|
||||
const rel = new Set(
|
||||
link.getAttribute('rel')?.split(/\s+/).filter(Boolean) ?? []
|
||||
)
|
||||
|
||||
rel.add('noopener')
|
||||
rel.add('noreferrer')
|
||||
link.setAttribute('rel', [...rel].join(' '))
|
||||
})
|
||||
|
||||
template.content.querySelectorAll('iframe').forEach((frame) => {
|
||||
frame.removeAttribute('srcdoc')
|
||||
frame.setAttribute('sandbox', isolatedContentSandbox)
|
||||
frame.setAttribute('referrerpolicy', 'no-referrer')
|
||||
|
||||
if (!frame.hasAttribute('loading')) {
|
||||
frame.setAttribute('loading', 'lazy')
|
||||
}
|
||||
})
|
||||
|
||||
return template.innerHTML
|
||||
}
|
||||
|
||||
function sanitizeHtmlContent(
|
||||
content: string,
|
||||
variant: HtmlContentVariant
|
||||
): string {
|
||||
if (variant === 'isolated') {
|
||||
const html = DOMPurify.sanitize(content, isolatedSanitizeOptions)
|
||||
|
||||
return hardenIsolatedHtml(html)
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(content)
|
||||
}
|
||||
|
||||
function syncDarkClass(wrapper: HTMLElement): void {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
wrapper.classList.toggle('dark', isDark)
|
||||
}
|
||||
|
||||
function IsolatedHtmlContent(props: {
|
||||
className?: string
|
||||
html: string
|
||||
}): React.ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const shadowRoot =
|
||||
container.shadowRoot ?? container.attachShadow({ mode: 'open' })
|
||||
const applicationStyleNodes = [
|
||||
...document.head.querySelectorAll<HTMLLinkElement | HTMLStyleElement>(
|
||||
'style, link[rel="stylesheet"]'
|
||||
),
|
||||
].map((node) => node.cloneNode(true))
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
syncDarkClass(wrapper)
|
||||
wrapper.innerHTML = props.html
|
||||
|
||||
const contentTemplate = document.createElement('template')
|
||||
contentTemplate.innerHTML = isolatedContentBaseStyles
|
||||
|
||||
shadowRoot.replaceChildren(
|
||||
...applicationStyleNodes,
|
||||
contentTemplate.content,
|
||||
wrapper
|
||||
)
|
||||
|
||||
const observer = new MutationObserver(() => syncDarkClass(wrapper))
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [props.html])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('block w-full', props.className)} />
|
||||
)
|
||||
}
|
||||
|
||||
export function HtmlContent(props: HtmlContentProps) {
|
||||
const variant = props.variant ?? 'inline'
|
||||
const html = useMemo(
|
||||
() => sanitizeHtmlContent(props.content, variant),
|
||||
[props.content, variant]
|
||||
)
|
||||
|
||||
if (variant === 'isolated') {
|
||||
return <IsolatedHtmlContent className={props.className} html={html} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-neutral dark:prose-invert max-w-none',
|
||||
props.className
|
||||
)}
|
||||
// eslint-disable-next-line react/no-danger -- html is sanitized above
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
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 { AlertCircle, Braces, CheckCircle2, Code2 } from 'lucide-react'
|
||||
import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type JsonCodeEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
heightClassName?: string
|
||||
}
|
||||
|
||||
export function JsonCodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
heightClassName = 'h-56 min-h-56 max-h-56',
|
||||
className,
|
||||
id,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-invalid': ariaInvalid,
|
||||
...rootProps
|
||||
}: JsonCodeEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const lineNumbers = useMemo(() => {
|
||||
const count = Math.max(1, value.split('\n').length)
|
||||
return Array.from({ length: count }, (_, index) => index + 1)
|
||||
}, [value])
|
||||
const jsonStatus = useMemo(() => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return { valid: true, message: t('JSON') }
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return { valid: true, message: t('JSON') }
|
||||
} catch {
|
||||
return { valid: false, message: t('Invalid JSON') }
|
||||
}
|
||||
}, [value, t])
|
||||
|
||||
const formatJson = () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
onChange(JSON.stringify(JSON.parse(trimmed), null, 2))
|
||||
} catch {
|
||||
// Keep invalid drafts untouched; validation feedback remains visible.
|
||||
}
|
||||
}
|
||||
|
||||
const updateValueWithSelection = (
|
||||
nextValue: string,
|
||||
selectionStart: number,
|
||||
selectionEnd = selectionStart
|
||||
) => {
|
||||
onChange(nextValue)
|
||||
window.requestAnimationFrame(() => {
|
||||
textareaRef.current?.setSelectionRange(selectionStart, selectionEnd)
|
||||
})
|
||||
}
|
||||
|
||||
const getLineIndent = (text: string, cursor: number) => {
|
||||
const lineStart = text.lastIndexOf('\n', cursor - 1) + 1
|
||||
return text.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? ''
|
||||
}
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const target = event.currentTarget
|
||||
const start = target.selectionStart
|
||||
const end = target.selectionEnd
|
||||
const selected = value.slice(start, end)
|
||||
const before = value.slice(0, start)
|
||||
const after = value.slice(end)
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
|
||||
if (start !== end && selected.includes('\n')) {
|
||||
const selectionLineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
const selectedBlock = value.slice(selectionLineStart, end)
|
||||
const lines = selectedBlock.split('\n')
|
||||
const nextBlock = event.shiftKey
|
||||
? lines
|
||||
.map((line) =>
|
||||
line.startsWith(' ')
|
||||
? line.slice(2)
|
||||
: line.startsWith('\t')
|
||||
? line.slice(1)
|
||||
: line
|
||||
)
|
||||
.join('\n')
|
||||
: lines.map((line) => ` ${line}`).join('\n')
|
||||
const nextValue =
|
||||
value.slice(0, selectionLineStart) + nextBlock + value.slice(end)
|
||||
updateValueWithSelection(
|
||||
nextValue,
|
||||
selectionLineStart,
|
||||
selectionLineStart + nextBlock.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
const removable = value.slice(lineStart, lineStart + 2)
|
||||
if (removable === ' ') {
|
||||
updateValueWithSelection(
|
||||
value.slice(0, lineStart) + value.slice(lineStart + 2),
|
||||
Math.max(lineStart, start - 2),
|
||||
Math.max(lineStart, end - 2)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
updateValueWithSelection(`${before} ${after}`, start + 2)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const indent = getLineIndent(value, start)
|
||||
const previousChar = before.trimEnd().at(-1)
|
||||
const nextChar = after.trimStart().at(0)
|
||||
const shouldNest = previousChar === '{' || previousChar === '['
|
||||
const shouldClose =
|
||||
(previousChar === '{' && nextChar === '}') ||
|
||||
(previousChar === '[' && nextChar === ']')
|
||||
|
||||
if (shouldNest && shouldClose) {
|
||||
const innerIndent = `${indent} `
|
||||
const insert = `\n${innerIndent}\n${indent}`
|
||||
updateValueWithSelection(
|
||||
`${before}${insert}${after}`,
|
||||
start + 1 + innerIndent.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndent = shouldNest ? `${indent} ` : indent
|
||||
const insert = `\n${nextIndent}`
|
||||
updateValueWithSelection(
|
||||
`${before}${insert}${after}`,
|
||||
start + insert.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const pairs: Record<string, string> = {
|
||||
'"': '"',
|
||||
'{': '}',
|
||||
'[': ']',
|
||||
}
|
||||
const closingChars = new Set(Object.values(pairs))
|
||||
|
||||
if (closingChars.has(event.key) && value[start] === event.key) {
|
||||
event.preventDefault()
|
||||
textareaRef.current?.setSelectionRange(start + 1, start + 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (pairs[event.key]) {
|
||||
event.preventDefault()
|
||||
const close = pairs[event.key]
|
||||
const wrapped = `${event.key}${selected}${close}`
|
||||
updateValueWithSelection(
|
||||
`${before}${wrapped}${after}`,
|
||||
start + 1,
|
||||
start + 1 + selected.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && start === end && start > 0) {
|
||||
const previousChar = value[start - 1]
|
||||
const nextChar = value[start]
|
||||
if (pairs[previousChar] === nextChar) {
|
||||
event.preventDefault()
|
||||
updateValueWithSelection(
|
||||
value.slice(0, start - 1) + value.slice(start + 1),
|
||||
start - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-input bg-background focus-within:border-ring focus-within:ring-ring/50 overflow-hidden rounded-lg border transition-colors focus-within:ring-3',
|
||||
className
|
||||
)}
|
||||
{...rootProps}
|
||||
>
|
||||
<div className='bg-muted/30 flex h-8 items-center justify-between border-b px-2'>
|
||||
<div className='text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs font-medium'>
|
||||
<Braces className='h-3.5 w-3.5' />
|
||||
<span>{t('JSON')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
jsonStatus.valid ? 'text-emerald-600' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{jsonStatus.valid ? (
|
||||
<CheckCircle2 className='h-3.5 w-3.5' />
|
||||
) : (
|
||||
<AlertCircle className='h-3.5 w-3.5' />
|
||||
)}
|
||||
{jsonStatus.message}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={formatJson}
|
||||
disabled={disabled || !jsonStatus.valid || !value.trim()}
|
||||
>
|
||||
<Code2 className='mr-1 h-3.5 w-3.5' />
|
||||
{t('Format JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('relative flex overflow-hidden', heightClassName)}>
|
||||
<div className='bg-muted/20 text-muted-foreground/70 relative w-10 shrink-0 overflow-hidden border-r font-mono text-xs leading-5 select-none'>
|
||||
<div
|
||||
className='px-2 py-2 text-right'
|
||||
style={{ transform: `translateY(-${scrollTop}px)` }}
|
||||
>
|
||||
{lineNumbers.map((lineNumber) => (
|
||||
<div key={lineNumber}>{lineNumber}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id={id}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-invalid={ariaInvalid}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||
className={cn(
|
||||
'[field-sizing:fixed] resize-none overflow-auto rounded-none border-0 bg-transparent px-3 py-2 font-mono text-xs leading-5 shadow-none ring-0 outline-none focus-visible:ring-0',
|
||||
heightClassName
|
||||
)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
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 { Code, Table, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type JsonEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
keyPlaceholder?: string
|
||||
valuePlaceholder?: string
|
||||
keyLabel?: string
|
||||
valueLabel?: string
|
||||
emptyMessage?: string
|
||||
template?: Record<string, unknown>
|
||||
valueType?: 'string' | 'number' | 'any'
|
||||
}
|
||||
|
||||
type EditorRow = {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function parseJsonRows(json: string): EditorRow[] {
|
||||
try {
|
||||
if (!json.trim()) return []
|
||||
const parsed = JSON.parse(json)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
return Object.entries(parsed).map(([key, val], index) => ({
|
||||
id: `${Date.now()}-${index}`,
|
||||
key,
|
||||
value: typeof val === 'object' ? JSON.stringify(val) : String(val),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function JsonEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
keyPlaceholder,
|
||||
valuePlaceholder,
|
||||
keyLabel,
|
||||
valueLabel,
|
||||
emptyMessage,
|
||||
template,
|
||||
valueType = 'string',
|
||||
}: JsonEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedEmptyMessage =
|
||||
emptyMessage ?? t('No mappings configured. Click "Add Row" to get started.')
|
||||
const resolvedKeyPlaceholder = keyPlaceholder ?? t('Key')
|
||||
const resolvedValuePlaceholder = valuePlaceholder ?? t('Value')
|
||||
const resolvedKeyLabel = keyLabel ?? t('Key')
|
||||
const resolvedValueLabel = valueLabel ?? t('Value')
|
||||
const [mode, setMode] = useState<'visual' | 'json'>('visual')
|
||||
const [rows, setRows] = useState<EditorRow[]>(() => parseJsonRows(value))
|
||||
const [jsonValue, setJsonValue] = useState(value)
|
||||
|
||||
const parseJsonToRows = (json: string) => {
|
||||
setRows(parseJsonRows(json))
|
||||
}
|
||||
|
||||
// Parse JSON to rows when value changes externally
|
||||
useEffect(() => {
|
||||
if (value !== jsonValue) {
|
||||
setJsonValue(value)
|
||||
parseJsonToRows(value)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value])
|
||||
|
||||
const convertRowsToJson = (updatedRows: EditorRow[]): string => {
|
||||
if (updatedRows.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const obj: Record<string, unknown> = {}
|
||||
updatedRows.forEach((row) => {
|
||||
if (row.key.trim()) {
|
||||
let parsedValue: unknown = row.value.trim()
|
||||
|
||||
// Try to parse value based on type
|
||||
if (valueType === 'number') {
|
||||
parsedValue = Number(parsedValue) || 0
|
||||
} else if (valueType === 'any') {
|
||||
// Try to parse as JSON first
|
||||
try {
|
||||
parsedValue = JSON.parse(row.value)
|
||||
} catch {
|
||||
// If not valid JSON, keep as string
|
||||
parsedValue = row.value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
obj[row.key.trim()] = parsedValue
|
||||
}
|
||||
})
|
||||
return JSON.stringify(obj, null, 2)
|
||||
}
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newRow: EditorRow = {
|
||||
id: `${Date.now()}`,
|
||||
key: '',
|
||||
value: '',
|
||||
}
|
||||
const updatedRows = [...rows, newRow]
|
||||
setRows(updatedRows)
|
||||
}
|
||||
|
||||
const handleDeleteRow = (id: string) => {
|
||||
const updatedRows = rows.filter((row) => row.id !== id)
|
||||
setRows(updatedRows)
|
||||
const json = convertRowsToJson(updatedRows)
|
||||
setJsonValue(json)
|
||||
onChange(json)
|
||||
}
|
||||
|
||||
const handleRowChange = (
|
||||
id: string,
|
||||
field: 'key' | 'value',
|
||||
newValue: string
|
||||
) => {
|
||||
const updatedRows = rows.map((row) =>
|
||||
row.id === id ? { ...row, [field]: newValue } : row
|
||||
)
|
||||
setRows(updatedRows)
|
||||
const json = convertRowsToJson(updatedRows)
|
||||
setJsonValue(json)
|
||||
onChange(json)
|
||||
}
|
||||
|
||||
const handleJsonChange = (newJson: string) => {
|
||||
setJsonValue(newJson)
|
||||
onChange(newJson)
|
||||
parseJsonToRows(newJson)
|
||||
}
|
||||
|
||||
const handleFillTemplate = () => {
|
||||
if (!template) return
|
||||
const templateJson = JSON.stringify(template, null, 2)
|
||||
setJsonValue(templateJson)
|
||||
onChange(templateJson)
|
||||
parseJsonToRows(templateJson)
|
||||
}
|
||||
|
||||
const toggleMode = () => {
|
||||
if (mode === 'visual') {
|
||||
// Switching to JSON mode: sync rows to JSON
|
||||
const json = convertRowsToJson(rows)
|
||||
setJsonValue(json)
|
||||
onChange(json)
|
||||
setMode('json')
|
||||
} else {
|
||||
// Switching to visual mode: sync JSON to rows
|
||||
parseJsonToRows(jsonValue)
|
||||
setMode('visual')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={toggleMode}
|
||||
disabled={disabled}
|
||||
>
|
||||
{mode === 'visual' ? (
|
||||
<>
|
||||
<Code className='mr-2 h-4 w-4' />
|
||||
{t('JSON Mode')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Table className='mr-2 h-4 w-4' />
|
||||
{t('Visual Mode')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{template && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto p-0'
|
||||
onClick={handleFillTemplate}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('Fill Template')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'visual' ? (
|
||||
<div className='space-y-2'>
|
||||
{rows.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
<div className='grid grid-cols-[1fr_1fr_auto] gap-2 text-sm font-medium'>
|
||||
<div>{resolvedKeyLabel}</div>
|
||||
<div>{resolvedValueLabel}</div>
|
||||
<div className='w-10' />
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className='grid grid-cols-[1fr_1fr_auto] gap-2'
|
||||
>
|
||||
<Input
|
||||
value={row.key}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'key', e.target.value)
|
||||
}
|
||||
placeholder={resolvedKeyPlaceholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'value', e.target.value)
|
||||
}
|
||||
placeholder={resolvedValuePlaceholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
aria-label='Delete row'
|
||||
onClick={() => handleDeleteRow(row.id)}
|
||||
disabled={disabled}
|
||||
className='h-10 w-10'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' aria-hidden='true' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex h-24 items-center justify-center rounded-md border border-dashed text-sm'>
|
||||
{resolvedEmptyMessage}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleAddRow}
|
||||
disabled={disabled}
|
||||
className='w-full'
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add Row')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={jsonValue}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
placeholder={
|
||||
template ? JSON.stringify(template, null, 2) : '{"key": "value"}'
|
||||
}
|
||||
disabled={disabled}
|
||||
rows={8}
|
||||
className={cn('font-mono text-sm')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
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 { Languages, Check } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
INTERFACE_LANGUAGE_OPTIONS,
|
||||
normalizeInterfaceLanguage,
|
||||
} from '@/i18n/languages'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const user = useAuthStore((s) => s.auth.user)
|
||||
const currentLanguage = normalizeInterfaceLanguage(i18n.language)
|
||||
const handleChangeLanguage = useCallback(
|
||||
async (code: string) => {
|
||||
await i18n.changeLanguage(code)
|
||||
if (user) {
|
||||
try {
|
||||
await api.put('/api/user/self', { language: code })
|
||||
} catch {
|
||||
// Best-effort persistence; don't block the UI on failure
|
||||
}
|
||||
}
|
||||
},
|
||||
[i18n, user]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant='ghost' size='icon' className='h-9 w-9' />}
|
||||
>
|
||||
<Languages className='size-[1.2rem]' />
|
||||
<span className='sr-only'>{t('Change language')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{INTERFACE_LANGUAGE_OPTIONS.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => handleChangeLanguage(lang.code)}
|
||||
>
|
||||
{lang.label}
|
||||
<Check
|
||||
size={14}
|
||||
className={cn(
|
||||
'ms-auto',
|
||||
currentLanguage !== lang.code && 'hidden'
|
||||
)}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
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 { ConfigDrawer } from '@/components/config-drawer'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationPopover } from '@/components/notification-popover'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { Search } from '@/components/search'
|
||||
import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||
|
||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
import { type TopNavLink } from '../types'
|
||||
import { Header } from './header'
|
||||
import { SystemBrand } from './system-brand'
|
||||
import { TopNav } from './top-nav'
|
||||
|
||||
/**
|
||||
* General application Header component
|
||||
* Integrates navigation bar, search, configuration and profile functions
|
||||
*
|
||||
* @example
|
||||
* // Basic usage
|
||||
* <AppHeader />
|
||||
*
|
||||
* @example
|
||||
* // Custom navigation links
|
||||
* <AppHeader navLinks={customLinks} />
|
||||
*
|
||||
* @example
|
||||
* // Hide navigation bar and search box
|
||||
* <AppHeader showTopNav={false} showSearch={false} />
|
||||
*
|
||||
* @example
|
||||
* // Fully customize left and right content
|
||||
* <AppHeader
|
||||
* leftContent={<CustomLeft />}
|
||||
* rightContent={<CustomRight />}
|
||||
* />
|
||||
*/
|
||||
type AppHeaderProps = {
|
||||
/**
|
||||
* Custom navigation links, uses default global navigation or dynamically generated from backend if not provided
|
||||
*/
|
||||
navLinks?: TopNavLink[]
|
||||
/**
|
||||
* Whether to show top navigation bar
|
||||
* @default true
|
||||
*/
|
||||
showTopNav?: boolean
|
||||
/**
|
||||
* Left content, overrides TopNav if provided
|
||||
*/
|
||||
leftContent?: React.ReactNode
|
||||
/**
|
||||
* Whether to show search box
|
||||
* @default true
|
||||
*/
|
||||
showSearch?: boolean
|
||||
/**
|
||||
* Custom right content, overrides default right content if provided
|
||||
*/
|
||||
rightContent?: React.ReactNode
|
||||
/**
|
||||
* Whether to show notification button
|
||||
* @default true
|
||||
*/
|
||||
showNotifications?: boolean
|
||||
/**
|
||||
* Whether to show config drawer
|
||||
* @default true
|
||||
*/
|
||||
showConfigDrawer?: boolean
|
||||
/**
|
||||
* Whether to show profile dropdown
|
||||
* @default true
|
||||
*/
|
||||
showProfileDropdown?: boolean
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
navLinks = defaultTopNavLinks,
|
||||
showTopNav = true,
|
||||
leftContent,
|
||||
showSearch = true,
|
||||
rightContent,
|
||||
showNotifications = true,
|
||||
showConfigDrawer = true,
|
||||
showProfileDropdown = true,
|
||||
}: AppHeaderProps) {
|
||||
// Prioritize dynamically generated links from backend
|
||||
const dynamicLinks = useTopNavLinks()
|
||||
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
|
||||
|
||||
// Notifications hook
|
||||
const notifications = useNotifications()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<SystemBrand variant='inline' />
|
||||
|
||||
{leftContent ? (
|
||||
<div className='ms-2 flex items-center'>{leftContent}</div>
|
||||
) : null}
|
||||
|
||||
{rightContent ?? (
|
||||
<div className='ms-auto flex items-center gap-1 sm:gap-2'>
|
||||
{showTopNav && (
|
||||
<div className='me-1 hidden lg:block'>
|
||||
<TopNav links={links} />
|
||||
</div>
|
||||
)}
|
||||
{showSearch && <Search />}
|
||||
{showNotifications && (
|
||||
<NotificationPopover
|
||||
open={notifications.popoverOpen}
|
||||
onOpenChange={notifications.setPopoverOpen}
|
||||
unreadCount={notifications.unreadCount}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
/>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
{showConfigDrawer && <ConfigDrawer />}
|
||||
{showProfileDropdown && <ProfileDropdown />}
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
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 { AnimatePresence, motion, useReducedMotion } from 'motion/react'
|
||||
|
||||
import { Sidebar, SidebarContent, SidebarRail } from '@/components/ui/sidebar'
|
||||
import { useLayout } from '@/context/layout-provider'
|
||||
import { useSidebarView } from '@/hooks/use-sidebar-view'
|
||||
import { MOTION_TRANSITION, MOTION_VARIANTS } from '@/lib/motion'
|
||||
|
||||
import { NavGroup } from './nav-group'
|
||||
import { SidebarViewHeader } from './sidebar-view-header'
|
||||
|
||||
/**
|
||||
* Application sidebar.
|
||||
*
|
||||
* Adopts the Vercel / Cloudflare "drill-in" pattern: the URL drives
|
||||
* which sidebar *view* is rendered. Clicking a top-level entry like
|
||||
* `System Settings` swaps the sidebar to a contextual workspace —
|
||||
* with a `← Back to Dashboard` affordance — instead of stacking the
|
||||
* sub-navigation inside the root tree.
|
||||
*
|
||||
* Architecture:
|
||||
* - View resolution + filtering: {@link useSidebarView}
|
||||
* - View registry: `layout/lib/sidebar-view-registry.ts`
|
||||
* - Per-view header: {@link SidebarViewHeader}
|
||||
*
|
||||
* Adding a new nested view only requires registering a {@link SidebarView}
|
||||
* in the registry; this component requires no changes.
|
||||
*/
|
||||
export function AppSidebar() {
|
||||
const { collapsible, variant } = useLayout()
|
||||
const { key, view, navGroups } = useSidebarView()
|
||||
const shouldReduce = useReducedMotion()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible={collapsible} variant={variant}>
|
||||
{view && <SidebarViewHeader view={view} />}
|
||||
|
||||
<SidebarContent className='py-2'>
|
||||
<AnimatePresence mode='wait' initial={false}>
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={
|
||||
shouldReduce ? false : MOTION_VARIANTS.sidebarSlide.initial
|
||||
}
|
||||
animate={MOTION_VARIANTS.sidebarSlide.animate}
|
||||
exit={shouldReduce ? undefined : MOTION_VARIANTS.sidebarSlide.exit}
|
||||
transition={MOTION_TRANSITION.fast}
|
||||
className='flex flex-col'
|
||||
>
|
||||
{navGroups.map((props) => (
|
||||
<NavGroup key={props.id || props.title} {...props} />
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
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 { AnimatedOutlet } from '@/components/page-transition'
|
||||
import { SkipToMain } from '@/components/skip-to-main'
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { LayoutProvider } from '@/context/layout-provider'
|
||||
import { SearchProvider } from '@/context/search-provider'
|
||||
import { getCookie } from '@/lib/cookies'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { AppHeader } from './app-header'
|
||||
import { AppSidebar } from './app-sidebar'
|
||||
|
||||
type AuthenticatedLayoutProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
|
||||
const defaultOpen = getCookie('sidebar_state') !== 'false'
|
||||
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<SearchProvider>
|
||||
<SidebarProvider defaultOpen={defaultOpen} className='flex-col'>
|
||||
<SkipToMain />
|
||||
<AppHeader />
|
||||
<div className='flex min-h-0 w-full flex-1'>
|
||||
<AppSidebar />
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
'@container/content',
|
||||
'h-[calc(100svh-var(--app-header-height,0px))]',
|
||||
'min-h-0 overflow-hidden',
|
||||
'peer-data-[variant=inset]:h-[calc(100svh-var(--app-header-height,0px)-(var(--spacing)*4))]'
|
||||
)}
|
||||
>
|
||||
{props.children ?? <AnimatedOutlet />}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</SearchProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
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 { Link, useLocation } from '@tanstack/react-router'
|
||||
import { ExternalLink, Loader2, ChevronRight } from 'lucide-react'
|
||||
import { useMemo, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { fetchActiveChatKey } from '@/features/chat/hooks/use-active-chat-key'
|
||||
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
|
||||
import {
|
||||
chatLinkRequiresApiKey,
|
||||
resolveChatUrl,
|
||||
type ChatPreset,
|
||||
} from '@/features/chat/lib/chat-links'
|
||||
|
||||
import { normalizeHref } from '../lib/url-utils'
|
||||
import type { NavChatPresets } from '../types'
|
||||
|
||||
/**
|
||||
* Sub-menu item for a single chat preset
|
||||
*/
|
||||
function ChatMenuItem({
|
||||
preset,
|
||||
active,
|
||||
loading,
|
||||
onOpen,
|
||||
onNavigate,
|
||||
}: {
|
||||
preset: ChatPreset
|
||||
active: boolean
|
||||
loading: boolean
|
||||
onOpen: (preset: ChatPreset) => void | Promise<void>
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
if (preset.type === 'web') {
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
isActive={active}
|
||||
render={
|
||||
<Link
|
||||
to='/chat/$chatId'
|
||||
params={{ chatId: preset.id }}
|
||||
onClick={onNavigate}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
|
||||
{preset.name}
|
||||
</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
onClick={() => {
|
||||
if (!loading) void onOpen(preset)
|
||||
}}
|
||||
aria-disabled={loading ? 'true' : undefined}
|
||||
isActive={false}
|
||||
className='justify-between'
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
|
||||
{preset.name}
|
||||
</span>
|
||||
{loading ? (
|
||||
<Loader2 className='h-4 w-4 shrink-0 animate-spin' />
|
||||
) : (
|
||||
<ExternalLink className='h-4 w-4 shrink-0' />
|
||||
)}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown menu item for a single chat preset
|
||||
*/
|
||||
function DropdownPresetItem({
|
||||
preset,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
preset: ChatPreset
|
||||
loading: boolean
|
||||
onOpen: (preset: ChatPreset) => void | Promise<void>
|
||||
}) {
|
||||
if (preset.type === 'web') {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
render={<Link to='/chat/$chatId' params={{ chatId: preset.id }} />}
|
||||
>
|
||||
{preset.name}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
if (!loading) void onOpen(preset)
|
||||
}}
|
||||
>
|
||||
{preset.name}
|
||||
{loading ? (
|
||||
<Loader2 className='ml-auto h-4 w-4 animate-spin opacity-70' />
|
||||
) : (
|
||||
<ExternalLink className='ml-auto h-4 w-4 opacity-70' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic chat presets navigation item
|
||||
*/
|
||||
export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
|
||||
const { t } = useTranslation()
|
||||
const { chatPresets, serverAddress } = useChatPresets()
|
||||
const { state, isMobile, setOpenMobile } = useSidebar()
|
||||
const href = useLocation({ select: (location) => location.href })
|
||||
const [loadingPresetId, setLoadingPresetId] = useState<string | null>(null)
|
||||
const loadingPresetIdRef = useRef<string | null>(null)
|
||||
|
||||
const visiblePresets = useMemo(
|
||||
() => chatPresets.filter((preset) => preset.type !== 'fluent'),
|
||||
[chatPresets]
|
||||
)
|
||||
|
||||
const handleOpenExternal = useCallback(
|
||||
async (preset: ChatPreset) => {
|
||||
if (preset.type === 'web') return
|
||||
|
||||
const needsKey = chatLinkRequiresApiKey(preset.url)
|
||||
let activeKey: string | undefined
|
||||
|
||||
if (needsKey && loadingPresetIdRef.current) {
|
||||
toast.info(t('Preparing your chat link, please try again in a moment.'))
|
||||
return
|
||||
}
|
||||
|
||||
if (needsKey) {
|
||||
loadingPresetIdRef.current = preset.id
|
||||
setLoadingPresetId(preset.id)
|
||||
try {
|
||||
activeKey = await fetchActiveChatKey()
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t(
|
||||
'Unable to prepare chat link. Please ensure you have an enabled API key.'
|
||||
)
|
||||
toast.error(message)
|
||||
return
|
||||
} finally {
|
||||
loadingPresetIdRef.current = null
|
||||
setLoadingPresetId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const url = resolveChatUrl({
|
||||
template: preset.url,
|
||||
apiKey: needsKey ? activeKey : undefined,
|
||||
serverAddress,
|
||||
})
|
||||
|
||||
if (!url) {
|
||||
toast.error(t('Invalid chat link. Please contact the administrator.'))
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.open(url, '_blank', 'noopener')
|
||||
setOpenMobile(false)
|
||||
},
|
||||
[serverAddress, setOpenMobile, t]
|
||||
)
|
||||
|
||||
const normalizedHref = normalizeHref(href)
|
||||
|
||||
// Don't render if no visible presets
|
||||
if (visiblePresets.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Collapsed state on non-mobile - render dropdown menu
|
||||
if (state === 'collapsed' && !isMobile) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<SidebarMenuButton tooltip={item.title} />}
|
||||
>
|
||||
{item.icon && <item.icon className='h-4 w-4 shrink-0' />}
|
||||
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
|
||||
<ChevronRight className='ms-auto h-4 w-4 shrink-0 opacity-70' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
{visiblePresets.map((preset) => (
|
||||
<DropdownPresetItem
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
loading={loadingPresetId === preset.id}
|
||||
onOpen={handleOpenExternal}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded state - render collapsible menu
|
||||
return (
|
||||
<Collapsible
|
||||
defaultOpen={normalizedHref.startsWith('/chat')}
|
||||
className='group/collapsible'
|
||||
render={<SidebarMenuItem />}
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className='group/collapsible-trigger'
|
||||
render={<SidebarMenuButton />}
|
||||
>
|
||||
{item.icon && <item.icon className='shrink-0' />}
|
||||
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
|
||||
<ChevronRight className='ms-auto size-4 shrink-0 transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='CollapsibleContent'>
|
||||
<SidebarMenuSub>
|
||||
{visiblePresets.map((preset) => (
|
||||
<ChatMenuItem
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
active={normalizedHref === `/chat/${preset.id}`}
|
||||
loading={loadingPresetId === preset.id}
|
||||
onOpen={handleOpenExternal}
|
||||
onNavigate={() => setOpenMobile(false)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
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 { Link } from '@tanstack/react-router'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FooterLink {
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
|
||||
interface FooterColumnProps {
|
||||
title: string
|
||||
links: FooterLink[]
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
logo?: string
|
||||
name?: string
|
||||
columns?: FooterColumnProps[]
|
||||
copyright?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const NEW_API_FOOTER_ATTRIBUTION_KEY = [
|
||||
'footer',
|
||||
'new' + 'api',
|
||||
'projectAttributionSuffix',
|
||||
].join('.')
|
||||
|
||||
function FooterLinkItem(props: { link: FooterLink }) {
|
||||
const { t } = useTranslation()
|
||||
const isExternal = props.link.href.startsWith('http')
|
||||
const label = t(props.link.text)
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
href={props.link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-muted-foreground hover:text-foreground text-sm transition-colors duration-200'
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={props.link.href}
|
||||
className='text-muted-foreground hover:text-foreground text-sm transition-colors duration-200'
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Renders User Agreement / Privacy Policy links inline with the parent's
|
||||
// copyright row when either is configured in System Settings → Site. Emits
|
||||
// fragmented siblings so the parent flex container's gap controls spacing.
|
||||
function LegalLinks(props: { leadingSeparator?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
const items: { key: string; label: string; href: string }[] = []
|
||||
if (status?.user_agreement_enabled) {
|
||||
items.push({
|
||||
key: 'user-agreement',
|
||||
label: t('User Agreement'),
|
||||
href: '/user-agreement',
|
||||
})
|
||||
}
|
||||
if (status?.privacy_policy_enabled) {
|
||||
items.push({
|
||||
key: 'privacy-policy',
|
||||
label: t('Privacy Policy'),
|
||||
href: '/privacy-policy',
|
||||
})
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.key}>
|
||||
{(props.leadingSeparator || index > 0) && (
|
||||
<span aria-hidden='true' className='text-muted-foreground/30'>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={item.href}
|
||||
className='hover:text-foreground transition-colors duration-200'
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// inline=true returns just the inner span for composition in a parent flex
|
||||
// row. inline=false wraps in a centered/right-aligned div (default).
|
||||
function ProjectAttribution(props: { currentYear: number; inline?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const content = (
|
||||
<span className='text-muted-foreground/45'>
|
||||
© {props.currentYear}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
|
||||
>
|
||||
{t('New API')}
|
||||
</a>
|
||||
. {t(NEW_API_FOOTER_ATTRIBUTION_KEY)}
|
||||
</span>
|
||||
)
|
||||
if (props.inline) {
|
||||
return content
|
||||
}
|
||||
return (
|
||||
<div className='text-muted-foreground/45 text-center text-xs sm:text-right'>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer(props: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
systemName,
|
||||
logo: systemLogo,
|
||||
footerHtml,
|
||||
demoSiteEnabled,
|
||||
} = useSystemConfig()
|
||||
|
||||
const displayLogo = systemLogo || props.logo || '/logo.png'
|
||||
const displayName = systemName || props.name || 'New API'
|
||||
const isDemoSiteMode = Boolean(demoSiteEnabled)
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const fallbackColumns = useMemo<FooterColumnProps[]>(
|
||||
() => [
|
||||
{
|
||||
title: t('footer.columns.about.title'),
|
||||
links: [
|
||||
{
|
||||
text: t('footer.columns.about.links.aboutProject'),
|
||||
href: 'https://docs.newapi.pro/wiki/project-introduction/',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.about.links.contact'),
|
||||
href: 'https://docs.newapi.pro/support/community-interaction/',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.about.links.features'),
|
||||
href: 'https://docs.newapi.pro/wiki/features-introduction/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('footer.columns.docs.title'),
|
||||
links: [
|
||||
{
|
||||
text: t('footer.columns.docs.links.quickStart'),
|
||||
href: 'https://docs.newapi.pro/getting-started/',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.docs.links.installation'),
|
||||
href: 'https://docs.newapi.pro/installation/',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.docs.links.apiDocs'),
|
||||
href: 'https://docs.newapi.pro/api/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('footer.columns.related.title'),
|
||||
links: [
|
||||
{
|
||||
text: t('footer.columns.related.links.oneApi'),
|
||||
href: 'https://github.com/songquanpeng/one-api',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.related.links.midjourney'),
|
||||
href: 'https://github.com/novicezk/midjourney-proxy',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.related.links.newApiKeyTool'),
|
||||
href: 'https://github.com/Calcium-Ion/new-api-key-tool',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const displayColumns = props.columns ?? fallbackColumns
|
||||
|
||||
if (footerHtml) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'border-border/40 relative z-10 border-t',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='mx-auto w-full max-w-6xl px-6 py-5'>
|
||||
<div className='bg-muted/20 border-border/50 flex flex-col items-center justify-between gap-4 rounded-2xl border px-4 py-4 backdrop-blur-sm sm:flex-row sm:px-5'>
|
||||
<div
|
||||
className='custom-footer text-muted-foreground min-w-0 text-center text-sm sm:text-left'
|
||||
dangerouslySetInnerHTML={{ __html: footerHtml }}
|
||||
/>
|
||||
<div className='border-border/60 text-muted-foreground/45 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
|
||||
<LegalLinks />
|
||||
<ProjectAttribution currentYear={currentYear} inline />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn('border-border/40 relative z-10 border-t', props.className)}
|
||||
>
|
||||
<div className='mx-auto max-w-6xl px-6 py-12 md:py-16'>
|
||||
<div className='flex flex-col justify-between gap-10 md:flex-row md:gap-16'>
|
||||
{/* Brand column */}
|
||||
<div className='shrink-0'>
|
||||
<Link to='/' className='group flex items-center gap-2.5'>
|
||||
<img
|
||||
src={displayLogo}
|
||||
alt={displayName}
|
||||
className='size-7 rounded-lg object-contain'
|
||||
/>
|
||||
<span className='text-sm font-semibold tracking-tight'>
|
||||
{displayName}
|
||||
</span>
|
||||
</Link>
|
||||
<p className='text-muted-foreground/60 mt-3 max-w-[200px] text-xs leading-relaxed'>
|
||||
{t('Powerful API Management Platform')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Links columns */}
|
||||
{isDemoSiteMode && (
|
||||
<div className='grid grid-cols-3 gap-8 md:gap-16'>
|
||||
{displayColumns.map((column, index) => (
|
||||
<div key={index}>
|
||||
<p className='text-muted-foreground/50 mb-3 text-xs font-medium tracking-wider uppercase'>
|
||||
{t(column.title)}
|
||||
</p>
|
||||
<ul className='space-y-2.5'>
|
||||
{column.links.map((link, linkIndex) => (
|
||||
<li key={linkIndex}>
|
||||
<FooterLinkItem link={link} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Copyright + optional legal links inline on the left, project
|
||||
attribution on the right; wraps on narrow screens. */}
|
||||
<div className='border-border/30 mt-12 flex flex-col items-center justify-between gap-x-3 gap-y-2 border-t pt-6 sm:flex-row'>
|
||||
<div className='text-muted-foreground/40 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs sm:justify-start'>
|
||||
<span>
|
||||
© {currentYear} {displayName}.{' '}
|
||||
{props.copyright ?? t('footer.defaultCopyright')}
|
||||
</span>
|
||||
<LegalLinks leadingSeparator />
|
||||
</div>
|
||||
<ProjectAttribution currentYear={currentYear} />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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 { cva, type VariantProps } from 'class-variance-authority'
|
||||
import React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const glowVariants = cva('absolute w-full', {
|
||||
variants: {
|
||||
variant: {
|
||||
top: 'top-0',
|
||||
above: '-top-[128px]',
|
||||
bottom: 'bottom-0',
|
||||
below: '-bottom-[128px]',
|
||||
center: 'top-[50%]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'top',
|
||||
},
|
||||
})
|
||||
|
||||
export interface GlowProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof glowVariants> {}
|
||||
|
||||
export function Glow({ className, variant, ...props }: GlowProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot='glow'
|
||||
className={cn(glowVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 h-[256px] w-[60%] -translate-x-1/2 scale-[2.5] rounded-[50%] bg-radial from-amber-500/60 from-10% to-amber-500/0 to-60% opacity-40 sm:h-[512px] dark:opacity-80',
|
||||
variant === 'center' && '-translate-y-1/2'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 h-[128px] w-[40%] -translate-x-1/2 scale-200 rounded-[50%] bg-radial from-yellow-400/50 from-10% to-yellow-400/0 to-60% opacity-30 sm:h-[256px] dark:opacity-70',
|
||||
variant === 'center' && '-translate-y-1/2'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user