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 # Optional
NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration 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 ## Getting Started
```bash ```bash

View File

@ -1,114 +1,130 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js"; import { createClient } from "@supabase/supabase-js";
import {
getGanttProjectUrl,
getGanttSprintUrl,
getGanttTaskUrl,
getMissionControlDocumentsUrl,
} from "@/lib/config/sites";
export const runtime = "nodejs"; export const runtime = "nodejs";
// Search result types - extendable for future search types // ============================================================================
export type SearchResultType = // SEARCHABLE PROTOCOL - Minimal search result interface
| "task" // ============================================================================
| "project"
| "document"
| "sprint"
| "activity";
export interface SearchResult { export type SearchableType = "task" | "project" | "document" | "sprint";
export interface SearchableResult {
id: string; id: string;
type: SearchResultType; type: SearchableType;
title: string; title: string;
subtitle?: string; snippet?: string; // Brief preview text (max 150 chars)
description?: string; url: string; // Deep link to full view
status?: string; icon: string;
priority?: string; status?: string; // For visual badges
color?: string; color?: string; // For project/task colors
url?: string;
icon?: string;
metadata?: Record<string, string>;
} }
export interface SearchResponse { export interface SearchResponse {
results: SearchResult[]; results: SearchableResult[];
total: number; total: number;
query: string; query: string;
} }
// Search configuration - easily extensible // ============================================================================
interface SearchConfig { // SEARCHABLE ENTITY CONFIGURATION
// Add new searchable types here following the pattern
// ============================================================================
interface SearchableEntityConfig {
table: string; table: string;
type: SearchResultType; type: SearchableType;
fields: string[];
titleField: string; titleField: string;
subtitleField?: string; snippetField?: string;
descriptionField?: string;
statusField?: string; statusField?: string;
priorityField?: string;
colorField?: string; colorField?: string;
urlGenerator: (item: any) => string;
icon: string; icon: string;
enabled: boolean; 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 searchableEntities: SearchableEntityConfig[] = [
const searchConfigs: SearchConfig[] = [
{ {
table: "tasks", table: "tasks",
type: "task", type: "task",
fields: ["id", "title", "description", "status", "priority", "project_id", "type"],
titleField: "title", titleField: "title",
subtitleField: "type", snippetField: "description",
descriptionField: "description",
statusField: "status", statusField: "status",
priorityField: "priority",
urlGenerator: (item) => `https://gantt-board.vercel.app/tasks/${item.id}`,
icon: "kanban", icon: "kanban",
enabled: true, enabled: true,
searchFields: ["title", "description"], 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", table: "projects",
type: "project", type: "project",
fields: ["id", "name", "description", "color", "status"],
titleField: "name", titleField: "name",
descriptionField: "description", snippetField: "description",
colorField: "color", colorField: "color",
statusField: "status", statusField: "status",
urlGenerator: (item) => `/projects`,
icon: "folder-kanban", icon: "folder-kanban",
enabled: true, enabled: true,
searchFields: ["name", "description"], 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", table: "sprints",
type: "sprint", type: "sprint",
fields: ["id", "name", "goal", "status", "start_date", "end_date", "project_id"],
titleField: "name", titleField: "name",
subtitleField: "goal", snippetField: "goal",
statusField: "status", statusField: "status",
urlGenerator: (item) => `https://gantt-board.vercel.app/sprints/${item.id}`,
icon: "timer", icon: "timer",
enabled: true, enabled: true,
searchFields: ["name", "goal"], 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", table: "mission_control_documents",
type: "document", type: "document",
fields: ["id", "title", "content", "folder", "tags"],
titleField: "title", titleField: "title",
subtitleField: "folder", snippetField: "content",
descriptionField: "content",
urlGenerator: (item) => `/documents`,
icon: "file-text", icon: "file-text",
enabled: true, enabled: true,
searchFields: ["title", "content"], 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", // table: "meetings",
// type: "meeting", // type: "meeting",
// ... // titleField: "title",
// icon: "calendar",
// searchFields: ["title", "notes"],
// getUrl: (item) => `/meetings/${item.id}`,
// } // }
]; ];
// ============================================================================
// API HANDLER
// ============================================================================
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
const { searchParams } = new URL(request.url); 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 supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; 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 supabase = createClient(supabaseUrl, supabaseKey);
const results: SearchResult[] = []; const results: SearchableResult[] = [];
// Search each configured entity // Search each enabled entity
for (const config of searchConfigs) { for (const entity of searchableEntities) {
if (!config.enabled) continue; if (!entity.enabled) continue;
try { try {
// Build OR filter dynamically based on searchFields // Build OR filter for search fields
const orConditions = config.searchFields const orConditions = entity.searchFields
.map(field => `${field}.ilike.%${query}%`) .map(field => `${field}.ilike.%${query}%`)
.join(","); .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 const { data, error } = await supabase
.from(config.table) .from(entity.table)
.select(config.fields.join(", ")) .select(selectFields.join(", "))
.or(orConditions) .or(orConditions)
.limit(10); .limit(10);
if (error) { if (error) {
console.error(`Search error in ${config.table}:`, error); console.error(`Search error in ${entity.table}:`, error);
continue; continue;
} }
if (data) { if (data) {
const mappedResults: SearchResult[] = data.map((item: any) => ({ const mappedResults: SearchableResult[] = data.map((item: any) => ({
id: item.id, id: item.id,
type: config.type, type: entity.type,
title: item[config.titleField] || "Untitled", title: item[entity.titleField] || "Untitled",
subtitle: config.subtitleField ? item[config.subtitleField] : undefined, snippet: entity.getSnippet ? entity.getSnippet(item) : undefined,
description: config.descriptionField ? item[config.descriptionField] : undefined, url: entity.getUrl(item),
status: config.statusField ? item[config.statusField] : undefined, icon: entity.icon,
priority: config.priorityField ? item[config.priorityField] : undefined, status: entity.statusField ? item[entity.statusField] : undefined,
color: config.colorField ? item[config.colorField] : undefined, color: entity.colorField ? item[entity.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); results.push(...mappedResults);
} }
} catch (err) { } 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) => { results.sort((a, b) => {
const aTitle = a.title.toLowerCase(); const aTitle = a.title.toLowerCase();
const bTitle = b.title.toLowerCase(); const bTitle = b.title.toLowerCase();
const lowerQuery = query.toLowerCase();
// Exact match gets highest priority if (aTitle === lowerQuery && bTitle !== lowerQuery) return -1;
if (aTitle === query && bTitle !== query) return -1; if (bTitle === lowerQuery && aTitle !== lowerQuery) return 1;
if (bTitle === query && aTitle !== query) 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); return aTitle.localeCompare(bTitle);
}); });
@ -219,4 +227,4 @@ export async function GET(request: Request) {
{ status: 500 } { status: 500 }
); );
} }
} }

View File

@ -23,6 +23,7 @@ import {
} from "@/components/calendar"; } from "@/components/calendar";
import { GoogleOAuthProvider } from "@react-oauth/google"; import { GoogleOAuthProvider } from "@react-oauth/google";
import { format } from "date-fns"; import { format } from "date-fns";
import { siteUrls } from "@/lib/config/sites";
// Google OAuth Client ID - should be from environment variable // Google OAuth Client ID - should be from environment variable
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""; const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
@ -145,7 +146,7 @@ function CalendarContent() {
</CardHeader> </CardHeader>
<CardContent className="space-y-1"> <CardContent className="space-y-1">
<a <a
href="https://calendar.google.com" href={siteUrls.googleCalendar}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors" 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 Open Google Calendar
</a> </a>
<a <a
href="https://calendar.google.com/calendar/u/0/r/settings" href={siteUrls.googleCalendarSettings}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors" 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 Calendar Settings
</a> </a>
<a <a
href="https://gantt-board.vercel.app" href={siteUrls.ganttBoard}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors" 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 type { Metadata, Viewport } from "next";
import { siteUrls } from "@/lib/config/sites";
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
@ -27,11 +28,11 @@ export const metadata: Metadata = {
], ],
authors: [{ name: "TopDogLabs" }], authors: [{ name: "TopDogLabs" }],
creator: "TopDogLabs", creator: "TopDogLabs",
metadataBase: new URL("https://mission-control-rho-pink.vercel.app"), metadataBase: new URL(siteUrls.missionControl),
openGraph: { openGraph: {
type: "website", type: "website",
locale: "en_US", locale: "en_US",
url: "https://mission-control-rho-pink.vercel.app", url: siteUrls.missionControl,
title: "Mission Control | TopDogLabs", title: "Mission Control | TopDogLabs",
description: description:
"Central hub for activity, tasks, goals, and tools. Build an iOS empire to achieve financial independence.", "Central hub for activity, tasks, goals, and tools. Build an iOS empire to achieve financial independence.",

View File

@ -24,6 +24,7 @@ import {
Milestone, Milestone,
NextStep NextStep
} from "@/lib/data/mission"; } from "@/lib/data/mission";
import { siteUrls } from "@/lib/config/sites";
// Revalidate every 5 minutes // Revalidate every 5 minutes
export const revalidate = 300; export const revalidate = 300;
@ -387,7 +388,7 @@ export default async function MissionPage() {
</p> </p>
</div> </div>
<Button variant="outline" size="sm" asChild className="shrink-0"> <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" /> View All <ArrowRight className="w-4 h-4 ml-1" />
</a> </a>
</Button> </Button>

View File

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

View File

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

View File

@ -27,6 +27,11 @@ import {
countOverdueTasks, countOverdueTasks,
Task, Task,
} from "@/lib/data/tasks"; } from "@/lib/data/tasks";
import {
getGanttTaskUrl,
getGanttTasksUrl,
siteUrls,
} from "@/lib/config/sites";
// Force dynamic rendering to fetch fresh data from Supabase on each request // Force dynamic rendering to fetch fresh data from Supabase on each request
export const dynamic = "force-dynamic"; 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="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
<div className="flex-1 min-w-0 overflow-hidden"> <div className="flex-1 min-w-0 overflow-hidden">
<Link <Link
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`} href={getGanttTaskUrl(task.id)}
target="_blank" target="_blank"
className="text-sm font-medium block truncate hover:text-primary hover:underline" 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{" "} Mission Control view of all tasks. Manage work in{" "}
<Link <Link
href="https://gantt-board.vercel.app" href={siteUrls.ganttBoard}
target="_blank" target="_blank"
className="text-primary hover:underline inline-flex items-center gap-1" 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"> <Button size="sm" className="w-full sm:w-auto">
<ExternalLink className="w-4 h-4 mr-2" /> <ExternalLink className="w-4 h-4 mr-2" />
Open gantt-board Open gantt-board
@ -394,7 +399,7 @@ export default async function TasksOverviewPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <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"> <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"> <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" /> <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> </Button>
</Link> </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"> <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"> <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" /> <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> </Button>
</Link> </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"> <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"> <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" /> <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> </Button>
</Link> </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"> <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"> <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" /> <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. Mission Control is read-only. All task creation and editing happens in gantt-board.
</p> </p>
</div> </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"> <Button variant="secondary" size="sm">
Go to gantt-board Go to gantt-board
<ArrowRight className="w-4 h-4 ml-2" /> <ArrowRight className="w-4 h-4 ml-2" />

View File

@ -22,6 +22,7 @@ import {
Check Check
} from "lucide-react"; } from "lucide-react";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { siteUrls } from "@/lib/config/sites";
// ============================================================================ // ============================================================================
// Types // Types
@ -43,35 +44,35 @@ const QUICK_LINKS: QuickLink[] = [
{ {
id: "github", id: "github",
name: "GitHub", name: "GitHub",
url: "https://github.com", url: siteUrls.github,
icon: <Github className="w-4 h-4" />, icon: <Github className="w-4 h-4" />,
color: "bg-slate-800" color: "bg-slate-800"
}, },
{ {
id: "vercel", id: "vercel",
name: "Vercel", name: "Vercel",
url: "https://vercel.com", url: siteUrls.vercel,
icon: <Cloud className="w-4 h-4" />, icon: <Cloud className="w-4 h-4" />,
color: "bg-black" color: "bg-black"
}, },
{ {
id: "supabase", id: "supabase",
name: "Supabase", name: "Supabase",
url: "https://supabase.com", url: siteUrls.supabase,
icon: <Database className="w-4 h-4" />, icon: <Database className="w-4 h-4" />,
color: "bg-emerald-600" color: "bg-emerald-600"
}, },
{ {
id: "gantt", id: "gantt",
name: "Gantt Board", name: "Gantt Board",
url: "https://gantt-board.vercel.app", url: siteUrls.ganttBoard,
icon: <Clock className="w-4 h-4" />, icon: <Clock className="w-4 h-4" />,
color: "bg-blue-600" color: "bg-blue-600"
}, },
{ {
id: "google", id: "google",
name: "Google", name: "Google",
url: "https://google.com", url: siteUrls.google,
icon: <Globe className="w-4 h-4" />, icon: <Globe className="w-4 h-4" />,
color: "bg-red-500" 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 { Calendar, Clock, MapPin, ExternalLink, FileText, X } from "lucide-react";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { siteUrls } from "@/lib/config/sites";
interface CalendarEvent { interface CalendarEvent {
id: string; id: string;
@ -103,7 +104,7 @@ function formatEventDate(event: CalendarEvent): string {
export function EventDetailModal({ event, isOpen, onClose }: EventDetailModalProps) { export function EventDetailModal({ event, isOpen, onClose }: EventDetailModalProps) {
if (!event) return null; 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 ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>

View File

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

View File

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

View File

@ -28,22 +28,20 @@ import {
Timer, Timer,
Circle, Circle,
} from "lucide-react"; } from "lucide-react";
import { siteUrls } from "@/lib/config/sites";
// Search result type from API // Search result type from API - matches SearchableResult interface
type SearchResultType = "task" | "project" | "document" | "sprint" | "activity"; type SearchResultType = "task" | "project" | "document" | "sprint";
interface SearchResult { interface SearchResult {
id: string; id: string;
type: SearchResultType; type: SearchResultType;
title: string; title: string;
subtitle?: string; snippet?: string; // Brief preview text (replaces subtitle/description)
description?: string; url: string; // Deep link to full view
metadata?: Record<string, string>; icon: string;
status?: string; status?: string; // For visual badges
priority?: string; color?: string; // For project/task colors
color?: string;
url?: string;
icon?: string;
} }
const navItems = [ const navItems = [
@ -58,9 +56,9 @@ const navItems = [
]; ];
const quickLinks = [ const quickLinks = [
{ name: "Gantt Board", url: "https://gantt-board.vercel.app", icon: ExternalLink }, { name: "Gantt Board", url: siteUrls.ganttBoard, icon: ExternalLink },
{ name: "Blog Backup", url: "https://blog-backup-two.vercel.app", icon: ExternalLink }, { name: "Blog Backup", url: siteUrls.blogBackup, icon: ExternalLink },
{ name: "Gitea", url: "http://192.168.1.128:3000", icon: ExternalLink }, { name: "Gitea", url: siteUrls.gitea, icon: ExternalLink },
]; ];
// Icon mapping for search result types // Icon mapping for search result types
@ -69,16 +67,6 @@ const typeIcons: Record<SearchResultType, React.ElementType> = {
project: FolderKanban, project: FolderKanban,
document: FileText, document: FileText,
sprint: Timer, 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 // Type labels
@ -87,7 +75,6 @@ const typeLabels: Record<SearchResultType, string> = {
project: "Project", project: "Project",
document: "Document", document: "Document",
sprint: "Sprint", sprint: "Sprint",
activity: "Activity",
}; };
function getStatusIcon(status?: string) { 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() { export function QuickSearch() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -243,33 +217,13 @@ export function QuickSearch() {
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<span className="truncate">{result.title}</span> <span className="truncate">{result.title}</span>
{result.subtitle && ( {result.snippet && (
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
{result.subtitle} {result.snippet}
</span> </span>
)} )}
</div> </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" && ( {result.status && result.type !== "task" && (
<span className="text-xs text-muted-foreground ml-2 capitalize"> <span className="text-xs text-muted-foreground ml-2 capitalize">
{result.status} {result.status}
@ -321,4 +275,4 @@ export function QuickSearch() {
</CommandDialog> </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 { getServiceSupabase } from "@/lib/supabase/client";
import { Task, fetchAllTasks } from "./tasks"; import { Task, fetchAllTasks } from "./tasks";
import { Project, fetchAllProjects } from "./projects"; import { Project, fetchAllProjects } from "./projects";
import { getGanttTaskUrl } from "@/lib/config/sites";
// ============================================================================ // ============================================================================
// Types // Types
@ -370,7 +371,7 @@ export async function getNextMissionSteps(): Promise<NextStep[]> {
priority: task.priority as "high" | "urgent", priority: task.priority as "high" | "urgent",
projectName: projectMap.get(task.project_id), projectName: projectMap.get(task.project_id),
dueDate: task.due_date, 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(); searchMissionControl();