Compare commits
4 Commits
a517988482
...
fb55c1d256
| Author | SHA1 | Date | |
|---|---|---|---|
| fb55c1d256 | |||
| 69fff64bfd | |||
| d794a3b6ea | |||
| 004b865e47 |
12
README.md
12
README.md
@ -140,8 +140,20 @@ SUPABASE_SERVICE_ROLE_KEY=
|
|||||||
|
|
||||||
# Optional
|
# Optional
|
||||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration
|
NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration
|
||||||
|
NEXT_PUBLIC_MISSION_CONTROL_URL=
|
||||||
|
NEXT_PUBLIC_GANTT_BOARD_URL=
|
||||||
|
NEXT_PUBLIC_BLOG_BACKUP_URL=
|
||||||
|
NEXT_PUBLIC_GITEA_URL=
|
||||||
|
NEXT_PUBLIC_GITHUB_URL=
|
||||||
|
NEXT_PUBLIC_VERCEL_URL=
|
||||||
|
NEXT_PUBLIC_SUPABASE_SITE_URL=
|
||||||
|
NEXT_PUBLIC_GOOGLE_URL=
|
||||||
|
NEXT_PUBLIC_GOOGLE_CALENDAR_URL=
|
||||||
|
NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL=
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Website URLs are centralized in `lib/config/sites.ts` and can be overridden via the optional variables above.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
230
app/api/search/route.ts
Normal file
230
app/api/search/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ import {
|
|||||||
} from "@/components/calendar";
|
} from "@/components/calendar";
|
||||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { siteUrls } from "@/lib/config/sites";
|
||||||
|
|
||||||
// Google OAuth Client ID - should be from environment variable
|
// Google OAuth Client ID - should be from environment variable
|
||||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||||
@ -145,7 +146,7 @@ function CalendarContent() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-1">
|
<CardContent className="space-y-1">
|
||||||
<a
|
<a
|
||||||
href="https://calendar.google.com"
|
href={siteUrls.googleCalendar}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
||||||
@ -153,7 +154,7 @@ function CalendarContent() {
|
|||||||
Open Google Calendar →
|
Open Google Calendar →
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://calendar.google.com/calendar/u/0/r/settings"
|
href={siteUrls.googleCalendarSettings}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
||||||
@ -161,7 +162,7 @@ function CalendarContent() {
|
|||||||
Calendar Settings →
|
Calendar Settings →
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://gantt-board.vercel.app"
|
href={siteUrls.ganttBoard}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { siteUrls } from "@/lib/config/sites";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
@ -27,11 +28,11 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
authors: [{ name: "TopDogLabs" }],
|
authors: [{ name: "TopDogLabs" }],
|
||||||
creator: "TopDogLabs",
|
creator: "TopDogLabs",
|
||||||
metadataBase: new URL("https://mission-control-rho-pink.vercel.app"),
|
metadataBase: new URL(siteUrls.missionControl),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
url: "https://mission-control-rho-pink.vercel.app",
|
url: siteUrls.missionControl,
|
||||||
title: "Mission Control | TopDogLabs",
|
title: "Mission Control | TopDogLabs",
|
||||||
description:
|
description:
|
||||||
"Central hub for activity, tasks, goals, and tools. Build an iOS empire to achieve financial independence.",
|
"Central hub for activity, tasks, goals, and tools. Build an iOS empire to achieve financial independence.",
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
Milestone,
|
Milestone,
|
||||||
NextStep
|
NextStep
|
||||||
} from "@/lib/data/mission";
|
} from "@/lib/data/mission";
|
||||||
|
import { siteUrls } from "@/lib/config/sites";
|
||||||
|
|
||||||
// Revalidate every 5 minutes
|
// Revalidate every 5 minutes
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
@ -387,7 +388,7 @@ export default async function MissionPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" asChild className="shrink-0">
|
<Button variant="outline" size="sm" asChild className="shrink-0">
|
||||||
<a href="https://gantt-board.vercel.app" target="_blank" rel="noopener noreferrer">
|
<a href={siteUrls.ganttBoard} target="_blank" rel="noopener noreferrer">
|
||||||
View All <ArrowRight className="w-4 h-4 ml-1" />
|
View All <ArrowRight className="w-4 h-4 ml-1" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -36,6 +36,13 @@ import {
|
|||||||
Sprint,
|
Sprint,
|
||||||
} from "@/lib/data/projects";
|
} from "@/lib/data/projects";
|
||||||
import { Task } from "@/lib/data/tasks";
|
import { Task } from "@/lib/data/tasks";
|
||||||
|
import {
|
||||||
|
getGanttProjectUrl,
|
||||||
|
getGanttSprintUrl,
|
||||||
|
getGanttTaskUrl,
|
||||||
|
getGanttTasksUrl,
|
||||||
|
siteUrls,
|
||||||
|
} from "@/lib/config/sites";
|
||||||
|
|
||||||
// Force dynamic rendering to fetch fresh data on each request
|
// Force dynamic rendering to fetch fresh data on each request
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@ -123,7 +130,7 @@ function TaskListItem({ task }: { task: Task }) {
|
|||||||
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<Link
|
<Link
|
||||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
href={getGanttTaskUrl(task.id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
||||||
>
|
>
|
||||||
@ -245,7 +252,7 @@ function ProjectCard({ stats }: { stats: ProjectStats }) {
|
|||||||
|
|
||||||
{/* Action Link */}
|
{/* Action Link */}
|
||||||
<Link
|
<Link
|
||||||
href={`https://gantt-board.vercel.app?project=${project.id}`}
|
href={getGanttProjectUrl(project.id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex items-center justify-between text-xs text-primary hover:underline mt-auto pt-2"
|
className="flex items-center justify-between text-xs text-primary hover:underline mt-auto pt-2"
|
||||||
>
|
>
|
||||||
@ -314,7 +321,7 @@ function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectSta
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`https://gantt-board.vercel.app?sprint=${sprint.id}`}
|
href={getGanttSprintUrl(sprint.id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Button variant="outline" size="sm" className="w-full mt-2">
|
<Button variant="outline" size="sm" className="w-full mt-2">
|
||||||
@ -373,7 +380,7 @@ export default async function ProjectsOverviewPage() {
|
|||||||
<>
|
<>
|
||||||
Manage all projects and track progress. Edit projects in{" "}
|
Manage all projects and track progress. Edit projects in{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://gantt-board.vercel.app"
|
href={siteUrls.ganttBoard}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
@ -383,7 +390,7 @@ export default async function ProjectsOverviewPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Link href="https://gantt-board.vercel.app" target="_blank">
|
<Link href={siteUrls.ganttBoard} target="_blank">
|
||||||
<Button size="sm">
|
<Button size="sm">
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
Open Gantt Board
|
Open Gantt Board
|
||||||
@ -534,7 +541,7 @@ export default async function ProjectsOverviewPage() {
|
|||||||
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<Link
|
<Link
|
||||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
href={getGanttTaskUrl(task.id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
||||||
>
|
>
|
||||||
@ -577,7 +584,7 @@ export default async function ProjectsOverviewPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="block">
|
<Link href={siteUrls.ganttBoard} target="_blank" className="block">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||||
<div className="p-2 rounded-lg bg-blue-500/15 mr-3 shrink-0">
|
<div className="p-2 rounded-lg bg-blue-500/15 mr-3 shrink-0">
|
||||||
<ExternalLink className="w-4 h-4 text-blue-500" />
|
<ExternalLink className="w-4 h-4 text-blue-500" />
|
||||||
@ -589,7 +596,7 @@ export default async function ProjectsOverviewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="https://gantt-board.vercel.app/tasks?priority=urgent,high" target="_blank" className="block">
|
<Link href={getGanttTasksUrl({ priority: "urgent,high" })} target="_blank" className="block">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||||
<div className="p-2 rounded-lg bg-orange-500/15 mr-3 shrink-0">
|
<div className="p-2 rounded-lg bg-orange-500/15 mr-3 shrink-0">
|
||||||
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||||
@ -601,7 +608,7 @@ export default async function ProjectsOverviewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="https://gantt-board.vercel.app/tasks?status=in-progress" target="_blank" className="block">
|
<Link href={getGanttTasksUrl({ status: "in-progress" })} target="_blank" className="block">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||||
<div className="p-2 rounded-lg bg-green-500/15 mr-3 shrink-0">
|
<div className="p-2 rounded-lg bg-green-500/15 mr-3 shrink-0">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
@ -614,7 +621,7 @@ export default async function ProjectsOverviewPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{activeSprint && (
|
{activeSprint && (
|
||||||
<Link href={`https://gantt-board.vercel.app?sprint=${activeSprint.id}`} target="_blank" className="block">
|
<Link href={getGanttSprintUrl(activeSprint.id)} target="_blank" className="block">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||||
<div className="p-2 rounded-lg bg-purple-500/15 mr-3 shrink-0">
|
<div className="p-2 rounded-lg bg-purple-500/15 mr-3 shrink-0">
|
||||||
<Zap className="w-4 h-4 text-purple-500" />
|
<Zap className="w-4 h-4 text-purple-500" />
|
||||||
@ -637,7 +644,7 @@ export default async function ProjectsOverviewPage() {
|
|||||||
Mission Control is read-only. Create and edit projects in gantt-board.
|
Mission Control is read-only. Create and edit projects in gantt-board.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="shrink-0">
|
<Link href={siteUrls.ganttBoard} target="_blank" className="shrink-0">
|
||||||
<Button variant="secondary" size="sm">
|
<Button variant="secondary" size="sm">
|
||||||
Go to gantt-board
|
Go to gantt-board
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { MetadataRoute } from "next";
|
import { MetadataRoute } from "next";
|
||||||
|
import { siteUrls } from "@/lib/config/sites";
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const baseUrl = "https://mission-control-rho-pink.vercel.app";
|
const baseUrl = siteUrls.missionControl;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -27,6 +27,11 @@ import {
|
|||||||
countOverdueTasks,
|
countOverdueTasks,
|
||||||
Task,
|
Task,
|
||||||
} from "@/lib/data/tasks";
|
} from "@/lib/data/tasks";
|
||||||
|
import {
|
||||||
|
getGanttTaskUrl,
|
||||||
|
getGanttTasksUrl,
|
||||||
|
siteUrls,
|
||||||
|
} from "@/lib/config/sites";
|
||||||
|
|
||||||
// Force dynamic rendering to fetch fresh data from Supabase on each request
|
// Force dynamic rendering to fetch fresh data from Supabase on each request
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@ -103,7 +108,7 @@ function TaskListItem({ task }: { task: Task }) {
|
|||||||
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<Link
|
<Link
|
||||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
href={getGanttTaskUrl(task.id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
||||||
>
|
>
|
||||||
@ -248,7 +253,7 @@ export default async function TasksOverviewPage() {
|
|||||||
<>
|
<>
|
||||||
Mission Control view of all tasks. Manage work in{" "}
|
Mission Control view of all tasks. Manage work in{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://gantt-board.vercel.app"
|
href={siteUrls.ganttBoard}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
@ -258,7 +263,7 @@ export default async function TasksOverviewPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Link href="https://gantt-board.vercel.app" target="_blank">
|
<Link href={siteUrls.ganttBoard} target="_blank">
|
||||||
<Button size="sm" className="w-full sm:w-auto">
|
<Button size="sm" className="w-full sm:w-auto">
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
Open gantt-board
|
Open gantt-board
|
||||||
@ -394,7 +399,7 @@ export default async function TasksOverviewPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="block">
|
<Link href={siteUrls.ganttBoard} target="_blank" className="block">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||||
<div className="p-2 rounded-lg bg-blue-500/15 mr-3 shrink-0">
|
<div className="p-2 rounded-lg bg-blue-500/15 mr-3 shrink-0">
|
||||||
<ExternalLink className="w-4 h-4 sm:w-5 sm:h-5 text-blue-500" />
|
<ExternalLink className="w-4 h-4 sm:w-5 sm:h-5 text-blue-500" />
|
||||||
@ -406,7 +411,7 @@ export default async function TasksOverviewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="https://gantt-board.vercel.app/tasks" target="_blank" className="block">
|
<Link href={getGanttTasksUrl()} target="_blank" className="block">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||||
<div className="p-2 rounded-lg bg-purple-500/15 mr-3 shrink-0">
|
<div className="p-2 rounded-lg bg-purple-500/15 mr-3 shrink-0">
|
||||||
<Layers className="w-4 h-4 sm:w-5 sm:h-5 text-purple-500" />
|
<Layers className="w-4 h-4 sm:w-5 sm:h-5 text-purple-500" />
|
||||||
@ -418,7 +423,7 @@ export default async function TasksOverviewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="https://gantt-board.vercel.app/tasks?priority=high,urgent" target="_blank" className="block">
|
<Link href={getGanttTasksUrl({ priority: "high,urgent" })} target="_blank" className="block">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||||
<div className="p-2 rounded-lg bg-orange-500/15 mr-3 shrink-0">
|
<div className="p-2 rounded-lg bg-orange-500/15 mr-3 shrink-0">
|
||||||
<AlertTriangle className="w-4 h-4 sm:w-5 sm:h-5 text-orange-500" />
|
<AlertTriangle className="w-4 h-4 sm:w-5 sm:h-5 text-orange-500" />
|
||||||
@ -430,7 +435,7 @@ export default async function TasksOverviewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="https://gantt-board.vercel.app/tasks?status=in-progress" target="_blank" className="block">
|
<Link href={getGanttTasksUrl({ status: "in-progress" })} target="_blank" className="block">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||||
<div className="p-2 rounded-lg bg-green-500/15 mr-3 shrink-0">
|
<div className="p-2 rounded-lg bg-green-500/15 mr-3 shrink-0">
|
||||||
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-green-500" />
|
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-green-500" />
|
||||||
@ -452,7 +457,7 @@ export default async function TasksOverviewPage() {
|
|||||||
Mission Control is read-only. All task creation and editing happens in gantt-board.
|
Mission Control is read-only. All task creation and editing happens in gantt-board.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="shrink-0">
|
<Link href={siteUrls.ganttBoard} target="_blank" className="shrink-0">
|
||||||
<Button variant="secondary" size="sm">
|
<Button variant="secondary" size="sm">
|
||||||
Go to gantt-board
|
Go to gantt-board
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
Check
|
Check
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { siteUrls } from "@/lib/config/sites";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
@ -43,35 +44,35 @@ const QUICK_LINKS: QuickLink[] = [
|
|||||||
{
|
{
|
||||||
id: "github",
|
id: "github",
|
||||||
name: "GitHub",
|
name: "GitHub",
|
||||||
url: "https://github.com",
|
url: siteUrls.github,
|
||||||
icon: <Github className="w-4 h-4" />,
|
icon: <Github className="w-4 h-4" />,
|
||||||
color: "bg-slate-800"
|
color: "bg-slate-800"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "vercel",
|
id: "vercel",
|
||||||
name: "Vercel",
|
name: "Vercel",
|
||||||
url: "https://vercel.com",
|
url: siteUrls.vercel,
|
||||||
icon: <Cloud className="w-4 h-4" />,
|
icon: <Cloud className="w-4 h-4" />,
|
||||||
color: "bg-black"
|
color: "bg-black"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "supabase",
|
id: "supabase",
|
||||||
name: "Supabase",
|
name: "Supabase",
|
||||||
url: "https://supabase.com",
|
url: siteUrls.supabase,
|
||||||
icon: <Database className="w-4 h-4" />,
|
icon: <Database className="w-4 h-4" />,
|
||||||
color: "bg-emerald-600"
|
color: "bg-emerald-600"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gantt",
|
id: "gantt",
|
||||||
name: "Gantt Board",
|
name: "Gantt Board",
|
||||||
url: "https://gantt-board.vercel.app",
|
url: siteUrls.ganttBoard,
|
||||||
icon: <Clock className="w-4 h-4" />,
|
icon: <Clock className="w-4 h-4" />,
|
||||||
color: "bg-blue-600"
|
color: "bg-blue-600"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "google",
|
id: "google",
|
||||||
name: "Google",
|
name: "Google",
|
||||||
url: "https://google.com",
|
url: siteUrls.google,
|
||||||
icon: <Globe className="w-4 h-4" />,
|
icon: <Globe className="w-4 h-4" />,
|
||||||
color: "bg-red-500"
|
color: "bg-red-500"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Calendar, Clock, MapPin, ExternalLink, FileText, X } from "lucide-react";
|
import { Calendar, Clock, MapPin, ExternalLink, FileText, X } from "lucide-react";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { siteUrls } from "@/lib/config/sites";
|
||||||
|
|
||||||
interface CalendarEvent {
|
interface CalendarEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@ -103,7 +104,7 @@ function formatEventDate(event: CalendarEvent): string {
|
|||||||
export function EventDetailModal({ event, isOpen, onClose }: EventDetailModalProps) {
|
export function EventDetailModal({ event, isOpen, onClose }: EventDetailModalProps) {
|
||||||
if (!event) return null;
|
if (!event) return null;
|
||||||
|
|
||||||
const eventUrl = event.htmlLink || `https://calendar.google.com/calendar/event?eid=${btoa(event.id)}`;
|
const eventUrl = event.htmlLink || `${siteUrls.googleCalendar}/calendar/event?eid=${btoa(event.id)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format, isSameDay, parseISO } from "date-fns";
|
import { format, isSameDay, parseISO } from "date-fns";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { siteUrls } from "@/lib/config/sites";
|
||||||
|
|
||||||
function getEventDate(event: { start: { dateTime?: string; date?: string } }): Date {
|
function getEventDate(event: { start: { dateTime?: string; date?: string } }): Date {
|
||||||
if (event.start.dateTime) {
|
if (event.start.dateTime) {
|
||||||
@ -185,7 +186,7 @@ function EventCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={`https://calendar.google.com/calendar/event?eid=${btoa(
|
href={`${siteUrls.googleCalendar}/calendar/event?eid=${btoa(
|
||||||
event.id
|
event.id
|
||||||
)}`}
|
)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { getGanttTaskUrl, getGanttTasksUrl } from "@/lib/config/sites";
|
||||||
|
|
||||||
interface TaskWithDueDate {
|
interface TaskWithDueDate {
|
||||||
id: string;
|
id: string;
|
||||||
@ -169,7 +170,7 @@ export function TaskCalendarIntegration({ maxTasks = 10 }: TaskCalendarIntegrati
|
|||||||
{getStatusIcon(task.status)}
|
{getStatusIcon(task.status)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Link
|
<Link
|
||||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
href={getGanttTaskUrl(task.id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="font-medium text-sm hover:text-primary hover:underline block truncate"
|
className="font-medium text-sm hover:text-primary hover:underline block truncate"
|
||||||
>
|
>
|
||||||
@ -217,7 +218,7 @@ export function TaskCalendarIntegration({ maxTasks = 10 }: TaskCalendarIntegrati
|
|||||||
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
<div className="mt-4 pt-4 border-t">
|
||||||
<Link
|
<Link
|
||||||
href="https://gantt-board.vercel.app/tasks"
|
href={getGanttTasksUrl()}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="sm" className="w-full gap-2">
|
<Button variant="ghost" size="sm" className="w-full gap-2">
|
||||||
|
|||||||
@ -25,20 +25,23 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Timer,
|
||||||
|
Circle,
|
||||||
} from "lucide-react";
|
} 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;
|
id: string;
|
||||||
|
type: SearchResultType;
|
||||||
title: string;
|
title: string;
|
||||||
status: string;
|
snippet?: string; // Brief preview text (replaces subtitle/description)
|
||||||
priority: string;
|
url: string; // Deep link to full view
|
||||||
project_id: string;
|
icon: string;
|
||||||
}
|
status?: string; // For visual badges
|
||||||
|
color?: string; // For project/task colors
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@ -53,73 +56,74 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{ name: "Gantt Board", url: "https://gantt-board.vercel.app", icon: ExternalLink },
|
{ name: "Gantt Board", url: siteUrls.ganttBoard, icon: ExternalLink },
|
||||||
{ name: "Blog Backup", url: "https://blog-backup-two.vercel.app", icon: ExternalLink },
|
{ name: "Blog Backup", url: siteUrls.blogBackup, icon: ExternalLink },
|
||||||
{ name: "Gitea", url: "http://192.168.1.128:3000", icon: ExternalLink },
|
{ name: "Gitea", url: siteUrls.gitea, icon: ExternalLink },
|
||||||
];
|
];
|
||||||
|
|
||||||
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) {
|
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 [tasks, setTasks] = useState<Task[]>([]);
|
const [query, setQuery] = useState("");
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Fetch tasks when search opens
|
// Debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open || query.length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const fetchData = async () => {
|
const timer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch tasks
|
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||||
const { data: tasksData } = await supabaseClient
|
const data = await res.json();
|
||||||
.from('tasks')
|
setResults(data.results || []);
|
||||||
.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('Error fetching search data:', err);
|
console.error("Search error:", err);
|
||||||
|
setResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, 150); // 150ms debounce
|
||||||
|
|
||||||
fetchData();
|
return () => clearTimeout(timer);
|
||||||
}, [open]);
|
}, [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)) {
|
||||||
@ -133,13 +137,32 @@ export function QuickSearch() {
|
|||||||
|
|
||||||
const runCommand = useCallback((command: () => void) => {
|
const runCommand = useCallback((command: () => void) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
command();
|
command();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getProjectName = (projectId: string) => {
|
const handleResultSelect = (result: SearchResult) => {
|
||||||
return projects.find(p => p.id === projectId)?.name || 'Unknown';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Search Button Trigger */}
|
{/* Search Button Trigger */}
|
||||||
@ -157,9 +180,63 @@ export function QuickSearch() {
|
|||||||
|
|
||||||
{/* Command Dialog */}
|
{/* Command Dialog */}
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
<CommandInput placeholder="Type a command or search..." />
|
<CommandInput
|
||||||
|
placeholder="Search tasks, projects, sprints, documents..."
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
/>
|
||||||
<CommandList>
|
<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 */}
|
{/* Navigation */}
|
||||||
<CommandGroup heading="Navigation">
|
<CommandGroup heading="Navigation">
|
||||||
@ -194,37 +271,6 @@ 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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
279
docs/sso-implementation-plan.md
Normal file
279
docs/sso-implementation-plan.md
Normal 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 project’s 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
98
lib/config/sites.ts
Normal 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");
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||||
import { Task, fetchAllTasks } from "./tasks";
|
import { Task, fetchAllTasks } from "./tasks";
|
||||||
import { Project, fetchAllProjects } from "./projects";
|
import { Project, fetchAllProjects } from "./projects";
|
||||||
|
import { getGanttTaskUrl } from "@/lib/config/sites";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
@ -370,7 +371,7 @@ export async function getNextMissionSteps(): Promise<NextStep[]> {
|
|||||||
priority: task.priority as "high" | "urgent",
|
priority: task.priority as "high" | "urgent",
|
||||||
projectName: projectMap.get(task.project_id),
|
projectName: projectMap.get(task.project_id),
|
||||||
dueDate: task.due_date,
|
dueDate: task.due_date,
|
||||||
ganttBoardUrl: `https://gantt-board.vercel.app/?task=${task.id}`,
|
ganttBoardUrl: getGanttTaskUrl(task.id),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
|
|||||||
27
search.ts
Normal file
27
search.ts
Normal 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
25
test-search.js
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user