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

This commit is contained in:
OpenClaw Bot 2026-02-23 15:06:38 -06:00
parent d794a3b6ea
commit 69fff64bfd
16 changed files with 276 additions and 183 deletions

View File

@ -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

View File

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

View File

@ -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() {
</CardHeader>
<CardContent className="space-y-1">
<a
href="https://calendar.google.com"
href={siteUrls.googleCalendar}
target="_blank"
rel="noopener noreferrer"
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
@ -153,7 +154,7 @@ function CalendarContent() {
Open Google Calendar
</a>
<a
href="https://calendar.google.com/calendar/u/0/r/settings"
href={siteUrls.googleCalendarSettings}
target="_blank"
rel="noopener noreferrer"
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
@ -161,7 +162,7 @@ function CalendarContent() {
Calendar Settings
</a>
<a
href="https://gantt-board.vercel.app"
href={siteUrls.ganttBoard}
target="_blank"
rel="noopener noreferrer"
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"

View File

@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import { siteUrls } from "@/lib/config/sites";
export const viewport: Viewport = {
width: "device-width",
@ -27,11 +28,11 @@ export const metadata: Metadata = {
],
authors: [{ name: "TopDogLabs" }],
creator: "TopDogLabs",
metadataBase: new URL("https://mission-control-rho-pink.vercel.app"),
metadataBase: new URL(siteUrls.missionControl),
openGraph: {
type: "website",
locale: "en_US",
url: "https://mission-control-rho-pink.vercel.app",
url: siteUrls.missionControl,
title: "Mission Control | TopDogLabs",
description:
"Central hub for activity, tasks, goals, and tools. Build an iOS empire to achieve financial independence.",

View File

@ -24,6 +24,7 @@ import {
Milestone,
NextStep
} from "@/lib/data/mission";
import { siteUrls } from "@/lib/config/sites";
// Revalidate every 5 minutes
export const revalidate = 300;
@ -387,7 +388,7 @@ export default async function MissionPage() {
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<a href="https://gantt-board.vercel.app" target="_blank" rel="noopener noreferrer">
<a href={siteUrls.ganttBoard} target="_blank" rel="noopener noreferrer">
View All <ArrowRight className="w-4 h-4 ml-1" />
</a>
</Button>

View File

@ -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 }) {
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<Link
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
href={getGanttTaskUrl(task.id)}
target="_blank"
className="text-sm font-medium block truncate hover:text-primary hover:underline"
>
@ -245,7 +252,7 @@ function ProjectCard({ stats }: { stats: ProjectStats }) {
{/* Action Link */}
<Link
href={`https://gantt-board.vercel.app?project=${project.id}`}
href={getGanttProjectUrl(project.id)}
target="_blank"
className="flex items-center justify-between text-xs text-primary hover:underline mt-auto pt-2"
>
@ -314,7 +321,7 @@ function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectSta
</div>
<Link
href={`https://gantt-board.vercel.app?sprint=${sprint.id}`}
href={getGanttSprintUrl(sprint.id)}
target="_blank"
>
<Button variant="outline" size="sm" className="w-full mt-2">
@ -373,7 +380,7 @@ export default async function ProjectsOverviewPage() {
<>
Manage all projects and track progress. Edit projects in{" "}
<Link
href="https://gantt-board.vercel.app"
href={siteUrls.ganttBoard}
target="_blank"
className="text-primary hover:underline inline-flex items-center gap-1"
>
@ -383,7 +390,7 @@ export default async function ProjectsOverviewPage() {
</>
}
>
<Link href="https://gantt-board.vercel.app" target="_blank">
<Link href={siteUrls.ganttBoard} target="_blank">
<Button size="sm">
<ExternalLink className="w-4 h-4 mr-2" />
Open Gantt Board
@ -534,7 +541,7 @@ export default async function ProjectsOverviewPage() {
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<Link
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
href={getGanttTaskUrl(task.id)}
target="_blank"
className="text-sm font-medium block truncate hover:text-primary hover:underline"
>
@ -577,7 +584,7 @@ export default async function ProjectsOverviewPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Link href="https://gantt-board.vercel.app" target="_blank" className="block">
<Link href={siteUrls.ganttBoard} target="_blank" className="block">
<Button variant="outline" className="w-full justify-start h-auto py-3">
<div className="p-2 rounded-lg bg-blue-500/15 mr-3 shrink-0">
<ExternalLink className="w-4 h-4 text-blue-500" />
@ -589,7 +596,7 @@ export default async function ProjectsOverviewPage() {
</Button>
</Link>
<Link href="https://gantt-board.vercel.app/tasks?priority=urgent,high" target="_blank" className="block">
<Link href={getGanttTasksUrl({ priority: "urgent,high" })} target="_blank" className="block">
<Button variant="outline" className="w-full justify-start h-auto py-3">
<div className="p-2 rounded-lg bg-orange-500/15 mr-3 shrink-0">
<AlertTriangle className="w-4 h-4 text-orange-500" />
@ -601,7 +608,7 @@ export default async function ProjectsOverviewPage() {
</Button>
</Link>
<Link href="https://gantt-board.vercel.app/tasks?status=in-progress" target="_blank" className="block">
<Link href={getGanttTasksUrl({ status: "in-progress" })} target="_blank" className="block">
<Button variant="outline" className="w-full justify-start h-auto py-3">
<div className="p-2 rounded-lg bg-green-500/15 mr-3 shrink-0">
<CheckCircle2 className="w-4 h-4 text-green-500" />
@ -614,7 +621,7 @@ export default async function ProjectsOverviewPage() {
</Link>
{activeSprint && (
<Link href={`https://gantt-board.vercel.app?sprint=${activeSprint.id}`} target="_blank" className="block">
<Link href={getGanttSprintUrl(activeSprint.id)} target="_blank" className="block">
<Button variant="outline" className="w-full justify-start h-auto py-3">
<div className="p-2 rounded-lg bg-purple-500/15 mr-3 shrink-0">
<Zap className="w-4 h-4 text-purple-500" />
@ -637,7 +644,7 @@ export default async function ProjectsOverviewPage() {
Mission Control is read-only. Create and edit projects in gantt-board.
</p>
</div>
<Link href="https://gantt-board.vercel.app" target="_blank" className="shrink-0">
<Link href={siteUrls.ganttBoard} target="_blank" className="shrink-0">
<Button variant="secondary" size="sm">
Go to gantt-board
<ArrowRight className="w-4 h-4 ml-2" />

View File

@ -1,7 +1,8 @@
import { MetadataRoute } from "next";
import { siteUrls } from "@/lib/config/sites";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://mission-control-rho-pink.vercel.app";
const baseUrl = siteUrls.missionControl;
return [
{

View File

@ -27,6 +27,11 @@ import {
countOverdueTasks,
Task,
} from "@/lib/data/tasks";
import {
getGanttTaskUrl,
getGanttTasksUrl,
siteUrls,
} from "@/lib/config/sites";
// Force dynamic rendering to fetch fresh data from Supabase on each request
export const dynamic = "force-dynamic";
@ -103,7 +108,7 @@ function TaskListItem({ task }: { task: Task }) {
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<Link
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
href={getGanttTaskUrl(task.id)}
target="_blank"
className="text-sm font-medium block truncate hover:text-primary hover:underline"
>
@ -248,7 +253,7 @@ export default async function TasksOverviewPage() {
<>
Mission Control view of all tasks. Manage work in{" "}
<Link
href="https://gantt-board.vercel.app"
href={siteUrls.ganttBoard}
target="_blank"
className="text-primary hover:underline inline-flex items-center gap-1"
>
@ -258,7 +263,7 @@ export default async function TasksOverviewPage() {
</>
}
>
<Link href="https://gantt-board.vercel.app" target="_blank">
<Link href={siteUrls.ganttBoard} target="_blank">
<Button size="sm" className="w-full sm:w-auto">
<ExternalLink className="w-4 h-4 mr-2" />
Open gantt-board
@ -394,7 +399,7 @@ export default async function TasksOverviewPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<Link href="https://gantt-board.vercel.app" target="_blank" className="block">
<Link href={siteUrls.ganttBoard} target="_blank" className="block">
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
<div className="p-2 rounded-lg bg-blue-500/15 mr-3 shrink-0">
<ExternalLink className="w-4 h-4 sm:w-5 sm:h-5 text-blue-500" />
@ -406,7 +411,7 @@ export default async function TasksOverviewPage() {
</Button>
</Link>
<Link href="https://gantt-board.vercel.app/tasks" target="_blank" className="block">
<Link href={getGanttTasksUrl()} target="_blank" className="block">
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
<div className="p-2 rounded-lg bg-purple-500/15 mr-3 shrink-0">
<Layers className="w-4 h-4 sm:w-5 sm:h-5 text-purple-500" />
@ -418,7 +423,7 @@ export default async function TasksOverviewPage() {
</Button>
</Link>
<Link href="https://gantt-board.vercel.app/tasks?priority=high,urgent" target="_blank" className="block">
<Link href={getGanttTasksUrl({ priority: "high,urgent" })} target="_blank" className="block">
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
<div className="p-2 rounded-lg bg-orange-500/15 mr-3 shrink-0">
<AlertTriangle className="w-4 h-4 sm:w-5 sm:h-5 text-orange-500" />
@ -430,7 +435,7 @@ export default async function TasksOverviewPage() {
</Button>
</Link>
<Link href="https://gantt-board.vercel.app/tasks?status=in-progress" target="_blank" className="block">
<Link href={getGanttTasksUrl({ status: "in-progress" })} target="_blank" className="block">
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
<div className="p-2 rounded-lg bg-green-500/15 mr-3 shrink-0">
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-green-500" />
@ -452,7 +457,7 @@ export default async function TasksOverviewPage() {
Mission Control is read-only. All task creation and editing happens in gantt-board.
</p>
</div>
<Link href="https://gantt-board.vercel.app" target="_blank" className="shrink-0">
<Link href={siteUrls.ganttBoard} target="_blank" className="shrink-0">
<Button variant="secondary" size="sm">
Go to gantt-board
<ArrowRight className="w-4 h-4 ml-2" />

View File

@ -22,6 +22,7 @@ import {
Check
} from "lucide-react";
import { useState, useEffect, useCallback, useRef } from "react";
import { siteUrls } from "@/lib/config/sites";
// ============================================================================
// Types
@ -43,35 +44,35 @@ const QUICK_LINKS: QuickLink[] = [
{
id: "github",
name: "GitHub",
url: "https://github.com",
url: siteUrls.github,
icon: <Github className="w-4 h-4" />,
color: "bg-slate-800"
},
{
id: "vercel",
name: "Vercel",
url: "https://vercel.com",
url: siteUrls.vercel,
icon: <Cloud className="w-4 h-4" />,
color: "bg-black"
},
{
id: "supabase",
name: "Supabase",
url: "https://supabase.com",
url: siteUrls.supabase,
icon: <Database className="w-4 h-4" />,
color: "bg-emerald-600"
},
{
id: "gantt",
name: "Gantt Board",
url: "https://gantt-board.vercel.app",
url: siteUrls.ganttBoard,
icon: <Clock className="w-4 h-4" />,
color: "bg-blue-600"
},
{
id: "google",
name: "Google",
url: "https://google.com",
url: siteUrls.google,
icon: <Globe className="w-4 h-4" />,
color: "bg-red-500"
},

View File

@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
import { Calendar, Clock, MapPin, ExternalLink, FileText, X } from "lucide-react";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { siteUrls } from "@/lib/config/sites";
interface CalendarEvent {
id: string;
@ -103,7 +104,7 @@ function formatEventDate(event: CalendarEvent): string {
export function EventDetailModal({ event, isOpen, onClose }: EventDetailModalProps) {
if (!event) return null;
const eventUrl = event.htmlLink || `https://calendar.google.com/calendar/event?eid=${btoa(event.id)}`;
const eventUrl = event.htmlLink || `${siteUrls.googleCalendar}/calendar/event?eid=${btoa(event.id)}`;
return (
<Dialog open={isOpen} onOpenChange={onClose}>

View File

@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { format, isSameDay, parseISO } from "date-fns";
import { ScrollArea } from "@/components/ui/scroll-area";
import { siteUrls } from "@/lib/config/sites";
function getEventDate(event: { start: { dateTime?: string; date?: string } }): Date {
if (event.start.dateTime) {
@ -185,7 +186,7 @@ function EventCard({
</div>
</div>
<a
href={`https://calendar.google.com/calendar/event?eid=${btoa(
href={`${siteUrls.googleCalendar}/calendar/event?eid=${btoa(
event.id
)}`}
target="_blank"

View File

@ -15,6 +15,7 @@ import {
User,
} from "lucide-react";
import Link from "next/link";
import { getGanttTaskUrl, getGanttTasksUrl } from "@/lib/config/sites";
interface TaskWithDueDate {
id: string;
@ -169,7 +170,7 @@ export function TaskCalendarIntegration({ maxTasks = 10 }: TaskCalendarIntegrati
{getStatusIcon(task.status)}
<div className="flex-1 min-w-0">
<Link
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
href={getGanttTaskUrl(task.id)}
target="_blank"
className="font-medium text-sm hover:text-primary hover:underline block truncate"
>
@ -217,7 +218,7 @@ export function TaskCalendarIntegration({ maxTasks = 10 }: TaskCalendarIntegrati
<div className="mt-4 pt-4 border-t">
<Link
href="https://gantt-board.vercel.app/tasks"
href={getGanttTasksUrl()}
target="_blank"
>
<Button variant="ghost" size="sm" className="w-full gap-2">

View File

@ -28,22 +28,20 @@ import {
Timer,
Circle,
} from "lucide-react";
import { siteUrls } from "@/lib/config/sites";
// Search result type from API
type SearchResultType = "task" | "project" | "document" | "sprint" | "activity";
// Search result type from API - matches SearchableResult interface
type SearchResultType = "task" | "project" | "document" | "sprint";
interface SearchResult {
id: string;
type: SearchResultType;
title: string;
subtitle?: string;
description?: string;
metadata?: Record<string, string>;
status?: string;
priority?: string;
color?: string;
url?: string;
icon?: string;
snippet?: string; // Brief preview text (replaces subtitle/description)
url: string; // Deep link to full view
icon: string;
status?: string; // For visual badges
color?: string; // For project/task colors
}
const navItems = [
@ -58,9 +56,9 @@ const navItems = [
];
const quickLinks = [
{ name: "Gantt Board", url: "https://gantt-board.vercel.app", icon: ExternalLink },
{ name: "Blog Backup", url: "https://blog-backup-two.vercel.app", icon: ExternalLink },
{ name: "Gitea", url: "http://192.168.1.128:3000", icon: ExternalLink },
{ name: "Gantt Board", url: siteUrls.ganttBoard, icon: ExternalLink },
{ name: "Blog Backup", url: siteUrls.blogBackup, icon: ExternalLink },
{ name: "Gitea", url: siteUrls.gitea, icon: ExternalLink },
];
// Icon mapping for search result types
@ -69,16 +67,6 @@ const typeIcons: Record<SearchResultType, React.ElementType> = {
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
@ -87,7 +75,6 @@ const typeLabels: Record<SearchResultType, string> = {
project: "Project",
document: "Document",
sprint: "Sprint",
activity: "Activity",
};
function getStatusIcon(status?: string) {
@ -105,19 +92,6 @@ function getStatusIcon(status?: string) {
}
}
function getPriorityColor(priority?: string): string {
switch (priority) {
case "urgent":
return "text-red-500";
case "high":
return "text-orange-500";
case "medium":
return "text-yellow-500";
default:
return "text-blue-500";
}
}
export function QuickSearch() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
@ -243,33 +217,13 @@ export function QuickSearch() {
<div className="flex flex-col flex-1 min-w-0">
<span className="truncate">{result.title}</span>
{result.subtitle && (
{result.snippet && (
<span className="text-xs text-muted-foreground truncate">
{result.subtitle}
{result.snippet}
</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}
@ -321,4 +275,4 @@ export function QuickSearch() {
</CommandDialog>
</>
);
}
}

98
lib/config/sites.ts Normal file
View File

@ -0,0 +1,98 @@
type SiteUrlKey =
| "missionControl"
| "ganttBoard"
| "blogBackup"
| "gitea"
| "github"
| "vercel"
| "supabase"
| "google"
| "googleCalendar"
| "googleCalendarSettings";
const DEFAULT_SITE_URLS: Record<SiteUrlKey, string> = {
missionControl: "https://mission-control-rho-pink.vercel.app",
ganttBoard: "https://gantt-board.vercel.app",
blogBackup: "https://blog-backup-two.vercel.app",
gitea: "http://192.168.1.128:3000",
github: "https://github.com",
vercel: "https://vercel.com",
supabase: "https://supabase.com",
google: "https://google.com",
googleCalendar: "https://calendar.google.com",
googleCalendarSettings: "https://calendar.google.com/calendar/u/0/r/settings",
};
const ENV_SITE_URLS: Partial<Record<SiteUrlKey, string | undefined>> = {
missionControl: process.env.NEXT_PUBLIC_MISSION_CONTROL_URL,
ganttBoard: process.env.NEXT_PUBLIC_GANTT_BOARD_URL,
blogBackup: process.env.NEXT_PUBLIC_BLOG_BACKUP_URL,
gitea: process.env.NEXT_PUBLIC_GITEA_URL,
github: process.env.NEXT_PUBLIC_GITHUB_URL,
vercel: process.env.NEXT_PUBLIC_VERCEL_URL,
supabase: process.env.NEXT_PUBLIC_SUPABASE_SITE_URL,
google: process.env.NEXT_PUBLIC_GOOGLE_URL,
googleCalendar: process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_URL,
googleCalendarSettings: process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL,
};
function normalizeBaseUrl(value: string, fallback: string): string {
try {
const normalized = new URL(value.trim()).toString();
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
} catch {
return fallback;
}
}
const configuredSiteUrls = Object.entries(DEFAULT_SITE_URLS).reduce(
(acc, [key, fallback]) => {
const envValue = ENV_SITE_URLS[key as SiteUrlKey];
acc[key as SiteUrlKey] = normalizeBaseUrl(envValue || fallback, fallback);
return acc;
},
{} as Record<SiteUrlKey, string>,
);
export const siteUrls = Object.freeze(configuredSiteUrls);
function toUrl(base: string, path = ""): string {
if (!path) return base;
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${base}${normalizedPath}`;
}
function toUrlWithQuery(
base: string,
path: string,
params: Record<string, string | undefined>,
): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value) query.set(key, value);
}
const queryString = query.toString();
return queryString ? `${toUrl(base, path)}?${queryString}` : toUrl(base, path);
}
export function getGanttTaskUrl(taskId: string): string {
return toUrl(siteUrls.ganttBoard, `/tasks/${encodeURIComponent(taskId)}`);
}
export function getGanttProjectUrl(projectId: string): string {
return toUrl(siteUrls.ganttBoard, `/projects/${encodeURIComponent(projectId)}`);
}
export function getGanttSprintUrl(sprintId: string): string {
return toUrl(siteUrls.ganttBoard, `/sprints/${encodeURIComponent(sprintId)}`);
}
export function getGanttTasksUrl(params?: Record<string, string | undefined>): string {
return params
? toUrlWithQuery(siteUrls.ganttBoard, "/tasks", params)
: toUrl(siteUrls.ganttBoard, "/tasks");
}
export function getMissionControlDocumentsUrl(): string {
return toUrl(siteUrls.missionControl, "/documents");
}

View File

@ -1,6 +1,7 @@
import { getServiceSupabase } from "@/lib/supabase/client";
import { Task, fetchAllTasks } from "./tasks";
import { Project, fetchAllProjects } from "./projects";
import { getGanttTaskUrl } from "@/lib/config/sites";
// ============================================================================
// Types
@ -370,7 +371,7 @@ export async function getNextMissionSteps(): Promise<NextStep[]> {
priority: task.priority as "high" | "urgent",
projectName: projectMap.get(task.project_id),
dueDate: task.due_date,
ganttBoardUrl: `https://gantt-board.vercel.app/?task=${task.id}`,
ganttBoardUrl: getGanttTaskUrl(task.id),
}));
}

View File

@ -22,6 +22,6 @@ async function searchMissionControl() {
}
}
google('Mission Control Strategy Plan')
console.log('Searching for: Mission Control Strategy Plan')
searchMissionControl();