chore: init monorepo snapshot

This commit is contained in:
liaibo
2025-11-23 10:55:04 +08:00
commit c70ff52869
941 changed files with 246586 additions and 0 deletions
@@ -0,0 +1,86 @@
import { useState, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/AlertDialog'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import { useSettingsStore } from '@/stores/settings'
import { useBackendState } from '@/stores/state'
import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'
interface ApiKeyAlertProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const ApiKeyAlert = ({ open: opened, onOpenChange: setOpened }: ApiKeyAlertProps) => {
const { t } = useTranslation()
const apiKey = useSettingsStore.use.apiKey()
const [tempApiKey, setTempApiKey] = useState<string>('')
const message = useBackendState.use.message()
useEffect(() => {
setTempApiKey(apiKey || '')
}, [apiKey, opened])
useEffect(() => {
if (message) {
if (message.includes(InvalidApiKeyError) || message.includes(RequireApiKeError)) {
setOpened(true)
}
}
}, [message, setOpened])
const setApiKey = useCallback(() => {
useSettingsStore.setState({ apiKey: tempApiKey || null })
setOpened(false)
}, [tempApiKey, setOpened])
const handleTempApiKeyChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setTempApiKey(e.target.value)
},
[setTempApiKey]
)
return (
<AlertDialog open={opened} onOpenChange={setOpened}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('apiKeyAlert.title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('apiKeyAlert.description')}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-4">
<form className="flex gap-2" onSubmit={(e) => e.preventDefault()}>
<Input
type="password"
value={tempApiKey}
onChange={handleTempApiKeyChange}
placeholder={t('apiKeyAlert.placeholder')}
className="max-h-full w-full min-w-0"
autoComplete="off"
/>
<Button onClick={setApiKey} variant="outline" size="sm">
{t('apiKeyAlert.save')}
</Button>
</form>
{message && (
<div className="text-sm text-red-500">
{message}
</div>
)}
</div>
</AlertDialogContent>
</AlertDialog>
)
}
export default ApiKeyAlert
@@ -0,0 +1,74 @@
import { useState, useCallback } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import Button from '@/components/ui/Button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'
import { useSettingsStore } from '@/stores/settings'
import { PaletteIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
interface AppSettingsProps {
className?: string
}
export default function AppSettings({ className }: AppSettingsProps) {
const [opened, setOpened] = useState<boolean>(false)
const { t } = useTranslation()
const language = useSettingsStore.use.language()
const setLanguage = useSettingsStore.use.setLanguage()
const theme = useSettingsStore.use.theme()
const setTheme = useSettingsStore.use.setTheme()
const handleLanguageChange = useCallback((value: string) => {
setLanguage(value as 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW')
}, [setLanguage])
const handleThemeChange = useCallback((value: string) => {
setTheme(value as 'light' | 'dark' | 'system')
}, [setTheme])
return (
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className={cn('h-9 w-9', className)}>
<PaletteIcon className="h-5 w-5" />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-56">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('settings.language')}</label>
<Select value={language} onValueChange={handleLanguageChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh"></SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="ar">العربية</SelectItem>
<SelectItem value="zh_TW"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('settings.theme')}</label>
<Select value={theme} onValueChange={handleThemeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">{t('settings.light')}</SelectItem>
<SelectItem value="dark">{t('settings.dark')}</SelectItem>
<SelectItem value="system">{t('settings.system')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,49 @@
import Button from '@/components/ui/Button'
import { useCallback } from 'react'
import { controlButtonVariant } from '@/lib/constants'
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '@/stores/settings'
/**
* Component that toggles the language between English and Chinese.
*/
export default function LanguageToggle() {
const { i18n } = useTranslation()
const currentLanguage = i18n.language
const setLanguage = useSettingsStore.use.setLanguage()
const setEnglish = useCallback(() => {
i18n.changeLanguage('en')
setLanguage('en')
}, [i18n, setLanguage])
const setChinese = useCallback(() => {
i18n.changeLanguage('zh')
setLanguage('zh')
}, [i18n, setLanguage])
if (currentLanguage === 'zh') {
return (
<Button
onClick={setEnglish}
variant={controlButtonVariant}
tooltip="Switch to English"
size="icon"
side="bottom"
>
</Button>
)
}
return (
<Button
onClick={setChinese}
variant={controlButtonVariant}
tooltip="切换到中文"
size="icon"
side="bottom"
>
EN
</Button>
)
}
@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import App from '@/App'
import '@/i18n'
export const Root = () => (
<StrictMode>
<App />
</StrictMode>
)
@@ -0,0 +1,59 @@
import { createContext, useEffect } from 'react'
import { Theme, useSettingsStore } from '@/stores/settings'
type ThemeProviderProps = {
children: React.ReactNode
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
/**
* Component that provides the theme state and setter function to its children.
*/
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const theme = useSettingsStore.use.theme()
const setTheme = useSettingsStore.use.setTheme()
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
root.classList.remove('light', 'dark')
root.classList.add(e.matches ? 'dark' : 'light')
}
root.classList.add(mediaQuery.matches ? 'dark' : 'light')
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
} else {
root.classList.add(theme)
}
}, [theme])
const value = {
theme,
setTheme
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export { ThemeProviderContext }
@@ -0,0 +1,41 @@
import Button from '@/components/ui/Button'
import useTheme from '@/hooks/useTheme'
import { MoonIcon, SunIcon } from 'lucide-react'
import { useCallback } from 'react'
import { controlButtonVariant } from '@/lib/constants'
import { useTranslation } from 'react-i18next'
/**
* Component that toggles the theme between light and dark.
*/
export default function ThemeToggle() {
const { theme, setTheme } = useTheme()
const setLight = useCallback(() => setTheme('light'), [setTheme])
const setDark = useCallback(() => setTheme('dark'), [setTheme])
const { t } = useTranslation()
if (theme === 'dark') {
return (
<Button
onClick={setLight}
variant={controlButtonVariant}
tooltip={t('header.themeToggle.switchToLight')}
size="icon"
side="bottom"
>
<MoonIcon />
</Button>
)
}
return (
<Button
onClick={setDark}
variant={controlButtonVariant}
tooltip={t('header.themeToggle.switchToDark')}
size="icon"
side="bottom"
>
<SunIcon />
</Button>
)
}
@@ -0,0 +1,211 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import Button from '@/components/ui/Button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
} from '@/components/ui/Dialog'
import Input from '@/components/ui/Input'
import Checkbox from '@/components/ui/Checkbox'
import { toast } from 'sonner'
import { errorMessage } from '@/lib/utils'
import { clearDocuments, clearCache } from '@/api/lightrag'
import { EraserIcon, AlertTriangleIcon, Loader2Icon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
// Simple Label component
const Label = ({
htmlFor,
className,
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement>) => (
<label
htmlFor={htmlFor}
className={className}
{...props}
>
{children}
</label>
)
interface ClearDocumentsDialogProps {
onDocumentsCleared?: () => Promise<void>
}
export default function ClearDocumentsDialog({ onDocumentsCleared }: ClearDocumentsDialogProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [clearCacheOption, setClearCacheOption] = useState(false)
const [isClearing, setIsClearing] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isConfirmEnabled = confirmText.toLowerCase() === 'yes'
// Timeout constant (30 seconds)
const CLEAR_TIMEOUT = 30000
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setConfirmText('')
setClearCacheOption(false)
setIsClearing(false)
// Clear timeout timer
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [open])
// Cleanup when component unmounts
useEffect(() => {
return () => {
// Clear timeout timer when component unmounts
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const handleClear = useCallback(async () => {
if (!isConfirmEnabled || isClearing) return
setIsClearing(true)
// Set timeout protection
timeoutRef.current = setTimeout(() => {
if (isClearing) {
toast.error(t('documentPanel.clearDocuments.timeout'))
setIsClearing(false)
setConfirmText('') // Reset confirmation text after timeout
}
}, CLEAR_TIMEOUT)
try {
const result = await clearDocuments()
if (result.status !== 'success') {
toast.error(t('documentPanel.clearDocuments.failed', { message: result.message }))
setConfirmText('')
return
}
toast.success(t('documentPanel.clearDocuments.success'))
if (clearCacheOption) {
try {
await clearCache()
toast.success(t('documentPanel.clearDocuments.cacheCleared'))
} catch (cacheErr) {
toast.error(t('documentPanel.clearDocuments.cacheClearFailed', { error: errorMessage(cacheErr) }))
}
}
// Refresh document list if provided
if (onDocumentsCleared) {
onDocumentsCleared().catch(console.error)
}
// Close dialog after all operations succeed
setOpen(false)
} catch (err) {
toast.error(t('documentPanel.clearDocuments.error', { error: errorMessage(err) }))
setConfirmText('')
} finally {
// Clear timeout timer
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setIsClearing(false)
}
}, [isConfirmEnabled, isClearing, clearCacheOption, setOpen, t, onDocumentsCleared, CLEAR_TIMEOUT])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" side="bottom" tooltip={t('documentPanel.clearDocuments.tooltip')} size="sm">
<EraserIcon/> {t('documentPanel.clearDocuments.button')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-500 dark:text-red-400 font-bold">
<AlertTriangleIcon className="h-5 w-5" />
{t('documentPanel.clearDocuments.title')}
</DialogTitle>
<DialogDescription className="pt-2">
{t('documentPanel.clearDocuments.description')}
</DialogDescription>
</DialogHeader>
<div className="text-red-500 dark:text-red-400 font-semibold mb-4">
{t('documentPanel.clearDocuments.warning')}
</div>
<div className="mb-4">
{t('documentPanel.clearDocuments.confirm')}
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="confirm-text" className="text-sm font-medium">
{t('documentPanel.clearDocuments.confirmPrompt')}
</Label>
<Input
id="confirm-text"
value={confirmText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmText(e.target.value)}
placeholder={t('documentPanel.clearDocuments.confirmPlaceholder')}
className="w-full"
disabled={isClearing}
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="clear-cache"
checked={clearCacheOption}
onCheckedChange={(checked: boolean | 'indeterminate') => setClearCacheOption(checked === true)}
disabled={isClearing}
/>
<Label htmlFor="clear-cache" className="text-sm font-medium cursor-pointer">
{t('documentPanel.clearDocuments.clearCache')}
</Label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isClearing}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleClear}
disabled={!isConfirmEnabled || isClearing}
>
{isClearing ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
{t('documentPanel.clearDocuments.clearing')}
</>
) : (
t('documentPanel.clearDocuments.confirmButton')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,191 @@
import { useState, useCallback, useEffect } from 'react'
import Button from '@/components/ui/Button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
} from '@/components/ui/Dialog'
import Input from '@/components/ui/Input'
import { toast } from 'sonner'
import { errorMessage } from '@/lib/utils'
import { deleteDocuments } from '@/api/lightrag'
import { TrashIcon, AlertTriangleIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
// Simple Label component
const Label = ({
htmlFor,
className,
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement>) => (
<label
htmlFor={htmlFor}
className={className}
{...props}
>
{children}
</label>
)
interface DeleteDocumentsDialogProps {
selectedDocIds: string[]
onDocumentsDeleted?: () => Promise<void>
}
export default function DeleteDocumentsDialog({ selectedDocIds, onDocumentsDeleted }: DeleteDocumentsDialogProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [deleteFile, setDeleteFile] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [deleteLLMCache, setDeleteLLMCache] = useState(false)
const isConfirmEnabled = confirmText.toLowerCase() === 'yes' && !isDeleting
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setConfirmText('')
setDeleteFile(false)
setDeleteLLMCache(false)
setIsDeleting(false)
}
}, [open])
const handleDelete = useCallback(async () => {
if (!isConfirmEnabled || selectedDocIds.length === 0) return
setIsDeleting(true)
try {
const result = await deleteDocuments(selectedDocIds, deleteFile, deleteLLMCache)
if (result.status === 'deletion_started') {
toast.success(t('documentPanel.deleteDocuments.success', { count: selectedDocIds.length }))
} else if (result.status === 'busy') {
toast.error(t('documentPanel.deleteDocuments.busy'))
setConfirmText('')
setIsDeleting(false)
return
} else if (result.status === 'not_allowed') {
toast.error(t('documentPanel.deleteDocuments.notAllowed'))
setConfirmText('')
setIsDeleting(false)
return
} else {
toast.error(t('documentPanel.deleteDocuments.failed', { message: result.message }))
setConfirmText('')
setIsDeleting(false)
return
}
// Refresh document list if provided
if (onDocumentsDeleted) {
onDocumentsDeleted().catch(console.error)
}
// Close dialog after successful operation
setOpen(false)
} catch (err) {
toast.error(t('documentPanel.deleteDocuments.error', { error: errorMessage(err) }))
setConfirmText('')
} finally {
setIsDeleting(false)
}
}, [isConfirmEnabled, selectedDocIds, deleteFile, deleteLLMCache, setOpen, t, onDocumentsDeleted])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="destructive"
side="bottom"
tooltip={t('documentPanel.deleteDocuments.tooltip', { count: selectedDocIds.length })}
size="sm"
>
<TrashIcon/> {t('documentPanel.deleteDocuments.button')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-500 dark:text-red-400 font-bold">
<AlertTriangleIcon className="h-5 w-5" />
{t('documentPanel.deleteDocuments.title')}
</DialogTitle>
<DialogDescription className="pt-2">
{t('documentPanel.deleteDocuments.description', { count: selectedDocIds.length })}
</DialogDescription>
</DialogHeader>
<div className="text-red-500 dark:text-red-400 font-semibold mb-4">
{t('documentPanel.deleteDocuments.warning')}
</div>
<div className="mb-4">
{t('documentPanel.deleteDocuments.confirm', { count: selectedDocIds.length })}
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="confirm-text" className="text-sm font-medium">
{t('documentPanel.deleteDocuments.confirmPrompt')}
</Label>
<Input
id="confirm-text"
value={confirmText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmText(e.target.value)}
placeholder={t('documentPanel.deleteDocuments.confirmPlaceholder')}
className="w-full"
disabled={isDeleting}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="delete-file"
checked={deleteFile}
onChange={(e) => setDeleteFile(e.target.checked)}
disabled={isDeleting}
className="h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded"
/>
<Label htmlFor="delete-file" className="text-sm font-medium cursor-pointer">
{t('documentPanel.deleteDocuments.deleteFileOption')}
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="delete-llm-cache"
checked={deleteLLMCache}
onChange={(e) => setDeleteLLMCache(e.target.checked)}
disabled={isDeleting}
className="h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded"
/>
<Label htmlFor="delete-llm-cache" className="text-sm font-medium cursor-pointer">
{t('documentPanel.deleteDocuments.deleteLLMCacheOption')}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isDeleting}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={!isConfirmEnabled}
>
{isDeleting ? t('documentPanel.deleteDocuments.deleting') : t('documentPanel.deleteDocuments.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,269 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AlignLeft, AlignCenter, AlignRight } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
import { getPipelineStatus, cancelPipeline, PipelineStatusResponse } from '@/api/lightrag'
import { errorMessage } from '@/lib/utils'
import { cn } from '@/lib/utils'
type DialogPosition = 'left' | 'center' | 'right'
interface PipelineStatusDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export default function PipelineStatusDialog({
open,
onOpenChange
}: PipelineStatusDialogProps) {
const { t } = useTranslation()
const [status, setStatus] = useState<PipelineStatusResponse | null>(null)
const [position, setPosition] = useState<DialogPosition>('center')
const [isUserScrolled, setIsUserScrolled] = useState(false)
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
const historyRef = useRef<HTMLDivElement>(null)
// Reset position when dialog opens
useEffect(() => {
if (open) {
setPosition('center')
setIsUserScrolled(false)
} else {
// Reset confirmation dialog state when main dialog closes
setShowCancelConfirm(false)
}
}, [open])
// Handle scroll position
useEffect(() => {
const container = historyRef.current
if (!container || isUserScrolled) return
container.scrollTop = container.scrollHeight
}, [status?.history_messages, isUserScrolled])
const handleScroll = () => {
const container = historyRef.current
if (!container) return
const isAtBottom = Math.abs(
(container.scrollHeight - container.scrollTop) - container.clientHeight
) < 1
if (isAtBottom) {
setIsUserScrolled(false)
} else {
setIsUserScrolled(true)
}
}
// Refresh status every 2 seconds
useEffect(() => {
if (!open) return
const fetchStatus = async () => {
try {
const data = await getPipelineStatus()
setStatus(data)
} catch (err) {
toast.error(t('documentPanel.pipelineStatus.errors.fetchFailed', { error: errorMessage(err) }))
}
}
fetchStatus()
const interval = setInterval(fetchStatus, 2000)
return () => clearInterval(interval)
}, [open, t])
// Handle cancel pipeline confirmation
const handleConfirmCancel = async () => {
setShowCancelConfirm(false)
try {
const result = await cancelPipeline()
if (result.status === 'cancellation_requested') {
toast.success(t('documentPanel.pipelineStatus.cancelSuccess'))
} else if (result.status === 'not_busy') {
toast.info(t('documentPanel.pipelineStatus.cancelNotBusy'))
}
} catch (err) {
toast.error(t('documentPanel.pipelineStatus.cancelFailed', { error: errorMessage(err) }))
}
}
// Determine if cancel button should be enabled
const canCancel = status?.busy === true && !status?.cancellation_requested
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'sm:max-w-[800px] transition-all duration-200 fixed',
position === 'left' && '!left-[25%] !translate-x-[-50%] !mx-4',
position === 'center' && '!left-1/2 !-translate-x-1/2',
position === 'right' && '!left-[75%] !translate-x-[-50%] !mx-4'
)}
>
<DialogDescription className="sr-only">
{status?.job_name
? `${t('documentPanel.pipelineStatus.jobName')}: ${status.job_name}, ${t('documentPanel.pipelineStatus.progress')}: ${status.cur_batch}/${status.batchs}`
: t('documentPanel.pipelineStatus.noActiveJob')
}
</DialogDescription>
<DialogHeader className="flex flex-row items-center">
<DialogTitle className="flex-1">
{t('documentPanel.pipelineStatus.title')}
</DialogTitle>
{/* Position control buttons */}
<div className="flex items-center gap-2 mr-8">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6',
position === 'left' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'
)}
onClick={() => setPosition('left')}
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6',
position === 'center' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'
)}
onClick={() => setPosition('center')}
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6',
position === 'right' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'
)}
onClick={() => setPosition('right')}
>
<AlignRight className="h-4 w-4" />
</Button>
</div>
</DialogHeader>
{/* Status Content */}
<div className="space-y-4 pt-4">
{/* Pipeline Status - with cancel button */}
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Left side: Status indicators */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.busy')}:</div>
<div className={`h-2 w-2 rounded-full ${status?.busy ? 'bg-green-500' : 'bg-gray-300'}`} />
</div>
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.requestPending')}:</div>
<div className={`h-2 w-2 rounded-full ${status?.request_pending ? 'bg-green-500' : 'bg-gray-300'}`} />
</div>
{/* Only show cancellation status when it's requested */}
{status?.cancellation_requested && (
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.cancellationRequested')}:</div>
<div className="h-2 w-2 rounded-full bg-red-500" />
</div>
)}
</div>
{/* Right side: Cancel button - only show when pipeline is busy */}
{status?.busy && (
<Button
variant="destructive"
size="sm"
disabled={!canCancel}
onClick={() => setShowCancelConfirm(true)}
title={
status?.cancellation_requested
? t('documentPanel.pipelineStatus.cancelInProgress')
: t('documentPanel.pipelineStatus.cancelTooltip')
}
>
{t('documentPanel.pipelineStatus.cancelButton')}
</Button>
)}
</div>
{/* Job Information */}
<div className="rounded-md border p-3 space-y-2">
<div>{t('documentPanel.pipelineStatus.jobName')}: {status?.job_name || '-'}</div>
<div className="flex justify-between">
<span>{t('documentPanel.pipelineStatus.startTime')}: {status?.job_start
? new Date(status.job_start).toLocaleString(undefined, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
})
: '-'}</span>
<span>{t('documentPanel.pipelineStatus.progress')}: {status ? `${status.cur_batch}/${status.batchs} ${t('documentPanel.pipelineStatus.unit')}` : '-'}</span>
</div>
</div>
{/* History Messages */}
<div className="space-y-2">
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.pipelineMessages')}:</div>
<div
ref={historyRef}
onScroll={handleScroll}
className="font-mono text-xs rounded-md bg-zinc-800 text-zinc-100 p-3 overflow-y-auto overflow-x-hidden min-h-[7.5em] max-h-[40vh]"
>
{status?.history_messages?.length ? (
status.history_messages.map((msg, idx) => (
<div key={idx} className="whitespace-pre-wrap break-all">{msg}</div>
))
) : '-'}
</div>
</div>
</div>
</DialogContent>
{/* Cancel Confirmation Dialog */}
<Dialog open={showCancelConfirm} onOpenChange={setShowCancelConfirm}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t('documentPanel.pipelineStatus.cancelConfirmTitle')}</DialogTitle>
<DialogDescription>
{t('documentPanel.pipelineStatus.cancelConfirmDescription')}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3 mt-4">
<Button
variant="outline"
onClick={() => setShowCancelConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleConfirmCancel}
>
{t('documentPanel.pipelineStatus.cancelConfirmButton')}
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
)
}
@@ -0,0 +1,220 @@
import { useState, useCallback } from 'react'
import { FileRejection } from 'react-dropzone'
import Button from '@/components/ui/Button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/Dialog'
import FileUploader from '@/components/ui/FileUploader'
import { toast } from 'sonner'
import { errorMessage } from '@/lib/utils'
import { uploadDocument } from '@/api/lightrag'
import { UploadIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface UploadDocumentsDialogProps {
onDocumentsUploaded?: () => Promise<void>
}
export default function UploadDocumentsDialog({ onDocumentsUploaded }: UploadDocumentsDialogProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [progresses, setProgresses] = useState<Record<string, number>>({})
const [fileErrors, setFileErrors] = useState<Record<string, string>>({})
const handleRejectedFiles = useCallback(
(rejectedFiles: FileRejection[]) => {
// Process rejected files and add them to fileErrors
rejectedFiles.forEach(({ file, errors }) => {
// Get the first error message
let errorMsg = errors[0]?.message || t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name })
// Simplify error message for unsupported file types
if (errorMsg.includes('file-invalid-type')) {
errorMsg = t('documentPanel.uploadDocuments.fileUploader.unsupportedType')
}
// Set progress to 100% to display error message
setProgresses((pre) => ({
...pre,
[file.name]: 100
}))
// Add error message to fileErrors
setFileErrors(prev => ({
...prev,
[file.name]: errorMsg
}))
})
},
[setProgresses, setFileErrors, t]
)
const handleDocumentsUpload = useCallback(
async (filesToUpload: File[]) => {
setIsUploading(true)
let hasSuccessfulUpload = false
// Only clear errors for files that are being uploaded, keep errors for rejected files
setFileErrors(prev => {
const newErrors = { ...prev };
filesToUpload.forEach(file => {
delete newErrors[file.name];
});
return newErrors;
});
// Show uploading toast
const toastId = toast.loading(t('documentPanel.uploadDocuments.batch.uploading'))
try {
// Track errors locally to ensure we have the final state
const uploadErrors: Record<string, string> = {}
// Create a collator that supports Chinese sorting
const collator = new Intl.Collator(['zh-CN', 'en'], {
sensitivity: 'accent', // consider basic characters, accents, and case
numeric: true // enable numeric sorting, e.g., "File 10" will be after "File 2"
});
const sortedFiles = [...filesToUpload].sort((a, b) =>
collator.compare(a.name, b.name)
);
// Upload files in sequence, not parallel
for (const file of sortedFiles) {
try {
// Initialize upload progress
setProgresses((pre) => ({
...pre,
[file.name]: 0
}))
const result = await uploadDocument(file, (percentCompleted: number) => {
console.debug(t('documentPanel.uploadDocuments.single.uploading', { name: file.name, percent: percentCompleted }))
setProgresses((pre) => ({
...pre,
[file.name]: percentCompleted
}))
})
if (result.status === 'duplicated') {
uploadErrors[file.name] = t('documentPanel.uploadDocuments.fileUploader.duplicateFile')
setFileErrors(prev => ({
...prev,
[file.name]: t('documentPanel.uploadDocuments.fileUploader.duplicateFile')
}))
} else if (result.status !== 'success') {
uploadErrors[file.name] = result.message
setFileErrors(prev => ({
...prev,
[file.name]: result.message
}))
} else {
// Mark that we had at least one successful upload
hasSuccessfulUpload = true
}
} catch (err) {
console.error(`Upload failed for ${file.name}:`, err)
// Handle HTTP errors, including 400 errors
let errorMsg = errorMessage(err)
// If it's an axios error with response data, try to extract more detailed error info
if (err && typeof err === 'object' && 'response' in err) {
const axiosError = err as { response?: { status: number, data?: { detail?: string } } }
if (axiosError.response?.status === 400) {
// Extract specific error message from backend response
errorMsg = axiosError.response.data?.detail || errorMsg
}
// Set progress to 100% to display error message
setProgresses((pre) => ({
...pre,
[file.name]: 100
}))
}
// Record error message in both local tracking and state
uploadErrors[file.name] = errorMsg
setFileErrors(prev => ({
...prev,
[file.name]: errorMsg
}))
}
}
// Check if any files failed to upload using our local tracking
const hasErrors = Object.keys(uploadErrors).length > 0
// Update toast status
if (hasErrors) {
toast.error(t('documentPanel.uploadDocuments.batch.error'), { id: toastId })
} else {
toast.success(t('documentPanel.uploadDocuments.batch.success'), { id: toastId })
}
// Only update if at least one file was uploaded successfully
if (hasSuccessfulUpload) {
// Refresh document list
if (onDocumentsUploaded) {
onDocumentsUploaded().catch(err => {
console.error('Error refreshing documents:', err)
})
}
}
} catch (err) {
console.error('Unexpected error during upload:', err)
toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }), { id: toastId })
} finally {
setIsUploading(false)
}
},
[setIsUploading, setProgresses, setFileErrors, t, onDocumentsUploaded]
)
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (isUploading) {
return
}
if (!open) {
setProgresses({})
setFileErrors({})
}
setOpen(open)
}}
>
<DialogTrigger asChild>
<Button variant="default" side="bottom" tooltip={t('documentPanel.uploadDocuments.tooltip')} size="sm">
<UploadIcon /> {t('documentPanel.uploadDocuments.button')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>{t('documentPanel.uploadDocuments.title')}</DialogTitle>
<DialogDescription>
{t('documentPanel.uploadDocuments.description')}
</DialogDescription>
</DialogHeader>
<FileUploader
maxFileCount={Infinity}
maxSize={200 * 1024 * 1024}
description={t('documentPanel.uploadDocuments.fileTypes')}
onUpload={handleDocumentsUpload}
onReject={handleRejectedFiles}
progresses={progresses}
fileErrors={fileErrors}
disabled={isUploading}
/>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,288 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
import { useGraphStore } from '@/stores/graph'
import { useSettingsStore } from '@/stores/settings'
import { SearchHistoryManager } from '@/utils/SearchHistoryManager'
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
import PropertyEditDialog from './PropertyEditDialog'
import MergeDialog from './MergeDialog'
/**
* Interface for the EditablePropertyRow component props
*/
interface EditablePropertyRowProps {
name: string // Property name to display and edit
value: any // Initial value of the property
onClick?: () => void // Optional click handler for the property value
nodeId?: string // ID of the node (for node type)
entityId?: string // ID of the entity (for node type)
edgeId?: string // ID of the edge (for edge type)
dynamicId?: string
entityType?: 'node' | 'edge' // Type of graph entity
sourceId?: string // Source node ID (for edge type)
targetId?: string // Target node ID (for edge type)
onValueChange?: (newValue: any) => void // Optional callback when value changes
isEditable?: boolean // Whether this property can be edited
tooltip?: string // Optional tooltip to display on hover
}
/**
* EditablePropertyRow component that supports editing property values
* This component is used in the graph properties panel to display and edit entity properties
*/
const EditablePropertyRow = ({
name,
value: initialValue,
onClick,
nodeId,
edgeId,
entityId,
dynamicId,
entityType,
sourceId,
targetId,
onValueChange,
isEditable = false,
tooltip
}: EditablePropertyRowProps) => {
const { t } = useTranslation()
const [isEditing, setIsEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [currentValue, setCurrentValue] = useState(initialValue)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [mergeDialogOpen, setMergeDialogOpen] = useState(false)
const [mergeDialogInfo, setMergeDialogInfo] = useState<{
targetEntity: string
sourceEntity: string
} | null>(null)
useEffect(() => {
setCurrentValue(initialValue)
}, [initialValue])
const handleEditClick = () => {
if (isEditable && !isEditing) {
setIsEditing(true)
setErrorMessage(null)
}
}
const handleCancel = () => {
setIsEditing(false)
setErrorMessage(null)
}
const handleSave = async (value: string, options?: { allowMerge?: boolean }) => {
if (isSubmitting || value === String(currentValue)) {
setIsEditing(false)
setErrorMessage(null)
return
}
setIsSubmitting(true)
setErrorMessage(null)
try {
if (entityType === 'node' && entityId && nodeId) {
let updatedData = { [name]: value }
const allowMerge = options?.allowMerge ?? false
if (name === 'entity_id') {
if (!allowMerge) {
const exists = await checkEntityNameExists(value)
if (exists) {
const errorMsg = t('graphPanel.propertiesView.errors.duplicateName')
setErrorMessage(errorMsg)
toast.error(errorMsg)
return
}
}
updatedData = { 'entity_name': value }
}
const response = await updateEntity(entityId, updatedData, true, allowMerge)
const operationSummary = response.operation_summary
const operationStatus = operationSummary?.operation_status || 'complete_success'
const finalValue = operationSummary?.final_entity ?? value
// Handle different operation statuses
if (operationStatus === 'success') {
if (operationSummary?.merged) {
// Node was successfully merged into an existing entity
setMergeDialogInfo({
targetEntity: finalValue,
sourceEntity: entityId,
})
setMergeDialogOpen(true)
// Remove old entity name from search history
SearchHistoryManager.removeLabel(entityId)
// Note: Search Label update is deferred until user clicks refresh button in merge dialog
toast.success(t('graphPanel.propertiesView.success.entityMerged'))
} else {
// Node was updated/renamed normally
try {
const graphValue = name === 'entity_id' ? finalValue : value
await useGraphStore
.getState()
.updateNodeAndSelect(nodeId, entityId, name, graphValue)
} catch (error) {
console.error('Error updating node in graph:', error)
throw new Error('Failed to update node in graph')
}
// Update search history: remove old name, add new name
if (name === 'entity_id') {
const currentLabel = useSettingsStore.getState().queryLabel
SearchHistoryManager.removeLabel(entityId)
SearchHistoryManager.addToHistory(finalValue)
// Trigger dropdown refresh to show updated search history
useSettingsStore.getState().triggerSearchLabelDropdownRefresh()
// If current queryLabel is the old entity name, update to new name
if (currentLabel === entityId) {
useSettingsStore.getState().setQueryLabel(finalValue)
}
}
toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
}
// Update local state and notify parent component
// For entity_id updates, use finalValue (which may be different due to merging)
// For other properties, use the original value the user entered
const valueToSet = name === 'entity_id' ? finalValue : value
setCurrentValue(valueToSet)
onValueChange?.(valueToSet)
} else if (operationStatus === 'partial_success') {
// Partial success: update succeeded but merge failed
// Do NOT update graph data to keep frontend in sync with backend
const mergeError = operationSummary?.merge_error || 'Unknown error'
const errorMsg = t('graphPanel.propertiesView.errors.updateSuccessButMergeFailed', {
error: mergeError
})
setErrorMessage(errorMsg)
toast.error(errorMsg)
// Do not update currentValue or call onValueChange
return
} else {
// Complete failure or unknown status
// Check if this was a merge attempt or just a regular update
if (operationSummary?.merge_status === 'failed') {
// Merge operation was attempted but failed
const mergeError = operationSummary?.merge_error || 'Unknown error'
const errorMsg = t('graphPanel.propertiesView.errors.mergeFailed', {
error: mergeError
})
setErrorMessage(errorMsg)
toast.error(errorMsg)
} else {
// Regular update failed (no merge involved)
const errorMsg = t('graphPanel.propertiesView.errors.updateFailed')
setErrorMessage(errorMsg)
toast.error(errorMsg)
}
// Do not update currentValue or call onValueChange
return
}
} else if (entityType === 'edge' && sourceId && targetId && edgeId && dynamicId) {
const updatedData = { [name]: value }
await updateRelation(sourceId, targetId, updatedData)
try {
await useGraphStore.getState().updateEdgeAndSelect(edgeId, dynamicId, sourceId, targetId, name, value)
} catch (error) {
console.error(`Error updating edge ${sourceId}->${targetId} in graph:`, error)
throw new Error('Failed to update edge in graph')
}
toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
setCurrentValue(value)
onValueChange?.(value)
}
setIsEditing(false)
} catch (error) {
console.error('Error updating property:', error)
const errorMsg = error instanceof Error ? error.message : t('graphPanel.propertiesView.errors.updateFailed')
setErrorMessage(errorMsg)
toast.error(errorMsg)
return
} finally {
setIsSubmitting(false)
}
}
const handleMergeRefresh = (useMergedStart: boolean) => {
const info = mergeDialogInfo
const graphState = useGraphStore.getState()
const settingsState = useSettingsStore.getState()
const currentLabel = settingsState.queryLabel
// Clear graph state
graphState.clearSelection()
graphState.setGraphDataFetchAttempted(false)
graphState.setLastSuccessfulQueryLabel('')
if (useMergedStart && info?.targetEntity) {
// Use merged entity as new start point (might already be set in handleSave)
settingsState.setQueryLabel(info.targetEntity)
} else {
// Keep current start point - refresh by resetting and restoring label
// This handles the case where user wants to stay with current label
settingsState.setQueryLabel('')
setTimeout(() => {
settingsState.setQueryLabel(currentLabel)
}, 50)
}
// Force graph re-render and reset zoom/scale (same as refresh button behavior)
graphState.incrementGraphDataVersion()
setMergeDialogOpen(false)
setMergeDialogInfo(null)
toast.info(t('graphPanel.propertiesView.mergeDialog.refreshing'))
}
return (
<div className="flex items-center gap-1 overflow-hidden">
<PropertyName name={name} />
<EditIcon onClick={handleEditClick} />:
<PropertyValue
value={currentValue}
onClick={onClick}
tooltip={tooltip || (typeof currentValue === 'string' ? currentValue : JSON.stringify(currentValue, null, 2))}
/>
<PropertyEditDialog
isOpen={isEditing}
onClose={handleCancel}
onSave={handleSave}
propertyName={name}
initialValue={String(currentValue)}
isSubmitting={isSubmitting}
errorMessage={errorMessage}
/>
<MergeDialog
mergeDialogOpen={mergeDialogOpen}
mergeDialogInfo={mergeDialogInfo}
onOpenChange={(open) => {
setMergeDialogOpen(open)
if (!open) {
setMergeDialogInfo(null)
}
}}
onRefresh={handleMergeRefresh}
/>
</div>
)
}
export default EditablePropertyRow
@@ -0,0 +1,54 @@
import { useCamera, useSigma } from '@react-sigma/core'
import { useEffect } from 'react'
import { useGraphStore } from '@/stores/graph'
/**
* Component that highlights a node and centers the camera on it.
*/
const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) => {
const sigma = useSigma()
const { gotoNode } = useCamera()
/**
* When the selected item changes, highlighted the node and center the camera on it.
*/
useEffect(() => {
const graph = sigma.getGraph();
if (move) {
if (node && graph.hasNode(node)) {
try {
graph.setNodeAttribute(node, 'highlighted', true);
gotoNode(node);
} catch (error) {
console.error('Error focusing on node:', error);
}
} else {
// If no node is selected but move is true, reset to default view
sigma.setCustomBBox(null);
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 });
}
useGraphStore.getState().setMoveToSelectedNode(false);
} else if (node && graph.hasNode(node)) {
try {
graph.setNodeAttribute(node, 'highlighted', true);
} catch (error) {
console.error('Error highlighting node:', error);
}
}
return () => {
if (node && graph.hasNode(node)) {
try {
graph.setNodeAttribute(node, 'highlighted', false);
} catch (error) {
console.error('Error cleaning up node highlight:', error);
}
}
}
}, [node, move, sigma, gotoNode])
return null
}
export default FocusOnNode
@@ -0,0 +1,29 @@
import { useFullScreen } from '@react-sigma/core'
import { MaximizeIcon, MinimizeIcon } from 'lucide-react'
import { controlButtonVariant } from '@/lib/constants'
import Button from '@/components/ui/Button'
import { useTranslation } from 'react-i18next'
/**
* Component that toggles full screen mode.
*/
const FullScreenControl = () => {
const { isFullScreen, toggle } = useFullScreen()
const { t } = useTranslation()
return (
<>
{isFullScreen ? (
<Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.windowed')} size="icon">
<MinimizeIcon />
</Button>
) : (
<Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.fullScreen')} size="icon">
<MaximizeIcon />
</Button>
)}
</>
)
}
export default FullScreenControl
@@ -0,0 +1,362 @@
import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
import { AbstractGraph } from 'graphology-types'
// import { useLayoutCircular } from '@react-sigma/layout-circular'
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
import { useEffect, useState } from 'react'
// import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph'
import { EdgeType, NodeType } from '@/hooks/useLightragGraph'
import useTheme from '@/hooks/useTheme'
import * as Constants from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
const isButtonPressed = (ev: MouseEvent | TouchEvent) => {
if (ev.type.startsWith('mouse')) {
if ((ev as MouseEvent).buttons !== 0) {
return true
}
}
return false
}
const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => {
const sigma = useSigma<NodeType, EdgeType>()
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
const setSettings = useSetSettings<NodeType, EdgeType>()
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
const { assign: assignLayout } = useLayoutForceAtlas2({
iterations: maxIterations
})
const { theme } = useTheme()
const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()
const renderLabels = useSettingsStore.use.showNodeLabel()
const minEdgeSize = useSettingsStore.use.minEdgeSize()
const maxEdgeSize = useSettingsStore.use.maxEdgeSize()
const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode()
const selectedEdge = useGraphStore.use.selectedEdge()
const focusedEdge = useGraphStore.use.focusedEdge()
const sigmaGraph = useGraphStore.use.sigmaGraph()
// Track system theme changes when theme is set to 'system'
const [systemThemeIsDark, setSystemThemeIsDark] = useState(() =>
window.matchMedia('(prefers-color-scheme: dark)').matches
)
useEffect(() => {
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handler = (e: MediaQueryListEvent) => setSystemThemeIsDark(e.matches)
mediaQuery.addEventListener('change', handler)
return () => mediaQuery.removeEventListener('change', handler)
}
}, [theme])
/**
* When component mount or maxIterations changes
* => ensure graph reference and apply layout
*/
useEffect(() => {
if (sigmaGraph && sigma) {
// Ensure sigma binding to sigmaGraph
try {
if (typeof sigma.setGraph === 'function') {
sigma.setGraph(sigmaGraph as unknown as AbstractGraph<NodeType, EdgeType>);
console.log('Binding graph to sigma instance');
} else {
(sigma as any).graph = sigmaGraph;
console.warn('Simgma missing setGraph function, set graph property directly');
}
} catch (error) {
console.error('Error setting graph on sigma instance:', error);
}
assignLayout();
console.log('Initial layout applied to graph');
}
}, [sigma, sigmaGraph, assignLayout, maxIterations])
/**
* Ensure the sigma instance is set in the store
* This provides a backup in case the instance wasn't set in GraphViewer
*/
useEffect(() => {
if (sigma) {
// Double-check that the store has the sigma instance
const currentInstance = useGraphStore.getState().sigmaInstance;
if (!currentInstance) {
console.log('Setting sigma instance from GraphControl');
useGraphStore.getState().setSigmaInstance(sigma);
}
}
}, [sigma]);
/**
* When component mount
* => register events
*/
useEffect(() => {
const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } =
useGraphStore.getState()
// Define event types
type NodeEvent = { node: string; event: { original: MouseEvent | TouchEvent } }
type EdgeEvent = { edge: string; event: { original: MouseEvent | TouchEvent } }
// Register all events, but edge events will only be processed if enableEdgeEvents is true
const events: Record<string, any> = {
enterNode: (event: NodeEvent) => {
if (!isButtonPressed(event.event.original)) {
const graph = sigma.getGraph()
if (graph.hasNode(event.node)) {
setFocusedNode(event.node)
}
}
},
leaveNode: (event: NodeEvent) => {
if (!isButtonPressed(event.event.original)) {
setFocusedNode(null)
}
},
clickNode: (event: NodeEvent) => {
const graph = sigma.getGraph()
if (graph.hasNode(event.node)) {
setSelectedNode(event.node)
setSelectedEdge(null)
}
},
clickStage: () => clearSelection()
}
// Only add edge event handlers if enableEdgeEvents is true
if (enableEdgeEvents) {
events.clickEdge = (event: EdgeEvent) => {
setSelectedEdge(event.edge)
setSelectedNode(null)
}
events.enterEdge = (event: EdgeEvent) => {
if (!isButtonPressed(event.event.original)) {
setFocusedEdge(event.edge)
}
}
events.leaveEdge = (event: EdgeEvent) => {
if (!isButtonPressed(event.event.original)) {
setFocusedEdge(null)
}
}
}
// Register the events
registerEvents(events)
// Cleanup function - basic cleanup without relying on specific APIs
return () => {
try {
console.log('Cleaning up graph event listeners')
} catch (error) {
console.warn('Error cleaning up graph event listeners:', error)
}
}
}, [registerEvents, enableEdgeEvents, sigma])
/**
* When edge size settings change, recalculate edge sizes and refresh the sigma instance
* to ensure changes take effect immediately
*/
useEffect(() => {
if (sigma && sigmaGraph) {
// Get the graph from sigma
const graph = sigma.getGraph()
// Find min and max weight values
let minWeight = Number.MAX_SAFE_INTEGER
let maxWeight = 0
graph.forEachEdge(edge => {
// Get original weight (before scaling)
const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1
if (typeof weight === 'number') {
minWeight = Math.min(minWeight, weight)
maxWeight = Math.max(maxWeight, weight)
}
})
// Scale edge sizes based on weight range and current min/max edge size settings
const weightRange = maxWeight - minWeight
if (weightRange > 0) {
const sizeScale = maxEdgeSize - minEdgeSize
graph.forEachEdge(edge => {
const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1
if (typeof weight === 'number') {
const scaledSize = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5)
graph.setEdgeAttribute(edge, 'size', scaledSize)
}
})
} else {
// If all weights are the same, use default size
graph.forEachEdge(edge => {
graph.setEdgeAttribute(edge, 'size', minEdgeSize)
})
}
// Refresh the sigma instance to apply changes
sigma.refresh()
}
}, [sigma, sigmaGraph, minEdgeSize, maxEdgeSize])
/**
* When component mount or hovered node change
* => Setting the sigma reducers
*/
useEffect(() => {
// Check if dark mode is actually applied (handles both 'dark' theme and 'system' theme when OS is dark)
const isDarkTheme = theme === 'dark' ||
(theme === 'system' && window.document.documentElement.classList.contains('dark'))
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
// Update all dynamic settings directly without recreating the sigma container
setSettings({
// Update display settings
enableEdgeEvents,
renderEdgeLabels,
renderLabels,
// Node reducer for node appearance
nodeReducer: (node, data) => {
const graph = sigma.getGraph()
// Add defensive check for node existence during theme switching
if (!graph.hasNode(node)) {
console.warn(`Node ${node} not found in graph during theme switch, returning default data`)
return { ...data, highlighted: false, labelColor }
}
const newData: NodeType & {
labelColor?: string
borderColor?: string
} = { ...data, highlighted: data.highlighted || false, labelColor }
if (!disableHoverEffect) {
newData.highlighted = false
const _focusedNode = focusedNode || selectedNode
const _focusedEdge = focusedEdge || selectedEdge
if (_focusedNode && graph.hasNode(_focusedNode)) {
try {
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
newData.highlighted = true
if (node === selectedNode) {
newData.borderColor = Constants.nodeBorderColorSelected
}
}
} catch (error) {
console.error('Error in nodeReducer:', error);
return { ...data, highlighted: false, labelColor }
}
} else if (_focusedEdge && graph.hasEdge(_focusedEdge)) {
try {
if (graph.extremities(_focusedEdge).includes(node)) {
newData.highlighted = true
newData.size = 3
}
} catch (error) {
console.error('Error accessing edge extremities in nodeReducer:', error);
return { ...data, highlighted: false, labelColor }
}
} else {
return newData
}
if (newData.highlighted) {
if (isDarkTheme) {
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
}
} else {
newData.color = Constants.nodeColorDisabled
}
}
return newData
},
// Edge reducer for edge appearance
edgeReducer: (edge, data) => {
const graph = sigma.getGraph()
// Add defensive check for edge existence during theme switching
if (!graph.hasEdge(edge)) {
console.warn(`Edge ${edge} not found in graph during theme switch, returning default data`)
return { ...data, hidden: false, labelColor, color: edgeColor }
}
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
if (!disableHoverEffect) {
const _focusedNode = focusedNode || selectedNode
// Choose edge highlight color based on theme
const edgeHighlightColor = isDarkTheme
? Constants.edgeColorHighlightedDarkTheme
: Constants.edgeColorHighlightedLightTheme
if (_focusedNode && graph.hasNode(_focusedNode)) {
try {
if (hideUnselectedEdges) {
if (!graph.extremities(edge).includes(_focusedNode)) {
newData.hidden = true
}
} else {
if (graph.extremities(edge).includes(_focusedNode)) {
newData.color = edgeHighlightColor
}
}
} catch (error) {
console.error('Error in edgeReducer:', error);
return { ...data, hidden: false, labelColor, color: edgeColor }
}
} else {
const _selectedEdge = selectedEdge && graph.hasEdge(selectedEdge) ? selectedEdge : null;
const _focusedEdge = focusedEdge && graph.hasEdge(focusedEdge) ? focusedEdge : null;
if (_selectedEdge || _focusedEdge) {
if (edge === _selectedEdge) {
newData.color = Constants.edgeColorSelected
} else if (edge === _focusedEdge) {
newData.color = edgeHighlightColor
} else if (hideUnselectedEdges) {
newData.hidden = true
}
}
}
}
return newData
}
})
}, [
selectedNode,
focusedNode,
selectedEdge,
focusedEdge,
setSettings,
sigma,
disableHoverEffect,
theme,
systemThemeIsDark,
hideUnselectedEdges,
enableEdgeEvents,
renderEdgeLabels,
renderLabels
])
return null
}
export default GraphControl
@@ -0,0 +1,316 @@
import { useCallback, useEffect, useState, useRef } from 'react'
import { AsyncSelect } from '@/components/ui/AsyncSelect'
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
import { useBackendState } from '@/stores/state'
import {
dropdownDisplayLimit,
controlButtonVariant,
popularLabelsDefaultLimit,
searchLabelsDefaultLimit
} from '@/lib/constants'
import { useTranslation } from 'react-i18next'
import { RefreshCw } from 'lucide-react'
import Button from '@/components/ui/Button'
import { SearchHistoryManager } from '@/utils/SearchHistoryManager'
import { getPopularLabels, searchLabels } from '@/api/lightrag'
const GraphLabels = () => {
const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel()
const dropdownRefreshTrigger = useSettingsStore.use.searchLabelDropdownRefreshTrigger()
const [isRefreshing, setIsRefreshing] = useState(false)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [selectKey, setSelectKey] = useState(0)
// Pipeline state monitoring
const pipelineBusy = useBackendState.use.pipelineBusy()
const prevPipelineBusy = useRef<boolean | undefined>(undefined)
const shouldRefreshPopularLabelsRef = useRef(false)
// Dynamic tooltip based on current label state
const getRefreshTooltip = useCallback(() => {
if (isRefreshing) {
return t('graphPanel.graphLabels.refreshingTooltip')
}
if (!label || label === '*') {
return t('graphPanel.graphLabels.refreshGlobalTooltip')
} else {
return t('graphPanel.graphLabels.refreshCurrentLabelTooltip', { label })
}
}, [label, t, isRefreshing])
// Initialize search history on component mount
useEffect(() => {
const initializeHistory = async () => {
const history = SearchHistoryManager.getHistory()
if (history.length === 0) {
// If no history exists, fetch popular labels and initialize
try {
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
await SearchHistoryManager.initializeWithDefaults(popularLabels)
} catch (error) {
console.error('Failed to initialize search history:', error)
// No fallback needed, API is the source of truth
}
}
}
initializeHistory()
}, [])
// Force AsyncSelect to re-render when label changes externally (e.g., from entity rename/merge)
useEffect(() => {
setSelectKey(prev => prev + 1)
}, [label])
// Force AsyncSelect to re-render when dropdown refresh is triggered (e.g., after entity rename)
useEffect(() => {
if (dropdownRefreshTrigger > 0) {
setSelectKey(prev => prev + 1)
}
}, [dropdownRefreshTrigger])
// Monitor pipeline state changes: busy -> idle
useEffect(() => {
if (prevPipelineBusy.current === true && pipelineBusy === false) {
console.log('Pipeline changed from busy to idle, marking for popular labels refresh')
shouldRefreshPopularLabelsRef.current = true
}
prevPipelineBusy.current = pipelineBusy
}, [pipelineBusy])
// Helper: Reload popular labels from backend
const reloadPopularLabels = useCallback(async () => {
if (!shouldRefreshPopularLabelsRef.current) return
console.log('Reloading popular labels (triggered by pipeline idle)')
try {
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
SearchHistoryManager.clearHistory()
if (popularLabels.length === 0) {
const fallbackLabels = ['entity', 'relationship', 'document', 'concept']
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
} else {
await SearchHistoryManager.initializeWithDefaults(popularLabels)
}
} catch (error) {
console.error('Failed to reload popular labels:', error)
const fallbackLabels = ['entity', 'relationship', 'document']
SearchHistoryManager.clearHistory()
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
} finally {
// Always clear the flag
shouldRefreshPopularLabelsRef.current = false
}
}, [])
// Helper: Bump dropdown data to trigger refresh
const bumpDropdownData = useCallback(({ forceSelectKey = false } = {}) => {
setRefreshTrigger(prev => prev + 1)
if (forceSelectKey) {
setSelectKey(prev => prev + 1)
}
}, [])
const fetchData = useCallback(
async (query?: string): Promise<string[]> => {
let results: string[] = [];
if (!query || query.trim() === '' || query.trim() === '*') {
// Empty query: return search history
results = SearchHistoryManager.getHistoryLabels(dropdownDisplayLimit)
} else {
// Non-empty query: call backend search API
try {
const apiResults = await searchLabels(query.trim(), searchLabelsDefaultLimit)
results = apiResults.length <= dropdownDisplayLimit
? apiResults
: [...apiResults.slice(0, dropdownDisplayLimit), '...']
} catch (error) {
console.error('Search API failed, falling back to local history search:', error)
// Fallback to local history search
const history = SearchHistoryManager.getHistory()
const queryLower = query.toLowerCase().trim()
results = history
.filter(item => item.label.toLowerCase().includes(queryLower))
.map(item => item.label)
.slice(0, dropdownDisplayLimit)
}
}
// Always show '*' at the top, and remove duplicates
const finalResults = ['*', ...results.filter(label => label !== '*')];
return finalResults;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[refreshTrigger] // Intentionally added to trigger re-creation when data changes
)
const handleRefresh = useCallback(async () => {
setIsRefreshing(true)
// Clear legend cache to ensure legend is re-generated on refresh
useGraphStore.getState().setTypeColorMap(new Map<string, string>())
try {
let currentLabel = label
// If queryLabel is empty, set it to '*'
if (!currentLabel || currentLabel.trim() === '') {
useSettingsStore.getState().setQueryLabel('*')
currentLabel = '*'
}
// Scenario 1: Manual refresh - reload popular labels if flag is set (regardless of current label)
if (shouldRefreshPopularLabelsRef.current) {
await reloadPopularLabels()
bumpDropdownData({ forceSelectKey: true })
}
if (currentLabel && currentLabel !== '*') {
// Scenario 1: Has specific label, try to refresh current label
console.log(`Refreshing current label: ${currentLabel}`)
// Reset graph data fetch status to trigger refresh
useGraphStore.getState().setGraphDataFetchAttempted(false)
useGraphStore.getState().setLastSuccessfulQueryLabel('')
// Force data refresh for current label
useGraphStore.getState().incrementGraphDataVersion()
// Note: If the current label has no data after refresh,
// the fallback logic would be handled by the graph component itself
// For now, we keep the current label and let the user see the result
} else {
// Scenario 3: queryLabel is "*", refresh global data and popular labels
console.log('Refreshing global data and popular labels')
try {
// Re-fetch popular labels and update search history (if not already done)
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
SearchHistoryManager.clearHistory()
if (popularLabels.length === 0) {
// If no popular labels, provide fallback defaults
const fallbackLabels = ['entity', 'relationship', 'document', 'concept']
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
} else {
await SearchHistoryManager.initializeWithDefaults(popularLabels)
}
} catch (error) {
console.error('Failed to reload popular labels:', error)
// Provide fallback even if API fails
const fallbackLabels = ['entity', 'relationship', 'document']
SearchHistoryManager.clearHistory()
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
}
// Reset graph data fetch status
useGraphStore.getState().setGraphDataFetchAttempted(false)
useGraphStore.getState().setLastSuccessfulQueryLabel('')
// Force global data refresh
useGraphStore.getState().incrementGraphDataVersion()
// Ensure data update completes before triggering UI refresh
await new Promise(resolve => setTimeout(resolve, 0))
// Trigger both refresh mechanisms to ensure dropdown updates
setRefreshTrigger(prev => prev + 1)
setSelectKey(prev => prev + 1)
}
} catch (error) {
console.error('Error during refresh:', error)
} finally {
setIsRefreshing(false)
}
}, [label, reloadPopularLabels, bumpDropdownData])
// Handle dropdown before open - reload popular labels if needed
const handleDropdownBeforeOpen = useCallback(async () => {
const currentLabel = useSettingsStore.getState().queryLabel
if (shouldRefreshPopularLabelsRef.current && (!currentLabel || currentLabel === '*')) {
await reloadPopularLabels()
bumpDropdownData()
}
}, [reloadPopularLabels, bumpDropdownData])
return (
<div className="flex items-center">
{/* Always show refresh button */}
<Button
size="icon"
variant={controlButtonVariant}
onClick={handleRefresh}
tooltip={getRefreshTooltip()}
className="mr-2"
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
<div className="w-full min-w-[280px] max-w-[500px]">
<AsyncSelect<string>
key={selectKey} // Force re-render when data changes
className="min-w-[300px]"
triggerClassName="max-h-8 w-full overflow-hidden"
searchInputClassName="max-h-8"
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
fetcher={fetchData}
onBeforeOpen={handleDropdownBeforeOpen}
renderOption={(item) => (
<div className="truncate" title={item}>
{item}
</div>
)}
getOptionValue={(item) => item}
getDisplayValue={(item) => (
<div className="min-w-0 flex-1 truncate text-left" title={item}>
{item}
</div>
)}
notFound={<div className="py-6 text-center text-sm">{t('graphPanel.graphLabels.noLabels')}</div>}
ariaLabel={t('graphPanel.graphLabels.label')}
placeholder={t('graphPanel.graphLabels.placeholder')}
searchPlaceholder={t('graphPanel.graphLabels.placeholder')}
noResultsMessage={t('graphPanel.graphLabels.noLabels')}
value={label !== null ? label : '*'}
onChange={(newLabel) => {
const currentLabel = useSettingsStore.getState().queryLabel;
// select the last item means query all
if (newLabel === '...') {
newLabel = '*';
}
// Handle reselecting the same label
if (newLabel === currentLabel && newLabel !== '*') {
newLabel = '*';
}
// Add selected label to search history (except for special cases)
if (newLabel && newLabel !== '*' && newLabel !== '...' && newLabel.trim() !== '') {
SearchHistoryManager.addToHistory(newLabel);
}
// Reset graphDataFetchAttempted flag to ensure data fetch is triggered
useGraphStore.getState().setGraphDataFetchAttempted(false);
// Update the label to trigger data loading
useSettingsStore.getState().setQueryLabel(newLabel);
// Force graph re-render and reset zoom/scale (must be AFTER setQueryLabel)
useGraphStore.getState().incrementGraphDataVersion();
}}
clearable={false} // Prevent clearing value on reselect
debounceTime={500}
/>
</div>
</div>
)
}
export default GraphLabels
@@ -0,0 +1,232 @@
import { FC, useCallback, useEffect } from 'react'
import {
EdgeById,
GraphSearchInputProps,
GraphSearchContextProviderProps
} from '@react-sigma/graph-search'
import { AsyncSearch } from '@/components/ui/AsyncSearch'
import { searchResultLimit } from '@/lib/constants'
import { useGraphStore } from '@/stores/graph'
import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next'
// Message item identifier for search results
export const messageId = '__message_item'
// Search result option item interface
export interface OptionItem {
id: string
type: 'nodes' | 'edges' | 'message'
message?: string
}
const NodeOption = ({ id }: { id: string }) => {
const graph = useGraphStore.use.sigmaGraph()
// Early return if no graph or node doesn't exist
if (!graph?.hasNode(id)) {
return null
}
// Safely get node attributes with fallbacks
const label = graph.getNodeAttribute(id, 'label') || id
const color = graph.getNodeAttribute(id, 'color') || '#666'
const size = graph.getNodeAttribute(id, 'size') || 4
// Custom node display component that doesn't rely on @react-sigma/graph-search
return (
<div className="flex items-center gap-2 p-2 text-sm">
<div
className="rounded-full flex-shrink-0"
style={{
width: Math.max(8, Math.min(size * 2, 16)),
height: Math.max(8, Math.min(size * 2, 16)),
backgroundColor: color
}}
/>
<span className="truncate">{label}</span>
</div>
)
}
function OptionComponent(item: OptionItem) {
return (
<div>
{item.type === 'nodes' && <NodeOption id={item.id} />}
{item.type === 'edges' && <EdgeById id={item.id} />}
{item.type === 'message' && <div>{item.message}</div>}
</div>
)
}
/**
* Component thats display the search input.
*/
export const GraphSearchInput = ({
onChange,
onFocus,
value
}: {
onChange: GraphSearchInputProps['onChange']
onFocus?: GraphSearchInputProps['onFocus']
value?: GraphSearchInputProps['value']
}) => {
const { t } = useTranslation()
const graph = useGraphStore.use.sigmaGraph()
const searchEngine = useGraphStore.use.searchEngine()
// Reset search engine when graph changes
useEffect(() => {
if (graph) {
useGraphStore.getState().resetSearchEngine()
}
}, [graph]);
// Create search engine when needed
useEffect(() => {
// Skip if no graph, empty graph, or search engine already exists
if (!graph || graph.nodes().length === 0 || searchEngine) {
return
}
// Create new search engine
const newSearchEngine = new MiniSearch({
idField: 'id',
fields: ['label'],
searchOptions: {
prefix: true,
fuzzy: 0.2,
boost: {
label: 2
}
}
})
// Add nodes to search engine with safety checks
const documents = graph.nodes()
.filter(id => graph.hasNode(id)) // Ensure node exists before accessing attributes
.map((id: string) => ({
id: id,
label: graph.getNodeAttribute(id, 'label')
}))
if (documents.length > 0) {
newSearchEngine.addAll(documents)
}
// Update search engine in store
useGraphStore.getState().setSearchEngine(newSearchEngine)
}, [graph, searchEngine])
/**
* Loading the options while the user is typing.
*/
const loadOptions = useCallback(
async (query?: string): Promise<OptionItem[]> => {
if (onFocus) onFocus(null)
// Safety checks to prevent crashes
if (!graph || !searchEngine) {
return []
}
// Verify graph has nodes before proceeding
if (graph.nodes().length === 0) {
return []
}
// If no query, return some nodes for user to select
if (!query) {
const nodeIds = graph.nodes()
.filter(id => graph.hasNode(id))
.slice(0, searchResultLimit)
return nodeIds.map(id => ({
id,
type: 'nodes'
}))
}
// If has query, search nodes and verify they still exist
let result: OptionItem[] = searchEngine.search(query)
.filter((r: { id: string }) => graph.hasNode(r.id))
.map((r: { id: string }) => ({
id: r.id,
type: 'nodes'
}))
// Add middle-content matching if results are few
// This enables matching content in the middle of text, not just from the beginning
if (result.length < 5) {
// Get already matched IDs to avoid duplicates
const matchedIds = new Set(result.map(item => item.id))
// Perform middle-content matching on all nodes with safety checks
const middleMatchResults = graph.nodes()
.filter(id => {
// Skip already matched nodes
if (matchedIds.has(id)) return false
// Ensure node exists before accessing attributes
if (!graph.hasNode(id)) return false
// Get node label safely
const label = graph.getNodeAttribute(id, 'label')
// Match if label contains query string but doesn't start with it
return label &&
typeof label === 'string' &&
!label.toLowerCase().startsWith(query.toLowerCase()) &&
label.toLowerCase().includes(query.toLowerCase())
})
.map(id => ({
id,
type: 'nodes' as const
}))
// Merge results
result = [...result, ...middleMatchResults]
}
// prettier-ignore
return result.length <= searchResultLimit
? result
: [
...result.slice(0, searchResultLimit),
{
type: 'message',
id: messageId,
message: t('graphPanel.search.message', { count: result.length - searchResultLimit })
}
]
},
[graph, searchEngine, onFocus, t]
)
return (
<AsyncSearch
className="bg-background/60 w-24 rounded-xl border-1 opacity-60 backdrop-blur-lg transition-all hover:w-fit hover:opacity-100 w-full"
fetcher={loadOptions}
renderOption={OptionComponent}
getOptionValue={(item) => item.id}
value={value && value.type !== 'message' ? value.id : null}
onChange={(id) => {
if (id !== messageId) onChange(id ? { id, type: 'nodes' } : null)
}}
onFocus={(id) => {
if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null)
}}
ariaLabel={t('graphPanel.search.placeholder')}
placeholder={t('graphPanel.search.placeholder')}
noResultsMessage={t('graphPanel.search.placeholder')}
/>
)
}
/**
* Component that display the search.
*/
const GraphSearch: FC<GraphSearchInputProps & GraphSearchContextProviderProps> = ({ ...props }) => {
return <GraphSearchInput {...props} />
}
export default GraphSearch
@@ -0,0 +1,345 @@
import { useSigma } from '@react-sigma/core'
import { animateNodes } from 'sigma/utils'
import { useLayoutCirclepack } from '@react-sigma/layout-circlepack'
import { useLayoutCircular } from '@react-sigma/layout-circular'
import { LayoutHook, LayoutWorkerHook, WorkerLayoutControlProps } from '@react-sigma/layout-core'
import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'
import { useLayoutRandom } from '@react-sigma/layout-random'
import { useCallback, useMemo, useState, useEffect, useRef } from 'react'
import Button from '@/components/ui/Button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/Command'
import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings'
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
type LayoutName =
| 'Circular'
| 'Circlepack'
| 'Random'
| 'Noverlaps'
| 'Force Directed'
| 'Force Atlas'
// Extend WorkerLayoutControlProps to include mainLayout
interface ExtendedWorkerLayoutControlProps extends WorkerLayoutControlProps {
mainLayout: LayoutHook;
}
const WorkerLayoutControl = ({ layout, autoRunFor, mainLayout }: ExtendedWorkerLayoutControlProps) => {
const sigma = useSigma()
// Use local state to track animation running status
const [isRunning, setIsRunning] = useState(false)
// Timer reference for animation
const animationTimerRef = useRef<number | null>(null)
const { t } = useTranslation()
// Function to update node positions using the layout algorithm
const updatePositions = useCallback(() => {
if (!sigma) return
try {
const graph = sigma.getGraph()
if (!graph || graph.order === 0) return
// Use mainLayout to get positions, similar to refreshLayout function
// console.log('Getting positions from mainLayout')
const positions = mainLayout.positions()
// Animate nodes to new positions
// console.log('Updating node positions with layout algorithm')
animateNodes(graph, positions, { duration: 300 }) // Reduced duration for more frequent updates
} catch (error) {
console.error('Error updating positions:', error)
// Stop animation if there's an error
if (animationTimerRef.current) {
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
setIsRunning(false)
}
}
}, [sigma, mainLayout])
// Improved click handler that uses our own animation timer
const handleClick = useCallback(() => {
if (isRunning) {
// Stop the animation
console.log('Stopping layout animation')
if (animationTimerRef.current) {
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
}
// Try to kill the layout algorithm if it's running
try {
if (typeof layout.kill === 'function') {
layout.kill()
console.log('Layout algorithm killed')
} else if (typeof layout.stop === 'function') {
layout.stop()
console.log('Layout algorithm stopped')
}
} catch (error) {
console.error('Error stopping layout algorithm:', error)
}
setIsRunning(false)
} else {
// Start the animation
console.log('Starting layout animation')
// Initial position update
updatePositions()
// Set up interval for continuous updates
animationTimerRef.current = window.setInterval(() => {
updatePositions()
}, 200) // Reduced interval to create overlapping animations for smoother transitions
setIsRunning(true)
// Set a timeout to automatically stop the animation after 3 seconds
setTimeout(() => {
if (animationTimerRef.current) {
console.log('Auto-stopping layout animation after 3 seconds')
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
setIsRunning(false)
// Try to stop the layout algorithm
try {
if (typeof layout.kill === 'function') {
layout.kill()
} else if (typeof layout.stop === 'function') {
layout.stop()
}
} catch (error) {
console.error('Error stopping layout algorithm:', error)
}
}
}, 3000)
}
}, [isRunning, layout, updatePositions])
/**
* Init component when Sigma or component settings change.
*/
useEffect(() => {
if (!sigma) {
console.log('No sigma instance available')
return
}
// Auto-run if specified
let timeout: number | null = null
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
console.log('Auto-starting layout animation')
// Initial position update
updatePositions()
// Set up interval for continuous updates
animationTimerRef.current = window.setInterval(() => {
updatePositions()
}, 200) // Reduced interval to create overlapping animations for smoother transitions
setIsRunning(true)
// Set a timeout to stop it if autoRunFor > 0
if (autoRunFor > 0) {
timeout = window.setTimeout(() => {
console.log('Auto-stopping layout animation after timeout')
if (animationTimerRef.current) {
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
}
setIsRunning(false)
}, autoRunFor)
}
}
// Cleanup function
return () => {
// console.log('Cleaning up WorkerLayoutControl')
if (animationTimerRef.current) {
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
}
if (timeout) {
window.clearTimeout(timeout)
}
setIsRunning(false)
}
}, [autoRunFor, sigma, updatePositions])
return (
<Button
size="icon"
onClick={handleClick}
tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')}
variant={controlButtonVariant}
>
{isRunning ? <PauseIcon /> : <PlayIcon />}
</Button>
)
}
/**
* Component that controls the layout of the graph.
*/
const LayoutsControl = () => {
const sigma = useSigma()
const { t } = useTranslation()
const [layout, setLayout] = useState<LayoutName>('Circular')
const [opened, setOpened] = useState<boolean>(false)
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
const layoutCircular = useLayoutCircular()
const layoutCirclepack = useLayoutCirclepack()
const layoutRandom = useLayoutRandom()
const layoutNoverlap = useLayoutNoverlap({
maxIterations: maxIterations,
settings: {
margin: 5,
expansion: 1.1,
gridSize: 1,
ratio: 1,
speed: 3,
}
})
// Add parameters for Force Directed layout to improve convergence
const layoutForce = useLayoutForce({
maxIterations: maxIterations,
settings: {
attraction: 0.0003, // Lower attraction force to reduce oscillation
repulsion: 0.02, // Lower repulsion force to reduce oscillation
gravity: 0.02, // Increase gravity to make nodes converge to center faster
inertia: 0.4, // Lower inertia to add damping effect
maxMove: 100 // Limit maximum movement per step to prevent large jumps
}
})
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: maxIterations })
const workerNoverlap = useWorkerLayoutNoverlap()
const workerForce = useWorkerLayoutForce()
const workerForceAtlas2 = useWorkerLayoutForceAtlas2()
const layouts = useMemo(() => {
return {
Circular: {
layout: layoutCircular
},
Circlepack: {
layout: layoutCirclepack
},
Random: {
layout: layoutRandom
},
Noverlaps: {
layout: layoutNoverlap,
worker: workerNoverlap
},
'Force Directed': {
layout: layoutForce,
worker: workerForce
},
'Force Atlas': {
layout: layoutForceAtlas2,
worker: workerForceAtlas2
}
} as { [key: string]: { layout: LayoutHook; worker?: LayoutWorkerHook } }
}, [
layoutCirclepack,
layoutCircular,
layoutForce,
layoutForceAtlas2,
layoutNoverlap,
layoutRandom,
workerForce,
workerNoverlap,
workerForceAtlas2
])
const runLayout = useCallback(
(newLayout: LayoutName) => {
console.debug('Running layout:', newLayout)
const { positions } = layouts[newLayout].layout
try {
const graph = sigma.getGraph()
if (!graph) {
console.error('No graph available')
return
}
const pos = positions()
console.log('Positions calculated, animating nodes')
animateNodes(graph, pos, { duration: 400 })
setLayout(newLayout)
} catch (error) {
console.error('Error running layout:', error)
}
},
[layouts, sigma]
)
return (
<div>
<div>
{layouts[layout] && 'worker' in layouts[layout] && (
<WorkerLayoutControl
layout={layouts[layout].worker!}
mainLayout={layouts[layout].layout}
/>
)}
</div>
<div>
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button
size="icon"
variant={controlButtonVariant}
onClick={() => setOpened((e: boolean) => !e)}
tooltip={t('graphPanel.sideBar.layoutsControl.layoutGraph')}
>
<GripIcon />
</Button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={8}
collisionPadding={5}
sticky="always"
className="p-1 min-w-auto"
>
<Command>
<CommandList>
<CommandGroup>
{Object.keys(layouts).map((name) => (
<CommandItem
onSelect={() => {
runLayout(name as LayoutName)
}}
key={name}
className="cursor-pointer text-xs"
>
{t(`graphPanel.sideBar.layoutsControl.layouts.${name}`)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)
}
export default LayoutsControl
@@ -0,0 +1,41 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useGraphStore } from '@/stores/graph'
import { Card } from '@/components/ui/Card'
import { ScrollArea } from '@/components/ui/ScrollArea'
interface LegendProps {
className?: string
}
const Legend: React.FC<LegendProps> = ({ className }) => {
const { t } = useTranslation()
const typeColorMap = useGraphStore.use.typeColorMap()
if (!typeColorMap || typeColorMap.size === 0) {
return null
}
return (
<Card className={`p-2 max-w-xs ${className}`}>
<h3 className="text-sm font-medium mb-2">{t('graphPanel.legend')}</h3>
<ScrollArea className="max-h-80">
<div className="flex flex-col gap-1">
{Array.from(typeColorMap.entries()).map(([type, color]) => (
<div key={type} className="flex items-center gap-2">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-xs truncate" title={type}>
{t(`graphPanel.nodeTypes.${type.toLowerCase().replace(/\s+/g, '')}`, type)}
</span>
</div>
))}
</div>
</ScrollArea>
</Card>
)
}
export default Legend
@@ -0,0 +1,32 @@
import { useCallback } from 'react'
import { BookOpenIcon } from 'lucide-react'
import Button from '@/components/ui/Button'
import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings'
import { useTranslation } from 'react-i18next'
/**
* Component that toggles legend visibility.
*/
const LegendButton = () => {
const { t } = useTranslation()
const showLegend = useSettingsStore.use.showLegend()
const setShowLegend = useSettingsStore.use.setShowLegend()
const toggleLegend = useCallback(() => {
setShowLegend(!showLegend)
}, [showLegend, setShowLegend])
return (
<Button
variant={controlButtonVariant}
onClick={toggleLegend}
tooltip={t('graphPanel.sideBar.legendControl.toggleLegend')}
size="icon"
>
<BookOpenIcon />
</Button>
)
}
export default LegendButton
@@ -0,0 +1,70 @@
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '@/stores/settings'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
interface MergeDialogProps {
mergeDialogOpen: boolean
mergeDialogInfo: {
targetEntity: string
sourceEntity: string
} | null
onOpenChange: (open: boolean) => void
onRefresh: (useMergedStart: boolean) => void
}
/**
* MergeDialog component that appears after a successful entity merge
* Allows user to choose whether to use the merged entity or keep current start point
*/
const MergeDialog = ({
mergeDialogOpen,
mergeDialogInfo,
onOpenChange,
onRefresh
}: MergeDialogProps) => {
const { t } = useTranslation()
const currentQueryLabel = useSettingsStore.use.queryLabel()
return (
<Dialog open={mergeDialogOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('graphPanel.propertiesView.mergeDialog.title')}</DialogTitle>
<DialogDescription>
{t('graphPanel.propertiesView.mergeDialog.description', {
source: mergeDialogInfo?.sourceEntity ?? '',
target: mergeDialogInfo?.targetEntity ?? '',
})}
</DialogDescription>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('graphPanel.propertiesView.mergeDialog.refreshHint')}
</p>
<DialogFooter className="mt-4 flex-col gap-2 sm:flex-row sm:justify-end">
{currentQueryLabel !== mergeDialogInfo?.sourceEntity && (
<Button
type="button"
variant="outline"
onClick={() => onRefresh(false)}
>
{t('graphPanel.propertiesView.mergeDialog.keepCurrentStart')}
</Button>
)}
<Button type="button" onClick={() => onRefresh(true)}>
{t('graphPanel.propertiesView.mergeDialog.useMergedStart')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default MergeDialog
@@ -0,0 +1,408 @@
import { useEffect, useState } from 'react'
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
import Text from '@/components/ui/Text'
import Button from '@/components/ui/Button'
import useLightragGraph from '@/hooks/useLightragGraph'
import { useTranslation } from 'react-i18next'
import { GitBranchPlus, Scissors } from 'lucide-react'
import EditablePropertyRow from './EditablePropertyRow'
/**
* Component that view properties of elements in graph.
*/
const PropertiesView = () => {
const { getNode, getEdge } = useLightragGraph()
const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode()
const selectedEdge = useGraphStore.use.selectedEdge()
const focusedEdge = useGraphStore.use.focusedEdge()
const graphDataVersion = useGraphStore.use.graphDataVersion()
const [currentElement, setCurrentElement] = useState<NodeType | EdgeType | null>(null)
const [currentType, setCurrentType] = useState<'node' | 'edge' | null>(null)
// This effect will run when selection changes or when graph data is updated
useEffect(() => {
let type: 'node' | 'edge' | null = null
let element: RawNodeType | RawEdgeType | null = null
if (focusedNode) {
type = 'node'
element = getNode(focusedNode)
} else if (selectedNode) {
type = 'node'
element = getNode(selectedNode)
} else if (focusedEdge) {
type = 'edge'
element = getEdge(focusedEdge, true)
} else if (selectedEdge) {
type = 'edge'
element = getEdge(selectedEdge, true)
}
if (element) {
if (type == 'node') {
setCurrentElement(refineNodeProperties(element as any))
} else {
setCurrentElement(refineEdgeProperties(element as any))
}
setCurrentType(type)
} else {
setCurrentElement(null)
setCurrentType(null)
}
}, [
focusedNode,
selectedNode,
focusedEdge,
selectedEdge,
graphDataVersion, // Add dependency on graphDataVersion to refresh when data changes
setCurrentElement,
setCurrentType,
getNode,
getEdge
])
if (!currentElement) {
return <></>
}
return (
<div className="bg-background/80 max-w-xs rounded-lg border-2 p-2 text-xs backdrop-blur-lg">
{currentType == 'node' ? (
<NodePropertiesView node={currentElement as any} />
) : (
<EdgePropertiesView edge={currentElement as any} />
)}
</div>
)
}
type NodeType = RawNodeType & {
relationships: {
type: string
id: string
label: string
}[]
}
type EdgeType = RawEdgeType & {
sourceNode?: RawNodeType
targetNode?: RawNodeType
}
const refineNodeProperties = (node: RawNodeType): NodeType => {
const state = useGraphStore.getState()
const relationships = []
if (state.sigmaGraph && state.rawGraph) {
try {
if (!state.sigmaGraph.hasNode(node.id)) {
console.warn('Node not found in sigmaGraph:', node.id)
return {
...node,
relationships: []
}
}
const edges = state.sigmaGraph.edges(node.id)
for (const edgeId of edges) {
if (!state.sigmaGraph.hasEdge(edgeId)) continue;
const edge = state.rawGraph.getEdge(edgeId, true)
if (edge) {
const isTarget = node.id === edge.source
const neighbourId = isTarget ? edge.target : edge.source
if (!state.sigmaGraph.hasNode(neighbourId)) continue;
const neighbour = state.rawGraph.getNode(neighbourId)
if (neighbour) {
relationships.push({
type: 'Neighbour',
id: neighbourId,
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
})
}
}
}
} catch (error) {
console.error('Error refining node properties:', error)
}
}
return {
...node,
relationships
}
}
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => {
const state = useGraphStore.getState()
let sourceNode: RawNodeType | undefined = undefined
let targetNode: RawNodeType | undefined = undefined
if (state.sigmaGraph && state.rawGraph) {
try {
if (!state.sigmaGraph.hasEdge(edge.dynamicId)) {
console.warn('Edge not found in sigmaGraph:', edge.id, 'dynamicId:', edge.dynamicId)
return {
...edge,
sourceNode: undefined,
targetNode: undefined
}
}
if (state.sigmaGraph.hasNode(edge.source)) {
sourceNode = state.rawGraph.getNode(edge.source)
}
if (state.sigmaGraph.hasNode(edge.target)) {
targetNode = state.rawGraph.getNode(edge.target)
}
} catch (error) {
console.error('Error refining edge properties:', error)
}
}
return {
...edge,
sourceNode,
targetNode
}
}
const PropertyRow = ({
name,
value,
onClick,
tooltip,
nodeId,
edgeId,
dynamicId,
entityId,
entityType,
sourceId,
targetId,
isEditable = false,
truncate
}: {
name: string
value: any
onClick?: () => void
tooltip?: string
nodeId?: string
entityId?: string
edgeId?: string
dynamicId?: string
entityType?: 'node' | 'edge'
sourceId?: string
targetId?: string
isEditable?: boolean
truncate?: string
}) => {
const { t } = useTranslation()
const getPropertyNameTranslation = (name: string) => {
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`
const translation = t(translationKey)
return translation === translationKey ? name : translation
}
// Utility function to convert <SEP> to newlines
const formatValueWithSeparators = (value: any): string => {
if (typeof value === 'string') {
return value.replace(/<SEP>/g, ';\n')
}
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
// Format the value to convert <SEP> to newlines
const formattedValue = formatValueWithSeparators(value)
let formattedTooltip = tooltip || formatValueWithSeparators(value)
// If this is source_id field and truncate info exists, append it to the tooltip
if (name === 'source_id' && truncate) {
formattedTooltip += `\n(Truncated: ${truncate})`
}
// Use EditablePropertyRow for editable fields (description, entity_id and entity_type)
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'entity_type' || name === 'keywords')) {
return (
<EditablePropertyRow
name={name}
value={value}
onClick={onClick}
nodeId={nodeId}
entityId={entityId}
edgeId={edgeId}
dynamicId={dynamicId}
entityType={entityType}
sourceId={sourceId}
targetId={targetId}
isEditable={true}
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
/>
)
}
// For non-editable fields, use the regular Text component
return (
<div className="flex items-center gap-2">
<span className="text-primary/60 tracking-wide whitespace-nowrap">
{getPropertyNameTranslation(name)}
{name === 'source_id' && truncate && <sup className="text-red-500"></sup>}
</span>:
<Text
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis"
tooltipClassName="max-w-96 -translate-x-13"
text={formattedValue}
tooltip={formattedTooltip}
side="left"
onClick={onClick}
/>
</div>
)
}
const NodePropertiesView = ({ node }: { node: NodeType }) => {
const { t } = useTranslation()
const handleExpandNode = () => {
useGraphStore.getState().triggerNodeExpand(node.id)
}
const handlePruneNode = () => {
useGraphStore.getState().triggerNodePrune(node.id)
}
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<h3 className="text-md pl-1 font-bold tracking-wide text-blue-700">{t('graphPanel.propertiesView.node.title')}</h3>
<div className="flex gap-3">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
onClick={handleExpandNode}
tooltip={t('graphPanel.propertiesView.node.expandNode')}
>
<GitBranchPlus className="h-4 w-4 text-gray-700 dark:text-gray-300" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
onClick={handlePruneNode}
tooltip={t('graphPanel.propertiesView.node.pruneNode')}
>
<Scissors className="h-4 w-4 text-gray-900 dark:text-gray-300" />
</Button>
</div>
</div>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={t('graphPanel.propertiesView.node.id')} value={String(node.id)} />
<PropertyRow
name={t('graphPanel.propertiesView.node.labels')}
value={node.labels.join(', ')}
onClick={() => {
useGraphStore.getState().setSelectedNode(node.id, true)
}}
/>
<PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />
</div>
<h3 className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.node.properties')}</h3>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{Object.keys(node.properties)
.sort()
.map((name) => {
if (name === 'created_at' || name === 'truncate') return null; // Hide created_at and truncate properties
return (
<PropertyRow
key={name}
name={name}
value={node.properties[name]}
nodeId={String(node.id)}
entityId={node.properties['entity_id']}
entityType="node"
isEditable={name === 'description' || name === 'entity_id' || name === 'entity_type'}
truncate={node.properties['truncate']}
/>
)
})}
</div>
{node.relationships.length > 0 && (
<>
<h3 className="text-md pl-1 font-bold tracking-wide text-emerald-700">
{t('graphPanel.propertiesView.node.relationships')}
</h3>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{node.relationships.map(({ type, id, label }) => {
return (
<PropertyRow
key={id}
name={type}
value={label}
onClick={() => {
useGraphStore.getState().setSelectedNode(id, true)
}}
/>
)
})}
</div>
</>
)}
</div>
)
}
const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2">
<h3 className="text-md pl-1 font-bold tracking-wide text-violet-700">{t('graphPanel.propertiesView.edge.title')}</h3>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
<PropertyRow
name={t('graphPanel.propertiesView.edge.source')}
value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source}
onClick={() => {
useGraphStore.getState().setSelectedNode(edge.source, true)
}}
/>
<PropertyRow
name={t('graphPanel.propertiesView.edge.target')}
value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target}
onClick={() => {
useGraphStore.getState().setSelectedNode(edge.target, true)
}}
/>
</div>
<h3 className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.edge.properties')}</h3>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{Object.keys(edge.properties)
.sort()
.map((name) => {
if (name === 'created_at' || name === 'truncate') return null; // Hide created_at and truncate properties
return (
<PropertyRow
key={name}
name={name}
value={edge.properties[name]}
edgeId={String(edge.id)}
dynamicId={String(edge.dynamicId)}
entityType="edge"
sourceId={edge.sourceNode?.properties['entity_id'] || edge.source}
targetId={edge.targetNode?.properties['entity_id'] || edge.target}
isEditable={name === 'description' || name === 'keywords'}
truncate={edge.properties['truncate']}
/>
)
})}
</div>
</div>
)
}
export default PropertiesView
@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
interface PropertyEditDialogProps {
isOpen: boolean
onClose: () => void
onSave: (value: string, options?: { allowMerge?: boolean }) => void
propertyName: string
initialValue: string
isSubmitting?: boolean
errorMessage?: string | null
}
/**
* Dialog component for editing property values
* Provides a modal with a title, multi-line text input, and save/cancel buttons
*/
const PropertyEditDialog = ({
isOpen,
onClose,
onSave,
propertyName,
initialValue,
isSubmitting = false,
errorMessage = null
}: PropertyEditDialogProps) => {
const { t } = useTranslation()
const [value, setValue] = useState('')
const [allowMerge, setAllowMerge] = useState(false)
// Initialize value when dialog opens
useEffect(() => {
if (isOpen) {
setValue(initialValue)
setAllowMerge(false)
}
}, [isOpen, initialValue])
// Get translated property name
const getPropertyNameTranslation = (name: string) => {
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`
const translation = t(translationKey)
return translation === translationKey ? name : translation
}
// Get textarea configuration based on property name
const getTextareaConfig = (propertyName: string) => {
switch (propertyName) {
case 'description':
return {
// No rows attribute for description to allow auto-sizing
className: 'max-h-[50vh] min-h-[10em] resize-y', // Maximum height 70% of viewport, minimum height ~20 lines, allow vertical resizing
style: {
height: '70vh', // Set initial height to 70% of viewport
minHeight: '20em', // Minimum height ~20 lines
resize: 'vertical' as const // Allow vertical resizing, using 'as const' to fix type
}
};
case 'entity_id':
return {
rows: 2,
className: '',
style: {}
};
case 'keywords':
return {
rows: 4,
className: '',
style: {}
};
default:
return {
rows: 5,
className: '',
style: {}
};
}
};
const handleSave = async () => {
const trimmedValue = value.trim()
if (trimmedValue !== '') {
const options = propertyName === 'entity_id' ? { allowMerge } : undefined
await onSave(trimmedValue, options)
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t('graphPanel.propertiesView.editProperty', {
property: getPropertyNameTranslation(propertyName)
})}
</DialogTitle>
<DialogDescription>
{t('graphPanel.propertiesView.editPropertyDescription')}
</DialogDescription>
</DialogHeader>
{/* Display error message if save fails */}
{errorMessage && (
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm">
{errorMessage}
</div>
)}
{/* Multi-line text input using textarea */}
<div className="grid gap-4 py-4">
{(() => {
const config = getTextareaConfig(propertyName);
return propertyName === 'description' ? (
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
className={`border-input focus-visible:ring-ring flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${config.className}`}
style={config.style}
disabled={isSubmitting}
/>
) : (
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
rows={config.rows}
className={`border-input focus-visible:ring-ring flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${config.className}`}
disabled={isSubmitting}
/>
);
})()}
</div>
{propertyName === 'entity_id' && (
<div className="rounded-md border border-border bg-muted/20 p-3">
<label className="flex items-start gap-2 text-sm font-medium">
<Checkbox
id="allow-merge"
checked={allowMerge}
disabled={isSubmitting}
onCheckedChange={(checked) => setAllowMerge(checked === true)}
/>
<div>
<span>{t('graphPanel.propertiesView.mergeOptionLabel')}</span>
<p className="text-xs font-normal text-muted-foreground">
{t('graphPanel.propertiesView.mergeOptionDescription')}
</p>
</div>
</label>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
>
{t('common.cancel')}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<span className="mr-2">
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
{t('common.saving')}
</>
) : (
t('common.save')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default PropertyEditDialog
@@ -0,0 +1,55 @@
import { PencilIcon } from 'lucide-react'
import Text from '@/components/ui/Text'
import { useTranslation } from 'react-i18next'
interface PropertyNameProps {
name: string
}
export const PropertyName = ({ name }: PropertyNameProps) => {
const { t } = useTranslation()
const getPropertyNameTranslation = (propName: string) => {
const translationKey = `graphPanel.propertiesView.node.propertyNames.${propName}`
const translation = t(translationKey)
return translation === translationKey ? propName : translation
}
return (
<span className="text-primary/60 tracking-wide whitespace-nowrap">
{getPropertyNameTranslation(name)}
</span>
)
}
interface EditIconProps {
onClick: () => void
}
export const EditIcon = ({ onClick }: EditIconProps) => (
<div>
<PencilIcon
className="h-3 w-3 text-gray-500 hover:text-gray-700 cursor-pointer"
onClick={onClick}
/>
</div>
)
interface PropertyValueProps {
value: any
onClick?: () => void
tooltip?: string
}
export const PropertyValue = ({ value, onClick, tooltip }: PropertyValueProps) => (
<div className="flex items-center gap-1 overflow-hidden">
<Text
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis whitespace-nowrap"
tooltipClassName="max-w-80 -translate-x-15"
text={value}
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
side="left"
onClick={onClick}
/>
</div>
)
@@ -0,0 +1,426 @@
import { useState, useCallback, useEffect} from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import Checkbox from '@/components/ui/Checkbox'
import Button from '@/components/ui/Button'
import Separator from '@/components/ui/Separator'
import Input from '@/components/ui/Input'
import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
import useRandomGraph from '@/hooks/useRandomGraph'
import { SettingsIcon, Undo2, Shuffle } from 'lucide-react'
import { useTranslation } from 'react-i18next';
/**
* Component that displays a checkbox with a label.
*/
const LabeledCheckBox = ({
checked,
onCheckedChange,
label
}: {
checked: boolean
onCheckedChange: () => void
label: string
}) => {
// Create unique ID using the label text converted to lowercase with spaces removed
const id = `checkbox-${label.toLowerCase().replace(/\s+/g, '-')}`;
return (
<div className="flex items-center gap-2">
<Checkbox id={id} checked={checked} onCheckedChange={onCheckedChange} />
<label
htmlFor={id}
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</label>
</div>
)
}
/**
* Component that displays a number input with a label.
*/
const LabeledNumberInput = ({
value,
onEditFinished,
label,
min,
max,
defaultValue
}: {
value: number
onEditFinished: (value: number) => void
label: string
min: number
max?: number
defaultValue?: number
}) => {
const { t } = useTranslation();
const [currentValue, setCurrentValue] = useState<number | null>(value)
// Create unique ID using the label text converted to lowercase with spaces removed
const id = `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
useEffect(() => {
setCurrentValue(value)
}, [value])
const onValueChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value.trim()
if (text.length === 0) {
setCurrentValue(null)
return
}
const newValue = Number.parseInt(text)
if (!isNaN(newValue) && newValue !== currentValue) {
if (min !== undefined && newValue < min) {
return
}
if (max !== undefined && newValue > max) {
return
}
setCurrentValue(newValue)
}
},
[currentValue, min, max]
)
const onBlur = useCallback(() => {
if (currentValue !== null && value !== currentValue) {
onEditFinished(currentValue)
}
}, [value, currentValue, onEditFinished])
const handleReset = useCallback(() => {
if (defaultValue !== undefined && value !== defaultValue) {
setCurrentValue(defaultValue)
onEditFinished(defaultValue)
}
}, [defaultValue, value, onEditFinished])
return (
<div className="flex flex-col gap-2">
<label
htmlFor={id}
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</label>
<div className="flex items-center gap-1">
<Input
id={id}
type="number"
value={currentValue === null ? '' : currentValue}
onChange={onValueChange}
className="h-6 w-full min-w-0 pr-1"
min={min}
max={max}
onBlur={onBlur}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onBlur()
}
}}
/>
{defaultValue !== undefined && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleReset}
type="button"
title={t('graphPanel.sideBar.settings.resetToDefault')}
>
<Undo2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)
}
/**
* Component that displays a popover with settings options.
*/
export default function Settings() {
const [opened, setOpened] = useState<boolean>(false)
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
const showNodeLabel = useSettingsStore.use.showNodeLabel()
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
const enableHideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
const showEdgeLabel = useSettingsStore.use.showEdgeLabel()
const minEdgeSize = useSettingsStore.use.minEdgeSize()
const maxEdgeSize = useSettingsStore.use.maxEdgeSize()
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
const graphMaxNodes = useSettingsStore.use.graphMaxNodes()
const backendMaxGraphNodes = useSettingsStore.use.backendMaxGraphNodes()
const graphLayoutMaxIterations = useSettingsStore.use.graphLayoutMaxIterations()
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
// Random graph functionality for development/testing
const { randomGraph } = useRandomGraph()
const setEnableNodeDrag = useCallback(
() => useSettingsStore.setState((pre) => ({ enableNodeDrag: !pre.enableNodeDrag })),
[]
)
const setEnableEdgeEvents = useCallback(
() => useSettingsStore.setState((pre) => ({ enableEdgeEvents: !pre.enableEdgeEvents })),
[]
)
const setEnableHideUnselectedEdges = useCallback(
() =>
useSettingsStore.setState((pre) => ({
enableHideUnselectedEdges: !pre.enableHideUnselectedEdges
})),
[]
)
const setShowEdgeLabel = useCallback(
() =>
useSettingsStore.setState((pre) => ({
showEdgeLabel: !pre.showEdgeLabel
})),
[]
)
//
const setShowPropertyPanel = useCallback(
() => useSettingsStore.setState((pre) => ({ showPropertyPanel: !pre.showPropertyPanel })),
[]
)
const setShowNodeSearchBar = useCallback(
() => useSettingsStore.setState((pre) => ({ showNodeSearchBar: !pre.showNodeSearchBar })),
[]
)
const setShowNodeLabel = useCallback(
() => useSettingsStore.setState((pre) => ({ showNodeLabel: !pre.showNodeLabel })),
[]
)
const setEnableHealthCheck = useCallback(
() => useSettingsStore.setState((pre) => ({ enableHealthCheck: !pre.enableHealthCheck })),
[]
)
const setGraphQueryMaxDepth = useCallback((depth: number) => {
if (depth < 1) return
useSettingsStore.setState({ graphQueryMaxDepth: depth })
const currentLabel = useSettingsStore.getState().queryLabel
useSettingsStore.getState().setQueryLabel('')
setTimeout(() => {
useSettingsStore.getState().setQueryLabel(currentLabel)
}, 300)
}, [])
const setGraphMaxNodes = useCallback((nodes: number) => {
const maxLimit = backendMaxGraphNodes || 1000
if (nodes < 1 || nodes > maxLimit) return
useSettingsStore.getState().setGraphMaxNodes(nodes, true)
}, [backendMaxGraphNodes])
const setGraphLayoutMaxIterations = useCallback((iterations: number) => {
if (iterations < 1) return
useSettingsStore.setState({ graphLayoutMaxIterations: iterations })
}, [])
const handleGenerateRandomGraph = useCallback(() => {
const graph = randomGraph()
useGraphStore.getState().setSigmaGraph(graph)
}, [randomGraph])
const { t } = useTranslation();
const saveSettings = () => setOpened(false);
return (
<>
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button
variant={controlButtonVariant}
tooltip={t('graphPanel.sideBar.settings.settings')}
size="icon"
>
<SettingsIcon />
</Button>
</PopoverTrigger>
<PopoverContent
side="right"
align="end"
sideOffset={8}
collisionPadding={5}
className="p-2 max-w-[200px]"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2">
<LabeledCheckBox
checked={enableHealthCheck}
onCheckedChange={setEnableHealthCheck}
label={t('graphPanel.sideBar.settings.healthCheck')}
/>
<Separator />
<LabeledCheckBox
checked={showPropertyPanel}
onCheckedChange={setShowPropertyPanel}
label={t('graphPanel.sideBar.settings.showPropertyPanel')}
/>
<LabeledCheckBox
checked={showNodeSearchBar}
onCheckedChange={setShowNodeSearchBar}
label={t('graphPanel.sideBar.settings.showSearchBar')}
/>
<Separator />
<LabeledCheckBox
checked={showNodeLabel}
onCheckedChange={setShowNodeLabel}
label={t('graphPanel.sideBar.settings.showNodeLabel')}
/>
<LabeledCheckBox
checked={enableNodeDrag}
onCheckedChange={setEnableNodeDrag}
label={t('graphPanel.sideBar.settings.nodeDraggable')}
/>
<Separator />
<LabeledCheckBox
checked={showEdgeLabel}
onCheckedChange={setShowEdgeLabel}
label={t('graphPanel.sideBar.settings.showEdgeLabel')}
/>
<LabeledCheckBox
checked={enableHideUnselectedEdges}
onCheckedChange={setEnableHideUnselectedEdges}
label={t('graphPanel.sideBar.settings.hideUnselectedEdges')}
/>
<LabeledCheckBox
checked={enableEdgeEvents}
onCheckedChange={setEnableEdgeEvents}
label={t('graphPanel.sideBar.settings.edgeEvents')}
/>
<div className="flex flex-col gap-2">
<label htmlFor="edge-size-min" className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t('graphPanel.sideBar.settings.edgeSizeRange')}
</label>
<div className="flex items-center gap-2">
<Input
id="edge-size-min"
type="number"
value={minEdgeSize}
onChange={(e) => {
const newValue = Number(e.target.value);
if (!isNaN(newValue) && newValue >= 1 && newValue <= maxEdgeSize) {
useSettingsStore.setState({ minEdgeSize: newValue });
}
}}
className="h-6 w-16 min-w-0 pr-1"
min={1}
max={Math.min(maxEdgeSize, 10)}
/>
<span>-</span>
<div className="flex items-center gap-1">
<Input
id="edge-size-max"
type="number"
value={maxEdgeSize}
onChange={(e) => {
const newValue = Number(e.target.value);
if (!isNaN(newValue) && newValue >= minEdgeSize && newValue >= 1 && newValue <= 10) {
useSettingsStore.setState({ maxEdgeSize: newValue });
}
}}
className="h-6 w-16 min-w-0 pr-1"
min={minEdgeSize}
max={10}
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={() => useSettingsStore.setState({ minEdgeSize: 1, maxEdgeSize: 5 })}
type="button"
title={t('graphPanel.sideBar.settings.resetToDefault')}
>
<Undo2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<Separator />
<LabeledNumberInput
label={t('graphPanel.sideBar.settings.maxQueryDepth')}
min={1}
value={graphQueryMaxDepth}
defaultValue={3}
onEditFinished={setGraphQueryMaxDepth}
/>
<LabeledNumberInput
label={`${t('graphPanel.sideBar.settings.maxNodes')} (≤ ${backendMaxGraphNodes || 1000})`}
min={1}
max={backendMaxGraphNodes || 1000}
value={graphMaxNodes}
defaultValue={backendMaxGraphNodes || 1000}
onEditFinished={setGraphMaxNodes}
/>
<LabeledNumberInput
label={t('graphPanel.sideBar.settings.maxLayoutIterations')}
min={1}
max={30}
value={graphLayoutMaxIterations}
defaultValue={15}
onEditFinished={setGraphLayoutMaxIterations}
/>
{/* Development/Testing Section - Only visible in development mode */}
{import.meta.env.DEV && (
<>
<Separator />
<div className="flex flex-col gap-2">
<label className="text-sm leading-none font-medium text-muted-foreground">
Dev Options
</label>
<Button
onClick={handleGenerateRandomGraph}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<Shuffle className="h-3.5 w-3.5" />
Gen Random Graph
</Button>
</div>
<Separator />
</>
)}
<Button
onClick={saveSettings}
variant="outline"
size="sm"
className="ml-auto px-4"
>
{t('graphPanel.sideBar.settings.save')}
</Button>
</div>
</PopoverContent>
</Popover>
</>
)
}
@@ -0,0 +1,21 @@
import { useSettingsStore } from '@/stores/settings'
import { useTranslation } from 'react-i18next'
/**
* Component that displays current values of important graph settings
* Positioned to the right of the toolbar at the bottom-left corner
*/
const SettingsDisplay = () => {
const { t } = useTranslation()
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
const graphMaxNodes = useSettingsStore.use.graphMaxNodes()
return (
<div className="absolute bottom-4 left-[calc(1rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
<div>{t('graphPanel.sideBar.settings.max')}: {graphMaxNodes}</div>
</div>
)
}
export default SettingsDisplay
@@ -0,0 +1,109 @@
import { useCamera, useSigma } from '@react-sigma/core'
import { useCallback } from 'react'
import Button from '@/components/ui/Button'
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon, RotateCwIcon, RotateCcwIcon } from 'lucide-react'
import { controlButtonVariant } from '@/lib/constants'
import { useTranslation } from 'react-i18next';
/**
* Component that provides zoom controls for the graph viewer.
*/
const ZoomControl = () => {
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
const sigma = useSigma()
const { t } = useTranslation();
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
const handleResetZoom = useCallback(() => {
if (!sigma) return
try {
// First clear any custom bounding box and refresh
sigma.setCustomBBox(null)
sigma.refresh()
// Get graph after refresh
const graph = sigma.getGraph()
// Check if graph has nodes before accessing them
if (!graph?.order || graph.nodes().length === 0) {
// Use reset() for empty graph case
reset()
return
}
sigma.getCamera().animate(
{ x: 0.5, y: 0.5, ratio: 1.1 },
{ duration: 1000 }
)
} catch (error) {
console.error('Error resetting zoom:', error)
// Use reset() as fallback on error
reset()
}
}, [sigma, reset])
const handleRotate = useCallback(() => {
if (!sigma) return
const camera = sigma.getCamera()
const currentAngle = camera.angle
const newAngle = currentAngle + Math.PI / 8
camera.animate(
{ angle: newAngle },
{ duration: 200 }
)
}, [sigma])
const handleRotateCounterClockwise = useCallback(() => {
if (!sigma) return
const camera = sigma.getCamera()
const currentAngle = camera.angle
const newAngle = currentAngle - Math.PI / 8
camera.animate(
{ angle: newAngle },
{ duration: 200 }
)
}, [sigma])
return (
<>
<Button
variant={controlButtonVariant}
onClick={handleRotate}
tooltip={t('graphPanel.sideBar.zoomControl.rotateCamera')}
size="icon"
>
<RotateCwIcon />
</Button>
<Button
variant={controlButtonVariant}
onClick={handleRotateCounterClockwise}
tooltip={t('graphPanel.sideBar.zoomControl.rotateCameraCounterClockwise')}
size="icon"
>
<RotateCcwIcon />
</Button>
<Button
variant={controlButtonVariant}
onClick={handleResetZoom}
tooltip={t('graphPanel.sideBar.zoomControl.resetZoom')}
size="icon"
>
<FullscreenIcon />
</Button>
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t('graphPanel.sideBar.zoomControl.zoomIn')} size="icon">
<ZoomInIcon />
</Button>
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t('graphPanel.sideBar.zoomControl.zoomOut')} size="icon">
<ZoomOutIcon />
</Button>
</>
)
}
export default ZoomControl
@@ -0,0 +1,508 @@
import { ReactNode, useEffect, useMemo, useRef, memo, useState } from 'react' // Import useMemo
import { Message } from '@/api/lightrag'
import useTheme from '@/hooks/useTheme'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeReact from 'rehype-react'
import rehypeRaw from 'rehype-raw'
import remarkMath from 'remark-math'
import mermaid from 'mermaid'
import { remarkFootnotes } from '@/utils/remarkFootnotes'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import { LoaderIcon, ChevronDownIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
// KaTeX configuration options interface
interface KaTeXOptions {
errorColor?: string;
throwOnError?: boolean;
displayMode?: boolean;
strict?: boolean;
trust?: boolean;
errorCallback?: (error: string, latex: string) => void;
}
export type MessageWithError = Message & {
id: string // Unique identifier for stable React keys
isError?: boolean
isThinking?: boolean // Flag to indicate if the message is in a "thinking" state
/**
* Indicates if the mermaid diagram in this message has been rendered.
* Used to persist the rendering state across updates and prevent flickering.
*/
mermaidRendered?: boolean
/**
* Indicates if the LaTeX formulas in this message are complete and ready for rendering.
* Used to prevent red error text during streaming of incomplete LaTeX formulas.
*/
latexRendered?: boolean
}
// Restore original component definition and export
export const ChatMessage = ({
message,
isTabActive = true
}: {
message: MessageWithError
isTabActive?: boolean
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
const [katexPlugin, setKatexPlugin] = useState<((options?: KaTeXOptions) => any) | null>(null)
const [isThinkingExpanded, setIsThinkingExpanded] = useState<boolean>(false)
// Directly use props passed from the parent.
const { thinkingContent, displayContent, thinkingTime, isThinking } = message
// Reset expansion state when new thinking starts
useEffect(() => {
if (isThinking) {
// When thinking starts, always reset to collapsed state
setIsThinkingExpanded(false)
}
}, [isThinking, message.id])
// The content to display is now non-ambiguous.
const finalThinkingContent = thinkingContent
// For user messages, displayContent will be undefined, so we fall back to content.
// For assistant messages, we prefer displayContent but fallback to content for backward compatibility
const finalDisplayContent = message.role === 'user'
? message.content
: (displayContent !== undefined ? displayContent : (message.content || ''))
// Load KaTeX dynamically
useEffect(() => {
const loadKaTeX = async () => {
try {
const { default: rehypeKatex } = await import('rehype-katex');
setKatexPlugin(() => rehypeKatex);
} catch (error) {
console.error('Failed to load KaTeX plugin:', error);
// Set to null to ensure we don't try to use a failed plugin
setKatexPlugin(null);
}
};
loadKaTeX();
}, []);
const mainMarkdownComponents = useMemo(() => ({
code: (props: any) => {
const { inline, className, children, ...restProps } = props;
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : undefined;
// Handle math blocks ($$...$$) - provide better container and styling
if (language === 'math' && !inline) {
return (
<div className="katex-display-wrapper my-4 overflow-x-auto">
<div className="text-current">{children}</div>
</div>
);
}
// Handle inline math ($...$) - ensure proper inline display
if (language === 'math' && inline) {
return (
<span className="katex-inline-wrapper">
<span className="text-current">{children}</span>
</span>
);
}
// Handle all other code (inline and block)
return (
<CodeHighlight
inline={inline}
className={className}
{...restProps}
renderAsDiagram={message.mermaidRendered ?? false}
messageRole={message.role}
>
{children}
</CodeHighlight>
);
},
p: ({ children }: { children?: ReactNode }) => <div className="my-2">{children}</div>,
h1: ({ children }: { children?: ReactNode }) => <h1 className="text-xl font-bold mt-4 mb-2">{children}</h1>,
h2: ({ children }: { children?: ReactNode }) => <h2 className="text-lg font-bold mt-4 mb-2">{children}</h2>,
h3: ({ children }: { children?: ReactNode }) => <h3 className="text-base font-bold mt-3 mb-2">{children}</h3>,
h4: ({ children }: { children?: ReactNode }) => <h4 className="text-base font-semibold mt-3 mb-2">{children}</h4>,
ul: ({ children }: { children?: ReactNode }) => <ul className="list-disc pl-5 my-2">{children}</ul>,
ol: ({ children }: { children?: ReactNode }) => <ol className="list-decimal pl-5 my-2">{children}</ol>,
li: ({ children }: { children?: ReactNode }) => <li className="my-1">{children}</li>
}), [message.mermaidRendered, message.role]);
const thinkingMarkdownComponents = useMemo(() => ({
code: (props: any) => (<CodeHighlight {...props} renderAsDiagram={message.mermaidRendered ?? false} messageRole={message.role} />)
}), [message.mermaidRendered, message.role]);
return (
<div
className={`${
message.role === 'user'
? 'max-w-[80%] bg-primary text-primary-foreground'
: message.isError
? 'w-[95%] bg-red-100 text-red-600 dark:bg-red-950 dark:text-red-400'
: 'w-[95%] bg-muted'
} rounded-lg px-4 py-2`}
>
{/* Thinking process display - only for assistant messages */}
{/* Always render to prevent layout shift when switching tabs */}
{message.role === 'assistant' && (isThinking || thinkingTime !== null) && (
<div className={cn(
'mb-2',
// Reduce visual priority in inactive tabs while maintaining layout
!isTabActive && 'opacity-50'
)}>
<div
className="flex items-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-200 text-sm cursor-pointer select-none"
onClick={() => {
// Allow expansion when there's thinking content, even during thinking process
if (finalThinkingContent && finalThinkingContent.trim() !== '') {
setIsThinkingExpanded(!isThinkingExpanded)
}
}}
>
{isThinking ? (
<>
{/* Only show spinner animation in active tab to save resources */}
{isTabActive && <LoaderIcon className="mr-2 size-4 animate-spin" />}
<span>{t('retrievePanel.chatMessage.thinking')}</span>
</>
) : (
typeof thinkingTime === 'number' && <span>{t('retrievePanel.chatMessage.thinkingTime', { time: thinkingTime })}</span>
)}
{/* Show chevron when there's thinking content, even during thinking process */}
{finalThinkingContent && finalThinkingContent.trim() !== '' && <ChevronDownIcon className={`ml-2 size-4 shrink-0 transition-transform ${isThinkingExpanded ? 'rotate-180' : ''}`} />}
</div>
{/* Show thinking content when expanded and content exists, even during thinking process */}
{isThinkingExpanded && finalThinkingContent && finalThinkingContent.trim() !== '' && (
<div className="mt-2 pl-4 border-l-2 border-primary/20 dark:border-primary/40 text-sm prose dark:prose-invert max-w-none break-words prose-p:my-1 prose-headings:my-2 [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-6 [&_.footnotes]:pt-3 [&_.footnotes]:border-t [&_.footnotes]:border-border [&_.footnotes_ol]:text-xs [&_.footnotes_li]:my-0.5 [&_a[href^='#fn']]:text-primary [&_a[href^='#fn']]:no-underline [&_a[href^='#fn']]:hover:underline [&_a[href^='#fnref']]:text-primary [&_a[href^='#fnref']]:no-underline [&_a[href^='#fnref']]:hover:underline text-foreground">
{isThinking && (
<div className="mb-2 text-xs text-gray-400 dark:text-gray-300 italic">
{t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')}
</div>
)}
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}
rehypePlugins={[
rehypeRaw,
...((katexPlugin && (message.latexRendered ?? true)) ? [[katexPlugin, {
errorColor: theme === 'dark' ? '#ef4444' : '#dc2626',
throwOnError: false,
displayMode: false,
strict: false,
trust: true,
// Add silent error handling to avoid console noise
errorCallback: (error: string, latex: string) => {
// Only show detailed errors in development environment
if (process.env.NODE_ENV === 'development') {
console.warn('KaTeX rendering error in thinking content:', error, 'for LaTeX:', latex);
}
}
}] as any] : []),
rehypeReact
]}
skipHtml={false}
components={thinkingMarkdownComponents}
>
{finalThinkingContent}
</ReactMarkdown>
</div>
)}
</div>
)}
{/* Main content display */}
{finalDisplayContent && (
<div className="relative">
<ReactMarkdown
className={`prose dark:prose-invert max-w-none text-sm break-words prose-headings:mt-4 prose-headings:mb-2 prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 [&_.katex]:text-current [&_.katex-display]:my-4 [&_.katex-display]:max-w-full [&_.katex-display_>.base]:overflow-x-auto [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-8 [&_.footnotes]:pt-4 [&_.footnotes]:border-t [&_.footnotes_ol]:text-sm [&_.footnotes_li]:my-1 ${
message.role === 'user' ? 'text-primary-foreground' : 'text-foreground'
} ${
message.role === 'user'
? '[&_.footnotes]:border-primary-foreground/30 [&_a[href^="#fn"]]:text-primary-foreground [&_a[href^="#fn"]]:no-underline [&_a[href^="#fn"]]:hover:underline [&_a[href^="#fnref"]]:text-primary-foreground [&_a[href^="#fnref"]]:no-underline [&_a[href^="#fnref"]]:hover:underline'
: '[&_.footnotes]:border-border [&_a[href^="#fn"]]:text-primary [&_a[href^="#fn"]]:no-underline [&_a[href^="#fn"]]:hover:underline [&_a[href^="#fnref"]]:text-primary [&_a[href^="#fnref"]]:no-underline [&_a[href^="#fnref"]]:hover:underline'
}`}
remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}
rehypePlugins={[
rehypeRaw,
...((katexPlugin && (message.latexRendered ?? true)) ? [[
katexPlugin,
{
errorColor: theme === 'dark' ? '#ef4444' : '#dc2626',
throwOnError: false,
displayMode: false,
strict: false,
trust: true,
// Add silent error handling to avoid console noise
errorCallback: (error: string, latex: string) => {
// Only show detailed errors in development environment
if (process.env.NODE_ENV === 'development') {
console.warn('KaTeX rendering error in main content:', error, 'for LaTeX:', latex);
}
}
}
] as any] : []),
rehypeReact
]}
skipHtml={false}
components={mainMarkdownComponents}
>
{finalDisplayContent}
</ReactMarkdown>
</div>
)}
{/* Loading indicator - only show in active tab */}
{isTabActive && (() => {
// More comprehensive loading state check
const hasVisibleContent = finalDisplayContent && finalDisplayContent.trim() !== '';
const isLoadingState = !hasVisibleContent && !isThinking && !thinkingTime;
return isLoadingState && <LoaderIcon className="animate-spin duration-2000" />
})()}
</div>
)
}
// Remove the incorrect memo export line
interface CodeHighlightProps {
inline?: boolean
className?: string
children?: ReactNode
renderAsDiagram?: boolean // Flag to indicate if rendering as diagram should be attempted
messageRole?: 'user' | 'assistant' // Message role for context-aware styling
}
// Check if it is a large JSON
const isLargeJson = (language: string | undefined, content: string | undefined): boolean => {
if (!content || language !== 'json') return false;
return content.length > 5000; // JSON larger than 5KB is considered large JSON
};
// Memoize the CodeHighlight component
const CodeHighlight = memo(({ inline, className, children, renderAsDiagram = false, messageRole, ...props }: CodeHighlightProps) => {
const { theme } = useTheme();
const [hasRendered, setHasRendered] = useState(false); // State to track successful render
const match = className?.match(/language-(\w+)/);
const language = match ? match[1] : undefined;
const mermaidRef = useRef<HTMLDivElement>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // Use ReturnType for better typing
// Get the content string, check if it is a large JSON
const contentStr = String(children || '').replace(/\n$/, '');
const isLargeJsonBlock = isLargeJson(language, contentStr);
// Handle Mermaid rendering with debounce
useEffect(() => {
// Effect should run when renderAsDiagram becomes true or hasRendered changes.
// The actual rendering logic inside checks language and hasRendered state.
if (renderAsDiagram && !hasRendered && language === 'mermaid' && mermaidRef.current) {
const container = mermaidRef.current; // Capture ref value
// Clear previous timer if dependencies change before timeout (e.g., renderAsDiagram flips quickly)
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
if (!container) return; // Container might have unmounted
// Double check hasRendered state inside timeout, in case it changed rapidly
if (hasRendered) return;
try {
// Initialize mermaid config
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
suppressErrorRendering: true,
});
// Show loading indicator
container.innerHTML = '<div class="flex justify-center items-center p-4"><svg class="animate-spin h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>';
// Preprocess mermaid content
const rawContent = String(children).replace(/\n$/, '').trim();
// Heuristic check for potentially complete graph definition
const looksPotentiallyComplete = rawContent.length > 10 && (
rawContent.startsWith('graph') ||
rawContent.startsWith('sequenceDiagram') ||
rawContent.startsWith('classDiagram') ||
rawContent.startsWith('stateDiagram') ||
rawContent.startsWith('gantt') ||
rawContent.startsWith('pie') ||
rawContent.startsWith('flowchart') ||
rawContent.startsWith('erDiagram')
);
if (!looksPotentiallyComplete) {
console.log('Mermaid content might be incomplete, skipping render attempt:', rawContent);
// Optionally keep loading indicator or show a message
// container.innerHTML = '<p class="text-sm text-muted-foreground">Waiting for complete diagram...</p>';
return;
}
const processedContent = rawContent
.split('\n')
.map(line => {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('subgraph')) {
const parts = trimmedLine.split(' ');
if (parts.length > 1) {
const title = parts.slice(1).join(' ').replace(/["']/g, '');
return `subgraph "${title}"`;
}
}
return trimmedLine;
})
.filter(line => !line.trim().startsWith('linkStyle'))
.join('\n');
const mermaidId = `mermaid-${Date.now()}`;
mermaid.render(mermaidId, processedContent)
.then(({ svg, bindFunctions }) => {
// Check ref and hasRendered state again inside async callback
if (mermaidRef.current === container && !hasRendered) {
container.innerHTML = svg;
setHasRendered(true); // Mark as rendered successfully
if (bindFunctions) {
try {
bindFunctions(container);
} catch (bindError) {
console.error('Mermaid bindFunctions error:', bindError);
container.innerHTML += '<p class="text-orange-500 text-xs">Diagram interactions might be limited.</p>';
}
}
} else if (mermaidRef.current !== container) {
console.log('Mermaid container changed before rendering completed.');
}
})
.catch(error => {
console.error('Mermaid rendering promise error (debounced):', error);
console.error('Failed content (debounced):', processedContent);
if (mermaidRef.current === container) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorPre = document.createElement('pre');
errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';
errorPre.textContent = `Mermaid diagram error: ${errorMessage}\n\nContent:\n${processedContent}`;
container.innerHTML = '';
container.appendChild(errorPre);
}
});
} catch (error) {
console.error('Mermaid synchronous error (debounced):', error);
console.error('Failed content (debounced):', String(children));
if (mermaidRef.current === container) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorPre = document.createElement('pre');
errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';
errorPre.textContent = `Mermaid diagram setup error: ${errorMessage}`;
container.innerHTML = '';
container.appendChild(errorPre);
}
}
}, 300); // Debounce delay
}
// Cleanup function to clear the timer on unmount or before re-running effect
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
// Dependencies: renderAsDiagram ensures effect runs when diagram should be shown.
// Dependencies include all values used inside the effect to satisfy exhaustive-deps.
// The !hasRendered check prevents re-execution of render logic after success.
}, [renderAsDiagram, hasRendered, language, children, theme]); // Add children and theme back
// For large JSON, skip syntax highlighting completely and use a simple pre tag
if (isLargeJsonBlock) {
return (
<pre className="whitespace-pre-wrap break-words bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
{contentStr}
</pre>
);
}
// Render based on language type
// If it's a mermaid language block and rendering as diagram is not requested (e.g., incomplete stream), display as plain text
if (language === 'mermaid' && !renderAsDiagram) {
return (
<SyntaxHighlighter
style={theme === 'dark' ? oneDark : oneLight}
PreTag="div"
language="text" // Use text as language to avoid syntax highlighting errors
{...props}
>
{contentStr}
</SyntaxHighlighter>
);
}
// If it's a mermaid language block and the message is complete, render as diagram
if (language === 'mermaid') {
// Container for Mermaid diagram
return <div className="mermaid-diagram-container my-4 overflow-x-auto" ref={mermaidRef}></div>;
}
// ReactMarkdown determines inline vs block based on markdown syntax
// Inline code: `code` (no className with language)
// Block code: ```language (has className like "language-js")
// If there's no language className and no explicit inline prop, it's likely inline code
const isInline = inline ?? !className?.startsWith('language-');
// Generate dynamic inline code styles based on message role and theme
const getInlineCodeStyles = () => {
if (messageRole === 'user') {
// User messages have dark background (bg-primary), need light inline code
return theme === 'dark'
? 'bg-primary-foreground/20 text-primary-foreground border border-primary-foreground/30'
: 'bg-primary-foreground/20 text-primary-foreground border border-primary-foreground/30';
} else {
// Assistant messages have light background (bg-muted), need contrasting inline code
return theme === 'dark'
? 'bg-muted-foreground/20 text-muted-foreground border border-muted-foreground/30'
: 'bg-slate-200 text-slate-800 border border-slate-300';
}
};
// Handle non-Mermaid code blocks
return !isInline ? (
<SyntaxHighlighter
style={theme === 'dark' ? oneDark : oneLight}
PreTag="div"
language={language}
{...props}
>
{contentStr}
</SyntaxHighlighter>
) : (
// Handle inline code with context-aware styling
<code
className={cn(
className,
'mx-1 rounded-sm px-1 py-0.5 font-mono text-sm',
getInlineCodeStyles()
)}
{...props}
>
{children}
</code>
);
});
// Assign display name for React DevTools
CodeHighlight.displayName = 'CodeHighlight';
@@ -0,0 +1,457 @@
import { useCallback, useMemo } from 'react'
import { QueryMode, QueryRequest } from '@/api/lightrag'
// Removed unused import for Text component
import Checkbox from '@/components/ui/Checkbox'
import Input from '@/components/ui/Input'
import UserPromptInputWithHistory from '@/components/ui/UserPromptInputWithHistory'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/Select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
import { useSettingsStore } from '@/stores/settings'
import { useTranslation } from 'react-i18next'
import { RotateCcw } from 'lucide-react'
export default function QuerySettings() {
const { t } = useTranslation()
const querySettings = useSettingsStore((state) => state.querySettings)
const userPromptHistory = useSettingsStore((state) => state.userPromptHistory)
const handleChange = useCallback((key: keyof QueryRequest, value: any) => {
useSettingsStore.getState().updateQuerySettings({ [key]: value })
}, [])
const handleSelectFromHistory = useCallback((prompt: string) => {
handleChange('user_prompt', prompt)
}, [handleChange])
const handleDeleteFromHistory = useCallback((index: number) => {
const newHistory = [...userPromptHistory]
newHistory.splice(index, 1)
useSettingsStore.getState().setUserPromptHistory(newHistory)
}, [userPromptHistory])
// Default values for reset functionality
const defaultValues = useMemo(() => ({
mode: 'mix' as QueryMode,
top_k: 40,
chunk_top_k: 20,
max_entity_tokens: 6000,
max_relation_tokens: 8000,
max_total_tokens: 30000
}), [])
const handleReset = useCallback((key: keyof typeof defaultValues) => {
handleChange(key, defaultValues[key])
}, [handleChange, defaultValues])
// Reset button component
const ResetButton = ({ onClick, title }: { onClick: () => void; title: string }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className="mr-1 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title={title}
>
<RotateCcw className="h-3 w-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
<p>{title}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
return (
<Card className="flex shrink-0 flex-col w-[280px]">
<CardHeader className="px-4 pt-4 pb-2">
<CardTitle>{t('retrievePanel.querySettings.parametersTitle')}</CardTitle>
<CardDescription className="sr-only">{t('retrievePanel.querySettings.parametersDescription')}</CardDescription>
</CardHeader>
<CardContent className="m-0 flex grow flex-col p-0 text-xs">
<div className="relative size-full">
<div className="absolute inset-0 flex flex-col gap-2 overflow-auto px-2 pr-2">
{/* User Prompt - Moved to top for better dropdown space */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="user_prompt" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.userPrompt')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.userPromptTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div>
<UserPromptInputWithHistory
id="user_prompt"
value={querySettings.user_prompt || ''}
onChange={(value) => handleChange('user_prompt', value)}
onSelectFromHistory={handleSelectFromHistory}
onDeleteFromHistory={handleDeleteFromHistory}
history={userPromptHistory}
placeholder={t('retrievePanel.querySettings.userPromptPlaceholder')}
className="h-9"
/>
</div>
</>
{/* Query Mode */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="query_mode_select" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.queryMode')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.queryModeTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Select
value={querySettings.mode}
onValueChange={(v) => handleChange('mode', v as QueryMode)}
>
<SelectTrigger
id="query_mode_select"
className="hover:bg-primary/5 h-9 cursor-pointer focus:ring-0 focus:ring-offset-0 focus:outline-0 active:right-0 flex-1 text-left [&>span]:break-all [&>span]:line-clamp-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="naive">{t('retrievePanel.querySettings.queryModeOptions.naive')}</SelectItem>
<SelectItem value="local">{t('retrievePanel.querySettings.queryModeOptions.local')}</SelectItem>
<SelectItem value="global">{t('retrievePanel.querySettings.queryModeOptions.global')}</SelectItem>
<SelectItem value="hybrid">{t('retrievePanel.querySettings.queryModeOptions.hybrid')}</SelectItem>
<SelectItem value="mix">{t('retrievePanel.querySettings.queryModeOptions.mix')}</SelectItem>
<SelectItem value="bypass">{t('retrievePanel.querySettings.queryModeOptions.bypass')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<ResetButton
onClick={() => handleReset('mode')}
title="Reset to default (Mix)"
/>
</div>
</>
{/* Top K */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="top_k" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.topK')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.topKTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Input
id="top_k"
type="number"
value={querySettings.top_k ?? ''}
onChange={(e) => {
const value = e.target.value
handleChange('top_k', value === '' ? '' : parseInt(value) || 0)
}}
onBlur={(e) => {
const value = e.target.value
if (value === '' || isNaN(parseInt(value))) {
handleChange('top_k', 40)
}
}}
min={1}
placeholder={t('retrievePanel.querySettings.topKPlaceholder')}
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<ResetButton
onClick={() => handleReset('top_k')}
title="Reset to default"
/>
</div>
</>
{/* Chunk Top K */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="chunk_top_k" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.chunkTopK')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.chunkTopKTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Input
id="chunk_top_k"
type="number"
value={querySettings.chunk_top_k ?? ''}
onChange={(e) => {
const value = e.target.value
handleChange('chunk_top_k', value === '' ? '' : parseInt(value) || 0)
}}
onBlur={(e) => {
const value = e.target.value
if (value === '' || isNaN(parseInt(value))) {
handleChange('chunk_top_k', 20)
}
}}
min={1}
placeholder={t('retrievePanel.querySettings.chunkTopKPlaceholder')}
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<ResetButton
onClick={() => handleReset('chunk_top_k')}
title="Reset to default"
/>
</div>
</>
{/* Max Entity Tokens */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="max_entity_tokens" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.maxEntityTokens')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.maxEntityTokensTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Input
id="max_entity_tokens"
type="number"
value={querySettings.max_entity_tokens ?? ''}
onChange={(e) => {
const value = e.target.value
handleChange('max_entity_tokens', value === '' ? '' : parseInt(value) || 0)
}}
onBlur={(e) => {
const value = e.target.value
if (value === '' || isNaN(parseInt(value))) {
handleChange('max_entity_tokens', 6000)
}
}}
min={1}
placeholder={t('retrievePanel.querySettings.maxEntityTokensPlaceholder')}
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<ResetButton
onClick={() => handleReset('max_entity_tokens')}
title="Reset to default"
/>
</div>
</>
{/* Max Relation Tokens */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="max_relation_tokens" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.maxRelationTokens')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.maxRelationTokensTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Input
id="max_relation_tokens"
type="number"
value={querySettings.max_relation_tokens ?? ''}
onChange={(e) => {
const value = e.target.value
handleChange('max_relation_tokens', value === '' ? '' : parseInt(value) || 0)
}}
onBlur={(e) => {
const value = e.target.value
if (value === '' || isNaN(parseInt(value))) {
handleChange('max_relation_tokens', 8000)
}
}}
min={1}
placeholder={t('retrievePanel.querySettings.maxRelationTokensPlaceholder')}
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<ResetButton
onClick={() => handleReset('max_relation_tokens')}
title="Reset to default"
/>
</div>
</>
{/* Max Total Tokens */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="max_total_tokens" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.maxTotalTokens')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.maxTotalTokensTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Input
id="max_total_tokens"
type="number"
value={querySettings.max_total_tokens ?? ''}
onChange={(e) => {
const value = e.target.value
handleChange('max_total_tokens', value === '' ? '' : parseInt(value) || 0)
}}
onBlur={(e) => {
const value = e.target.value
if (value === '' || isNaN(parseInt(value))) {
handleChange('max_total_tokens', 30000)
}
}}
min={1}
placeholder={t('retrievePanel.querySettings.maxTotalTokensPlaceholder')}
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<ResetButton
onClick={() => handleReset('max_total_tokens')}
title="Reset to default"
/>
</div>
</>
{/* Toggle Options */}
<>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="enable_rerank" className="flex-1 ml-1 cursor-help">
{t('retrievePanel.querySettings.enableRerank')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.enableRerankTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
className="mr-10 cursor-pointer"
id="enable_rerank"
checked={querySettings.enable_rerank}
onCheckedChange={(checked) => handleChange('enable_rerank', checked)}
/>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="only_need_context" className="flex-1 ml-1 cursor-help">
{t('retrievePanel.querySettings.onlyNeedContext')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.onlyNeedContextTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
className="mr-10 cursor-pointer"
id="only_need_context"
checked={querySettings.only_need_context}
onCheckedChange={(checked) => {
handleChange('only_need_context', checked)
if (checked) {
handleChange('only_need_prompt', false)
}
}}
/>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="only_need_prompt" className="flex-1 ml-1 cursor-help">
{t('retrievePanel.querySettings.onlyNeedPrompt')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.onlyNeedPromptTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
className="mr-10 cursor-pointer"
id="only_need_prompt"
checked={querySettings.only_need_prompt}
onCheckedChange={(checked) => {
handleChange('only_need_prompt', checked)
if (checked) {
handleChange('only_need_context', false)
}
}}
/>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="stream" className="flex-1 ml-1 cursor-help">
{t('retrievePanel.querySettings.streamResponse')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.streamResponseTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
className="mr-10 cursor-pointer"
id="stream"
checked={querySettings.stream}
onCheckedChange={(checked) => handleChange('stream', checked)}
/>
</div>
</>
</div>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,91 @@
import { LightragStatus } from '@/api/lightrag'
import { useTranslation } from 'react-i18next'
const StatusCard = ({ status }: { status: LightragStatus | null }) => {
const { t } = useTranslation()
if (!status) {
return <div className="text-foreground text-xs">{t('graphPanel.statusCard.unavailable')}</div>
}
return (
<div className="min-w-[300px] space-y-2 text-xs">
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.serverInfo')}</h4>
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
<span>{t('graphPanel.statusCard.workingDirectory')}:</span>
<span className="truncate">{status.working_directory}</span>
<span>{t('graphPanel.statusCard.inputDirectory')}:</span>
<span className="truncate">{status.input_directory}</span>
<span>{t('graphPanel.statusCard.summarySettings')}:</span>
<span>{status.configuration.summary_language} / LLM summary on {status.configuration.force_llm_summary_on_merge.toString()} fragments</span>
<span>{t('graphPanel.statusCard.threshold')}:</span>
<span>cosine {status.configuration.cosine_threshold} / rerank_score {status.configuration.min_rerank_score} / max_related {status.configuration.related_chunk_number}</span>
<span>{t('graphPanel.statusCard.maxParallelInsert')}:</span>
<span>{status.configuration.max_parallel_insert}</span>
</div>
</div>
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.llmConfig')}</h4>
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
<span>{t('graphPanel.statusCard.llmBindingHost')}:</span>
<span>{status.configuration.llm_binding_host}</span>
<span>{t('graphPanel.statusCard.llmModel')}:</span>
<span>{status.configuration.llm_binding}: {status.configuration.llm_model} (#{status.configuration.max_async} Async)</span>
</div>
</div>
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.embeddingConfig')}</h4>
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
<span>{t('graphPanel.statusCard.embeddingBindingHost')}:</span>
<span>{status.configuration.embedding_binding_host}</span>
<span>{t('graphPanel.statusCard.embeddingModel')}:</span>
<span>{status.configuration.embedding_binding}: {status.configuration.embedding_model} (#{status.configuration.embedding_func_max_async} Async * {status.configuration.embedding_batch_num} batches)</span>
</div>
</div>
{status.configuration.enable_rerank && (
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.rerankerConfig')}</h4>
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
<span>{t('graphPanel.statusCard.rerankerBindingHost')}:</span>
<span>{status.configuration.rerank_binding_host || '-'}</span>
<span>{t('graphPanel.statusCard.rerankerModel')}:</span>
<span>{(status.configuration.rerank_binding || '-')} : {(status.configuration.rerank_model || '-')}</span>
</div>
</div>
)}
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.storageConfig')}</h4>
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
<span>{t('graphPanel.statusCard.kvStorage')}:</span>
<span>{status.configuration.kv_storage}</span>
<span>{t('graphPanel.statusCard.docStatusStorage')}:</span>
<span>{status.configuration.doc_status_storage}</span>
<span>{t('graphPanel.statusCard.graphStorage')}:</span>
<span>{status.configuration.graph_storage}</span>
<span>{t('graphPanel.statusCard.vectorStorage')}:</span>
<span>{status.configuration.vector_storage}</span>
<span>{t('graphPanel.statusCard.workspace')}:</span>
<span>{status.configuration.workspace || '-'}</span>
<span>{t('graphPanel.statusCard.maxGraphNodes')}:</span>
<span>{status.configuration.max_graph_nodes || '-'}</span>
{status.keyed_locks && (
<>
<span>{t('graphPanel.statusCard.lockStatus')}:</span>
<span>
mp {status.keyed_locks.current_status.pending_mp_cleanup}/{status.keyed_locks.current_status.total_mp_locks} |
async {status.keyed_locks.current_status.pending_async_cleanup}/{status.keyed_locks.current_status.total_async_locks}
(pid: {status.keyed_locks.process_id})
</span>
</>
)}
</div>
</div>
</div>
)
}
export default StatusCard
@@ -0,0 +1,36 @@
import { LightragStatus } from '@/api/lightrag'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/Dialog'
import StatusCard from './StatusCard'
interface StatusDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
status: LightragStatus | null
}
const StatusDialog = ({ open, onOpenChange, status }: StatusDialogProps) => {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>{t('graphPanel.statusDialog.title')}</DialogTitle>
<DialogDescription>
{t('graphPanel.statusDialog.description')}
</DialogDescription>
</DialogHeader>
<StatusCard status={status} />
</DialogContent>
</Dialog>
)
}
export default StatusDialog
@@ -0,0 +1,52 @@
import { cn } from '@/lib/utils'
import { useBackendState } from '@/stores/state'
import { useEffect, useState } from 'react'
import StatusDialog from './StatusDialog'
import { useTranslation } from 'react-i18next'
const StatusIndicator = () => {
const { t } = useTranslation()
const health = useBackendState.use.health()
const lastCheckTime = useBackendState.use.lastCheckTime()
const status = useBackendState.use.status()
const [animate, setAnimate] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
// listen to health change
useEffect(() => {
setAnimate(true)
const timer = setTimeout(() => setAnimate(false), 300)
return () => clearTimeout(timer)
}, [lastCheckTime])
return (
<div className="fixed right-4 bottom-4 flex items-center gap-2 opacity-80 select-none">
<div
className="flex cursor-pointer items-center gap-2"
onClick={() => setDialogOpen(true)}
>
<div
className={cn(
'h-3 w-3 rounded-full transition-all duration-300',
'shadow-[0_0_8px_rgba(0,0,0,0.2)]',
health ? 'bg-green-500' : 'bg-red-500',
animate && 'scale-125',
animate && health && 'shadow-[0_0_12px_rgba(34,197,94,0.4)]',
animate && !health && 'shadow-[0_0_12px_rgba(239,68,68,0.4)]'
)}
/>
<span className="text-muted-foreground text-xs">
{health ? t('graphPanel.statusIndicator.connected') : t('graphPanel.statusIndicator.disconnected')}
</span>
</div>
<StatusDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
status={status}
/>
</div>
)
}
export default StatusIndicator
@@ -0,0 +1,49 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive'
}
},
defaultVariants: {
variant: 'default'
}
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
{...props}
/>
)
)
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,115 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/Button'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
)
AlertDialogHeader.displayName = 'AlertDialogHeader'
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
const AlertDialogTitle = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel
}
@@ -0,0 +1,242 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Loader2 } from 'lucide-react'
import { useDebounce } from '@/hooks/useDebounce'
import { cn } from '@/lib/utils'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/Command'
export interface Option {
value: string
label: string
disabled?: boolean
description?: string
icon?: React.ReactNode
}
export interface AsyncSearchProps<T> {
/** Async function to fetch options */
fetcher: (query?: string) => Promise<T[]>
/** Preload all data ahead of time */
preload?: boolean
/** Function to filter options */
filterFn?: (option: T, query: string) => boolean
/** Function to render each option */
renderOption: (option: T) => React.ReactNode
/** Function to get the value from an option */
getOptionValue: (option: T) => string
/** Custom not found message */
notFound?: React.ReactNode
/** Custom loading skeleton */
loadingSkeleton?: React.ReactNode
/** Currently selected value */
value: string | null
/** Callback when selection changes */
onChange: (value: string) => void
/** Callback when focus changes */
onFocus: (value: string) => void
/** Accessibility label for the search field */
ariaLabel?: string
/** Placeholder text when no selection */
placeholder?: string
/** Disable the entire select */
disabled?: boolean
/** Custom width for the popover */
width?: string | number
/** Custom class names */
className?: string
/** Custom trigger button class names */
triggerClassName?: string
/** Custom no results message */
noResultsMessage?: string
/** Allow clearing the selection */
clearable?: boolean
}
export function AsyncSearch<T>({
fetcher,
preload,
filterFn,
renderOption,
getOptionValue,
notFound,
loadingSkeleton,
ariaLabel,
placeholder = 'Select...',
value,
onChange,
onFocus,
disabled = false,
className,
noResultsMessage
}: AsyncSearchProps<T>) {
const [mounted, setMounted] = useState(false)
const [open, setOpen] = useState(false)
const [options, setOptions] = useState<T[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setMounted(true)
}, [])
// Handle clicks outside of the component
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node) &&
open
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [open])
const fetchOptions = useCallback(async (query: string) => {
try {
setLoading(true)
setError(null)
const data = await fetcher(query)
setOptions(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch options')
} finally {
setLoading(false)
}
}, [fetcher])
// Load options when search term changes
useEffect(() => {
if (!mounted) return
if (preload) {
if (debouncedSearchTerm) {
setOptions((prev) =>
prev.filter((option) =>
filterFn ? filterFn(option, debouncedSearchTerm) : true
)
)
}
} else {
fetchOptions(debouncedSearchTerm)
}
}, [mounted, debouncedSearchTerm, preload, filterFn, fetchOptions])
// Load initial value
useEffect(() => {
if (!mounted || !value) return
fetchOptions(value)
}, [mounted, value, fetchOptions])
const handleSelect = useCallback((currentValue: string) => {
onChange(currentValue)
requestAnimationFrame(() => {
// Blur the input to ensure focus event triggers on next click
const input = document.activeElement as HTMLElement
input?.blur()
// Close the dropdown
setOpen(false)
})
}, [onChange])
const handleFocus = useCallback(() => {
setOpen(true)
// Use current search term to fetch options
fetchOptions(searchTerm)
}, [searchTerm, fetchOptions])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (target.closest('.cmd-item')) {
e.preventDefault()
}
}, [])
return (
<div
ref={containerRef}
className={cn(disabled && 'cursor-not-allowed opacity-50', className)}
onMouseDown={handleMouseDown}
>
<Command shouldFilter={false} className="bg-transparent">
<div>
<CommandInput
placeholder={placeholder}
value={searchTerm}
className="max-h-8"
aria-label={ariaLabel}
onFocus={handleFocus}
onValueChange={(value) => {
setSearchTerm(value)
if (!open) setOpen(true)
}}
/>
{loading && (
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
<CommandList hidden={!open}>
{error && <div className="text-destructive p-4 text-center">{error}</div>}
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
{!loading &&
!error &&
options.length === 0 &&
(notFound || (
<CommandEmpty>{noResultsMessage || 'No results found.'}</CommandEmpty>
))}
<CommandGroup>
{options.map((option, idx) => (
<React.Fragment key={getOptionValue(option) + `-fragment-${idx}`}>
<CommandItem
key={getOptionValue(option) + `${idx}`}
value={getOptionValue(option)}
onSelect={handleSelect}
onMouseMove={() => onFocus(getOptionValue(option))}
className="truncate cmd-item"
>
{renderOption(option)}
</CommandItem>
{idx !== options.length - 1 && (
<div key={`divider-${idx}`} className="bg-foreground/10 h-[1px]" />
)}
</React.Fragment>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
)
}
function DefaultLoadingSkeleton() {
return (
<CommandGroup>
<CommandItem disabled>
<div className="flex w-full items-center gap-2">
<div className="bg-muted h-6 w-6 animate-pulse rounded-full" />
<div className="flex flex-1 flex-col gap-1">
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
</div>
</div>
</CommandItem>
</CommandGroup>
)
}
@@ -0,0 +1,315 @@
import { useState, useEffect, useCallback } from 'react'
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import { useDebounce } from '@/hooks/useDebounce'
import { cn } from '@/lib/utils'
import Button from '@/components/ui/Button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/Command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
export interface Option {
value: string
label: string
disabled?: boolean
description?: string
icon?: React.ReactNode
}
export interface AsyncSelectProps<T> {
/** Async function to fetch options */
fetcher: (query?: string) => Promise<T[]>
/** Preload all data ahead of time */
preload?: boolean
/** Function to filter options */
filterFn?: (option: T, query: string) => boolean
/** Function to render each option */
renderOption: (option: T) => React.ReactNode
/** Function to get the value from an option */
getOptionValue: (option: T) => string
/** Function to get the display value for the selected option */
getDisplayValue: (option: T) => React.ReactNode
/** Custom not found message */
notFound?: React.ReactNode
/** Custom loading skeleton */
loadingSkeleton?: React.ReactNode
/** Currently selected value */
value: string
/** Callback when selection changes */
onChange: (value: string) => void
/** Callback before opening the dropdown (async supported) */
onBeforeOpen?: () => void | Promise<void>
/** Accessibility label for the select field */
ariaLabel?: string
/** Placeholder text when no selection */
placeholder?: string
/** Display text for search placeholder */
searchPlaceholder?: string
/** Disable the entire select */
disabled?: boolean
/** Custom width for the popover *
width?: string | number
/** Custom class names */
className?: string
/** Custom trigger button class names */
triggerClassName?: string
/** Custom search input class names */
searchInputClassName?: string
/** Custom no results message */
noResultsMessage?: string
/** Custom trigger tooltip */
triggerTooltip?: string
/** Allow clearing the selection */
clearable?: boolean
/** Debounce time in milliseconds */
debounceTime?: number
}
export function AsyncSelect<T>({
fetcher,
preload,
filterFn,
renderOption,
getOptionValue,
getDisplayValue,
notFound,
loadingSkeleton,
ariaLabel,
placeholder = 'Select...',
searchPlaceholder,
value,
onChange,
onBeforeOpen,
disabled = false,
className,
triggerClassName,
searchInputClassName,
noResultsMessage,
triggerTooltip,
clearable = true,
debounceTime = 150
}: AsyncSelectProps<T>) {
const [mounted, setMounted] = useState(false)
const [open, setOpen] = useState(false)
const [options, setOptions] = useState<T[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedValue, setSelectedValue] = useState(value)
const [selectedOption, setSelectedOption] = useState<T | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : debounceTime)
const [originalOptions, setOriginalOptions] = useState<T[]>([])
const [initialValueDisplay, setInitialValueDisplay] = useState<React.ReactNode | null>(null)
useEffect(() => {
setMounted(true)
setSelectedValue(value)
}, [value])
// Add an effect to handle initial value display
useEffect(() => {
if (value && (!options.length || !selectedOption)) {
// Create a temporary display until options are loaded
setInitialValueDisplay(<div>{value}</div>)
} else if (selectedOption) {
// Once we find the actual selectedOption, clear the temporary display
setInitialValueDisplay(null)
}
}, [value, options.length, selectedOption])
// Initialize selectedOption when options are loaded and value exists
useEffect(() => {
if (value && options.length > 0) {
const option = options.find((opt) => getOptionValue(opt) === value)
if (option) {
setSelectedOption(option)
}
}
}, [value, options, getOptionValue])
// Effect for initial fetch
useEffect(() => {
const initializeOptions = async () => {
try {
setLoading(true)
setError(null)
// Always use empty query for initial load to show search history
const data = await fetcher('')
setOriginalOptions(data)
setOptions(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch options')
} finally {
setLoading(false)
}
}
if (!mounted) {
initializeOptions()
}
}, [mounted, fetcher])
useEffect(() => {
const fetchOptions = async () => {
try {
setLoading(true)
setError(null)
const data = await fetcher(debouncedSearchTerm)
setOriginalOptions(data)
setOptions(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch options')
} finally {
setLoading(false)
}
}
if (!mounted) {
fetchOptions()
} else if (!preload) {
fetchOptions()
} else if (preload) {
if (debouncedSearchTerm) {
setOptions(
originalOptions.filter((option) =>
filterFn ? filterFn(option, debouncedSearchTerm) : true
)
)
} else {
setOptions(originalOptions)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher, debouncedSearchTerm, mounted, preload, filterFn])
const handleSelect = useCallback(
(currentValue: string) => {
const newValue = clearable && currentValue === selectedValue ? '' : currentValue
setSelectedValue(newValue)
setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null)
onChange(newValue)
setOpen(false)
},
[selectedValue, onChange, clearable, options, getOptionValue]
)
const handleOpenChange = useCallback(
async (newOpen: boolean) => {
if (newOpen && onBeforeOpen) {
await onBeforeOpen()
}
setOpen(newOpen)
},
[onBeforeOpen]
)
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={ariaLabel}
className={cn(
'justify-between',
disabled && 'cursor-not-allowed opacity-50',
triggerClassName
)}
disabled={disabled}
tooltip={triggerTooltip}
side="bottom"
>
{value === '*' ? <div>*</div> : (selectedOption ? getDisplayValue(selectedOption) : (initialValueDisplay || placeholder))}
<ChevronsUpDown className="opacity-50" size={10} />
</Button>
</PopoverTrigger>
<PopoverContent
className={cn('p-0', className)}
onCloseAutoFocus={(e) => e.preventDefault()}
align="start"
sideOffset={8}
collisionPadding={5}
>
<Command shouldFilter={false}>
<div className="relative w-full border-b">
<CommandInput
placeholder={searchPlaceholder || 'Search...'}
value={searchTerm}
onValueChange={(value) => {
setSearchTerm(value)
}}
className={searchInputClassName}
/>
{loading && options.length > 0 && (
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
<CommandList>
{error && <div className="text-destructive p-4 text-center">{error}</div>}
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
{!loading &&
!error &&
options.length === 0 &&
(notFound || (
<CommandEmpty>
{noResultsMessage || 'No results found.'}
</CommandEmpty>
))}
<CommandGroup>
{options.map((option) => {
const optionValue = getOptionValue(option);
// Fix cmdk filtering issue: use empty string when search is empty
// This ensures all items are shown when searchTerm is empty
const itemValue = searchTerm.trim() === '' ? '' : optionValue;
return (
<CommandItem
key={optionValue}
value={itemValue}
onSelect={() => {
handleSelect(optionValue);
}}
className="truncate"
>
{renderOption(option)}
<Check
className={cn(
'ml-auto h-3 w-3',
selectedValue === optionValue ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
function DefaultLoadingSkeleton() {
return (
<CommandGroup>
<CommandItem disabled>
<div className="flex w-full items-center gap-2">
<div className="bg-muted h-6 w-6 animate-pulse rounded-full" />
<div className="flex flex-1 flex-col gap-1">
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
</div>
</div>
</CommandItem>
</CommandGroup>
)
}
@@ -0,0 +1,33 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground'
}
},
defaultVariants: {
variant: 'default'
}
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export default Badge
@@ -0,0 +1,78 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
import { cn } from '@/lib/utils'
// eslint-disable-next-line react-refresh/only-export-components
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'size-8'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
side?: 'top' | 'right' | 'bottom' | 'left'
tooltip?: string
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, tooltip, size, side = 'right', asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
if (!tooltip) {
return (
<Comp
className={cn(buttonVariants({ variant, size, className }), 'cursor-pointer')}
ref={ref}
{...props}
/>
)
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Comp
className={cn(buttonVariants({ variant, size, className }), 'cursor-pointer')}
ref={ref}
{...props}
/>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
)
Button.displayName = 'Button'
export type ButtonVariantType = Exclude<
NonNullable<Parameters<typeof buttonVariants>[0]>['variant'],
undefined
>
export default Button
@@ -0,0 +1,55 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('leading-none font-semibold tracking-tight', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,26 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-muted data-[state=checked]:text-muted-foreground h-4 w-4 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export default Checkbox
@@ -0,0 +1,143 @@
import * as React from 'react'
import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Dialog, DialogContent } from './Dialog'
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
// eslint-disable-next-line react/no-unknown-property
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
// eslint-disable-next-line @stylistic/js/quotes
"data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator
}
@@ -0,0 +1,64 @@
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/Table'
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel()
})
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,102 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/30',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}
@@ -0,0 +1,38 @@
import { cn } from '@/lib/utils'
import { Card, CardDescription, CardTitle } from '@/components/ui/Card'
import { FilesIcon } from 'lucide-react'
interface EmptyCardProps extends React.ComponentPropsWithoutRef<typeof Card> {
title: string
description?: string
action?: React.ReactNode
icon?: React.ComponentType<{ className?: string }>
}
export default function EmptyCard({
title,
description,
icon: Icon = FilesIcon,
action,
className,
...props
}: EmptyCardProps) {
return (
<Card
className={cn(
'flex w-full flex-col items-center justify-center space-y-6 bg-transparent p-16',
className
)}
{...props}
>
<div className="mr-4 shrink-0 rounded-full border border-dashed p-4">
<Icon className="text-muted-foreground size-8" aria-hidden="true" />
</div>
<div className="flex flex-col items-center gap-1.5 text-center">
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
{action ? action : null}
</Card>
)
}
@@ -0,0 +1,454 @@
/**
* @see https://github.com/sadmann7/file-uploader
*/
import * as React from 'react'
import { FileText, Upload, X } from 'lucide-react'
import Dropzone, { type DropzoneProps, type FileRejection } from 'react-dropzone'
import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import Button from '@/components/ui/Button'
import { ScrollArea } from '@/components/ui/ScrollArea'
import { supportedFileTypes } from '@/lib/constants'
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Value of the uploader.
* @type File[]
* @default undefined
* @example value={files}
*/
value?: File[]
/**
* Function to be called when the value changes.
* @type (files: File[]) => void
* @default undefined
* @example onValueChange={(files) => setFiles(files)}
*/
onValueChange?: (files: File[]) => void
/**
* Function to be called when files are uploaded.
* @type (files: File[]) => Promise<void>
* @default undefined
* @example onUpload={(files) => uploadFiles(files)}
*/
onUpload?: (files: File[]) => Promise<void>
/**
* Function to be called when files are rejected.
* @type (rejections: FileRejection[]) => void
* @default undefined
* @example onReject={(rejections) => handleRejectedFiles(rejections)}
*/
onReject?: (rejections: FileRejection[]) => void
/**
* Progress of the uploaded files.
* @type Record<string, number> | undefined
* @default undefined
* @example progresses={{ "file1.png": 50 }}
*/
progresses?: Record<string, number>
/**
* Error messages for failed uploads.
* @type Record<string, string> | undefined
* @default undefined
* @example fileErrors={{ "file1.png": "Upload failed" }}
*/
fileErrors?: Record<string, string>
/**
* Accepted file types for the uploader.
* @type { [key: string]: string[]}
* @default
* ```ts
* { "text/*": [] }
* ```
* @example accept={["text/plain", "application/pdf"]}
*/
accept?: DropzoneProps['accept']
/**
* Maximum file size for the uploader.
* @type number | undefined
* @default 1024 * 1024 * 200 // 200MB
* @example maxSize={1024 * 1024 * 2} // 2MB
*/
maxSize?: DropzoneProps['maxSize']
/**
* Maximum number of files for the uploader.
* @type number | undefined
* @default 1
* @example maxFileCount={4}
*/
maxFileCount?: DropzoneProps['maxFiles']
/**
* Whether the uploader should accept multiple files.
* @type boolean
* @default false
* @example multiple
*/
multiple?: boolean
/**
* Whether the uploader is disabled.
* @type boolean
* @default false
* @example disabled
*/
disabled?: boolean
description?: string
}
function formatBytes(
bytes: number,
opts: {
decimals?: number
sizeType?: 'accurate' | 'normal'
} = {}
) {
const { decimals = 0, sizeType = 'normal' } = opts
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB']
if (bytes === 0) return '0 Byte'
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
sizeType === 'accurate' ? (accurateSizes[i] ?? 'Bytes') : (sizes[i] ?? 'Bytes')
}`
}
function FileUploader(props: FileUploaderProps) {
const { t } = useTranslation()
const {
value: valueProp,
onValueChange,
onUpload,
onReject,
progresses,
fileErrors,
accept = supportedFileTypes,
maxSize = 1024 * 1024 * 200,
maxFileCount = 1,
multiple = false,
disabled = false,
description,
className,
...dropzoneProps
} = props
const [files, setFiles] = useControllableState({
prop: valueProp,
onChange: onValueChange
})
const onDrop = React.useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
// Calculate total file count including both accepted and rejected files
const totalFileCount = (files?.length ?? 0) + acceptedFiles.length + rejectedFiles.length
// Check file count limits
if (!multiple && maxFileCount === 1 && (acceptedFiles.length + rejectedFiles.length) > 1) {
toast.error(t('documentPanel.uploadDocuments.fileUploader.singleFileLimit'))
return
}
if (totalFileCount > maxFileCount) {
toast.error(t('documentPanel.uploadDocuments.fileUploader.maxFilesLimit', { count: maxFileCount }))
return
}
// Handle rejected files first - this will set error states
if (rejectedFiles.length > 0) {
if (onReject) {
// Use the onReject callback if provided
onReject(rejectedFiles)
} else {
// Fall back to toast notifications if no callback is provided
rejectedFiles.forEach(({ file }) => {
toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name }))
})
}
}
// Process accepted files
const newAcceptedFiles = acceptedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file)
})
)
// Process rejected files for UI display
const newRejectedFiles = rejectedFiles.map(({ file }) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
rejected: true
})
)
// Combine all files for display
const allNewFiles = [...newAcceptedFiles, ...newRejectedFiles]
const updatedFiles = files ? [...files, ...allNewFiles] : allNewFiles
// Update the files state with all files
setFiles(updatedFiles)
// Only upload accepted files - make sure we're not uploading rejected files
if (onUpload && acceptedFiles.length > 0) {
// Filter out any files that might have been rejected by our custom validator
const validFiles = acceptedFiles.filter(file => {
// Skip files without a name
if (!file.name) {
return false;
}
// Check if file type is accepted
const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;
const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt));
});
// Check file size
const isSizeValid = file.size <= maxSize;
return isAccepted && isSizeValid;
});
if (validFiles.length > 0) {
onUpload(validFiles);
}
}
},
[files, maxFileCount, multiple, onUpload, onReject, setFiles, t, accept, maxSize]
)
function onRemove(index: number) {
if (!files) return
const newFiles = files.filter((_, i) => i !== index)
setFiles(newFiles)
onValueChange?.(newFiles)
}
// Revoke preview url when component unmounts
React.useEffect(() => {
return () => {
if (!files) return
files.forEach((file) => {
if (isFileWithPreview(file)) {
URL.revokeObjectURL(file.preview)
}
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount
return (
<div className="relative flex flex-col gap-6 overflow-hidden">
<Dropzone
onDrop={onDrop}
// remove acceptuse customizd validator
noClick={false}
noKeyboard={false}
maxSize={maxSize}
maxFiles={maxFileCount}
multiple={maxFileCount > 1 || multiple}
disabled={isDisabled}
validator={(file) => {
// Ensure file name exists
if (!file.name) {
return {
code: 'invalid-file-name',
message: t('documentPanel.uploadDocuments.fileUploader.invalidFileName',
{ fallback: 'Invalid file name' })
};
}
// Safely extract file extension
const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;
// Ensure accept object exists and has correct format
const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
// Ensure extensions is an array before calling includes
return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt));
});
if (!isAccepted) {
return {
code: 'file-invalid-type',
message: t('documentPanel.uploadDocuments.fileUploader.unsupportedType')
};
}
// Check file size
if (file.size > maxSize) {
return {
code: 'file-too-large',
message: t('documentPanel.uploadDocuments.fileUploader.fileTooLarge', {
maxSize: formatBytes(maxSize)
})
};
}
return null;
}}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={cn(
'group border-muted-foreground/25 hover:bg-muted/25 relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed px-5 py-2.5 text-center transition',
'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
isDragActive && 'border-muted-foreground/50',
isDisabled && 'pointer-events-none opacity-60',
className
)}
{...dropzoneProps}
>
<input {...getInputProps()} />
{isDragActive ? (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3">
<Upload className="text-muted-foreground size-7" aria-hidden="true" />
</div>
<p className="text-muted-foreground font-medium">{t('documentPanel.uploadDocuments.fileUploader.dropHere')}</p>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3">
<Upload className="text-muted-foreground size-7" aria-hidden="true" />
</div>
<div className="flex flex-col gap-px">
<p className="text-muted-foreground font-medium">
{t('documentPanel.uploadDocuments.fileUploader.dragAndDrop')}
</p>
{description ? (
<p className="text-muted-foreground/70 text-sm">{description}</p>
) : (
<p className="text-muted-foreground/70 text-sm">
{t('documentPanel.uploadDocuments.fileUploader.uploadDescription', {
count: maxFileCount,
isMultiple: maxFileCount === Infinity,
maxSize: formatBytes(maxSize)
})}
{t('documentPanel.uploadDocuments.fileTypes')}
</p>
)}
</div>
</div>
)}
</div>
)}
</Dropzone>
{files?.length ? (
<ScrollArea className="h-fit w-full px-3">
<div className="flex max-h-48 flex-col gap-4">
{files?.map((file, index) => (
<FileCard
key={index}
file={file}
onRemove={() => onRemove(index)}
progress={progresses?.[file.name]}
error={fileErrors?.[file.name]}
/>
))}
</div>
</ScrollArea>
) : null}
</div>
)
}
interface ProgressProps {
value: number
error?: boolean
showIcon?: boolean // New property to control icon display
}
function Progress({ value, error }: ProgressProps) {
return (
<div className="relative h-2 w-full">
<div className="h-full w-full overflow-hidden rounded-full bg-secondary">
<div
className={cn(
'h-full transition-all',
error ? 'bg-red-400' : 'bg-primary'
)}
style={{ width: `${value}%` }}
/>
</div>
</div>
)
}
interface FileCardProps {
file: File
onRemove: () => void
progress?: number
error?: string
}
function FileCard({ file, progress, error, onRemove }: FileCardProps) {
const { t } = useTranslation()
return (
<div className="relative flex items-center gap-2.5">
<div className="flex flex-1 gap-2.5">
{error ? (
<FileText className="text-red-400 size-10" aria-hidden="true" />
) : (
isFileWithPreview(file) ? <FilePreview file={file} /> : null
)}
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-px">
<p className="text-foreground/80 line-clamp-1 text-sm font-medium">{file.name}</p>
<p className="text-muted-foreground text-xs">{formatBytes(file.size)}</p>
</div>
{error ? (
<div className="text-red-400 text-sm">
<div className="relative mb-2">
<Progress value={100} error={true} />
</div>
<p>{error}</p>
</div>
) : (
progress ? <Progress value={progress} /> : null
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="icon" className="size-7" onClick={onRemove}>
<X className="size-4" aria-hidden="true" />
<span className="sr-only">{t('documentPanel.uploadDocuments.fileUploader.removeFile')}</span>
</Button>
</div>
</div>
)
}
function isFileWithPreview(file: File): file is File & { preview: string } {
return 'preview' in file && typeof file.preview === 'string'
}
interface FilePreviewProps {
file: File & { preview: string }
}
function FilePreview({ file }: FilePreviewProps) {
if (file.type.startsWith('image/')) {
return <div className="aspect-square shrink-0 rounded-md object-cover" />
}
return <FileText className="text-muted-foreground size-10" aria-hidden="true" />
}
export default FileUploader
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm [&::-webkit-inner-spin-button]:opacity-50 [&::-webkit-outer-spin-button]:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export default Input
@@ -0,0 +1,131 @@
import { ChevronDown, ChevronUp } from 'lucide-react'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { NumericFormat, NumericFormatProps } from 'react-number-format'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import { cn } from '@/lib/utils'
export interface NumberInputProps extends Omit<NumericFormatProps, 'value' | 'onValueChange'> {
stepper?: number
thousandSeparator?: string
placeholder?: string
defaultValue?: number
min?: number
max?: number
value?: number // Controlled value
suffix?: string
prefix?: string
onValueChange?: (value: number | undefined) => void
fixedDecimalScale?: boolean
decimalScale?: number
}
const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
(
{
stepper,
thousandSeparator,
placeholder,
defaultValue,
min = -Infinity,
max = Infinity,
onValueChange,
fixedDecimalScale = false,
decimalScale = 0,
className = undefined,
suffix,
prefix,
value: controlledValue,
...props
},
ref
) => {
const [value, setValue] = useState<number | undefined>(controlledValue ?? defaultValue)
const handleIncrement = useCallback(() => {
setValue((prev) =>
prev === undefined ? (stepper ?? 1) : Math.min(prev + (stepper ?? 1), max)
)
}, [stepper, max])
const handleDecrement = useCallback(() => {
setValue((prev) =>
prev === undefined ? -(stepper ?? 1) : Math.max(prev - (stepper ?? 1), min)
)
}, [stepper, min])
useEffect(() => {
if (controlledValue !== undefined) {
setValue(controlledValue)
}
}, [controlledValue])
const handleChange = (values: { value: string; floatValue: number | undefined }) => {
const newValue = values.floatValue === undefined ? undefined : values.floatValue
setValue(newValue)
if (onValueChange) {
onValueChange(newValue)
}
}
const handleBlur = () => {
if (value !== undefined) {
if (value < min) {
setValue(min)
;(ref as React.RefObject<HTMLInputElement>).current!.value = String(min)
} else if (value > max) {
setValue(max)
;(ref as React.RefObject<HTMLInputElement>).current!.value = String(max)
}
}
}
return (
<div className="relative flex">
<NumericFormat
value={value}
onValueChange={handleChange}
thousandSeparator={thousandSeparator}
decimalScale={decimalScale}
fixedDecimalScale={fixedDecimalScale}
allowNegative={min < 0}
valueIsNumericString
onBlur={handleBlur}
max={max}
min={min}
suffix={suffix}
prefix={prefix}
customInput={(props) => <Input {...props} className={cn('w-full', className)} />}
placeholder={placeholder}
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
getInputRef={ref}
{...props}
/>
<div className="absolute top-0 right-0 bottom-0 flex flex-col">
<Button
aria-label="Increase value"
className="border-input h-1/2 rounded-l-none rounded-br-none border-b border-l px-2 focus-visible:relative"
variant="outline"
onClick={handleIncrement}
disabled={value === max}
>
<ChevronUp size={15} />
</Button>
<Button
aria-label="Decrease value"
className="border-input h-1/2 rounded-l-none rounded-tr-none border-b border-l px-2 focus-visible:relative"
variant="outline"
onClick={handleDecrement}
disabled={value === min}
>
<ChevronDown size={15} />
</Button>
</div>
</div>
)
}
)
NumberInput.displayName = 'NumberInput'
export default NumberInput
@@ -0,0 +1,259 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from './Button'
import Input from './Input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './Select'
import { cn } from '@/lib/utils'
import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react'
export type PaginationControlsProps = {
currentPage: number
totalPages: number
pageSize: number
totalCount: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
isLoading?: boolean
compact?: boolean
className?: string
}
const PAGE_SIZE_OPTIONS = [
{ value: 10, label: '10' },
{ value: 20, label: '20' },
{ value: 50, label: '50' },
{ value: 100, label: '100' },
{ value: 200, label: '200' }
]
export default function PaginationControls({
currentPage,
totalPages,
pageSize,
totalCount,
onPageChange,
onPageSizeChange,
isLoading = false,
compact = false,
className
}: PaginationControlsProps) {
const { t } = useTranslation()
const [inputPage, setInputPage] = useState(currentPage.toString())
// Update input when currentPage changes
useEffect(() => {
setInputPage(currentPage.toString())
}, [currentPage])
// Handle page input change with debouncing
const handlePageInputChange = useCallback((value: string) => {
setInputPage(value)
}, [])
// Handle page input submit
const handlePageInputSubmit = useCallback(() => {
const pageNum = parseInt(inputPage, 10)
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
onPageChange(pageNum)
} else {
// Reset to current page if invalid
setInputPage(currentPage.toString())
}
}, [inputPage, totalPages, onPageChange, currentPage])
// Handle page input key press
const handlePageInputKeyPress = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handlePageInputSubmit()
}
}, [handlePageInputSubmit])
// Handle page size change
const handlePageSizeChange = useCallback((value: string) => {
const newPageSize = parseInt(value, 10)
if (!isNaN(newPageSize)) {
onPageSizeChange(newPageSize)
}
}, [onPageSizeChange])
// Navigation handlers
const goToFirstPage = useCallback(() => {
if (currentPage > 1 && !isLoading) {
onPageChange(1)
}
}, [currentPage, onPageChange, isLoading])
const goToPrevPage = useCallback(() => {
if (currentPage > 1 && !isLoading) {
onPageChange(currentPage - 1)
}
}, [currentPage, onPageChange, isLoading])
const goToNextPage = useCallback(() => {
if (currentPage < totalPages && !isLoading) {
onPageChange(currentPage + 1)
}
}, [currentPage, totalPages, onPageChange, isLoading])
const goToLastPage = useCallback(() => {
if (currentPage < totalPages && !isLoading) {
onPageChange(totalPages)
}
}, [currentPage, totalPages, onPageChange, isLoading])
if (totalPages <= 1) {
return null
}
if (compact) {
return (
<div className={cn('flex items-center gap-2', className)}>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={goToPrevPage}
disabled={currentPage <= 1 || isLoading}
className="h-8 w-8 p-0"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<Input
type="text"
value={inputPage}
onChange={(e) => handlePageInputChange(e.target.value)}
onBlur={handlePageInputSubmit}
onKeyPress={handlePageInputKeyPress}
disabled={isLoading}
className="h-8 w-12 text-center text-sm"
/>
<span className="text-sm text-gray-500">/ {totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={currentPage >= totalPages || isLoading}
className="h-8 w-8 p-0"
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
</div>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
disabled={isLoading}
>
<SelectTrigger className="h-8 w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
return (
<div className={cn('flex items-center justify-between gap-4', className)}>
<div className="text-sm text-gray-500">
{t('pagination.showing', {
start: Math.min((currentPage - 1) * pageSize + 1, totalCount),
end: Math.min(currentPage * pageSize, totalCount),
total: totalCount
})}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={goToFirstPage}
disabled={currentPage <= 1 || isLoading}
className="h-8 w-8 p-0"
tooltip={t('pagination.firstPage')}
>
<ChevronsLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={goToPrevPage}
disabled={currentPage <= 1 || isLoading}
className="h-8 w-8 p-0"
tooltip={t('pagination.prevPage')}
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<span className="text-sm">{t('pagination.page')}</span>
<Input
type="text"
value={inputPage}
onChange={(e) => handlePageInputChange(e.target.value)}
onBlur={handlePageInputSubmit}
onKeyPress={handlePageInputKeyPress}
disabled={isLoading}
className="h-8 w-16 text-center text-sm"
/>
<span className="text-sm">/ {totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={currentPage >= totalPages || isLoading}
className="h-8 w-8 p-0"
tooltip={t('pagination.nextPage')}
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={goToLastPage}
disabled={currentPage >= totalPages || isLoading}
className="h-8 w-8 p-0"
tooltip={t('pagination.lastPage')}
>
<ChevronsRightIcon className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">{t('pagination.pageSize')}</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
disabled={isLoading}
>
<SelectTrigger className="h-8 w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)
}
@@ -0,0 +1,39 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
// Define the props type to include positioning props
type PopoverContentProps = React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
sticky?: 'partial' | 'always';
avoidCollisions?: boolean;
};
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
PopoverContentProps
>(({ className, align = 'center', sideOffset = 4, collisionPadding, sticky, avoidCollisions = false, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
sticky={sticky}
avoidCollisions={avoidCollisions}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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 z-50 rounded-md border p-4 shadow-md outline-none',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
@@ -0,0 +1,23 @@
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ComponentRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('bg-secondary relative h-4 w-full overflow-hidden rounded-full', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export default Progress
@@ -0,0 +1,44 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
@@ -0,0 +1,151 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pr-2 pl-8 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton
}
@@ -0,0 +1,24 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export default Separator
@@ -0,0 +1,37 @@
import React, { useEffect } from 'react';
import { useTabVisibility } from '@/contexts/useTabVisibility';
interface TabContentProps {
tabId: string;
children: React.ReactNode;
className?: string;
}
/**
* TabContent component that manages visibility based on tab selection
* Works with the TabVisibilityContext to show/hide content based on active tab
*/
const TabContent: React.FC<TabContentProps> = ({ tabId, children, className = '' }) => {
const { isTabVisible, setTabVisibility } = useTabVisibility();
const isVisible = isTabVisible(tabId);
// Register this tab with the context when mounted
useEffect(() => {
setTabVisibility(tabId, true);
// Cleanup when unmounted
return () => {
setTabVisibility(tabId, false);
};
}, [tabId, setTabVisibility]);
// Use CSS to hide content instead of not rendering it
// This prevents components from unmounting when tabs are switched
return (
<div className={`${className} ${isVisible ? '' : 'hidden'}`}>
{children}
</div>
);
};
export default TabContent;
@@ -0,0 +1,96 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
)
)
Table.displayName = 'Table'
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
))
TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
))
TableFooter.displayName = 'TableFooter'
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className
)}
{...props}
/>
)
)
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
// eslint-disable-next-line react/prop-types
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
))
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
// eslint-disable-next-line react/prop-types
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
))
TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn('text-muted-foreground mt-4 text-sm', className)} {...props} />
))
TableCaption.displayName = 'TableCaption'
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
@@ -0,0 +1,57 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'data-[state=inactive]:invisible data-[state=active]:visible',
'h-full w-full',
className
)}
// Force mounting of inactive tabs to preserve WebGL contexts
forceMount
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,49 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
import { cn } from '@/lib/utils'
const Text = ({
text,
className,
tooltipClassName,
tooltip,
side,
onClick
}: {
text: string
className?: string
tooltipClassName?: string
tooltip?: string
side?: 'top' | 'right' | 'bottom' | 'left'
onClick?: () => void
}) => {
if (!tooltip) {
return (
<label
className={cn(className, onClick !== undefined ? 'cursor-pointer' : undefined)}
onClick={onClick}
>
{text}
</label>
)
}
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<label
className={cn(className, onClick !== undefined ? 'cursor-pointer' : undefined)}
onClick={onClick}
>
{text}
</label>
</TooltipTrigger>
<TooltipContent side={side} className={tooltipClassName}>
{tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export default Text
@@ -0,0 +1,25 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
className?: string
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none',
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export default Textarea
@@ -0,0 +1,52 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const processTooltipContent = (content: string) => {
if (typeof content !== 'string') return content
return (
<div className="relative top-0 pt-1 whitespace-pre-wrap break-words">
{content}
</div>
)
}
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
}
>(({ className, side = 'left', align = 'start', children, ...props }, ref) => {
const contentRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = 0;
}
}, [children]);
return (
<TooltipPrimitive.Content
ref={ref}
side={side}
align={align}
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm shadow-md z-60',
className
)}
{...props}
>
{typeof children === 'string' ? processTooltipContent(children) : children}
</TooltipPrimitive.Content>
);
})
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,203 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { ChevronDown, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import Input from './Input'
interface UserPromptInputWithHistoryProps {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
id?: string
history: string[]
onSelectFromHistory: (prompt: string) => void
onDeleteFromHistory?: (index: number) => void
}
export default function UserPromptInputWithHistory({
value,
onChange,
placeholder,
className,
id,
history,
onSelectFromHistory,
onDeleteFromHistory
}: UserPromptInputWithHistoryProps) {
const [isOpen, setIsOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const [isHovered, setIsHovered] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
setSelectedIndex(-1)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
// Handle keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === 'ArrowDown' && history.length > 0) {
e.preventDefault()
setIsOpen(true)
setSelectedIndex(0)
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev =>
prev < history.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)
if (selectedIndex === 0) {
setSelectedIndex(-1)
}
break
case 'Enter':
if (selectedIndex >= 0 && selectedIndex < history.length) {
e.preventDefault()
const selectedPrompt = history[selectedIndex]
onSelectFromHistory(selectedPrompt)
setIsOpen(false)
setSelectedIndex(-1)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
setSelectedIndex(-1)
break
}
}, [isOpen, selectedIndex, history, onSelectFromHistory])
const handleInputClick = () => {
if (history.length > 0) {
setIsOpen(!isOpen)
setSelectedIndex(-1)
}
}
const handleDropdownItemClick = (prompt: string) => {
onSelectFromHistory(prompt)
setIsOpen(false)
setSelectedIndex(-1)
inputRef.current?.focus()
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleMouseEnter = () => {
setIsHovered(true)
}
const handleMouseLeave = () => {
setIsHovered(false)
}
// Handle delete history item with boundary cases
const handleDeleteHistoryItem = useCallback((index: number, e: React.MouseEvent) => {
e.stopPropagation() // Prevent triggering item selection
onDeleteFromHistory?.(index)
// Handle boundary cases
if (history.length === 1) {
// Deleting the last item, close dropdown
setIsOpen(false)
setSelectedIndex(-1)
} else if (selectedIndex === index) {
// Deleting currently selected item, adjust selection
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)
} else if (selectedIndex > index) {
// Deleting item before selected item, adjust index
setSelectedIndex(prev => prev - 1)
}
}, [onDeleteFromHistory, history.length, selectedIndex])
return (
<div className="relative" ref={dropdownRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div className="relative">
<Input
ref={inputRef}
id={id}
value={value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onClick={handleInputClick}
placeholder={placeholder}
autoComplete="off"
className={cn(isHovered && history.length > 0 ? 'pr-5' : 'pr-2', 'w-full', className)}
/>
{isHovered && history.length > 0 && (
<button
type="button"
onClick={handleInputClick}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
tabIndex={-1}
>
<ChevronDown
className={cn(
'h-3 w-3 transition-transform duration-200 text-gray-500',
isOpen && 'rotate-180'
)}
/>
</button>
)}
</div>
{/* Dropdown */}
{isOpen && history.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-0.5 bg-gray-100 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-96 overflow-auto min-w-0">
{history.map((prompt, index) => (
<div
key={index}
className={cn(
'flex items-center justify-between pl-3 pr-1 py-2 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors',
'border-b border-gray-100 dark:border-gray-700 last:border-b-0',
'focus-within:bg-gray-100 dark:focus-within:bg-gray-700',
selectedIndex === index && 'bg-gray-100 dark:bg-gray-700'
)}
>
<button
type="button"
onClick={() => handleDropdownItemClick(prompt)}
className="flex-1 text-left truncate focus:outline-none mr-0"
title={prompt}
>
{prompt}
</button>
{onDeleteFromHistory && (
<button
type="button"
onClick={(e) => handleDeleteHistoryItem(index, e)}
className="flex-shrink-0 p-0 rounded hover:bg-red-100 dark:hover:bg-red-900 transition-colors focus:outline-none ml-auto"
title="Delete this history item"
>
<X className="h-3 w-3 text-gray-400 hover:text-red-500" />
</button>
)}
</div>
))}
</div>
)}
</div>
)
}