mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
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:
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
143
archon-ui-main/src/features/knowledge/components/TagInput.tsx
Normal file
143
archon-ui-main/src/features/knowledge/components/TagInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user