Compare commits
No commits in common. "fb55c1d2561bed203807a94789fd3dae7693e3d3" and "a51798848297a4d60e8f8b72a76ecf4c5db1f2dc" have entirely different histories.
fb55c1d256
...
a517988482
12
README.md
12
README.md
@ -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
|
||||||
|
|||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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 [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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 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`.
|
|
||||||
@ -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");
|
|
||||||
}
|
|
||||||
@ -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}`,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
27
search.ts
27
search.ts
@ -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();
|
|
||||||
@ -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();
|
|
||||||
Loading…
Reference in New Issue
Block a user