chore: init monorepo snapshot
This commit is contained in:
@@ -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 accept,use 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user