feat: Complete UX redesign of Add Knowledge Modal with modern glassmorphism styling (#661)

* feat: Complete UX redesign of Add Knowledge Modal with modern glassmorphism styling

🎨 Enhanced Tab Navigation
- Replaced basic tabs with large, card-style buttons
- Added glassmorphism effects with backdrop blur and gradients
- Color-coded themes: Cyan for crawl, Purple for upload
- Top accent glow bars for active states matching KnowledgeCard
- Two-line layout with descriptive subtitles

🌐 Modern URL Input Enhancement
- Added prominent Globe icon with proper visibility
- Enhanced glassmorphism styling with gradient backgrounds
- Larger input height for better interaction
- Improved placeholder text with example URLs
- Enhanced focus states with cyan glow effects

📁 Professional File Upload Area
- Custom drag & drop zone replacing basic file input
- Visual upload area with glassmorphism effects
- Dynamic Upload icon with state-based colors
- File name and size display when selected
- Purple theme colors matching document context

🏷️ Visual Tag Management System
- Replaced comma-separated input with modern tag pills
- Individual tag removal with X buttons
- Enter or comma to add tags (backward compatible)
- Tag count display and proper accessibility
- Blue accent colors matching knowledge base theme

🎯 Circular Level Selection
- Replaced dropdown with visual circular selector
- Clear representation of crawl depth (1,2,3,5 levels)
- Informative tooltips with detailed explanations
- Selection indicators with animations
- Info icon with comprehensive guidance

📋 Knowledge Type Selection Enhancement
- Replaced dropdown with large visual radio cards
- Technical vs Business with distinct styling
- Color-coded themes and descriptive icons
- Enhanced selection indicators

 Technical Improvements
- Created reusable LevelSelector, KnowledgeTypeSelector, TagInput components
- Updated state management from strings to arrays for tags
- Maintained backward compatibility with existing API
- Enhanced accessibility with proper ARIA labels
- Minimal bundle impact with optimized imports

🚀 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Improve TagInput race conditions and enhance LevelSelector accessibility

🏷️ TagInput Race Condition Fix:
- Fixed race condition in handleInputChange when pasting comma-separated tags
- Replaced forEach addTag loop with batched update approach
- Use Set for proper deduplication of tags
- Enforce maxTags limit on final combined array
- Single onTagsChange call prevents multiple re-renders and stale state issues
- Prevents duplicates and exceeding maxTags when pasting multiple tags

 LevelSelector Accessibility Enhancement:
- Added proper radio group semantics with role="radiogroup"
- Added aria-labelledby linking to crawl depth label
- Each button now has role="radio" and aria-checked state
- Implemented roving tabindex (selected=0, others=-1)
- Added descriptive aria-label for each level option
- Enhanced keyboard support with Enter/Space key handlers
- Added proper focus ring styling for keyboard navigation
- Improved screen reader experience with semantic structure

🚀 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Address CodeRabbit feedback - improve plural logic and remove unused dependency

- Fix plural logic in LevelSelector.tsx for better readability
- Remove unused @radix-ui/themes dependency to reduce bundle size
- Update package-lock.json after dependency removal

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
DIY Smart Code
2025-09-16 13:20:53 +02:00
committed by GitHub
parent ee3af433c8
commit 7d37ef76db
5 changed files with 644 additions and 106 deletions

View File

@@ -7,11 +7,14 @@ import { Globe, Loader2, Upload } from "lucide-react";
import { useId, useState } from "react";
import { useToast } from "../../ui/hooks/useToast";
import { Button, Input, Label } from "../../ui/primitives";
import { cn } from "../../ui/primitives/styles";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../ui/primitives/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/primitives/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives/tabs";
import { useCrawlUrl, useUploadDocument } from "../hooks";
import type { CrawlRequest, UploadMetadata } from "../types";
import { KnowledgeTypeSelector } from "./KnowledgeTypeSelector";
import { LevelSelector } from "./LevelSelector";
import { TagInput } from "./TagInput";
interface AddKnowledgeDialogProps {
open: boolean;
@@ -33,32 +36,27 @@ export const AddKnowledgeDialog: React.FC<AddKnowledgeDialogProps> = ({
// Generate unique IDs for form elements
const urlId = useId();
const typeId = useId();
const depthId = useId();
const tagsId = useId();
const fileId = useId();
const uploadTypeId = useId();
const uploadTagsId = useId();
// Crawl form state
const [crawlUrl, setCrawlUrl] = useState("");
const [crawlType, setCrawlType] = useState<"technical" | "business">("technical");
const [maxDepth, setMaxDepth] = useState("2");
const [tags, setTags] = useState("");
const [tags, setTags] = useState<string[]>([]);
// Upload form state
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadType, setUploadType] = useState<"technical" | "business">("technical");
const [uploadTags, setUploadTags] = useState("");
const [uploadTags, setUploadTags] = useState<string[]>([]);
const resetForm = () => {
setCrawlUrl("");
setCrawlType("technical");
setMaxDepth("2");
setTags("");
setTags([]);
setSelectedFile(null);
setUploadType("technical");
setUploadTags("");
setUploadTags([]);
};
const handleCrawl = async () => {
@@ -72,7 +70,7 @@ export const AddKnowledgeDialog: React.FC<AddKnowledgeDialogProps> = ({
url: crawlUrl,
knowledge_type: crawlType,
max_depth: parseInt(maxDepth, 10),
tags: tags ? tags.split(",").map((t) => t.trim()) : undefined,
tags: tags.length > 0 ? tags : undefined,
};
const response = await crawlMutation.mutateAsync(request);
@@ -102,7 +100,7 @@ export const AddKnowledgeDialog: React.FC<AddKnowledgeDialogProps> = ({
try {
const metadata: UploadMetadata = {
knowledge_type: uploadType,
tags: uploadTags ? uploadTags.split(",").map((t) => t.trim()) : undefined,
tags: uploadTags.length > 0 ? uploadTags : undefined,
};
const response = await uploadMutation.mutateAsync({ file: selectedFile, metadata });
@@ -136,76 +134,118 @@ export const AddKnowledgeDialog: React.FC<AddKnowledgeDialogProps> = ({
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "crawl" | "upload")}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="crawl" className="data-[state=active]:bg-cyan-500/20">
<Globe className="w-4 h-4 mr-2" />
Crawl Website
</TabsTrigger>
<TabsTrigger value="upload" className="data-[state=active]:bg-cyan-500/20">
<Upload className="w-4 h-4 mr-2" />
Upload Document
</TabsTrigger>
</TabsList>
{/* Enhanced Tab Buttons */}
<div className="grid grid-cols-2 gap-3 p-2 rounded-xl backdrop-blur-md bg-gradient-to-b from-gray-100/30 via-gray-50/20 to-white/40 dark:from-gray-900/30 dark:via-gray-800/20 dark:to-black/40 border border-gray-200/40 dark:border-gray-700/40">
{/* Crawl Website Tab */}
<button
type="button"
onClick={() => setActiveTab("crawl")}
className={cn(
"relative flex items-center justify-center gap-3 px-6 py-4 rounded-lg transition-all duration-300",
"backdrop-blur-md border-2 font-medium text-sm",
activeTab === "crawl"
? "bg-gradient-to-b from-cyan-100/70 via-cyan-50/40 to-white/80 dark:from-cyan-900/40 dark:via-cyan-800/25 dark:to-black/50 border-cyan-400/60 text-cyan-700 dark:text-cyan-300 shadow-[0_0_20px_rgba(34,211,238,0.25)]"
: "bg-gradient-to-b from-white/40 via-white/30 to-white/60 dark:from-gray-800/40 dark:via-gray-800/30 dark:to-black/60 border-gray-300/40 dark:border-gray-600/40 text-gray-600 dark:text-gray-300 hover:border-cyan-300/50 hover:text-cyan-600 dark:hover:text-cyan-400 hover:shadow-[0_0_15px_rgba(34,211,238,0.15)]"
)}
>
{/* Top accent glow for active state */}
{activeTab === "crawl" && (
<div className="pointer-events-none absolute inset-x-0 top-0">
<div className="mx-2 mt-0.5 h-[2px] rounded-full bg-cyan-500" />
<div className="-mt-1 h-8 w-full bg-gradient-to-b from-cyan-500/30 to-transparent blur-md" />
</div>
)}
<Globe className={cn(
"w-5 h-5",
activeTab === "crawl" ? "text-cyan-500" : "text-current"
)} />
<div className="flex flex-col items-start gap-0.5">
<span className="font-semibold">Crawl Website</span>
<span className="text-xs opacity-80">Scan web pages</span>
</div>
</button>
{/* Upload Document Tab */}
<button
type="button"
onClick={() => setActiveTab("upload")}
className={cn(
"relative flex items-center justify-center gap-3 px-6 py-4 rounded-lg transition-all duration-300",
"backdrop-blur-md border-2 font-medium text-sm",
activeTab === "upload"
? "bg-gradient-to-b from-purple-100/70 via-purple-50/40 to-white/80 dark:from-purple-900/40 dark:via-purple-800/25 dark:to-black/50 border-purple-400/60 text-purple-700 dark:text-purple-300 shadow-[0_0_20px_rgba(147,51,234,0.25)]"
: "bg-gradient-to-b from-white/40 via-white/30 to-white/60 dark:from-gray-800/40 dark:via-gray-800/30 dark:to-black/60 border-gray-300/40 dark:border-gray-600/40 text-gray-600 dark:text-gray-300 hover:border-purple-300/50 hover:text-purple-600 dark:hover:text-purple-400 hover:shadow-[0_0_15px_rgba(147,51,234,0.15)]"
)}
>
{/* Top accent glow for active state */}
{activeTab === "upload" && (
<div className="pointer-events-none absolute inset-x-0 top-0">
<div className="mx-2 mt-0.5 h-[2px] rounded-full bg-purple-500" />
<div className="-mt-1 h-8 w-full bg-gradient-to-b from-purple-500/30 to-transparent blur-md" />
</div>
)}
<Upload className={cn(
"w-5 h-5",
activeTab === "upload" ? "text-purple-500" : "text-current"
)} />
<div className="flex flex-col items-start gap-0.5">
<span className="font-semibold">Upload Document</span>
<span className="text-xs opacity-80">Add local files</span>
</div>
</button>
</div>
{/* Crawl Tab */}
<TabsContent value="crawl" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor={urlId}>Website URL</Label>
<Input
id={urlId}
type="url"
placeholder="https://example.com"
value={crawlUrl}
onChange={(e) => setCrawlUrl(e.target.value)}
<TabsContent value="crawl" className="space-y-6 mt-6">
{/* Enhanced URL Input Section */}
<div className="space-y-3">
<Label htmlFor={urlId} className="text-sm font-medium text-gray-900 dark:text-white/90">
Website URL
</Label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Globe className="h-5 w-5" style={{ color: '#0891b2' }} />
</div>
<Input
id={urlId}
type="url"
placeholder="https://docs.example.com or https://github.com/..."
value={crawlUrl}
onChange={(e) => setCrawlUrl(e.target.value)}
disabled={isProcessing}
className="pl-10 h-12 backdrop-blur-md bg-gradient-to-r from-white/60 to-white/50 dark:from-black/60 dark:to-black/50 border-gray-300/60 dark:border-gray-600/60 focus:border-cyan-400/70 focus:shadow-[0_0_20px_rgba(34,211,238,0.15)]"
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Enter the URL of a website you want to crawl for knowledge
</p>
</div>
<div className="space-y-6">
<KnowledgeTypeSelector
value={crawlType}
onValueChange={setCrawlType}
disabled={isProcessing}
/>
<LevelSelector
value={maxDepth}
onValueChange={setMaxDepth}
disabled={isProcessing}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={typeId}>Knowledge Type</Label>
<Select value={crawlType} onValueChange={(v) => setCrawlType(v as "technical" | "business")}>
<SelectTrigger id={typeId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="technical">Technical</SelectItem>
<SelectItem value="business">Business</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={depthId}>Max Depth</Label>
<Select value={maxDepth} onValueChange={setMaxDepth}>
<SelectTrigger id={depthId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 level</SelectItem>
<SelectItem value="2">2 levels</SelectItem>
<SelectItem value="3">3 levels</SelectItem>
<SelectItem value="5">5 levels</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={tagsId}>Tags (comma-separated)</Label>
<Input
id={tagsId}
placeholder="documentation, api, guide"
value={tags}
onChange={(e) => setTags(e.target.value)}
disabled={isProcessing}
/>
</div>
<TagInput
tags={tags}
onTagsChange={setTags}
disabled={isProcessing}
placeholder="Add tags like 'api', 'documentation', 'guide'..."
/>
<Button
onClick={handleCrawl}
disabled={isProcessing || !crawlUrl}
className="w-full bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700"
className="w-full bg-gradient-to-r from-cyan-500 to-cyan-600 hover:from-cyan-600 hover:to-cyan-700 backdrop-blur-md border border-cyan-400/50 shadow-[0_0_20px_rgba(6,182,212,0.25)] hover:shadow-[0_0_30px_rgba(6,182,212,0.35)] transition-all duration-200"
>
{crawlMutation.isPending ? (
<>
@@ -222,54 +262,78 @@ export const AddKnowledgeDialog: React.FC<AddKnowledgeDialogProps> = ({
</TabsContent>
{/* Upload Tab */}
<TabsContent value="upload" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor={fileId}>Document File</Label>
<div className="flex items-center gap-2">
<Input
<TabsContent value="upload" className="space-y-6 mt-6">
{/* Enhanced File Input Section */}
<div className="space-y-3">
<Label htmlFor={fileId} className="text-sm font-medium text-gray-900 dark:text-white/90">
Document File
</Label>
{/* Custom File Upload Area */}
<div className="relative">
<input
id={fileId}
type="file"
accept=".txt,.md,.pdf,.doc,.docx"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
disabled={isProcessing}
className="flex-1"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-10"
/>
<div className={cn(
"relative h-20 rounded-xl border-2 border-dashed transition-all duration-200",
"backdrop-blur-md bg-gradient-to-b from-white/60 via-white/40 to-white/50 dark:from-black/60 dark:via-black/40 dark:to-black/50",
"flex flex-col items-center justify-center gap-2 text-center p-4",
selectedFile
? "border-purple-400/70 bg-gradient-to-b from-purple-50/60 to-white/60 dark:from-purple-900/20 dark:to-black/50"
: "border-gray-300/60 dark:border-gray-600/60 hover:border-purple-400/50 hover:bg-gradient-to-b hover:from-purple-50/40 hover:to-white/60 dark:hover:from-purple-900/10 dark:hover:to-black/50",
isProcessing && "opacity-50 cursor-not-allowed"
)}>
<Upload className={cn(
"w-6 h-6",
selectedFile ? "text-purple-500" : "text-gray-400 dark:text-gray-500"
)} />
<div className="text-sm">
{selectedFile ? (
<div className="space-y-1">
<p className="font-medium text-purple-700 dark:text-purple-400">
{selectedFile.name}
</p>
<p className="text-xs text-purple-600 dark:text-purple-400">
{Math.round(selectedFile.size / 1024)} KB
</p>
</div>
) : (
<div className="space-y-1">
<p className="font-medium text-gray-700 dark:text-gray-300">
Click to browse or drag & drop
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
PDF, DOC, DOCX, TXT, MD files supported
</p>
</div>
)}
</div>
</div>
</div>
{selectedFile && (
<p className="text-sm text-gray-400">
Selected: {selectedFile.name} ({Math.round(selectedFile.size / 1024)} KB)
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor={uploadTypeId}>Knowledge Type</Label>
<Select value={uploadType} onValueChange={(v) => setUploadType(v as "technical" | "business")}>
<SelectTrigger id={uploadTypeId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="technical">Technical</SelectItem>
<SelectItem value="business">Business</SelectItem>
</SelectContent>
</Select>
</div>
<KnowledgeTypeSelector
value={uploadType}
onValueChange={setUploadType}
disabled={isProcessing}
/>
<div className="space-y-2">
<Label htmlFor={uploadTagsId}>Tags (comma-separated)</Label>
<Input
id={uploadTagsId}
placeholder="documentation, manual, reference"
value={uploadTags}
onChange={(e) => setUploadTags(e.target.value)}
disabled={isProcessing}
/>
</div>
<TagInput
tags={uploadTags}
onTagsChange={setUploadTags}
disabled={isProcessing}
placeholder="Add tags like 'manual', 'reference', 'guide'..."
/>
<Button
onClick={handleUpload}
disabled={isProcessing || !selectedFile}
className="w-full bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700"
className="w-full bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 backdrop-blur-md border border-purple-400/50 shadow-[0_0_20px_rgba(147,51,234,0.25)] hover:shadow-[0_0_30px_rgba(147,51,234,0.35)] transition-all duration-200"
>
{uploadMutation.isPending ? (
<>

View File

@@ -0,0 +1,162 @@
/**
* Knowledge Type Selection Component
* Radio cards for Technical vs Business knowledge type selection
*/
import { motion } from "framer-motion";
import { Briefcase, Check, Terminal } from "lucide-react";
import { cn } from "../../ui/primitives/styles";
interface KnowledgeTypeSelectorProps {
value: "technical" | "business";
onValueChange: (value: "technical" | "business") => void;
disabled?: boolean;
}
const TYPES = [
{
value: "technical" as const,
label: "Technical",
description: "Code, APIs, dev docs",
icon: Terminal,
gradient: {
selected: "from-cyan-100/60 via-cyan-50/30 to-white/70 dark:from-cyan-900/30 dark:via-cyan-900/15 dark:to-black/40",
unselected: "from-gray-50/50 via-gray-25/25 to-white/60 dark:from-gray-800/20 dark:via-gray-800/10 dark:to-black/30",
},
border: {
selected: "border-cyan-500/60",
unselected: "border-gray-300/50 dark:border-gray-700/50",
hover: "hover:border-cyan-400/50",
},
colors: {
selected: "text-cyan-700 dark:text-cyan-400",
unselected: "text-gray-700 dark:text-gray-300",
description: {
selected: "text-cyan-600 dark:text-cyan-400",
unselected: "text-gray-500 dark:text-gray-400",
},
},
accent: "bg-cyan-500",
smear: "from-cyan-500/25",
},
{
value: "business" as const,
label: "Business",
description: "Guides, policies, general",
icon: Briefcase,
gradient: {
selected: "from-pink-100/60 via-pink-50/30 to-white/70 dark:from-pink-900/30 dark:via-pink-900/15 dark:to-black/40",
unselected: "from-gray-50/50 via-gray-25/25 to-white/60 dark:from-gray-800/20 dark:via-gray-800/10 dark:to-black/30",
},
border: {
selected: "border-pink-500/60",
unselected: "border-gray-300/50 dark:border-gray-700/50",
hover: "hover:border-pink-400/50",
},
colors: {
selected: "text-pink-700 dark:text-pink-400",
unselected: "text-gray-700 dark:text-gray-300",
description: {
selected: "text-pink-600 dark:text-pink-400",
unselected: "text-gray-500 dark:text-gray-400",
},
},
accent: "bg-pink-500",
smear: "from-pink-500/25",
},
];
export const KnowledgeTypeSelector: React.FC<KnowledgeTypeSelectorProps> = ({
value,
onValueChange,
disabled = false,
}) => {
return (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-900 dark:text-white/90">
Knowledge Type
</div>
<div className="grid grid-cols-2 gap-4">
{TYPES.map((type) => {
const isSelected = value === type.value;
const Icon = type.icon;
return (
<motion.div
key={type.value}
whileHover={!disabled ? { scale: 1.02 } : {}}
whileTap={!disabled ? { scale: 0.98 } : {}}
>
<button
type="button"
onClick={() => !disabled && onValueChange(type.value)}
disabled={disabled}
className={cn(
"relative w-full h-24 rounded-xl transition-all duration-200 border-2",
"flex flex-col items-center justify-center gap-2 p-4",
"backdrop-blur-md",
isSelected
? `${type.border.selected} bg-gradient-to-b ${type.gradient.selected}`
: `${type.border.unselected} bg-gradient-to-b ${type.gradient.unselected}`,
!disabled && !isSelected && type.border.hover,
!disabled && !isSelected && "hover:shadow-[0_0_15px_rgba(0,0,0,0.05)] dark:hover:shadow-[0_0_15px_rgba(255,255,255,0.05)]",
isSelected && "shadow-[0_0_20px_rgba(6,182,212,0.15)]",
disabled && "opacity-50 cursor-not-allowed"
)}
aria-label={`Select ${type.label}: ${type.description}`}
>
{/* Top accent glow for selected state */}
{isSelected && (
<div className="pointer-events-none absolute inset-x-0 top-0">
<div className={cn("mx-1 mt-0.5 h-[2px] rounded-full", type.accent)} />
<div className={cn("-mt-1 h-6 w-full bg-gradient-to-b to-transparent blur-md", type.smear)} />
</div>
)}
{/* Selection indicator */}
{isSelected && (
<div className={cn("absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center", type.accent)}>
<Check className="w-3 h-3 text-white" />
</div>
)}
{/* Icon */}
<Icon className={cn(
"w-6 h-6",
isSelected
? type.colors.selected
: type.colors.unselected
)} />
{/* Label */}
<div className={cn(
"text-sm font-semibold",
isSelected
? type.colors.selected
: type.colors.unselected
)}>
{type.label}
</div>
{/* Description */}
<div className={cn(
"text-xs text-center leading-tight",
isSelected
? type.colors.description.selected
: type.colors.description.unselected
)}>
{type.description}
</div>
</button>
</motion.div>
);
})}
</div>
{/* Help text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center">
Choose the type that best describes your content
</div>
</div>
);
};

View File

@@ -0,0 +1,166 @@
/**
* Level Selection Component
* Circular level selector for crawl depth using radio-like selection
*/
import { motion } from "framer-motion";
import { Check, Info } from "lucide-react";
import { cn } from "../../ui/primitives/styles";
import { SimpleTooltip } from "../../ui/primitives/tooltip";
interface LevelSelectorProps {
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
}
const LEVELS = [
{
value: "1",
label: "1",
description: "Single page only",
details: "1-50 pages • Best for: Single articles, specific pages"
},
{
value: "2",
label: "2",
description: "Page + immediate links",
details: "10-200 pages • Best for: Documentation sections, blogs"
},
{
value: "3",
label: "3",
description: "2 levels deep",
details: "50-500 pages • Best for: Entire sites, comprehensive docs"
},
{
value: "5",
label: "5",
description: "Very deep crawling",
details: "100-1000+ pages • Warning: May include irrelevant content"
},
];
export const LevelSelector: React.FC<LevelSelectorProps> = ({
value,
onValueChange,
disabled = false,
}) => {
const tooltipContent = (
<div className="space-y-2 text-xs">
<div className="font-semibold mb-2">Crawl Depth Level Explanations:</div>
{LEVELS.map((level) => (
<div key={level.value} className="space-y-1">
<div className="font-medium">Level {level.value}: "{level.description}"</div>
<div className="text-gray-300 dark:text-gray-400 pl-2">
{level.details}
</div>
</div>
))}
<div className="mt-3 pt-2 border-t border-gray-600 dark:border-gray-400">
<div className="flex items-center gap-1">
<span>💡</span>
<span className="font-medium">More data isn't always better. Choose based on your needs.</span>
</div>
</div>
</div>
);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-white/90" id="crawl-depth-label">
Crawl Depth
</div>
<SimpleTooltip content={tooltipContent}>
<Info className="w-4 h-4 text-gray-400 hover:text-cyan-500 transition-colors cursor-help" />
</SimpleTooltip>
</div>
<div
className="grid grid-cols-4 gap-3"
role="radiogroup"
aria-labelledby="crawl-depth-label"
>
{LEVELS.map((level) => {
const isSelected = value === level.value;
return (
<motion.div
key={level.value}
whileHover={!disabled ? { scale: 1.05 } : {}}
whileTap={!disabled ? { scale: 0.95 } : {}}
>
<SimpleTooltip content={level.details}>
<button
type="button"
role="radio"
aria-checked={isSelected}
aria-label={`Level ${level.value}: ${level.description}`}
tabIndex={isSelected ? 0 : -1}
onClick={() => !disabled && onValueChange(level.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!disabled) onValueChange(level.value);
}
}}
disabled={disabled}
className={cn(
"relative w-full h-16 rounded-xl transition-all duration-200 border-2",
"flex flex-col items-center justify-center gap-1",
"backdrop-blur-md focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2",
isSelected
? "border-cyan-500/60 bg-gradient-to-b from-cyan-100/60 via-cyan-50/30 to-white/70 dark:from-cyan-900/30 dark:via-cyan-900/15 dark:to-black/40"
: "border-gray-300/50 dark:border-gray-700/50 bg-gradient-to-b from-gray-50/50 via-gray-25/25 to-white/60 dark:from-gray-800/20 dark:via-gray-800/10 dark:to-black/30",
!disabled && "hover:border-cyan-400/50 hover:shadow-[0_0_15px_rgba(6,182,212,0.15)]",
disabled && "opacity-50 cursor-not-allowed"
)}
>
{/* Top accent glow for selected state */}
{isSelected && (
<div className="pointer-events-none absolute inset-x-0 top-0">
<div className="mx-1 mt-0.5 h-[2px] rounded-full bg-cyan-500" />
<div className="-mt-1 h-6 w-full bg-gradient-to-b from-cyan-500/25 to-transparent blur-md" />
</div>
)}
{/* Selection indicator */}
{isSelected && (
<div className="absolute -top-1 -right-1 w-5 h-5 bg-cyan-500 rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
)}
{/* Level number */}
<div className={cn(
"text-lg font-bold",
isSelected
? "text-cyan-700 dark:text-cyan-400"
: "text-gray-700 dark:text-gray-300"
)}>
{level.label}
</div>
{/* Level description */}
<div className={cn(
"text-xs text-center leading-tight",
isSelected
? "text-cyan-600 dark:text-cyan-400"
: "text-gray-500 dark:text-gray-400"
)}>
{level.value === "1" ? "level" : "levels"}
</div>
</button>
</SimpleTooltip>
</motion.div>
);
})}
</div>
{/* Help text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center">
Higher levels crawl deeper into the website structure
</div>
</div>
);
};

View File

@@ -0,0 +1,143 @@
/**
* Tag Input Component
* Visual tag management with add/remove functionality
*/
import { motion } from "framer-motion";
import { Plus, X } from "lucide-react";
import { useState } from "react";
import { Input } from "../../ui/primitives";
import { cn } from "../../ui/primitives/styles";
interface TagInputProps {
tags: string[];
onTagsChange: (tags: string[]) => void;
placeholder?: string;
disabled?: boolean;
maxTags?: number;
}
export const TagInput: React.FC<TagInputProps> = ({
tags,
onTagsChange,
placeholder = "Enter a tag and press Enter",
disabled = false,
maxTags = 10,
}) => {
const [inputValue, setInputValue] = useState("");
const addTag = (tag: string) => {
const trimmedTag = tag.trim();
if (
trimmedTag &&
!tags.includes(trimmedTag) &&
tags.length < maxTags
) {
onTagsChange([...tags, trimmedTag]);
setInputValue("");
}
};
const removeTag = (tagToRemove: string) => {
onTagsChange(tags.filter(tag => tag !== tagToRemove));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(inputValue);
} else if (e.key === "Backspace" && !inputValue && tags.length > 0) {
// Remove last tag when backspace is pressed on empty input
removeTag(tags[tags.length - 1]);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Handle comma-separated input for backwards compatibility
if (value.includes(",")) {
// Collect pasted candidates, trim and filter them
const newCandidates = value.split(",")
.map(tag => tag.trim())
.filter(Boolean);
// Merge with current tags using Set to dedupe
const combinedTags = new Set([...tags, ...newCandidates]);
const combinedArray = Array.from(combinedTags);
// Enforce maxTags limit by taking only the first N allowed tags
const finalTags = combinedArray.slice(0, maxTags);
// Single batched update
onTagsChange(finalTags);
setInputValue("");
} else {
setInputValue(value);
}
};
return (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-900 dark:text-white/90">
Tags
</div>
{/* Tag Display */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag, index) => (
<motion.div
key={tag}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium",
"backdrop-blur-md bg-gradient-to-r from-blue-100/80 to-blue-50/60 dark:from-blue-900/40 dark:to-blue-800/30",
"border border-blue-300/50 dark:border-blue-700/50",
"text-blue-700 dark:text-blue-300",
"transition-all duration-200"
)}
>
<span className="max-w-24 truncate">{tag}</span>
{!disabled && (
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-0.5 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 transition-colors"
aria-label={`Remove ${tag} tag`}
>
<X className="w-3 h-3" />
</button>
)}
</motion.div>
))}
</div>
)}
{/* Tag Input */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Plus className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={tags.length >= maxTags ? "Maximum tags reached" : placeholder}
disabled={disabled || tags.length >= maxTags}
className="pl-9 backdrop-blur-md bg-gradient-to-r from-white/60 to-white/50 dark:from-black/60 dark:to-black/50 border-gray-300/60 dark:border-gray-600/60 focus:border-blue-400/70 focus:shadow-[0_0_15px_rgba(59,130,246,0.15)]"
/>
</div>
{/* Help Text */}
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p>Press Enter or comma to add tags Backspace to remove last tag</p>
{maxTags && (
<p>{tags.length}/{maxTags} tags used</p>
)}
</div>
</div>
);
};

View File

@@ -2,3 +2,6 @@ export * from "./AddKnowledgeDialog";
export * from "./DocumentBrowser";
export * from "./KnowledgeCard";
export * from "./KnowledgeList";
export * from "./KnowledgeTypeSelector";
export * from "./LevelSelector";
export * from "./TagInput";