From 69fff64bfd71de64db11969b27abacfc8a813bac Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Mon, 23 Feb 2026 15:06:38 -0600 Subject: [PATCH] Signed-off-by: OpenClaw Bot --- README.md | 12 ++ app/api/search/route.ts | 180 +++++++++--------- app/calendar/page.tsx | 7 +- app/metadata.ts | 5 +- app/mission/page.tsx | 3 +- app/projects/page.tsx | 29 +-- app/sitemap.ts | 3 +- app/tasks/page.tsx | 21 +- app/tools/page.tsx | 11 +- components/calendar/EventDetailModal.tsx | 3 +- components/calendar/EventList.tsx | 3 +- .../calendar/TaskCalendarIntegration.tsx | 5 +- components/layout/quick-search.tsx | 74 ++----- lib/config/sites.ts | 98 ++++++++++ lib/data/mission.ts | 3 +- search.ts | 2 +- 16 files changed, 276 insertions(+), 183 deletions(-) create mode 100644 lib/config/sites.ts diff --git a/README.md b/README.md index bb1294c..5a68555 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,20 @@ SUPABASE_SERVICE_ROLE_KEY= # Optional NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration +NEXT_PUBLIC_MISSION_CONTROL_URL= +NEXT_PUBLIC_GANTT_BOARD_URL= +NEXT_PUBLIC_BLOG_BACKUP_URL= +NEXT_PUBLIC_GITEA_URL= +NEXT_PUBLIC_GITHUB_URL= +NEXT_PUBLIC_VERCEL_URL= +NEXT_PUBLIC_SUPABASE_SITE_URL= +NEXT_PUBLIC_GOOGLE_URL= +NEXT_PUBLIC_GOOGLE_CALENDAR_URL= +NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL= ``` +Website URLs are centralized in `lib/config/sites.ts` and can be overridden via the optional variables above. + ## Getting Started ```bash diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 4079cb2..207c0b3 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,114 +1,130 @@ import { NextResponse } from "next/server"; import { createClient } from "@supabase/supabase-js"; +import { + getGanttProjectUrl, + getGanttSprintUrl, + getGanttTaskUrl, + getMissionControlDocumentsUrl, +} from "@/lib/config/sites"; export const runtime = "nodejs"; -// Search result types - extendable for future search types -export type SearchResultType = - | "task" - | "project" - | "document" - | "sprint" - | "activity"; +// ============================================================================ +// SEARCHABLE PROTOCOL - Minimal search result interface +// ============================================================================ -export interface SearchResult { +export type SearchableType = "task" | "project" | "document" | "sprint"; + +export interface SearchableResult { id: string; - type: SearchResultType; + type: SearchableType; title: string; - subtitle?: string; - description?: string; - status?: string; - priority?: string; - color?: string; - url?: string; - icon?: string; - metadata?: Record; + snippet?: string; // Brief preview text (max 150 chars) + url: string; // Deep link to full view + icon: string; + status?: string; // For visual badges + color?: string; // For project/task colors } export interface SearchResponse { - results: SearchResult[]; + results: SearchableResult[]; total: number; query: string; } -// Search configuration - easily extensible -interface SearchConfig { +// ============================================================================ +// SEARCHABLE ENTITY CONFIGURATION +// Add new searchable types here following the pattern +// ============================================================================ + +interface SearchableEntityConfig { table: string; - type: SearchResultType; - fields: string[]; + type: SearchableType; titleField: string; - subtitleField?: string; - descriptionField?: string; + snippetField?: string; statusField?: string; - priorityField?: string; colorField?: string; - urlGenerator: (item: any) => string; icon: string; enabled: boolean; - searchFields: string[]; // Fields to search in + searchFields: string[]; + // Generate URL for deep linking to full view + getUrl: (item: any) => string; + // Generate snippet from content + getSnippet?: (item: any) => string | undefined; } -// Define searchable entities - add new ones here -const searchConfigs: SearchConfig[] = [ +const searchableEntities: SearchableEntityConfig[] = [ { table: "tasks", type: "task", - fields: ["id", "title", "description", "status", "priority", "project_id", "type"], titleField: "title", - subtitleField: "type", - descriptionField: "description", + snippetField: "description", statusField: "status", - priorityField: "priority", - urlGenerator: (item) => `https://gantt-board.vercel.app/tasks/${item.id}`, icon: "kanban", enabled: true, searchFields: ["title", "description"], + getUrl: (item) => getGanttTaskUrl(String(item.id)), + getSnippet: (item) => item.description + ? `${item.description.substring(0, 150)}${item.description.length > 150 ? "..." : ""}` + : undefined, }, { table: "projects", type: "project", - fields: ["id", "name", "description", "color", "status"], titleField: "name", - descriptionField: "description", + snippetField: "description", colorField: "color", statusField: "status", - urlGenerator: (item) => `/projects`, icon: "folder-kanban", enabled: true, searchFields: ["name", "description"], + getUrl: (item) => getGanttProjectUrl(String(item.id)), + getSnippet: (item) => item.description + ? `${item.description.substring(0, 150)}${item.description.length > 150 ? "..." : ""}` + : undefined, }, { table: "sprints", type: "sprint", - fields: ["id", "name", "goal", "status", "start_date", "end_date", "project_id"], titleField: "name", - subtitleField: "goal", + snippetField: "goal", statusField: "status", - urlGenerator: (item) => `https://gantt-board.vercel.app/sprints/${item.id}`, icon: "timer", enabled: true, searchFields: ["name", "goal"], + getUrl: (item) => getGanttSprintUrl(String(item.id)), + getSnippet: (item) => item.goal + ? `${item.goal.substring(0, 150)}${item.goal.length > 150 ? "..." : ""}` + : undefined, }, { table: "mission_control_documents", type: "document", - fields: ["id", "title", "content", "folder", "tags"], titleField: "title", - subtitleField: "folder", - descriptionField: "content", - urlGenerator: (item) => `/documents`, + snippetField: "content", icon: "file-text", enabled: true, searchFields: ["title", "content"], + getUrl: () => getMissionControlDocumentsUrl(), + getSnippet: (item) => item.content + ? `${item.content.substring(0, 150)}${item.content.length > 150 ? "..." : ""}` + : undefined, }, - // Future: Add more search types here + // Add new searchable entities here: // { // table: "meetings", // type: "meeting", - // ... + // titleField: "title", + // icon: "calendar", + // searchFields: ["title", "notes"], + // getUrl: (item) => `/meetings/${item.id}`, // } ]; +// ============================================================================ +// API HANDLER +// ============================================================================ + export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); @@ -122,7 +138,7 @@ export async function GET(request: Request) { }); } - // Create Supabase client with service role for full access + // Create Supabase client const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; @@ -134,73 +150,65 @@ export async function GET(request: Request) { } const supabase = createClient(supabaseUrl, supabaseKey); - const results: SearchResult[] = []; + const results: SearchableResult[] = []; - // Search each configured entity - for (const config of searchConfigs) { - if (!config.enabled) continue; + // Search each enabled entity + for (const entity of searchableEntities) { + if (!entity.enabled) continue; try { - // Build OR filter dynamically based on searchFields - const orConditions = config.searchFields + // Build OR filter for search fields + const orConditions = entity.searchFields .map(field => `${field}.ilike.%${query}%`) .join(","); + // Only select fields we need for search results + const selectFields = ["id", entity.titleField]; + if (entity.snippetField) selectFields.push(entity.snippetField); + if (entity.statusField) selectFields.push(entity.statusField); + if (entity.colorField) selectFields.push(entity.colorField); + const { data, error } = await supabase - .from(config.table) - .select(config.fields.join(", ")) + .from(entity.table) + .select(selectFields.join(", ")) .or(orConditions) .limit(10); if (error) { - console.error(`Search error in ${config.table}:`, error); + console.error(`Search error in ${entity.table}:`, error); continue; } if (data) { - const mappedResults: SearchResult[] = data.map((item: any) => ({ + const mappedResults: SearchableResult[] = 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), - }, + type: entity.type, + title: item[entity.titleField] || "Untitled", + snippet: entity.getSnippet ? entity.getSnippet(item) : undefined, + url: entity.getUrl(item), + icon: entity.icon, + status: entity.statusField ? item[entity.statusField] : undefined, + color: entity.colorField ? item[entity.colorField] : undefined, })); results.push(...mappedResults); } } catch (err) { - console.error(`Error searching ${config.table}:`, err); + console.error(`Error searching ${entity.table}:`, err); } } - // Sort results by relevance (exact matches first, then partial) + // Sort by relevance: exact match > starts with > contains results.sort((a, b) => { const aTitle = a.title.toLowerCase(); const bTitle = b.title.toLowerCase(); + const lowerQuery = query.toLowerCase(); - // Exact match gets highest priority - if (aTitle === query && bTitle !== query) return -1; - if (bTitle === query && aTitle !== query) return 1; + if (aTitle === lowerQuery && bTitle !== lowerQuery) return -1; + if (bTitle === lowerQuery && aTitle !== lowerQuery) return 1; + if (aTitle.startsWith(lowerQuery) && !bTitle.startsWith(lowerQuery)) return -1; + if (bTitle.startsWith(lowerQuery) && !aTitle.startsWith(lowerQuery)) 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); }); @@ -219,4 +227,4 @@ export async function GET(request: Request) { { status: 500 } ); } -} \ No newline at end of file +} diff --git a/app/calendar/page.tsx b/app/calendar/page.tsx index e02d430..72ded36 100644 --- a/app/calendar/page.tsx +++ b/app/calendar/page.tsx @@ -23,6 +23,7 @@ import { } from "@/components/calendar"; import { GoogleOAuthProvider } from "@react-oauth/google"; import { format } from "date-fns"; +import { siteUrls } from "@/lib/config/sites"; // Google OAuth Client ID - should be from environment variable const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""; @@ -145,7 +146,7 @@ function CalendarContent() { diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 06217d0..209222a 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -36,6 +36,13 @@ import { Sprint, } from "@/lib/data/projects"; import { Task } from "@/lib/data/tasks"; +import { + getGanttProjectUrl, + getGanttSprintUrl, + getGanttTaskUrl, + getGanttTasksUrl, + siteUrls, +} from "@/lib/config/sites"; // Force dynamic rendering to fetch fresh data on each request export const dynamic = "force-dynamic"; @@ -123,7 +130,7 @@ function TaskListItem({ task }: { task: Task }) {
{getStatusIcon(task.status)}
@@ -245,7 +252,7 @@ function ProjectCard({ stats }: { stats: ProjectStats }) { {/* Action Link */} @@ -314,7 +321,7 @@ function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectSta
- + - + - + - + - +