feat: publish v1.0.0-rc.36-custom.1

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit 3a7e8f3b54
2381 files changed
+551494

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
/*
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 {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
interface TimestampCellProps {
timestamp: number
now?: number
format?: 'relative' | 'absolute'
locale?: string
justNowLabel: string
className?: string
}
export function TimestampCell(props: TimestampCellProps) {
if (!props.timestamp || props.timestamp === -1) {
return <span className='text-muted-foreground'>-</span>
}
const timestampMs = props.timestamp * 1000
const absoluteTime = formatTimestampToDate(props.timestamp)
if (props.format === 'absolute') {
return (
<time
dateTime={new Date(timestampMs).toISOString()}
className={cn('block whitespace-nowrap tabular-nums', props.className)}
>
{absoluteTime}
</time>
)
}
const now = props.now ?? Date.now()
const isJustNow = timestampMs <= now && now - timestampMs < 60_000
const relativeTime = isJustNow
? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
)
return (
<Tooltip>
<TooltipTrigger
render={
<time
dateTime={new Date(timestampMs).toISOString()}
tabIndex={0}
className={cn('block truncate', props.className)}
/>
}
>
{relativePrefix}
{relativeNumber && (
<span className='tabular-nums'>{relativeNumber}</span>
)}
{relativeSuffix}
</TooltipTrigger>
<TooltipContent>
<span className='tabular-nums'>{absoluteTime}</span>
</TooltipContent>
</Tooltip>
)
}
export function ActivityTimeCell(props: {
createdAt: number
lastAt: number
lastLabel: string
lastClassName?: string
now?: number
format?: 'relative' | 'absolute'
layout?: 'rows' | 'columns'
}) {
const { t, i18n } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
return (
<div
data-table-text='secondary'
className={cn(
'grid min-w-0 gap-y-1 text-xs font-normal',
props.layout === 'columns'
? 'grid-flow-col grid-cols-2 grid-rows-[auto_1fr] items-start gap-x-3'
: 'grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2'
)}
>
<span className='text-muted-foreground'>{t('Created')}</span>
<TimestampCell
timestamp={props.createdAt}
now={props.now}
format={props.format}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
<span className='text-muted-foreground'>{props.lastLabel}</span>
<TimestampCell
timestamp={props.lastAt}
now={props.now}
format={props.format}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
props.lastClassName ?? 'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
</div>
)
}
@@ -0,0 +1,58 @@
/*
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 { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, test } from 'vitest'
import { CodeBlockEditor } from '../code-block'
afterEach(() => {
cleanup()
})
function editorTree(value: string) {
// A fresh inline onKeyDown per call mirrors PlaygroundMessageEditor, which
// recreates its handler on every keystroke-driven render.
return (
<CodeBlockEditor
ariaLabel='Edit message'
language='markdown'
onChange={() => undefined}
onKeyDown={() => undefined}
value={value}
/>
)
}
describe('CodeBlockEditor', () => {
test('keeps the same editor instance when value and onKeyDown change on rerender', () => {
const { rerender } = render(editorTree('h'))
const contentBefore = document.querySelector('.cm-content')
expect(contentBefore).not.toBeNull()
rerender(editorTree('hi'))
const contentAfter = document.querySelector('.cm-content')
// If the EditorView were torn down and rebuilt, the content node would be
// replaced and the cursor would reset to the document start, making typed
// characters pile up at the beginning (text appears right-to-left).
expect(contentAfter).toBe(contentBefore)
expect(contentAfter?.textContent).toContain('hi')
})
})
@@ -0,0 +1,114 @@
/*
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 { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { Response } from '../response'
import {
FADE_DURATION_MS,
FADE_HYDRATION_THRESHOLD,
FADE_STAGGER_MAX_MS,
} from '../response-fade'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('Response streaming fade', () => {
test('wraps newly streamed words when final is false', () => {
const { rerender } = render(<Response final={false}>Hello</Response>)
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
0
)
expect(screen.getByText('Hello')).toBeTruthy()
rerender(<Response final={false}>Hello world</Response>)
const fades = [...document.querySelectorAll('[data-stream-fade]')]
expect(fades.some((node) => node.textContent === 'world')).toBe(true)
})
test('renders settled content with zero fade wrappers when final is true', () => {
render(<Response final>Hello world</Response>)
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
expect(screen.getByText(/Hello world/)).toBeTruthy()
})
test('does not fade inline code or fenced code blocks', () => {
render(
<Response final={false}>
{['Use `code` and:', '', '```', 'block', '```'].join('\n')}
</Response>
)
const fades = [...document.querySelectorAll('[data-stream-fade]')]
const fadedText = fades.map((node) => node.textContent ?? '').join('')
expect(fadedText.includes('block')).toBe(false)
expect(
fades.every((node) => {
const text = node.textContent ?? ''
return text.trim() !== 'code' && text.trim() !== 'block'
})
).toBe(true)
})
test('does not re-animate words after markdown restructuring around strong', () => {
vi.spyOn(performance, 'now').mockReturnValue(1000)
const { rerender } = render(<Response final={false}>**fin</Response>)
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
0
)
vi.spyOn(performance, 'now').mockReturnValue(
1000 + FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1
)
rerender(<Response final={false}>**final**</Response>)
const strong = document.querySelector('strong')
expect(strong?.textContent).toContain('final')
const fades = [...document.querySelectorAll('[data-stream-fade]')]
expect(
fades.every((node) => !(node.textContent ?? '').includes('final'))
).toBe(true)
})
test('suppresses fades on the first streaming render of hydrated content', () => {
const hydrated = 'word '.repeat(FADE_HYDRATION_THRESHOLD)
render(<Response final={false}>{hydrated}</Response>)
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
})
test('drops all fade wrappers once the stream settles', () => {
const { rerender } = render(
<Response final={false}>Streaming text</Response>
)
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
0
)
rerender(<Response final>Streaming text</Response>)
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
})
})
@@ -0,0 +1,190 @@
/*
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 { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import {
beginRun,
classifyValue,
createFadeState,
endRun,
FADE_DURATION_MS,
FADE_HYDRATION_THRESHOLD,
FADE_STAGGER_MAX_MS,
FADE_STAGGER_MS,
splitWords,
stageRun,
} from '../response-fade'
describe('splitWords', () => {
test('round-trips ASCII words with trailing whitespace', () => {
const value = 'Hello world, stream.\n'
expect(splitWords(value).join('')).toBe(value)
})
test('keeps leading whitespace as its own part', () => {
expect(splitWords(' hi')).toEqual([' ', 'hi'])
})
test('segments CJK without spaces via Intl.Segmenter', () => {
const value = '你好世界'
const parts = splitWords(value)
expect(parts.join('')).toBe(value)
expect(parts.length).toBeGreaterThan(1)
})
})
describe('classifyValue', () => {
beforeEach(() => {
vi.spyOn(performance, 'now').mockReturnValue(1000)
})
afterEach(() => {
vi.restoreAllMocks()
})
test('animates only newly appended words with capped stagger', () => {
const state = createFadeState()
const first = beginRun(state)
const firstSegments = classifyValue(first, 'one two ')
endRun(first)
expect(firstSegments.filter((s) => s.animated)).toHaveLength(2)
expect(firstSegments[0]?.delay).toBe(0)
expect(firstSegments[1]?.delay).toBe(FADE_STAGGER_MS)
vi.spyOn(performance, 'now').mockReturnValue(
1000 + FADE_DURATION_MS + FADE_STAGGER_MS + 1
)
const second = beginRun(state)
const secondSegments = classifyValue(second, 'one two three four')
endRun(second)
const animated = secondSegments.filter((s) => s.animated)
expect(animated.map((s) => s.value.trim())).toEqual(['three', 'four'])
expect(animated[0]?.delay).toBe(0)
expect(animated[1]?.delay).toBe(FADE_STAGGER_MS)
})
test('caps stagger delay at FADE_STAGGER_MAX_MS', () => {
const state = createFadeState()
const run = beginRun(state)
const words = Array.from({ length: 20 }, (_, i) => `w${i}`).join(' ')
const segments = classifyValue(run, words)
endRun(run)
const delays = segments.filter((s) => s.animated).map((s) => s.delay)
expect(Math.max(...delays)).toBe(FADE_STAGGER_MAX_MS)
})
test('replays identical delay while still inside the animation window', () => {
const state = createFadeState()
const first = beginRun(state)
classifyValue(first, 'hello ')
endRun(first)
vi.spyOn(performance, 'now').mockReturnValue(1000 + FADE_DURATION_MS / 2)
const second = beginRun(state)
const segments = classifyValue(second, 'hello world')
endRun(second)
expect(segments[0]).toMatchObject({
animated: true,
delay: 0,
start: 0,
value: 'hello ',
})
expect(segments[1]).toMatchObject({
animated: true,
start: 6,
value: 'world',
})
})
test('keeps the same start offset when the head word grows', () => {
const state = createFadeState()
const first = beginRun(state)
const head = classifyValue(first, 'hel')
endRun(first)
expect(head[0]?.start).toBe(0)
vi.spyOn(performance, 'now').mockReturnValue(1050)
const second = beginRun(state)
const grown = classifyValue(second, 'hello')
endRun(second)
expect(grown[0]?.start).toBe(0)
expect(grown[0]?.animated).toBe(true)
expect(grown[0]?.value).toBe('hello')
})
test('does not animate whitespace-only parts', () => {
const state = createFadeState()
const run = beginRun(state)
const segments = classifyValue(run, ' \n')
endRun(run)
expect(segments.every((s) => !s.animated)).toBe(true)
})
test('suppresses animation on the hydration baseline', () => {
const state = createFadeState()
const longText = 'a'.repeat(FADE_HYDRATION_THRESHOLD + 1)
const run = beginRun(state, true)
const segments = classifyValue(run, longText)
endRun(run)
expect(segments.every((s) => !s.animated)).toBe(true)
expect(state.prevCount).toBe(longText.length)
})
test('stops replaying animation after the window expires', () => {
const state = createFadeState()
const first = beginRun(state)
classifyValue(first, 'done ')
endRun(first)
vi.spyOn(performance, 'now').mockReturnValue(
1000 + FADE_DURATION_MS + 1
)
const second = beginRun(state)
const segments = classifyValue(second, 'done next')
endRun(second)
expect(segments[0]).toMatchObject({ animated: false, value: 'done ' })
expect(segments[1]).toMatchObject({ animated: true, value: 'next' })
expect(state.active.has(0)).toBe(false)
})
test('abandoned staged runs leave committed state untouched', () => {
const state = createFadeState()
const first = beginRun(state)
classifyValue(first, 'keep ')
endRun(first)
expect(state.prevCount).toBe(5)
const abandoned = beginRun(state)
classifyValue(abandoned, 'keep extra')
stageRun(abandoned)
// Never commit — simulate React discarding the render
state.pending = null
expect(state.prevCount).toBe(5)
expect(state.active.size).toBe(1)
})
})
@@ -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
}
+170
View File
@@ -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} />
)
+246
View File
@@ -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>
)
}
+43
View File
@@ -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,694 @@
/*
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 { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { EditorState, type Extension } from '@codemirror/state'
import {
EditorView,
lineNumbers,
placeholder as placeholderExtension,
} 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
autoFocus?: boolean
language: BundledLanguage | string
onChange: (value: string) => void
onKeyDown?: (event: globalThis.KeyboardEvent) => void
placeholder?: string
rows?: number
title?: ReactNode
value: string
}
type CodeMirrorCodeViewProps = {
ariaLabel: string
autoFocus?: boolean
language: BundledLanguage | string
onChange?: (value: string) => void
onKeyDown?: (event: globalThis.KeyboardEvent) => void
placeholder?: string
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()
}
if (requestedLanguage === 'javascript' || requestedLanguage === 'jsx') {
return javascript({ jsx: requestedLanguage === 'jsx' })
}
if (requestedLanguage === 'typescript' || requestedLanguage === 'tsx') {
return javascript({ jsx: requestedLanguage === 'tsx', typescript: true })
}
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
placeholder?: string
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),
EditorView.domEventHandlers({
keydown(event) {
options.onKeyDown(event)
return event.defaultPrevented
},
}),
]
if (options.placeholder) {
extensions.push(placeholderExtension(options.placeholder))
}
if (options.showLineNumbers) {
extensions.unshift(lineNumbers())
}
return extensions
}
function CodeMirrorCodeView({
ariaLabel,
autoFocus = false,
language,
onChange,
onKeyDown,
placeholder,
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 onKeyDownRef = useRef(onKeyDown)
const editorMinHeight = `${Math.max(4, rows) * 1.5 + 2}rem`
// onKeyDown is delivered through a ref so a new handler identity from the
// parent (recreated on every keystroke-driven render) does not invalidate
// the extensions and tear down the EditorView, which would reset the cursor
// to the document start and make typing appear right-to-left.
const editorExtensions = useMemo(
() =>
getCodeMirrorExtensions({
language,
onKeyDown: (event) => onKeyDownRef.current?.(event),
placeholder,
readOnly,
showLineNumbers,
}),
[language, placeholder, readOnly, showLineNumbers]
)
useEffect(() => {
onChangeRef.current = onChange
onKeyDownRef.current = onKeyDown
}, [onChange, onKeyDown])
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(() => {
// Track the latest value so a future editor rebuild (e.g. language change)
// starts from the current document instead of the mount-time snapshot.
initialValueRef.current = value
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,
autoFocus = true,
className,
language,
onChange,
onKeyDown,
placeholder,
rows = 8,
title,
value,
...props
}: CodeBlockEditorProps) => {
return (
<CodeBlockFrame
bodyClassName='p-0'
className={className}
endActions={actions}
showToolbar
title={title}
{...props}
>
<CodeMirrorCodeView
ariaLabel={ariaLabel}
autoFocus={autoFocus}
language={language}
onChange={onChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
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>
)
+440
View File
@@ -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>
)
)
}
+159
View File
@@ -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,
}
+43
View File
@@ -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>
)
+119
View File
@@ -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>
)
+90
View File
@@ -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>
)
}
+34
View File
@@ -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}
/>
)
+171
View File
@@ -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
+294
View File
@@ -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,222 @@
/*
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) => {
const { isStreaming } = useReasoning()
return (
<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'
final={!isStreaming}
parserId='new-api-reasoning'
>
{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,222 @@
/*
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
*/
/** Must match the animation duration on `[data-stream-fade]` in styles/index.css */
export const FADE_DURATION_MS = 250
export const FADE_STAGGER_MS = 25
export const FADE_STAGGER_MAX_MS = 250
/**
* Text already longer than this when animation starts is hydrated/resumed
* content (reconnected stream, conversation switch), not a fresh delta —
* that content becomes the baseline instead of re-fading.
*/
export const FADE_HYDRATION_THRESHOLD = 120
type FadeEntry = { at: number; delay: number }
type PendingRun = {
prevCount: number
additions: Map<number, FadeEntry>
now: number
}
export type FadeState = {
/** Total characters classified during the last committed run */
prevCount: number
/** Parts still mid-animation, keyed by start offset */
active: Map<number, FadeEntry>
/** True until the first run commits */
firstRun: boolean
/** Staged result of the latest render; published on commit */
pending: PendingRun | null
}
export type FadeRun = {
state: FadeState
now: number
count: number
newIndex: number
/** Baseline mode: classify everything as already seen */
suppress: boolean
additions: Map<number, FadeEntry>
}
export type FadeSegment = {
start: number
value: string
animated: boolean
delay: number
}
const WORD_REGEX = /\S+\s*/g
const NON_WHITESPACE_REGEX = /\S/
/**
* Scripts without word-delimiting spaces: Thai, Lao, Myanmar, Khmer,
* Tibetan, CJK ideographs/kana, Hangul, and CJK compatibility ideographs.
*/
const SPACELESS_REGEX =
/[\u0E00-\u0EFF\u0F00-\u0FFF\u1000-\u109F\u1780-\u17FF\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/
let wordSegmenter: Intl.Segmenter | null | undefined
function getWordSegmenter(): Intl.Segmenter | null {
if (wordSegmenter === undefined) {
wordSegmenter =
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
? new Intl.Segmenter(undefined, { granularity: 'word' })
: null
}
return wordSegmenter
}
function pushSegmentedParts(parts: string[], token: string): void {
const segmenter = getWordSegmenter()
if (segmenter == null) {
parts.push(token)
return
}
const trailing = /\s+$/.exec(token)
const word = trailing == null ? token : token.slice(0, trailing.index)
for (const segment of segmenter.segment(word)) {
parts.push(segment.segment)
}
if (trailing != null) {
parts.push(trailing[0])
}
}
/**
* Splits text into word parts (non-whitespace run plus trailing whitespace).
* Spaceless scripts are further split via Intl.Segmenter.
* Concatenating the result always reproduces the input exactly.
*/
export function splitWords(value: string): string[] {
const parts: string[] = []
WORD_REGEX.lastIndex = 0
let index = 0
let match: RegExpExecArray | null
while ((match = WORD_REGEX.exec(value)) !== null) {
if (match.index > index) {
parts.push(value.slice(index, match.index))
}
const token = match[0]
if (SPACELESS_REGEX.test(token)) {
pushSegmentedParts(parts, token)
} else {
parts.push(token)
}
index = match.index + token.length
}
if (index < value.length) {
parts.push(value.slice(index))
}
return parts
}
export function createFadeState(): FadeState {
return { prevCount: 0, active: new Map(), firstRun: true, pending: null }
}
export function beginRun(state: FadeState, suppress = false): FadeRun {
return {
state,
now: performance.now(),
count: 0,
newIndex: 0,
suppress,
additions: new Map(),
}
}
/**
* Stages the run's result without publishing. Classification never mutates
* committed state during render, so abandoned renders leave no trace.
*/
export function stageRun(run: FadeRun): void {
run.state.pending = {
prevCount: run.count,
additions: run.additions,
now: run.now,
}
}
/** Publishes the staged run: baseline offset, new animations, pruned entries. */
export function commitRun(state: FadeState): void {
const pending = state.pending
if (pending == null) {
return
}
state.pending = null
state.firstRun = false
state.prevCount = pending.prevCount
for (const [start, entry] of pending.additions) {
state.active.set(start, entry)
}
for (const [start, entry] of state.active) {
if (pending.now - entry.at >= entry.delay + FADE_DURATION_MS) {
state.active.delete(start)
}
}
}
/** Stages and immediately commits — for callers without a commit phase. */
export function endRun(run: FadeRun): void {
stageRun(run)
commitRun(run.state)
}
/**
* Classifies one text value into fade segments, advancing document-order
* character offset. New parts (start >= prevCount) animate; parts still
* inside their animation window replay identical props.
*/
export function classifyValue(run: FadeRun, value: string): FadeSegment[] {
const { state, now } = run
const segments: FadeSegment[] = []
for (const part of splitWords(value)) {
const start = run.count
run.count += part.length
if (run.suppress || !NON_WHITESPACE_REGEX.test(part)) {
segments.push({ start, value: part, animated: false, delay: 0 })
continue
}
if (start >= state.prevCount) {
const staged = run.additions.get(start)
const delay =
staged?.delay ??
Math.min(run.newIndex * FADE_STAGGER_MS, FADE_STAGGER_MAX_MS)
run.newIndex += 1
run.additions.set(start, staged ?? { at: now, delay })
segments.push({ start, value: part, animated: true, delay })
continue
}
const entry = state.active.get(start)
if (entry != null && now - entry.at < entry.delay + FADE_DURATION_MS) {
segments.push({
start,
value: part,
animated: true,
delay: entry.delay,
})
continue
}
segments.push({ start, value: part, animated: false, delay: 0 })
}
return segments
}
@@ -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,91 @@
/*
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 { Fragment, type CSSProperties, type ReactNode } from 'react'
import {
shouldOpenLinkInNewTab,
type ImageNode,
type LinkNode,
type TextNode,
} from 'stream-markdown-parser'
import { classifyValue, type FadeRun } from './response-fade'
import { ResponseImage } from './response-renderer-image'
import type { RenderChildren } from './response-types'
const STREAM_FADE_DELAY_VAR = '--stream-fade-delay'
export function renderTextNode(
node: TextNode,
fadeRun?: FadeRun
): ReactNode {
if (!fadeRun) {
return node.content
}
const segments = classifyValue(fadeRun, node.content)
if (segments.every((segment) => !segment.animated)) {
return node.content
}
return segments.map((segment) => {
if (!segment.animated) {
return <Fragment key={segment.start}>{segment.value}</Fragment>
}
const style =
segment.delay > 0
? ({
[STREAM_FADE_DELAY_VAR]: `${segment.delay}ms`,
} as CSSProperties)
: undefined
return (
<span data-stream-fade='' key={segment.start} style={style}>
{segment.value}
</span>
)
})
}
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,258 @@
/*
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 type { FadeRun } from './response-fade'
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'
import type { BlockRendererOptions, RenderChildren } from './response-types'
function createRenderChildren(fadeRun?: FadeRun): RenderChildren {
return (nodes) => renderChildren(nodes, fadeRun)
}
export function renderChildren(
nodes: ParsedNode[],
fadeRun?: FadeRun
): ReactNode {
const options: BlockRendererOptions = {
fadeRun,
renderChildren: createRenderChildren(fadeRun),
}
return nodes.map((node, index) =>
renderNode(node, getNodeKey(node, index), options)
)
}
export function renderFootnotes(
footnotes: FootnoteNode[],
fadeRun?: FadeRun
): ReactNode {
return renderFootnotesBlock(footnotes, {
fadeRun,
renderChildren: createRenderChildren(fadeRun),
})
}
/** Settled (non-animated) renderChildren for skipped subtrees */
const settledRenderChildren = createRenderChildren()
function renderNode(
node: ParsedNode,
key: string,
options: BlockRendererOptions
): ReactNode {
if (isTextNode(node)) {
return renderTextNode(node, options.fadeRun)
}
if (isHeadingNode(node)) {
return renderHeading(node, key, options)
}
if (node.type === 'paragraph' && hasParsedChildren(node)) {
return (
<p className='my-3 leading-7' key={key}>
{options.renderChildren(node.children)}
</p>
)
}
if (node.type === 'inline' && hasParsedChildren(node)) {
return <span key={key}>{options.renderChildren(node.children)}</span>
}
if (isListNode(node)) {
return renderList(node, key, options)
}
// Skip list: code / math / html / image — no fade wrapping, offset not advanced
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, options.renderChildren)
}
if (isImageNode(node)) {
return renderImage(node, key)
}
if (isBlockquoteNode(node)) {
return renderBlockquote(node, key, options)
}
if (isTableNode(node)) {
return renderTable(node, key, options)
}
if (isDefinitionListNode(node)) {
return renderDefinitionList(node, key, options)
}
if (node.type === 'strong' && hasParsedChildren(node)) {
return (
<strong className='text-foreground font-semibold' key={key}>
{options.renderChildren(node.children)}
</strong>
)
}
if (node.type === 'emphasis' && hasParsedChildren(node)) {
return <em key={key}>{options.renderChildren(node.children)}</em>
}
if (node.type === 'strikethrough' && hasParsedChildren(node)) {
return <del key={key}>{options.renderChildren(node.children)}</del>
}
if (node.type === 'highlight' && hasParsedChildren(node)) {
return <mark key={key}>{options.renderChildren(node.children)}</mark>
}
if (node.type === 'insert' && hasParsedChildren(node)) {
return <ins key={key}>{options.renderChildren(node.children)}</ins>
}
if (node.type === 'subscript' && hasParsedChildren(node)) {
return <sub key={key}>{options.renderChildren(node.children)}</sub>
}
if (node.type === 'superscript' && hasParsedChildren(node)) {
return <sup key={key}>{options.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: settledRenderChildren,
})
}
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}>{options.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,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 type { ReactNode } from 'react'
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
import type { FadeRun } from './response-fade'
export type ResponseProps = {
children?: ReactNode
className?: string
final?: boolean
/** Distinct stream-markdown-parser cache id when multiple Responses stream concurrently */
parserId?: string
}
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
fadeRun?: FadeRun
}
+125
View File
@@ -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
*/
'use client'
import { memo, useLayoutEffect, useMemo, useRef } from 'react'
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { cn } from '@/lib/utils'
import { getMarkdownContent, parseResponseContent } from './response-content'
import {
beginRun,
commitRun,
createFadeState,
FADE_HYDRATION_THRESHOLD,
stageRun,
type FadeRun,
type FadeState,
} from './response-fade'
import { renderChildren, renderFootnotes } from './response-renderer'
import type { ResponseProps } from './response-types'
const DEFAULT_PARSER_ID = 'new-api-response'
const MAX_PARSED_MARKDOWN_CHARS = 20_000
type MarkdownInstance = ReturnType<typeof getMarkdown>
const markdownByParserId = new Map<string, MarkdownInstance>()
function getCachedMarkdown(parserId: string): MarkdownInstance {
const cached = markdownByParserId.get(parserId)
if (cached != null) {
return cached
}
const markdown = getMarkdown(parserId)
markdownByParserId.set(parserId, markdown)
return markdown
}
export const Response = memo((props: ResponseProps) => {
const content = getMarkdownContent(props.children)
const isFinal = props.final ?? true
const shouldAnimate = !isFinal
const parserId = props.parserId ?? DEFAULT_PARSER_ID
const markdown = getCachedMarkdown(parserId)
const shouldParseMarkdown = content.length <= MAX_PARSED_MARKDOWN_CHARS
const fadeStateRef = useRef<FadeState | null>(null)
if (fadeStateRef.current == null) {
fadeStateRef.current = createFadeState()
}
const nodes = useMemo(() => {
if (!shouldParseMarkdown) {
return []
}
return parseMarkdownToStructure(content, markdown, {
final: isFinal,
validateLink: markdown.options.validateLink,
})
}, [content, isFinal, markdown, shouldParseMarkdown])
const parsedContent = useMemo(() => parseResponseContent(nodes), [nodes])
let fadeRun: FadeRun | undefined
let renderedContent
let footnotes
if (parsedContent.bodyNodes.length > 0) {
if (shouldAnimate) {
const fadeState = fadeStateRef.current
const suppress =
fadeState.firstRun && content.length > FADE_HYDRATION_THRESHOLD
fadeRun = beginRun(fadeState, suppress)
renderedContent = renderChildren(parsedContent.bodyNodes, fadeRun)
footnotes = renderFootnotes(parsedContent.footnotes, fadeRun)
stageRun(fadeRun)
} else {
renderedContent = renderChildren(parsedContent.bodyNodes)
footnotes = renderFootnotes(parsedContent.footnotes)
}
} else {
renderedContent = content
footnotes = renderFootnotes(parsedContent.footnotes)
}
useLayoutEffect(() => {
if (!shouldAnimate) {
return
}
const fadeState = fadeStateRef.current
if (fadeState == null) {
return
}
commitRun(fadeState)
})
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)
+102
View File
@@ -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>
)
}
+112
View File
@@ -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>
)
+193
View File
@@ -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>
)
}
+85
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+125
View File
@@ -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>
)
}
+745
View File
@@ -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>
)
}
+88
View File
@@ -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>
)
}
+92
View File
@@ -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
}
+17
View File
@@ -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,67 @@
/*
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 {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, it } from 'vitest'
import { DataTablePagination } from '../pagination'
const rows = [{ id: 1 }, { id: 2 }, { id: 3 }]
const emptyRows: { id: number }[] = []
function Fixture(props: { empty?: boolean; compact?: boolean }) {
const table = useReactTable({
data: props.empty ? emptyRows : rows,
columns: [{ accessorKey: 'id' }],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageIndex: 0, pageSize: 2 } },
})
return <DataTablePagination table={table} compact={props.compact} />
}
it('moves between pages in compact mode and disables the boundary actions', async () => {
const user = userEvent.setup()
render(<Fixture compact />)
expect(screen.getByText('1 / 2')).toBeVisible()
expect(
screen.getByRole('button', { name: 'Go to previous page' })
).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Go to next page' }))
expect(screen.getByText('2 / 2')).toBeVisible()
expect(screen.getByRole('button', { name: 'Go to next page' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Go to previous page' }))
expect(screen.getByText('1 / 2')).toBeVisible()
})
it('shows a valid empty page with navigation disabled', () => {
render(<Fixture empty compact />)
expect(screen.getByText('1 / 1')).toBeVisible()
expect(
screen.getByRole('button', { name: 'Go to previous page' })
).toBeDisabled()
expect(screen.getByRole('button', { name: 'Go to next page' })).toBeDisabled()
})
it('keeps page size selection available in the default layout', () => {
render(<Fixture />)
expect(screen.getByRole('combobox')).toBeVisible()
})
@@ -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,116 @@
/*
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 { StatusBadgeList } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
interface BadgeListCellProps {
items: React.ReactNode[]
max?: number
tooltipClassName?: string
expandable?: boolean
expandLabel?: 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,
expandable = false,
expandLabel,
}: BadgeListCellProps) {
const { t } = useTranslation()
if (items.length === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const showTooltip = items.length > max
if (expandable && showTooltip) {
return (
<div className='flex min-w-0 items-center gap-1'>
<StatusBadgeList
items={items.slice(0, max)}
max={max}
renderItem={(item) => item}
/>
<Popover>
<PopoverTrigger
render={
<Button
variant='ghost'
size='sm'
className='h-6 shrink-0 px-1'
aria-label={expandLabel ?? t('Show all tags')}
/>
}
>
+{items.length - max}
</PopoverTrigger>
<PopoverContent className='max-h-64 overflow-auto'>
<div className='flex flex-wrap gap-2'>{items}</div>
</PopoverContent>
</Popover>
</div>
)
}
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 '1%'
}
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,328 @@
/*
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
withContainer={false}
className={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,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 { 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>
compact?: boolean
}
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,
compact = false,
}: 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)
const pageItems = pageNumbers.map((page, index) => ({
page,
key: page === '...' ? `gap-after-${pageNumbers[index - 1]}` : String(page),
}))
if (compact) {
return (
<nav
aria-label={t('Page')}
className='flex w-full min-w-0 flex-wrap items-center justify-between gap-2 text-sm'
>
<span className='text-muted-foreground min-w-0 [overflow-wrap:anywhere]'>
{t('Total:')} {totalRows.toLocaleString()}
</span>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='size-11'
aria-label={t('Go to previous page')}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeftIcon />
</Button>
<span className='tabular-nums' aria-live='polite'>
{currentPage} / {Math.max(1, totalPages)}
</span>
<Button
variant='outline'
size='icon'
className='size-11'
aria-label={t('Go to next page')}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRightIcon />
</Button>
</div>
</nav>
)
}
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>
{pageItems.map(({ page: pageNumber, key }) => (
<div key={key} 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,96 @@
/*
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
tabIndex?: number
}
export function TruncatedCell({
children,
cellClassName,
className,
contentClassName,
side = 'top',
tooltipClassName,
tooltipContent,
tabIndex,
}: TruncatedCellProps) {
const content = tooltipContent ?? getTextContent(children)
if (!content) {
return (
<div
tabIndex={tabIndex}
className={cn(
'block max-w-full min-w-0 truncate',
cellClassName,
className
)}
>
{children}
</div>
)
}
return (
<Tooltip>
<TooltipTrigger
render={
<div
tabIndex={tabIndex}
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,
}
}
+69
View File
@@ -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,562 @@
/*
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
/** Allow selection actions in the mobile list when opted in. */
showMobileBulkActions?: boolean
/**
* 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?: {
enableRowSelection?: boolean
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
/** Minimal previous/next pagination for narrow feature layouts. */
compactPagination?: 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.showMobileBulkActions) && 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}
compact={props.compactPagination}
/>
)
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
enableRowSelection={props.mobileProps?.enableRowSelection}
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,208 @@
/*
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 { Checkbox } from '@/components/ui/checkbox'
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>
enableRowSelection?: boolean
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,
enableRowSelection = false,
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'>
{enableRowSelection && (
<label className='flex items-center gap-2 px-3 py-2 text-xs'>
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(Boolean(value))
}
/>
{t('Select all')}
</label>
)}
{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)
)}
>
<div className='flex min-w-0 items-start gap-2'>
{enableRowSelection && (
<Checkbox
className='mt-0.5'
checked={row.getIsSelected()}
onCheckedChange={(value) =>
row.toggleSelected(Boolean(value))
}
aria-label={t('Select row {{number}}', {
number: row.index + 1,
})}
/>
)}
<div className='min-w-0 flex-1'>
<CardRowContent row={row} compact={hasCompactMeta} />
</div>
</div>
</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,248 @@
/*
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>
placement?: 'floating' | 'inline'
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,
placement = 'floating',
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<HTMLButtonElement[]>([])
const [announcement, setAnnouncement] = useState('')
useLayoutEffect(() => {
buttonsRef.current = toolbarRef.current
? [...toolbarRef.current.querySelectorAll('button')]
: []
})
// Announce selection changes to screen readers
useEffect(() => {
if (selectedCount > 0) {
const message = t(
'{{count}} records selected. Bulk actions are available.',
{ count: selectedCount }
)
// 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, t])
const handleClearSelection = () => {
table.resetRowSelection()
}
const handleKeyDown = (event: React.KeyboardEvent) => {
const buttons = buttonsRef.current
if (buttons.length === 0) return
const currentIndex = buttons.indexOf(
document.activeElement as HTMLButtonElement
)
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.at(-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={t('Bulk actions for {{count}} selected records', {
count: selectedCount,
})}
aria-describedby='bulk-actions-description'
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cn(
placement === 'floating'
? 'fixed bottom-6 left-1/2 z-50 w-max max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-xl transition-all delay-100 duration-300 ease-out hover:scale-105'
: 'shrink-0 rounded-xl',
'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 flex-wrap items-center gap-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={t('{{count}} selected', { count: selectedCount })}
>
{selectedCount}
</Badge>{' '}
<span className='hidden sm:inline'>
{entityName}
{selectedCount > 1 && /^[a-z]+$/i.test(entityName) ? '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,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 { Grid2X2, Table2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
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'
variant={isActive ? 'default' : 'ghost'}
size='icon-sm'
onClick={() => props.onChange(segment.value)}
aria-label={segment.tooltip}
aria-pressed={isActive}
className='h-full w-7'
>
<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>
)
}
+88
View File
@@ -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>
)
}
+165
View File
@@ -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>
)
}
+128
View File
@@ -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>
)
}
+105
View File
@@ -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
)
)
}
+69
View File
@@ -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>
)
}
+73
View File
@@ -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>
)
}
+133
View File
@@ -0,0 +1,133 @@
/*
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 { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { StatusBadge, type StatusBadgeProps } from './status-badge'
import { Badge } from './ui/badge'
export function GroupMultiplierBadge(props: {
children?: ReactNode
className?: string
label?: string
ratio?: number | null
}) {
let colorClassName =
'border-muted-foreground/30 bg-muted text-muted-foreground'
if (props.ratio != null && props.ratio > 1) {
colorClassName = 'border-warning/30 bg-warning/10 text-warning'
} else if (props.ratio != null && props.ratio < 1) {
colorClassName = 'border-info/30 bg-info/10 text-info'
}
return (
<Badge
variant='outline'
className={cn(
'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-sm leading-none font-medium shadow-none',
!props.label && 'tabular-nums',
colorClassName,
props.className
)}
>
{props.children}
<span>{props.label ?? `${props.ratio}x`}</span>
</Badge>
)
}
type GroupBadgeProps = Omit<
StatusBadgeProps,
'autoColor' | 'label' | 'variant'
> & {
group?: string | null
label?: string
ratio?: number | null
ratioLabel?: string
containerClassName?: string
}
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,
ratioLabel,
containerClassName,
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 && !ratioLabel) {
return badge
}
return (
<span
className={cn(
'inline-flex max-w-full min-w-0 items-center gap-2 text-sm',
containerClassName
)}
>
<span className='max-w-full min-w-0 overflow-hidden'>{badge}</span>
<GroupMultiplierBadge ratio={ratio} label={ratioLabel} />
</span>
)
}
+207
View File
@@ -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 }}
/>
)
}
+335
View File
@@ -0,0 +1,335 @@
/*
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, Copy } from 'lucide-react'
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Yace, type Plugin } from 'yace'
import { code } from 'yace/highlighters/code'
import { autoClose, history, tab } from 'yace/plugins'
import {
createScrollLayerSynchronizer,
formatJsonDraft,
getCursorLocation,
getJsonValidationState,
jsonSmartEnter,
type CursorLocation,
} from '@/components/json-code-editor/json-code-editor-utils'
import { Button } from '@/components/ui/button'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { cn } from '@/lib/utils'
export type JsonCodeEditorProps = Omit<
ComponentProps<'div'>,
'name' | 'onBlur' | 'onChange'
> & {
value: string
onChange: (value: string) => void
name?: string
onBlur?: () => void
textareaRef?: (element: HTMLTextAreaElement | null) => void
disabled?: boolean
heightClassName?: string
placeholder?: string
ariaLabel?: string
'data-form-root'?: string
}
export function JsonCodeEditor({
value,
onChange,
name,
onBlur,
textareaRef,
disabled,
heightClassName = 'h-56 min-h-56 max-h-56',
placeholder,
ariaLabel,
className,
id,
'aria-describedby': ariaDescribedBy,
'aria-invalid': ariaInvalid,
'data-form-root': dataFormRoot,
...rootProps
}: JsonCodeEditorProps) {
const { t } = useTranslation()
const mountRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Yace | null>(null)
const latestValueRef = useRef(value)
const latestOnChangeRef = useRef(onChange)
const latestOnBlurRef = useRef(onBlur)
const [cursorLocation, setCursorLocation] = useState<CursorLocation>({
line: 1,
column: 1,
})
const jsonStatus = useMemo(() => getJsonValidationState(value), [value])
const editorPlugins = useMemo<Plugin[]>(
() => [
history(),
tab(' '),
jsonSmartEnter(),
autoClose({ '"': '"', '{': '}', '[': ']' }),
],
[]
)
latestValueRef.current = value
latestOnChangeRef.current = onChange
latestOnBlurRef.current = onBlur
useEffect(() => {
const mountNode = mountRef.current
if (!mountNode) {
return
}
const editor = new Yace(mountNode, {
value: latestValueRef.current,
lineNumbers: true,
highlighters: [code()],
plugins: editorPlugins,
styles: {
color: 'inherit',
fontSize: '0.75rem',
lineHeight: '1.25rem',
minHeight: '100%',
overflow: 'hidden',
padding: '0.5rem 0.75rem 0.5rem 0.5rem',
},
})
editorRef.current = editor
const handleUpdate = (nextValue: string) => {
if (nextValue !== latestValueRef.current) {
latestOnChangeRef.current(nextValue)
}
}
const updateCursorLocation = () => {
setCursorLocation(
getCursorLocation(editor.value, editor.textarea.selectionStart)
)
}
const lineNumberLayer = [...mountNode.querySelectorAll('pre')].find(
(preLayer) => preLayer !== editor.pre
)
const scrollSynchronizer = lineNumberLayer
? createScrollLayerSynchronizer(editor.textarea, {
contentLayer: editor.pre,
lineNumberLayer,
})
: null
const syncScrollLayers = () => scrollSynchronizer?.sync()
const handleBlur = () => latestOnBlurRef.current?.()
editor.onUpdate(handleUpdate)
editor.textarea.addEventListener('click', updateCursorLocation)
editor.textarea.addEventListener('input', updateCursorLocation)
editor.textarea.addEventListener('keyup', updateCursorLocation)
editor.textarea.addEventListener('select', updateCursorLocation)
editor.textarea.addEventListener('blur', handleBlur)
editor.textarea.addEventListener('scroll', syncScrollLayers, {
passive: true,
})
editor.textarea.classList.add('json-code-editor-textarea')
editor.pre.classList.add('json-code-editor-highlight')
editor.pre.setAttribute('aria-hidden', 'true')
if (lineNumberLayer) {
lineNumberLayer.classList.add('json-code-editor-lines')
lineNumberLayer.setAttribute('aria-hidden', 'true')
}
updateCursorLocation()
return () => {
editor.textarea.removeEventListener('click', updateCursorLocation)
editor.textarea.removeEventListener('input', updateCursorLocation)
editor.textarea.removeEventListener('keyup', updateCursorLocation)
editor.textarea.removeEventListener('select', updateCursorLocation)
editor.textarea.removeEventListener('blur', handleBlur)
editor.textarea.removeEventListener('scroll', syncScrollLayers)
editor.destroy()
editorRef.current = null
}
}, [editorPlugins])
useEffect(() => {
const textarea = editorRef.current?.textarea ?? null
textareaRef?.(textarea)
return () => textareaRef?.(null)
}, [textareaRef])
useEffect(() => {
const editor = editorRef.current
if (!editor || editor.value === value) {
return
}
editor.update({ value })
}, [value])
useEffect(() => {
const editor = editorRef.current
if (!editor) {
return
}
const resolvedAriaInvalid = ariaInvalid ?? !jsonStatus.isValid
editor.textarea.disabled = Boolean(disabled)
editor.textarea.id = id ?? ''
editor.textarea.name = name ?? ''
if (ariaLabel) {
editor.textarea.setAttribute('aria-label', ariaLabel)
} else {
editor.textarea.removeAttribute('aria-label')
}
if (dataFormRoot) {
editor.textarea.setAttribute('data-form-root', String(dataFormRoot))
} else {
editor.textarea.removeAttribute('data-form-root')
}
if (placeholder) {
editor.textarea.placeholder = placeholder
} else {
editor.textarea.removeAttribute('placeholder')
}
if (resolvedAriaInvalid) {
editor.textarea.setAttribute('aria-invalid', String(resolvedAriaInvalid))
} else {
editor.textarea.removeAttribute('aria-invalid')
}
if (ariaDescribedBy) {
editor.textarea.setAttribute('aria-describedby', ariaDescribedBy)
} else {
editor.textarea.removeAttribute('aria-describedby')
}
}, [
ariaDescribedBy,
ariaInvalid,
ariaLabel,
disabled,
dataFormRoot,
id,
jsonStatus.isValid,
name,
placeholder,
])
const formatJson = () => {
const result = formatJsonDraft(value)
if (result.didFormat) {
onChange(result.value)
}
}
const handleCopy = async () => {
const didCopy = await copyToClipboard(value)
if (didCopy) {
toast.success(t('Copied to clipboard'))
return
}
toast.error(t('Failed to copy'))
}
const statusMessage = t(jsonStatus.messageKey)
const cursorText = `${cursorLocation.line}:${cursorLocation.column}`
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
)}
data-form-root={dataFormRoot}
{...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' aria-hidden='true' />
<span>{t('JSON')}</span>
<span className='text-muted-foreground/70 font-mono'>
{cursorText}
</span>
</div>
<div className='flex items-center gap-2'>
<span
className={cn(
'flex items-center gap-1 text-xs',
jsonStatus.isValid ? 'text-emerald-600' : 'text-destructive'
)}
>
{jsonStatus.isValid ? (
<CheckCircle2 className='h-3.5 w-3.5' aria-hidden='true' />
) : (
<AlertCircle className='h-3.5 w-3.5' aria-hidden='true' />
)}
{statusMessage}
</span>
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={handleCopy}
disabled={disabled || !value}
>
<Copy className='mr-1 h-3.5 w-3.5' aria-hidden='true' />
{t('Copy')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={formatJson}
disabled={disabled || !jsonStatus.isValid || !value.trim()}
>
<Code2 className='mr-1 h-3.5 w-3.5' aria-hidden='true' />
{t('Format JSON')}
</Button>
</div>
</div>
<div
className={cn(
'bg-background relative overflow-hidden pl-2',
'has-[textarea:disabled]:bg-input/30 has-[textarea:disabled]:opacity-70',
heightClassName
)}
>
<div
ref={mountRef}
className='json-code-editor-yace text-foreground h-full font-mono text-xs leading-5'
/>
</div>
</div>
)
}
@@ -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 { describe, expect, test } from 'vitest'
import {
applyJsonSmartEnter,
createScrollLayerSynchronizer,
formatJsonDraft,
getCursorLocation,
getJsonValidationState,
} from '../json-code-editor-utils'
describe('json code editor utils', () => {
test('treats empty drafts as valid editable JSON drafts', () => {
expect(getJsonValidationState(' \n')).toEqual({
isValid: true,
messageKey: 'JSON',
})
})
test('reports invalid JSON without throwing away the draft', () => {
expect(getJsonValidationState('{"model": }')).toEqual({
isValid: false,
messageKey: 'Invalid JSON',
})
})
test('formats valid JSON with stable two-space indentation', () => {
expect(formatJsonDraft('{"model":{"ratio":2}}')).toEqual({
didFormat: true,
value: '{\n "model": {\n "ratio": 2\n }\n}',
})
})
test('keeps invalid JSON drafts unchanged when formatting is requested', () => {
expect(formatJsonDraft('{"model": }')).toEqual({
didFormat: false,
value: '{"model": }',
})
})
test('derives the one-based cursor line and column from text offsets', () => {
expect(getCursorLocation('{\n "model": 1\n}', 5)).toEqual({
line: 2,
column: 4,
})
})
test('expands paired JSON brackets with a nested indentation line', () => {
expect(applyJsonSmartEnter('{}', 1, 1)).toEqual({
value: '{\n \n}',
selectionStart: 4,
selectionEnd: 4,
})
})
test('coalesces scroll updates while keeping line numbers horizontally fixed', () => {
const source = { scrollLeft: 12, scrollTop: 40 }
const contentLayer = { style: { transform: '' } }
const lineNumberLayer = { style: { transform: '' } }
const queuedFrames: Array<() => void> = []
const synchronizer = createScrollLayerSynchronizer(
source,
{ contentLayer, lineNumberLayer },
(callback) => {
queuedFrames.push(callback)
return queuedFrames.length
}
)
synchronizer.sync()
source.scrollLeft = 24
source.scrollTop = 80
synchronizer.sync()
expect(queuedFrames.length).toBe(1)
queuedFrames[0]()
expect(contentLayer.style.transform).toBe('translate3d(-24px, -80px, 0)')
expect(lineNumberLayer.style.transform).toBe('translate3d(0, -80px, 0)')
})
})
@@ -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 { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import { JsonCodeEditor } from '../../json-code-editor'
describe('JsonCodeEditor component', () => {
test('forwards form attributes and the textarea ref', () => {
const textareaRef = vi.fn()
const rendered = render(
<JsonCodeEditor
value='{"model":"gpt"}'
onChange={() => undefined}
id='json-input'
name='model_config'
placeholder='{"model":"gpt"}'
disabled
ariaLabel='Model configuration'
aria-describedby='model-help'
aria-invalid
data-form-root='settings-form'
textareaRef={textareaRef}
/>
)
const textarea = screen.getByRole('textbox', {
name: 'Model configuration',
})
expect(textarea).toHaveAttribute('id', 'json-input')
expect(textarea).toHaveAttribute('name', 'model_config')
expect(textarea).toHaveAttribute('placeholder', '{"model":"gpt"}')
expect(textarea).toBeDisabled()
expect(textarea).toHaveAttribute('aria-describedby', 'model-help')
expect(textarea).toHaveAttribute('aria-invalid', 'true')
expect(textarea).toHaveAttribute('data-form-root', 'settings-form')
expect(textareaRef).toHaveBeenCalledWith(textarea)
rendered.unmount()
expect(textareaRef).toHaveBeenLastCalledWith(null)
})
test('calls onBlur when focus leaves the editor', () => {
const onBlur = vi.fn()
render(
<JsonCodeEditor
value='{}'
onChange={() => undefined}
onBlur={onBlur}
ariaLabel='Model configuration'
/>
)
fireEvent.blur(screen.getByRole('textbox', { name: 'Model configuration' }))
expect(onBlur).toHaveBeenCalledOnce()
})
test('emits user edits and synchronizes a controlled value', () => {
const onChange = vi.fn()
const rendered = render(
<JsonCodeEditor
value='{"count":1}'
onChange={onChange}
ariaLabel='Model configuration'
/>
)
const textarea = screen.getByRole('textbox', {
name: 'Model configuration',
})
fireEvent.input(textarea, { target: { value: '{"count":2}' } })
expect(onChange).toHaveBeenCalledWith('{"count":2}')
rendered.rerender(
<JsonCodeEditor
value='{"count":3}'
onChange={onChange}
ariaLabel='Model configuration'
/>
)
expect(textarea).toHaveValue('{"count":3}')
})
test('formats valid JSON through the public toolbar action', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<JsonCodeEditor value='{"model":{"ratio":2}}' onChange={onChange} />)
await user.click(screen.getByRole('button', { name: 'Format JSON' }))
expect(onChange).toHaveBeenCalledWith(
'{\n "model": {\n "ratio": 2\n }\n}'
)
})
})
Loaded 100 of 2381 files, more files were not shown because too many files have changed in this diff. Show more