mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
refactor: simplify to inline tag editing only and fix tooltip z-index
Based on user feedback, simplified the implementation to focus on core inline tag editing functionality by removing: - BulkTagEditor component and bulk selection UI - TagSuggestions component and autocomplete functionality - useTagSuggestions hook and related caching - Selection mode and bulk operations - PRP files from repository (development artifacts) Fixed tooltip z-index issue where "+N more..." tooltip appeared behind other UI elements like the Recrawl button. Kept essential features: - ✅ Inline tag editing (click to edit, Enter/Escape shortcuts) - ✅ Add/remove tags with "+" and "×" buttons - ✅ Tag editing in EditKnowledgeItemModal - ✅ Input validation and error handling - ✅ Toast notifications for success/error states - ✅ Proper tooltip layering (z-index: 100) This maintains the core user experience while significantly reducing complexity and removing features that weren't needed. Resolves #538 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,336 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Plus, Minus, Replace, RefreshCw, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { KnowledgeItem, knowledgeBaseService } from '../../services/knowledgeBaseService';
|
||||
import { TagSuggestions } from './TagSuggestions';
|
||||
import { EditableTags } from './EditableTags';
|
||||
import { useTagSuggestions } from '../../hooks/useTagSuggestions';
|
||||
|
||||
interface BulkTagEditorProps {
|
||||
selectedItems: KnowledgeItem[];
|
||||
onClose: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
interface BulkOperationResult {
|
||||
sourceId: string;
|
||||
title: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const BulkTagEditor: React.FC<BulkTagEditorProps> = ({
|
||||
selectedItems,
|
||||
onClose,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [replaceTags, setReplaceTags] = useState<string[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [results, setResults] = useState<BulkOperationResult[]>([]);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
const { data: tagSuggestions = [], isLoading: isLoadingSuggestions, error: suggestionsError } = useTagSuggestions();
|
||||
|
||||
// Handle escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isProcessing) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, isProcessing]);
|
||||
|
||||
const performBulkOperation = async (
|
||||
operation: 'add' | 'remove' | 'replace',
|
||||
tagsToProcess: string[]
|
||||
) => {
|
||||
if (tagsToProcess.length === 0) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setResults([]);
|
||||
setShowResults(true);
|
||||
|
||||
// Process items in batches of 5 for better performance
|
||||
const batchSize = 5;
|
||||
const batches: KnowledgeItem[][] = [];
|
||||
for (let i = 0; i < selectedItems.length; i += batchSize) {
|
||||
batches.push(selectedItems.slice(i, i + batchSize));
|
||||
}
|
||||
|
||||
const allResults: BulkOperationResult[] = [];
|
||||
|
||||
try {
|
||||
for (const batch of batches) {
|
||||
const batchPromises = batch.map(async (item): Promise<BulkOperationResult> => {
|
||||
try {
|
||||
const currentTags = item.metadata.tags || [];
|
||||
let newTags: string[] = [];
|
||||
|
||||
switch (operation) {
|
||||
case 'add':
|
||||
// Add tags that don't already exist
|
||||
newTags = [...new Set([...currentTags, ...tagsToProcess])];
|
||||
break;
|
||||
case 'remove':
|
||||
// Remove specified tags
|
||||
newTags = currentTags.filter(tag => !tagsToProcess.includes(tag));
|
||||
break;
|
||||
case 'replace':
|
||||
// Replace all tags with new ones
|
||||
newTags = [...tagsToProcess];
|
||||
break;
|
||||
}
|
||||
|
||||
await knowledgeBaseService.updateKnowledgeItemTags(item.source_id, newTags);
|
||||
|
||||
return {
|
||||
sourceId: item.source_id,
|
||||
title: item.title,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
sourceId: item.source_id,
|
||||
title: item.title,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
allResults.push(...batchResults);
|
||||
setResults([...allResults]); // Update results incrementally
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Bulk operation failed:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
onUpdate(); // Refresh the parent component
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTags = () => {
|
||||
if (selectedTag.trim()) {
|
||||
performBulkOperation('add', [selectedTag.trim()]);
|
||||
setSelectedTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTags = () => {
|
||||
if (selectedTag.trim()) {
|
||||
performBulkOperation('remove', [selectedTag.trim()]);
|
||||
setSelectedTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceTags = async () => {
|
||||
performBulkOperation('replace', replaceTags);
|
||||
};
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const errorCount = results.filter(r => !r.success).length;
|
||||
|
||||
return createPortal(
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 flex items-center justify-center z-50 bg-black/60 backdrop-blur-sm"
|
||||
onClick={!isProcessing ? onClose : undefined}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="relative w-full max-w-2xl max-h-[90vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Purple accent line at the top */}
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-purple-500 to-pink-500 shadow-[0_0_20px_5px_rgba(168,85,247,0.5)] z-10 rounded-t-xl"></div>
|
||||
|
||||
<Card className="relative overflow-hidden h-full">
|
||||
<div className="flex flex-col h-full max-h-[85vh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">
|
||||
Bulk Tag Editor
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Editing tags for {selectedItems.length} items
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={!isProcessing ? onClose : undefined}
|
||||
disabled={isProcessing}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-6">
|
||||
{/* Tag Operations */}
|
||||
{!showResults && (
|
||||
<div className="space-y-6">
|
||||
{/* Add/Remove Tags Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-800 dark:text-white">
|
||||
Add or Remove Tags
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<TagSuggestions
|
||||
suggestions={tagSuggestions || []}
|
||||
onSelect={setSelectedTag}
|
||||
placeholder="Select or type a tag..."
|
||||
isLoading={isLoadingSuggestions}
|
||||
allowCustomValue={true}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddTags}
|
||||
disabled={!selectedTag.trim() || isProcessing}
|
||||
className="flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add to All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRemoveTags}
|
||||
disabled={!selectedTag.trim() || isProcessing}
|
||||
className="flex items-center gap-2 bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
Remove from All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replace All Tags Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-800 dark:text-white">
|
||||
Replace All Tags
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This will replace all existing tags with the tags you specify below.
|
||||
</p>
|
||||
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<EditableTags
|
||||
tags={replaceTags}
|
||||
onTagsUpdate={async (tags) => {
|
||||
setReplaceTags(tags);
|
||||
}}
|
||||
maxVisibleTags={10}
|
||||
isUpdating={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleReplaceTags}
|
||||
disabled={isProcessing}
|
||||
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
<Replace className="w-4 h-4" />
|
||||
Replace All Tags
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Section */}
|
||||
{showResults && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-800 dark:text-white">
|
||||
Operation Results
|
||||
</h3>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{successCount} Success
|
||||
</span>
|
||||
{errorCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-600">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{errorCount} Failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{results.map((result) => (
|
||||
<div
|
||||
key={result.sourceId}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
result.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||
)}
|
||||
<span className="font-medium text-sm">
|
||||
{result.title}
|
||||
</span>
|
||||
</div>
|
||||
{result.error && (
|
||||
<span className="text-xs text-red-600">
|
||||
{result.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isProcessing && results.length < selectedItems.length && (
|
||||
<div className="flex items-center justify-center p-3">
|
||||
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Processing... ({results.length}/{selectedItems.length})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-4 mt-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
{showResults ? (
|
||||
<Button
|
||||
onClick={onClose}
|
||||
disabled={isProcessing}
|
||||
accentColor="purple"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -340,7 +340,7 @@ export const EditableTags: React.FC<EditableTagsProps> = ({
|
||||
+{remainingTags.length} more...
|
||||
</Badge>
|
||||
{showTooltip && (
|
||||
<div className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2 bg-black dark:bg-zinc-800 text-white text-xs rounded-lg py-2 px-3 shadow-lg z-50 whitespace-nowrap max-w-xs">
|
||||
<div className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2 bg-black dark:bg-zinc-800 text-white text-xs rounded-lg py-2 px-3 shadow-lg z-[100] whitespace-nowrap max-w-xs">
|
||||
<div className="font-semibold text-purple-300 mb-1">
|
||||
Additional Tags:
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import { Link as LinkIcon, Upload, Trash2, RefreshCw, Code, FileText, Brain, BoxIcon, Pencil } from 'lucide-react';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { Checkbox } from '../ui/Checkbox';
|
||||
import { KnowledgeItem, knowledgeBaseService } from '../../services/knowledgeBaseService';
|
||||
import { useCardTilt } from '../../hooks/useCardTilt';
|
||||
import { CodeViewerModal, CodeExample } from '../code/CodeViewerModal';
|
||||
@@ -73,9 +72,6 @@ interface KnowledgeItemCardProps {
|
||||
onUpdate?: () => void;
|
||||
onRefresh?: (sourceId: string) => void;
|
||||
onBrowseDocuments?: (sourceId: string) => void;
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelection?: (event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const KnowledgeItemCard = ({
|
||||
@@ -84,9 +80,6 @@ export const KnowledgeItemCard = ({
|
||||
onUpdate,
|
||||
onRefresh,
|
||||
onBrowseDocuments,
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelection
|
||||
}: KnowledgeItemCardProps) => {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showCodeModal, setShowCodeModal] = useState(false);
|
||||
@@ -142,10 +135,10 @@ export const KnowledgeItemCard = ({
|
||||
const sourceIconColor = getSourceIconColor();
|
||||
const typeIconColor = getTypeIconColor();
|
||||
|
||||
// Use the tilt effect hook - disable in selection mode
|
||||
// Use the tilt effect hook
|
||||
const { cardRef, tiltStyles, handlers } = useCardTilt({
|
||||
max: isSelectionMode ? 0 : 10,
|
||||
scale: isSelectionMode ? 1 : 1.02,
|
||||
max: 10,
|
||||
scale: 1.02,
|
||||
perspective: 1200,
|
||||
});
|
||||
|
||||
@@ -241,26 +234,8 @@ export const KnowledgeItemCard = ({
|
||||
>
|
||||
<Card
|
||||
accentColor={accentColor}
|
||||
className={`relative h-full flex flex-col overflow-hidden ${
|
||||
isSelected ? 'ring-2 ring-blue-500 dark:ring-blue-400' : ''
|
||||
} ${isSelectionMode ? 'cursor-pointer' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (isSelectionMode && onToggleSelection) {
|
||||
e.stopPropagation();
|
||||
onToggleSelection(e);
|
||||
}
|
||||
}}
|
||||
className="relative h-full flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Checkbox for selection mode */}
|
||||
{isSelectionMode && (
|
||||
<div className="absolute top-3 right-3 z-20">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reflection overlay */}
|
||||
<div
|
||||
@@ -306,8 +281,7 @@ export const KnowledgeItemCard = ({
|
||||
<h3 className="text-gray-800 dark:text-white font-medium flex-1 line-clamp-1 truncate min-w-0">
|
||||
{item.title}
|
||||
</h3>
|
||||
{!isSelectionMode && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -329,7 +303,6 @@ export const KnowledgeItemCard = ({
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description section - fixed height */}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ComboBox, ComboBoxOption } from '../../features/ui/primitives/combobox';
|
||||
|
||||
interface TagSuggestionsProps {
|
||||
suggestions: string[];
|
||||
onSelect: (tag: string) => void;
|
||||
placeholder?: string;
|
||||
allowCustomValue?: boolean;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const TagSuggestions: React.FC<TagSuggestionsProps> = ({
|
||||
suggestions = [],
|
||||
onSelect,
|
||||
placeholder = 'Search or create tag...',
|
||||
allowCustomValue = true,
|
||||
className,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
// Convert string suggestions to ComboBox options
|
||||
const options: ComboBoxOption[] = suggestions.map((tag) => ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
description: undefined,
|
||||
}));
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
onSelect(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<ComboBox
|
||||
options={options}
|
||||
onValueChange={handleValueChange}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder="Type to search tags..."
|
||||
emptyMessage="No tags found"
|
||||
allowCustomValue={allowCustomValue}
|
||||
isLoading={isLoading}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { knowledgeBaseService } from "../services/knowledgeBaseService";
|
||||
|
||||
interface TagSuggestionsResult {
|
||||
data?: string[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage tag suggestions from knowledge base
|
||||
* Uses TanStack Query for caching and deduplication
|
||||
*/
|
||||
export const useTagSuggestions = (): TagSuggestionsResult => {
|
||||
const queryResult = useQuery({
|
||||
queryKey: ["knowledge-base", "tags", "suggestions"],
|
||||
queryFn: async (): Promise<string[]> => {
|
||||
try {
|
||||
// Get all knowledge items to extract tags
|
||||
const response = await knowledgeBaseService.getKnowledgeItems({ per_page: 1000 });
|
||||
|
||||
// Extract all tags from all items
|
||||
const allTags: string[] = [];
|
||||
const tagFrequency: Record<string, number> = {};
|
||||
|
||||
response.items.forEach(item => {
|
||||
if (item.metadata.tags && Array.isArray(item.metadata.tags)) {
|
||||
item.metadata.tags.forEach(tag => {
|
||||
if (typeof tag === 'string' && tag.trim()) {
|
||||
const cleanTag = tag.trim();
|
||||
allTags.push(cleanTag);
|
||||
tagFrequency[cleanTag] = (tagFrequency[cleanTag] || 0) + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate and sort by frequency (most used first)
|
||||
const uniqueTags = Array.from(new Set(allTags));
|
||||
const sortedTags = uniqueTags.sort((a, b) => {
|
||||
const freqA = tagFrequency[a] || 0;
|
||||
const freqB = tagFrequency[b] || 0;
|
||||
|
||||
// Sort by frequency (descending), then alphabetically if same frequency
|
||||
if (freqA !== freqB) {
|
||||
return freqB - freqA;
|
||||
}
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`📋 [TagSuggestions] Found ${sortedTags.length} unique tags from ${response.items.length} items`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`📋 [TagSuggestions] Top tags:`, sortedTags.slice(0, 10));
|
||||
|
||||
return sortedTags;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error
|
||||
? `Failed to fetch tag suggestions: ${error.message}`
|
||||
: 'Failed to fetch tag suggestions: Unknown error occurred';
|
||||
|
||||
console.error('❌ [TagSuggestions] Error details:', error);
|
||||
throw new Error(errorMessage); // Let TanStack Query handle the error state
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch on window focus
|
||||
retry: 2, // Retry failed requests twice
|
||||
retryDelay: 1000, // Wait 1 second between retries
|
||||
});
|
||||
|
||||
return {
|
||||
data: queryResult.data,
|
||||
isLoading: queryResult.isLoading,
|
||||
error: queryResult.error,
|
||||
isError: queryResult.isError,
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { Search, Grid, Plus, Filter, BoxIcon, List, BookOpen, CheckSquare, Brain } from 'lucide-react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Search, Grid, Plus, Filter, BoxIcon, List, BookOpen, Brain } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { Badge } from '../components/ui/Badge';
|
||||
import { useStaggeredEntrance } from '../hooks/useStaggeredEntrance';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { knowledgeBaseService, KnowledgeItem, KnowledgeItemMetadata } from '../services/knowledgeBaseService';
|
||||
@@ -17,7 +15,6 @@ import { GroupCreationModal } from '../components/knowledge-base/GroupCreationMo
|
||||
import { AddKnowledgeModal } from '../components/knowledge-base/AddKnowledgeModal';
|
||||
import { CrawlingTab } from '../components/knowledge-base/CrawlingTab';
|
||||
import { DocumentBrowser } from '../components/knowledge-base/DocumentBrowser';
|
||||
import { BulkTagEditor } from '../components/knowledge-base/BulkTagEditor';
|
||||
|
||||
interface GroupedKnowledgeItem {
|
||||
id: string;
|
||||
@@ -34,11 +31,9 @@ export const KnowledgeBasePage = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||
const [isBulkEditModalOpen, setIsBulkEditModalOpen] = useState(false);
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'technical' | 'business'>('all');
|
||||
const [knowledgeItems, setKnowledgeItems] = useState<KnowledgeItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [progressItems, setProgressItemsRaw] = useState<CrawlProgressData[]>([]);
|
||||
const [showCrawlingTab, setShowCrawlingTab] = useState(false);
|
||||
|
||||
@@ -51,10 +46,6 @@ export const KnowledgeBasePage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Selection state
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
// Document browser state
|
||||
const [documentBrowserSourceId, setDocumentBrowserSourceId] = useState<string | null>(null);
|
||||
@@ -71,7 +62,6 @@ export const KnowledgeBasePage = () => {
|
||||
per_page: 100
|
||||
});
|
||||
setKnowledgeItems(response.items);
|
||||
setTotalItems(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load knowledge items:', error);
|
||||
showToast('Failed to load knowledge items', 'error');
|
||||
@@ -280,96 +270,7 @@ export const KnowledgeBasePage = () => {
|
||||
setIsDocumentBrowserOpen(true);
|
||||
};
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setIsSelectionMode(!isSelectionMode);
|
||||
if (isSelectionMode) {
|
||||
setSelectedItems(new Set());
|
||||
setLastSelectedIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemSelection = (itemId: string, index: number, event: React.MouseEvent) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
|
||||
if (event.shiftKey && lastSelectedIndex !== null) {
|
||||
const start = Math.min(lastSelectedIndex, index);
|
||||
const end = Math.max(lastSelectedIndex, index);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (filteredItems[i]) {
|
||||
newSelected.add(filteredItems[i].id);
|
||||
}
|
||||
}
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
if (newSelected.has(itemId)) {
|
||||
newSelected.delete(itemId);
|
||||
} else {
|
||||
newSelected.add(itemId);
|
||||
}
|
||||
} else {
|
||||
if (newSelected.has(itemId)) {
|
||||
newSelected.delete(itemId);
|
||||
} else {
|
||||
newSelected.add(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedItems(newSelected);
|
||||
setLastSelectedIndex(index);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const allIds = new Set(filteredItems.map(item => item.id));
|
||||
setSelectedItems(allIds);
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedItems(new Set());
|
||||
setLastSelectedIndex(null);
|
||||
};
|
||||
|
||||
const deleteSelectedItems = async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
const count = selectedItems.size;
|
||||
const confirmed = window.confirm(`Are you sure you want to delete ${count} selected item${count > 1 ? 's' : ''}?`);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const deletePromises = Array.from(selectedItems).map(itemId =>
|
||||
knowledgeBaseService.deleteKnowledgeItem(itemId)
|
||||
);
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
setKnowledgeItems(prev => prev.filter(item => !selectedItems.has(item.id)));
|
||||
setSelectedItems(new Set());
|
||||
setIsSelectionMode(false);
|
||||
|
||||
showToast(`Successfully deleted ${count} item${count > 1 ? 's' : ''}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete selected items:', error);
|
||||
showToast('Failed to delete some items', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a' && isSelectionMode) {
|
||||
e.preventDefault();
|
||||
selectAll();
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && isSelectionMode) {
|
||||
toggleSelectionMode();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isSelectionMode, filteredItems]);
|
||||
|
||||
const handleRefreshItem = async (sourceId: string) => {
|
||||
try {
|
||||
@@ -656,16 +557,6 @@ export const KnowledgeBasePage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={toggleSelectionMode}
|
||||
variant={isSelectionMode ? "secondary" : "ghost"}
|
||||
accentColor="blue"
|
||||
className={isSelectionMode ? "bg-blue-500/10 border-blue-500/40" : ""}
|
||||
>
|
||||
<CheckSquare className="w-4 h-4 mr-2 inline" />
|
||||
<span>{isSelectionMode ? 'Cancel' : 'Select'}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleAddKnowledge}
|
||||
variant="primary"
|
||||
@@ -678,44 +569,6 @@ export const KnowledgeBasePage = () => {
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Selection Toolbar */}
|
||||
<AnimatePresence>
|
||||
{isSelectionMode && selectedItems.size > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<Card className="p-4 bg-gradient-to-r from-blue-500/10 to-purple-500/10 border-blue-500/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{selectedItems.size} item{selectedItems.size > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<Button onClick={selectAll} variant="ghost" size="sm" accentColor="blue">
|
||||
Select All
|
||||
</Button>
|
||||
<Button onClick={deselectAll} variant="ghost" size="sm" accentColor="gray">
|
||||
Clear Selection
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => setIsBulkEditModalOpen(true)} variant="secondary" size="sm" accentColor="purple">
|
||||
Edit Tags
|
||||
</Button>
|
||||
<Button onClick={() => setIsGroupModalOpen(true)} variant="secondary" size="sm" accentColor="blue">
|
||||
Create Group
|
||||
</Button>
|
||||
<Button onClick={deleteSelectedItems} variant="secondary" size="sm" accentColor="pink">
|
||||
Delete Selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Active Crawls Tab */}
|
||||
{showCrawlingTab && progressItems.length > 0 && (
|
||||
@@ -757,7 +610,7 @@ export const KnowledgeBasePage = () => {
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{ungroupedItems.map((item, index) => (
|
||||
{ungroupedItems.map((item, _index) => (
|
||||
<motion.div key={item.id} variants={contentItemVariants}>
|
||||
<KnowledgeItemCard
|
||||
item={item}
|
||||
@@ -765,9 +618,6 @@ export const KnowledgeBasePage = () => {
|
||||
onUpdate={loadKnowledgeItems}
|
||||
onRefresh={handleRefreshItem}
|
||||
onBrowseDocuments={handleBrowseDocuments}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={selectedItems.has(item.id)}
|
||||
onToggleSelection={(e) => toggleItemSelection(item.id, index, e)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -797,11 +647,10 @@ export const KnowledgeBasePage = () => {
|
||||
|
||||
{isGroupModalOpen && (
|
||||
<GroupCreationModal
|
||||
selectedItems={knowledgeItems.filter(item => selectedItems.has(item.id))}
|
||||
selectedItems={[]}
|
||||
onClose={() => setIsGroupModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
setIsGroupModalOpen(false);
|
||||
toggleSelectionMode();
|
||||
loadKnowledgeItems();
|
||||
}}
|
||||
/>
|
||||
@@ -819,18 +668,6 @@ export const KnowledgeBasePage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBulkEditModalOpen && (
|
||||
<BulkTagEditor
|
||||
selectedItems={knowledgeItems.filter(item => selectedItems.has(item.id))}
|
||||
onClose={() => setIsBulkEditModalOpen(false)}
|
||||
onUpdate={() => {
|
||||
setIsBulkEditModalOpen(false);
|
||||
setSelectedItems(new Set());
|
||||
setIsSelectionMode(false);
|
||||
loadKnowledgeItems();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user