From 004b865e477afac51b36588c6cdd5a373b2befc5 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Mon, 23 Feb 2026 14:25:05 -0600 Subject: [PATCH] Signed-off-by: OpenClaw Bot --- app/api/search/route.ts | 222 +++++++++++++++++++++++++++ components/layout/quick-search.tsx | 236 ++++++++++++++++++++--------- search.ts | 27 ++++ test-search.js | 25 +++ 4 files changed, 438 insertions(+), 72 deletions(-) create mode 100644 app/api/search/route.ts create mode 100644 search.ts create mode 100644 test-search.js diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..4079cb2 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,222 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@supabase/supabase-js"; + +export const runtime = "nodejs"; + +// Search result types - extendable for future search types +export type SearchResultType = + | "task" + | "project" + | "document" + | "sprint" + | "activity"; + +export interface SearchResult { + id: string; + type: SearchResultType; + title: string; + subtitle?: string; + description?: string; + status?: string; + priority?: string; + color?: string; + url?: string; + icon?: string; + metadata?: Record; +} + +export interface SearchResponse { + results: SearchResult[]; + total: number; + query: string; +} + +// Search configuration - easily extensible +interface SearchConfig { + table: string; + type: SearchResultType; + fields: string[]; + titleField: string; + subtitleField?: string; + descriptionField?: string; + statusField?: string; + priorityField?: string; + colorField?: string; + urlGenerator: (item: any) => string; + icon: string; + enabled: boolean; + searchFields: string[]; // Fields to search in +} + +// Define searchable entities - add new ones here +const searchConfigs: SearchConfig[] = [ + { + table: "tasks", + type: "task", + fields: ["id", "title", "description", "status", "priority", "project_id", "type"], + titleField: "title", + subtitleField: "type", + descriptionField: "description", + statusField: "status", + priorityField: "priority", + urlGenerator: (item) => `https://gantt-board.vercel.app/tasks/${item.id}`, + icon: "kanban", + enabled: true, + searchFields: ["title", "description"], + }, + { + table: "projects", + type: "project", + fields: ["id", "name", "description", "color", "status"], + titleField: "name", + descriptionField: "description", + colorField: "color", + statusField: "status", + urlGenerator: (item) => `/projects`, + icon: "folder-kanban", + enabled: true, + searchFields: ["name", "description"], + }, + { + table: "sprints", + type: "sprint", + fields: ["id", "name", "goal", "status", "start_date", "end_date", "project_id"], + titleField: "name", + subtitleField: "goal", + statusField: "status", + urlGenerator: (item) => `https://gantt-board.vercel.app/sprints/${item.id}`, + icon: "timer", + enabled: true, + searchFields: ["name", "goal"], + }, + { + table: "mission_control_documents", + type: "document", + fields: ["id", "title", "content", "folder", "tags"], + titleField: "title", + subtitleField: "folder", + descriptionField: "content", + urlGenerator: (item) => `/documents`, + icon: "file-text", + enabled: true, + searchFields: ["title", "content"], + }, + // Future: Add more search types here + // { + // table: "meetings", + // type: "meeting", + // ... + // } +]; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const query = searchParams.get("q")?.trim().toLowerCase(); + + if (!query || query.length < 2) { + return NextResponse.json({ + results: [], + total: 0, + query: query || "" + }); + } + + // Create Supabase client with service role for full access + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseKey) { + return NextResponse.json( + { error: "Database not configured" }, + { status: 500 } + ); + } + + const supabase = createClient(supabaseUrl, supabaseKey); + const results: SearchResult[] = []; + + // Search each configured entity + for (const config of searchConfigs) { + if (!config.enabled) continue; + + try { + // Build OR filter dynamically based on searchFields + const orConditions = config.searchFields + .map(field => `${field}.ilike.%${query}%`) + .join(","); + + const { data, error } = await supabase + .from(config.table) + .select(config.fields.join(", ")) + .or(orConditions) + .limit(10); + + if (error) { + console.error(`Search error in ${config.table}:`, error); + continue; + } + + if (data) { + const mappedResults: SearchResult[] = data.map((item: any) => ({ + id: item.id, + type: config.type, + title: item[config.titleField] || "Untitled", + subtitle: config.subtitleField ? item[config.subtitleField] : undefined, + description: config.descriptionField ? item[config.descriptionField] : undefined, + status: config.statusField ? item[config.statusField] : undefined, + priority: config.priorityField ? item[config.priorityField] : undefined, + color: config.colorField ? item[config.colorField] : undefined, + url: config.urlGenerator(item), + icon: config.icon, + metadata: { + table: config.table, + ...Object.entries(item).reduce((acc, [key, value]) => { + if (typeof value === "string" || typeof value === "number") { + acc[key] = String(value); + } + return acc; + }, {} as Record), + }, + })); + + results.push(...mappedResults); + } + } catch (err) { + console.error(`Error searching ${config.table}:`, err); + } + } + + // Sort results by relevance (exact matches first, then partial) + results.sort((a, b) => { + const aTitle = a.title.toLowerCase(); + const bTitle = b.title.toLowerCase(); + + // Exact match gets highest priority + if (aTitle === query && bTitle !== query) return -1; + if (bTitle === query && aTitle !== query) return 1; + + // Starts with query gets second priority + if (aTitle.startsWith(query) && !bTitle.startsWith(query)) return -1; + if (bTitle.startsWith(query) && !aTitle.startsWith(query)) return 1; + + // Otherwise alphabetical + return aTitle.localeCompare(bTitle); + }); + + // Limit total results + const limitedResults = results.slice(0, 50); + + return NextResponse.json({ + results: limitedResults, + total: limitedResults.length, + query, + }); + } catch (error) { + console.error("Search API error:", error); + return NextResponse.json( + { error: "Search failed" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/components/layout/quick-search.tsx b/components/layout/quick-search.tsx index b601dbe..f14a20e 100644 --- a/components/layout/quick-search.tsx +++ b/components/layout/quick-search.tsx @@ -25,20 +25,25 @@ import { CheckCircle2, Clock, AlertCircle, + Timer, + Circle, } from "lucide-react"; -import { supabaseClient } from "@/lib/supabase/client"; -interface Task { +// Search result type from API +type SearchResultType = "task" | "project" | "document" | "sprint" | "activity"; + +interface SearchResult { id: string; + type: SearchResultType; title: string; - status: string; - priority: string; - project_id: string; -} - -interface Project { - id: string; - name: string; + subtitle?: string; + description?: string; + metadata?: Record; + status?: string; + priority?: string; + color?: string; + url?: string; + icon?: string; } const navItems = [ @@ -58,18 +63,49 @@ const quickLinks = [ { name: "Gitea", url: "http://192.168.1.128:3000", icon: ExternalLink }, ]; -function getStatusIcon(status: string) { +// Icon mapping for search result types +const typeIcons: Record = { + task: Kanban, + project: FolderKanban, + document: FileText, + sprint: Timer, + activity: Activity, +}; + +// Tag colors for documents +const tagColors: Record = { + infrastructure: "bg-blue-500/20 text-blue-400", + monitoring: "bg-green-500/20 text-green-400", + security: "bg-red-500/20 text-red-400", + urgent: "bg-orange-500/20 text-orange-400", + guide: "bg-purple-500/20 text-purple-400", +}; + +// Type labels +const typeLabels: Record = { + task: "Task", + project: "Project", + document: "Document", + sprint: "Sprint", + activity: "Activity", +}; + +function getStatusIcon(status?: string) { switch (status) { case "done": + case "completed": return ; case "in-progress": return ; + case "open": + case "todo": + return ; default: return ; } } -function getPriorityColor(priority: string): string { +function getPriorityColor(priority?: string): string { switch (priority) { case "urgent": return "text-red-500"; @@ -84,42 +120,36 @@ function getPriorityColor(priority: string): string { export function QuickSearch() { const [open, setOpen] = useState(false); - const [tasks, setTasks] = useState([]); - const [projects, setProjects] = useState([]); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const router = useRouter(); - // Fetch tasks when search opens + // Debounced search useEffect(() => { - if (!open) return; - + if (!open || query.length < 2) { + setResults([]); + return; + } + setLoading(true); - const fetchData = async () => { + const timer = setTimeout(async () => { try { - // Fetch tasks - const { data: tasksData } = await supabaseClient - .from('tasks') - .select('id, title, status, priority, project_id') - .order('updated_at', { ascending: false }) - .limit(50); - - // Fetch projects for names - const { data: projectsData } = await supabaseClient - .from('projects') - .select('id, name'); - - setTasks(tasksData || []); - setProjects(projectsData || []); + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); + const data = await res.json(); + setResults(data.results || []); } catch (err) { - console.error('Error fetching search data:', err); + console.error("Search error:", err); + setResults([]); } finally { setLoading(false); } - }; - - fetchData(); - }, [open]); + }, 150); // 150ms debounce + return () => clearTimeout(timer); + }, [query, open]); + + // Keyboard shortcut useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { @@ -133,13 +163,32 @@ export function QuickSearch() { const runCommand = useCallback((command: () => void) => { setOpen(false); + setQuery(""); command(); }, []); - const getProjectName = (projectId: string) => { - return projects.find(p => p.id === projectId)?.name || 'Unknown'; + const handleResultSelect = (result: SearchResult) => { + runCommand(() => { + if (result.url?.startsWith("http")) { + window.open(result.url, "_blank"); + } else if (result.url) { + router.push(result.url); + } + }); }; + // Group results by type + const groupedResults = results.reduce((acc, result) => { + if (!acc[result.type]) acc[result.type] = []; + acc[result.type].push(result); + return acc; + }, {} as Record); + + // Get ordered list of types that have results + const activeTypes = (Object.keys(groupedResults) as SearchResultType[]).filter( + (type) => groupedResults[type]?.length > 0 + ); + return ( <> {/* Search Button Trigger */} @@ -157,9 +206,83 @@ export function QuickSearch() { {/* Command Dialog */} - + - No results found. + + {loading ? "Searching..." : query.length < 2 ? "Type to search..." : "No results found."} + + + {/* Show search results when query exists */} + {query.length >= 2 && activeTypes.length > 0 && ( + <> + {activeTypes.map((type) => { + const Icon = typeIcons[type]; + const typeResults = groupedResults[type]; + + return ( + + {typeResults.slice(0, 5).map((result) => ( + handleResultSelect(result)} + > + {result.type === "task" ? ( + getStatusIcon(result.status) + ) : result.type === "project" && result.color ? ( +
+ ) : ( + + )} + +
+ {result.title} + {result.subtitle && ( + + {result.subtitle} + + )} +
+ + {/* Document tags */} + {result.type === "document" && result.metadata?.tags && ( +
+ {result.metadata.tags.split(",").slice(0, 2).map((tag: string) => ( + + {tag.trim()} + + ))} +
+ )} + + {result.priority && ( + + {result.priority} + + )} + + {result.status && result.type !== "task" && ( + + {result.status} + + )} + + ))} + + ); + })} + + + )} {/* Navigation */} @@ -194,37 +317,6 @@ export function QuickSearch() { ); })} - - {/* Tasks */} - {tasks.length > 0 && ( - <> - - - {tasks.slice(0, 10).map((task) => ( - - runCommand(() => - window.open( - `https://gantt-board.vercel.app/tasks/${task.id}`, - "_blank" - ) - ) - } - > - {getStatusIcon(task.status)} - {task.title} - - {task.priority} - - - {getProjectName(task.project_id)} - - - ))} - - - )} diff --git a/search.ts b/search.ts new file mode 100644 index 0000000..17b4fb3 --- /dev/null +++ b/search.ts @@ -0,0 +1,27 @@ +const { createClient } = require('@supabase/supabase-js'); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +const supabase = createClient(supabaseUrl, supabaseKey); + +async function searchMissionControl() { + try { + const { data, error } = await supabase + .from('mission_control_documents') + .select('title, content') + .or('title.ilike.%mission%,content.ilike.%mission%'); + + if (error) { + console.error('Query error:', error); + } else { + console.log('Search results:', data); + } + } catch (err) { + console.error('Unexpected error:', err); + } +} + +google('Mission Control Strategy Plan') + +searchMissionControl(); \ No newline at end of file diff --git a/test-search.js b/test-search.js new file mode 100644 index 0000000..03c6e42 --- /dev/null +++ b/test-search.js @@ -0,0 +1,25 @@ +const { createClient } = require('@supabase/supabase-js'); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +const supabase = createClient(supabaseUrl, supabaseKey); + +async function testSearch() { + try { + const { data, error } = await supabase + .from('mission_control_documents') + .select('title, content') + .or('title.ilike.%mission%,content.ilike.%mission%'); + + if (error) { + console.error('Query error:', error); + } else { + console.log('Search results:', data); + } + } catch (err) { + console.error('Unexpected error:', err); + } +} + +testSearch(); \ No newline at end of file