Compare commits

...

4 Commits

19 changed files with 863 additions and 125 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

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

@ -0,0 +1,230 @@
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import {
getGanttProjectUrl,
getGanttSprintUrl,
getGanttTaskUrl,
getMissionControlDocumentsUrl,
} from "@/lib/config/sites";
export const runtime = "nodejs";
// ============================================================================
// SEARCHABLE PROTOCOL - Minimal search result interface
// ============================================================================
export type SearchableType = "task" | "project" | "document" | "sprint";
export interface SearchableResult {
id: string;
type: SearchableType;
title: 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: SearchableResult[];
total: number;
query: string;
}
// ============================================================================
// SEARCHABLE ENTITY CONFIGURATION
// Add new searchable types here following the pattern
// ============================================================================
interface SearchableEntityConfig {
table: string;
type: SearchableType;
titleField: string;
snippetField?: string;
statusField?: string;
colorField?: string;
icon: string;
enabled: boolean;
searchFields: string[];
// Generate URL for deep linking to full view
getUrl: (item: any) => string;
// Generate snippet from content
getSnippet?: (item: any) => string | undefined;
}
const searchableEntities: SearchableEntityConfig[] = [
{
table: "tasks",
type: "task",
titleField: "title",
snippetField: "description",
statusField: "status",
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",
titleField: "name",
snippetField: "description",
colorField: "color",
statusField: "status",
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",
titleField: "name",
snippetField: "goal",
statusField: "status",
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",
titleField: "title",
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,
},
// 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);
const query = searchParams.get("q")?.trim().toLowerCase();
if (!query || query.length < 2) {
return NextResponse.json({
results: [],
total: 0,
query: query || ""
});
}
// 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;
if (!supabaseUrl || !supabaseKey) {
return NextResponse.json(
{ error: "Database not configured" },
{ status: 500 }
);
}
const supabase = createClient(supabaseUrl, supabaseKey);
const results: SearchableResult[] = [];
// Search each enabled entity
for (const entity of searchableEntities) {
if (!entity.enabled) continue;
try {
// 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(entity.table)
.select(selectFields.join(", "))
.or(orConditions)
.limit(10);
if (error) {
console.error(`Search error in ${entity.table}:`, error);
continue;
}
if (data) {
const mappedResults: SearchableResult[] = data.map((item: any) => ({
id: item.id,
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 ${entity.table}:`, err);
}
}
// 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();
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;
return aTitle.localeCompare(bTitle);
});
// Limit total results
const limitedResults = results.slice(0, 50);
return NextResponse.json({
results: limitedResults,
total: limitedResults.length,
query,
});
} catch (error) {
console.error("Search API error:", error);
return NextResponse.json(
{ error: "Search failed" },
{ status: 500 }
);
}
}

View File

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

@ -25,20 +25,23 @@ import {
CheckCircle2,
Clock,
AlertCircle,
Timer,
Circle,
} from "lucide-react";
import { supabaseClient } from "@/lib/supabase/client";
import { siteUrls } from "@/lib/config/sites";
interface Task {
// Search result type from API - matches SearchableResult interface
type SearchResultType = "task" | "project" | "document" | "sprint";
interface SearchResult {
id: string;
type: SearchResultType;
title: string;
status: string;
priority: string;
project_id: string;
}
interface Project {
id: string;
name: 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 = [
@ -53,73 +56,74 @@ 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 },
];
function getStatusIcon(status: string) {
// Icon mapping for search result types
const typeIcons: Record<SearchResultType, React.ElementType> = {
task: Kanban,
project: FolderKanban,
document: FileText,
sprint: Timer,
};
// Type labels
const typeLabels: Record<SearchResultType, string> = {
task: "Task",
project: "Project",
document: "Document",
sprint: "Sprint",
};
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 {
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 [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);
}
};
fetchData();
}, [open]);
}, 150); // 150ms debounce
return () => clearTimeout(timer);
}, [query, open]);
// Keyboard shortcut
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@ -133,13 +137,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 +180,63 @@ 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.snippet && (
<span className="text-xs text-muted-foreground truncate">
{result.snippet}
</span>
)}
</div>
{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,39 +271,8 @@ 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>
</>
);
}
}

View File

@ -0,0 +1,279 @@
# Shared SSO Implementation Plan (Mission Control + Related Apps)
## Summary
Implement single sign-on across apps by using one parent domain and one shared session cookie, with Supabase Auth as the identity source and a central auth origin (`auth.topdoglabs.com`).
Use one dedicated auth project/service for this logic so auth code is not duplicated across app projects.
This gives:
- One login across apps
- Silent cross-app authentication
- Global logout
---
## Chosen Decisions
1. Use custom subdomains under one parent domain.
2. Keep Supabase as the central identity provider.
3. Use silent auto-login between apps.
4. Use global logout across apps.
---
## Target Domain Layout
- `auth.topdoglabs.com` -> central auth service
- `mission.topdoglabs.com` -> Mission Control
- `gantt.topdoglabs.com` -> Gantt Board
- (optional) `blog.topdoglabs.com` -> Blog Backup
Important: shared-cookie SSO will not work across `*.vercel.app` app URLs.
---
## Auth and Session Architecture
1. Centralize login/logout/session endpoints on `auth.topdoglabs.com`.
2. Keep credentials and user identity in Supabase Auth.
3. Use a shared `sessions` table in Supabase for web sessions.
4. Issue one cookie for all subdomains:
- Name: `tdl_sso_session`
- `Domain=.topdoglabs.com`
- `Path=/`
- `HttpOnly`
- `Secure` (production)
- `SameSite=Lax`
5. Each app middleware:
- reads `tdl_sso_session`
- validates via auth/session endpoint
- redirects to auth login if missing/invalid, with `return_to` URL
6. Logout from any app:
- revoke server session
- clear shared cookie for `.topdoglabs.com`
- signed out everywhere
---
## API Contracts
### `POST /api/sso/login` (auth domain)
Input:
```json
{ "email": "user@example.com", "password": "********", "rememberMe": true }
```
Behavior:
- authenticates against Supabase Auth
- creates shared session row
- sets shared cookie
Response:
```json
{ "success": true, "user": { "...": "..." }, "session": { "expiresAt": "ISO8601", "rememberMe": true } }
```
### `GET /api/sso/session` (auth domain)
Behavior:
- reads `tdl_sso_session`
- validates against sessions table
Response:
```json
{ "authenticated": true, "user": { "...": "..." } }
```
or
```json
{ "authenticated": false }
```
### `POST /api/sso/logout` (auth domain)
Behavior:
- revokes current session in DB
- clears shared cookie on `.topdoglabs.com`
Response:
```json
{ "success": true }
```
### `GET /api/sso/authorize?return_to=<url>` (auth domain)
Behavior:
- if authenticated: redirect (302) to `return_to`
- if unauthenticated: redirect to login
- validate `return_to` against allowlist
---
## Data Model (Supabase)
Use/standardize `sessions` table:
- `user_id` (uuid, fk)
- `token_hash` (text, unique)
- `created_at` (timestamptz)
- `expires_at` (timestamptz)
- `revoked_at` (timestamptz, nullable)
Recommended optional fields:
- `last_seen_at`
- `ip`
- `user_agent`
- `remember_me`
Indexes:
- `token_hash`
- `user_id`
- `expires_at`
Security:
- store only SHA-256 hash of token
- never store raw session token
---
## Vercel Subdomain Setup (Step-by-Step)
1. In each Vercel project, open `Settings -> Domains`.
2. Add the desired subdomain for that specific project:
- Mission Control project -> `mission.topdoglabs.com`
- Gantt project -> `gantt.topdoglabs.com`
- Auth project -> `auth.topdoglabs.com`
3. If domain DNS is managed by Vercel nameservers, Vercel usually auto-creates the needed records.
4. If DNS is external:
- create the exact DNS record shown by Vercel on the domain card
- subdomains are typically `CNAME`
- apex/root is typically `A`
5. Wait for each to show verified/valid in Vercel.
6. Keep the `*.vercel.app` URLs for testing, but use custom subdomains for production SSO.
---
## Subdomain Knowledge (Quick Primer)
### What a subdomain is
- A subdomain is a child host under a parent domain.
- Example:
- parent/root domain: `topdoglabs.com`
- subdomains: `mission.topdoglabs.com`, `gantt.topdoglabs.com`, `auth.topdoglabs.com`
### Why this matters for SSO
- Browsers only allow one site to share a cookie with another site when both are under the same parent domain and the cookie `Domain` is set to that parent.
- For your case: set cookie `Domain=.topdoglabs.com` so all `*.topdoglabs.com` apps can read/send it.
- `mission-control-rho-pink.vercel.app` and `gantt-board.vercel.app` cannot share a parent-domain cookie with each other.
### DNS records you will see
- Subdomain to app mapping is commonly a `CNAME` record.
- Apex/root domain usually uses `A`/`ALIAS`/`ANAME` depending on DNS provider.
- In Vercel, always follow the exact record values shown in each projects Domains panel.
### SSL/TLS on subdomains
- Vercel provisions certificates for added custom domains/subdomains after verification.
- SSO cookies with `Secure` require HTTPS in production, so certificate readiness is required before final auth testing.
### Cookie scope rules (important)
- `Domain=.topdoglabs.com`: cookie is sent to all subdomains under `topdoglabs.com`.
- `Domain=mission.topdoglabs.com` or no `Domain` set: cookie is host-only, not shared with other subdomains.
- `SameSite=Lax`: good default for first-party web app navigation between your subdomains.
### Recommended host roles
- `auth.topdoglabs.com`: login, logout, session validation endpoints
- `mission.topdoglabs.com`: Mission Control app
- `gantt.topdoglabs.com`: Gantt Board app
- Optional static/marketing hosts can stay separate, but app surfaces participating in SSO should remain under `*.topdoglabs.com`.
---
## Subdomain Rollout Checklist
1. Add `mission.topdoglabs.com` to Mission Control Vercel project.
2. Add `gantt.topdoglabs.com` to Gantt project.
3. Add `auth.topdoglabs.com` to auth project (or auth routes host).
4. Verify each domain shows valid configuration and active HTTPS.
5. Update environment variables:
- `AUTH_ORIGIN=https://auth.topdoglabs.com`
- `COOKIE_DOMAIN=.topdoglabs.com`
6. Deploy auth cookie changes with `Domain=.topdoglabs.com`.
7. Test login on one app, then open the other app in a new tab.
8. Test global logout propagation across both apps.
---
## Common Subdomain Troubleshooting
1. Symptom: still getting separate logins.
- Check cookie `Domain` is `.topdoglabs.com`, not host-only.
2. Symptom: login works on one app but not another.
- Confirm both apps are truly on `*.topdoglabs.com`, not `*.vercel.app`.
3. Symptom: cookie not set in production.
- Confirm HTTPS is active and cookie has `Secure`.
4. Symptom: redirect loops.
- Check `return_to` allowlist and ensure auth/session endpoint trusts both app origins.
5. Symptom: logout not global.
- Ensure logout revokes DB session and clears cookie with same name + same domain/path attributes.
---
## Reusable Pattern for Future Websites
Use this as the standard for any new web app that should join shared SSO.
### Onboarding standard for a new app
1. Put the app on a subdomain under `*.topdoglabs.com` (for example `newapp.topdoglabs.com`).
2. Add middleware/route guard that checks `tdl_sso_session`.
3. If no valid session, redirect to `https://auth.topdoglabs.com/login?return_to=<current_url>`.
4. On sign-out, call central logout endpoint (`POST /api/sso/logout`) instead of local-only logout.
5. Ensure app URLs are added to auth `return_to` allowlist.
### Required environment variables (per app)
- `AUTH_ORIGIN=https://auth.topdoglabs.com`
- `COOKIE_NAME=tdl_sso_session`
- `COOKIE_DOMAIN=.topdoglabs.com`
### Required auth behavior (per app)
- Do not create app-specific login cookie names for SSO surfaces.
- Do not set host-only session cookies for authenticated app pages.
- Treat auth service as source of truth for session validity.
### Minimal integration contract
- App must be able to:
- redirect unauthenticated users to auth origin with `return_to`
- accept authenticated return and continue to requested page
- trigger global logout
- handle expired/revoked session as immediate signed-out state
### Naming convention recommendation
- Domain pattern: `<app>.topdoglabs.com`
- Session cookie: `tdl_sso_session`
- Auth host: `auth.topdoglabs.com`
This keeps every future app consistent and avoids one-off auth logic.
---
## Migration Plan
1. Move apps to custom subdomains.
2. Introduce shared cookie `tdl_sso_session`.
3. Update auth cookie set/clear to include `Domain=.topdoglabs.com`.
4. Centralize auth endpoints on `auth.topdoglabs.com`.
5. Update app middleware to redirect to auth domain with `return_to`.
6. Keep legacy app-local cookie compatibility for 7-14 days.
7. Remove legacy cookie logic after rollout.
---
## Security Controls
1. Strict allowlist for `return_to` URLs (prevent open redirects).
2. CSRF protection on login/logout POST endpoints.
3. Session TTL:
- short session: 12 hours
- remember me: 30 days
4. Treat expired/revoked sessions as immediate logout.
5. Rotate session token on sensitive account actions.
---
## Test Cases
1. Login once, open app A then app B, no second login prompt.
2. Deep-link directly into app B while logged in from app A.
3. Logout in app A, refresh app B, user is logged out.
4. Expired cookie redirects to auth login.
5. Revoked session is denied in all apps.
6. Invalid `return_to` is rejected and replaced by safe default.
7. Remember-me survives browser restart; non-remember does not.
8. Cookie flags validated in production (`Secure`, `HttpOnly`, domain-scoped).
---
## Assumptions
1. Both apps remain in the same Supabase project/database.
2. Both apps can be served under `*.topdoglabs.com`.
3. Global logout means current browser session is invalidated across all apps.
4. Existing `mission_control_session` is replaced by `tdl_sso_session`.

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

@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "eslint"

27
search.ts Normal file
View File

@ -0,0 +1,27 @@
const { createClient } = require('@supabase/supabase-js');
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
async function searchMissionControl() {
try {
const { data, error } = await supabase
.from('mission_control_documents')
.select('title, content')
.or('title.ilike.%mission%,content.ilike.%mission%');
if (error) {
console.error('Query error:', error);
} else {
console.log('Search results:', data);
}
} catch (err) {
console.error('Unexpected error:', err);
}
}
console.log('Searching for: Mission Control Strategy Plan')
searchMissionControl();

25
test-search.js Normal file
View File

@ -0,0 +1,25 @@
const { createClient } = require('@supabase/supabase-js');
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
async function testSearch() {
try {
const { data, error } = await supabase
.from('mission_control_documents')
.select('title, content')
.or('title.ilike.%mission%,content.ilike.%mission%');
if (error) {
console.error('Query error:', error);
} else {
console.log('Search results:', data);
}
} catch (err) {
console.error('Unexpected error:', err);
}
}
testSearch();