Compare commits

..

No commits in common. "fb55c1d2561bed203807a94789fd3dae7693e3d3" and "a51798848297a4d60e8f8b72a76ecf4c5db1f2dc" have entirely different histories.

19 changed files with 125 additions and 863 deletions

View File

@ -140,20 +140,8 @@ 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,230 +0,0 @@
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,7 +23,6 @@ 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 || "";
@ -146,7 +145,7 @@ function CalendarContent() {
</CardHeader> </CardHeader>
<CardContent className="space-y-1"> <CardContent className="space-y-1">
<a <a
href={siteUrls.googleCalendar} href="https://calendar.google.com"
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"
@ -154,7 +153,7 @@ function CalendarContent() {
Open Google Calendar Open Google Calendar
</a> </a>
<a <a
href={siteUrls.googleCalendarSettings} href="https://calendar.google.com/calendar/u/0/r/settings"
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"
@ -162,7 +161,7 @@ function CalendarContent() {
Calendar Settings Calendar Settings
</a> </a>
<a <a
href={siteUrls.ganttBoard} href="https://gantt-board.vercel.app"
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,5 +1,4 @@
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",
@ -28,11 +27,11 @@ export const metadata: Metadata = {
], ],
authors: [{ name: "TopDogLabs" }], authors: [{ name: "TopDogLabs" }],
creator: "TopDogLabs", creator: "TopDogLabs",
metadataBase: new URL(siteUrls.missionControl), metadataBase: new URL("https://mission-control-rho-pink.vercel.app"),
openGraph: { openGraph: {
type: "website", type: "website",
locale: "en_US", locale: "en_US",
url: siteUrls.missionControl, url: "https://mission-control-rho-pink.vercel.app",
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,7 +24,6 @@ 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;
@ -388,7 +387,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={siteUrls.ganttBoard} target="_blank" rel="noopener noreferrer"> <a href="https://gantt-board.vercel.app" 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,13 +36,6 @@ 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";
@ -130,7 +123,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={getGanttTaskUrl(task.id)} href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(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"
> >
@ -252,7 +245,7 @@ function ProjectCard({ stats }: { stats: ProjectStats }) {
{/* Action Link */} {/* Action Link */}
<Link <Link
href={getGanttProjectUrl(project.id)} href={`https://gantt-board.vercel.app?project=${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"
> >
@ -321,7 +314,7 @@ function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectSta
</div> </div>
<Link <Link
href={getGanttSprintUrl(sprint.id)} href={`https://gantt-board.vercel.app?sprint=${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">
@ -380,7 +373,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={siteUrls.ganttBoard} href="https://gantt-board.vercel.app"
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"
> >
@ -390,7 +383,7 @@ export default async function ProjectsOverviewPage() {
</> </>
} }
> >
<Link href={siteUrls.ganttBoard} target="_blank"> <Link href="https://gantt-board.vercel.app" 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
@ -541,7 +534,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={getGanttTaskUrl(task.id)} href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(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"
> >
@ -584,7 +577,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={siteUrls.ganttBoard} target="_blank" className="block"> <Link href="https://gantt-board.vercel.app" 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" />
@ -596,7 +589,7 @@ export default async function ProjectsOverviewPage() {
</Button> </Button>
</Link> </Link>
<Link href={getGanttTasksUrl({ priority: "urgent,high" })} target="_blank" className="block"> <Link href="https://gantt-board.vercel.app/tasks?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" />
@ -608,7 +601,7 @@ export default async function ProjectsOverviewPage() {
</Button> </Button>
</Link> </Link>
<Link href={getGanttTasksUrl({ status: "in-progress" })} target="_blank" className="block"> <Link href="https://gantt-board.vercel.app/tasks?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" />
@ -621,7 +614,7 @@ export default async function ProjectsOverviewPage() {
</Link> </Link>
{activeSprint && ( {activeSprint && (
<Link href={getGanttSprintUrl(activeSprint.id)} target="_blank" className="block"> <Link href={`https://gantt-board.vercel.app?sprint=${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" />
@ -644,7 +637,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={siteUrls.ganttBoard} target="_blank" className="shrink-0"> <Link href="https://gantt-board.vercel.app" 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,8 +1,7 @@
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 = siteUrls.missionControl; const baseUrl = "https://mission-control-rho-pink.vercel.app";
return [ return [
{ {

View File

@ -27,11 +27,6 @@ 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";
@ -108,7 +103,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={getGanttTaskUrl(task.id)} href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(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"
> >
@ -253,7 +248,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={siteUrls.ganttBoard} href="https://gantt-board.vercel.app"
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"
> >
@ -263,7 +258,7 @@ export default async function TasksOverviewPage() {
</> </>
} }
> >
<Link href={siteUrls.ganttBoard} target="_blank"> <Link href="https://gantt-board.vercel.app" 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
@ -399,7 +394,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={siteUrls.ganttBoard} target="_blank" className="block"> <Link href="https://gantt-board.vercel.app" 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" />
@ -411,7 +406,7 @@ export default async function TasksOverviewPage() {
</Button> </Button>
</Link> </Link>
<Link href={getGanttTasksUrl()} target="_blank" className="block"> <Link href="https://gantt-board.vercel.app/tasks" 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" />
@ -423,7 +418,7 @@ export default async function TasksOverviewPage() {
</Button> </Button>
</Link> </Link>
<Link href={getGanttTasksUrl({ priority: "high,urgent" })} target="_blank" className="block"> <Link href="https://gantt-board.vercel.app/tasks?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" />
@ -435,7 +430,7 @@ export default async function TasksOverviewPage() {
</Button> </Button>
</Link> </Link>
<Link href={getGanttTasksUrl({ status: "in-progress" })} target="_blank" className="block"> <Link href="https://gantt-board.vercel.app/tasks?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" />
@ -457,7 +452,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={siteUrls.ganttBoard} target="_blank" className="shrink-0"> <Link href="https://gantt-board.vercel.app" 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,7 +22,6 @@ 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
@ -44,35 +43,35 @@ const QUICK_LINKS: QuickLink[] = [
{ {
id: "github", id: "github",
name: "GitHub", name: "GitHub",
url: siteUrls.github, url: "https://github.com",
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: siteUrls.vercel, url: "https://vercel.com",
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: siteUrls.supabase, url: "https://supabase.com",
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: siteUrls.ganttBoard, url: "https://gantt-board.vercel.app",
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: siteUrls.google, url: "https://google.com",
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,7 +12,6 @@ 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;
@ -104,7 +103,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 || `${siteUrls.googleCalendar}/calendar/event?eid=${btoa(event.id)}`; const eventUrl = event.htmlLink || `https://calendar.google.com/calendar/event?eid=${btoa(event.id)}`;
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>

View File

@ -11,7 +11,6 @@ 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) {
@ -186,7 +185,7 @@ function EventCard({
</div> </div>
</div> </div>
<a <a
href={`${siteUrls.googleCalendar}/calendar/event?eid=${btoa( href={`https://calendar.google.com/calendar/event?eid=${btoa(
event.id event.id
)}`} )}`}
target="_blank" target="_blank"

View File

@ -15,7 +15,6 @@ 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;
@ -170,7 +169,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={getGanttTaskUrl(task.id)} href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(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"
> >
@ -218,7 +217,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={getGanttTasksUrl()} href="https://gantt-board.vercel.app/tasks"
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

@ -25,23 +25,20 @@ import {
CheckCircle2, CheckCircle2,
Clock, Clock,
AlertCircle, AlertCircle,
Timer,
Circle,
} from "lucide-react"; } from "lucide-react";
import { siteUrls } from "@/lib/config/sites"; import { supabaseClient } from "@/lib/supabase/client";
// Search result type from API - matches SearchableResult interface interface Task {
type SearchResultType = "task" | "project" | "document" | "sprint";
interface SearchResult {
id: string; id: string;
type: SearchResultType;
title: string; title: string;
snippet?: string; // Brief preview text (replaces subtitle/description) status: string;
url: string; // Deep link to full view priority: string;
icon: string; project_id: string;
status?: string; // For visual badges }
color?: string; // For project/task colors
interface Project {
id: string;
name: string;
} }
const navItems = [ const navItems = [
@ -56,74 +53,73 @@ const navItems = [
]; ];
const quickLinks = [ const quickLinks = [
{ name: "Gantt Board", url: siteUrls.ganttBoard, icon: ExternalLink }, { name: "Gantt Board", url: "https://gantt-board.vercel.app", icon: ExternalLink },
{ name: "Blog Backup", url: siteUrls.blogBackup, icon: ExternalLink }, { name: "Blog Backup", url: "https://blog-backup-two.vercel.app", icon: ExternalLink },
{ name: "Gitea", url: siteUrls.gitea, icon: ExternalLink }, { name: "Gitea", url: "http://192.168.1.128:3000", icon: ExternalLink },
]; ];
// Icon mapping for search result types function getStatusIcon(status: string) {
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) { switch (status) {
case "done": case "done":
case "completed":
return <CheckCircle2 className="w-4 h-4 text-green-500" />; return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case "in-progress": case "in-progress":
return <Clock className="w-4 h-4 text-blue-500" />; 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: default:
return <AlertCircle className="w-4 h-4 text-muted-foreground" />; 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() { export function QuickSearch() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [tasks, setTasks] = useState<Task[]>([]);
const [results, setResults] = useState<SearchResult[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const router = useRouter(); const router = useRouter();
// Debounced search // Fetch tasks when search opens
useEffect(() => { useEffect(() => {
if (!open || query.length < 2) { if (!open) return;
setResults([]);
return;
}
setLoading(true); setLoading(true);
const timer = setTimeout(async () => { const fetchData = async () => {
try { try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); // Fetch tasks
const data = await res.json(); const { data: tasksData } = await supabaseClient
setResults(data.results || []); .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 || []);
} catch (err) { } catch (err) {
console.error("Search error:", err); console.error('Error fetching search data:', err);
setResults([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, 150); // 150ms debounce };
fetchData();
}, [open]);
return () => clearTimeout(timer);
}, [query, open]);
// Keyboard shortcut
useEffect(() => { useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) { if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@ -137,32 +133,13 @@ export function QuickSearch() {
const runCommand = useCallback((command: () => void) => { const runCommand = useCallback((command: () => void) => {
setOpen(false); setOpen(false);
setQuery("");
command(); command();
}, []); }, []);
const handleResultSelect = (result: SearchResult) => { const getProjectName = (projectId: string) => {
runCommand(() => { return projects.find(p => p.id === projectId)?.name || 'Unknown';
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 ( return (
<> <>
{/* Search Button Trigger */} {/* Search Button Trigger */}
@ -180,63 +157,9 @@ export function QuickSearch() {
{/* Command Dialog */} {/* Command Dialog */}
<CommandDialog open={open} onOpenChange={setOpen}> <CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput <CommandInput placeholder="Type a command or search..." />
placeholder="Search tasks, projects, sprints, documents..."
value={query}
onValueChange={setQuery}
/>
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>No results found.</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 */} {/* Navigation */}
<CommandGroup heading="Navigation"> <CommandGroup heading="Navigation">
@ -271,8 +194,39 @@ export function QuickSearch() {
); );
})} })}
</CommandGroup> </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> </CommandList>
</CommandDialog> </CommandDialog>
</> </>
); );
} }

View File

@ -1,279 +0,0 @@
# 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`.

View File

@ -1,98 +0,0 @@
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,7 +1,6 @@
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
@ -371,7 +370,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: getGanttTaskUrl(task.id), ganttBoardUrl: `https://gantt-board.vercel.app/?task=${task.id}`,
})); }));
} }

View File

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

View File

@ -1,27 +0,0 @@
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();

View File

@ -1,25 +0,0 @@
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();