Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-23 14:25:05 -06:00
parent a517988482
commit 004b865e47
4 changed files with 438 additions and 72 deletions

222
app/api/search/route.ts Normal file
View File

@ -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<string, string>;
}
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<string, string>),
},
}));
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 }
);
}
}

View File

@ -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<string, string>;
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<SearchResultType, React.ElementType> = {
task: Kanban,
project: FolderKanban,
document: FileText,
sprint: Timer,
activity: Activity,
};
// Tag colors for documents
const tagColors: Record<string, string> = {
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<SearchResultType, string> = {
task: "Task",
project: "Project",
document: "Document",
sprint: "Sprint",
activity: "Activity",
};
function getStatusIcon(status?: string) {
switch (status) {
case "done":
case "completed":
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case "in-progress":
return <Clock className="w-4 h-4 text-blue-500" />;
case "open":
case "todo":
return <Circle className="w-4 h-4 text-muted-foreground" />;
default:
return <AlertCircle className="w-4 h-4 text-muted-foreground" />;
}
}
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<Task[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
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);
}
};
}, 150); // 150ms debounce
fetchData();
}, [open]);
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<SearchResultType, SearchResult[]>);
// 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 */}
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandInput
placeholder="Search tasks, projects, sprints, documents..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandEmpty>
{loading ? "Searching..." : query.length < 2 ? "Type to search..." : "No results found."}
</CommandEmpty>
{/* Show search results when query exists */}
{query.length >= 2 && activeTypes.length > 0 && (
<>
{activeTypes.map((type) => {
const Icon = typeIcons[type];
const typeResults = groupedResults[type];
return (
<CommandGroup key={type} heading={`${typeLabels[type]}s (${typeResults.length})`}>
{typeResults.slice(0, 5).map((result) => (
<CommandItem
key={`${result.type}-${result.id}`}
onSelect={() => handleResultSelect(result)}
>
{result.type === "task" ? (
getStatusIcon(result.status)
) : result.type === "project" && result.color ? (
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: result.color }}
/>
) : (
<Icon className="w-4 h-4 mr-2" />
)}
<div className="flex flex-col flex-1 min-w-0">
<span className="truncate">{result.title}</span>
{result.subtitle && (
<span className="text-xs text-muted-foreground truncate">
{result.subtitle}
</span>
)}
</div>
{/* Document tags */}
{result.type === "document" && result.metadata?.tags && (
<div className="flex gap-1 ml-2">
{result.metadata.tags.split(",").slice(0, 2).map((tag: string) => (
<span
key={tag}
className={`text-[10px] px-1.5 py-0.5 rounded ${tagColors[tag.trim()] || "bg-muted text-muted-foreground"}`}
>
{tag.trim()}
</span>
))}
</div>
)}
{result.priority && (
<span className={`text-xs ${getPriorityColor(result.priority)} ml-2`}>
{result.priority}
</span>
)}
{result.status && result.type !== "task" && (
<span className="text-xs text-muted-foreground ml-2 capitalize">
{result.status}
</span>
)}
</CommandItem>
))}
</CommandGroup>
);
})}
<CommandSeparator />
</>
)}
{/* Navigation */}
<CommandGroup heading="Navigation">
@ -194,37 +317,6 @@ export function QuickSearch() {
);
})}
</CommandGroup>
{/* Tasks */}
{tasks.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading={`Tasks (${tasks.length})`}>
{tasks.slice(0, 10).map((task) => (
<CommandItem
key={task.id}
onSelect={() =>
runCommand(() =>
window.open(
`https://gantt-board.vercel.app/tasks/${task.id}`,
"_blank"
)
)
}
>
{getStatusIcon(task.status)}
<span className="mr-2 truncate">{task.title}</span>
<span className={`text-xs ${getPriorityColor(task.priority)}`}>
{task.priority}
</span>
<span className="text-xs text-muted-foreground ml-auto">
{getProjectName(task.project_id)}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
</>

27
search.ts Normal file
View File

@ -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();

25
test-search.js Normal file
View File

@ -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();