Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
a517988482
commit
004b865e47
222
app/api/search/route.ts
Normal file
222
app/api/search/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
27
search.ts
Normal 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
25
test-search.js
Normal 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();
|
||||
Loading…
Reference in New Issue
Block a user