Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
d794a3b6ea
commit
69fff64bfd
12
README.md
12
README.md
@ -140,8 +140,20 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# Optional
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration
|
||||
NEXT_PUBLIC_MISSION_CONTROL_URL=
|
||||
NEXT_PUBLIC_GANTT_BOARD_URL=
|
||||
NEXT_PUBLIC_BLOG_BACKUP_URL=
|
||||
NEXT_PUBLIC_GITEA_URL=
|
||||
NEXT_PUBLIC_GITHUB_URL=
|
||||
NEXT_PUBLIC_VERCEL_URL=
|
||||
NEXT_PUBLIC_SUPABASE_SITE_URL=
|
||||
NEXT_PUBLIC_GOOGLE_URL=
|
||||
NEXT_PUBLIC_GOOGLE_CALENDAR_URL=
|
||||
NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL=
|
||||
```
|
||||
|
||||
Website URLs are centralized in `lib/config/sites.ts` and can be overridden via the optional variables above.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
|
||||
@ -1,114 +1,130 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import {
|
||||
getGanttProjectUrl,
|
||||
getGanttSprintUrl,
|
||||
getGanttTaskUrl,
|
||||
getMissionControlDocumentsUrl,
|
||||
} from "@/lib/config/sites";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Search result types - extendable for future search types
|
||||
export type SearchResultType =
|
||||
| "task"
|
||||
| "project"
|
||||
| "document"
|
||||
| "sprint"
|
||||
| "activity";
|
||||
// ============================================================================
|
||||
// SEARCHABLE PROTOCOL - Minimal search result interface
|
||||
// ============================================================================
|
||||
|
||||
export interface SearchResult {
|
||||
export type SearchableType = "task" | "project" | "document" | "sprint";
|
||||
|
||||
export interface SearchableResult {
|
||||
id: string;
|
||||
type: SearchResultType;
|
||||
type: SearchableType;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
color?: string;
|
||||
url?: string;
|
||||
icon?: string;
|
||||
metadata?: Record<string, 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: SearchResult[];
|
||||
results: SearchableResult[];
|
||||
total: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
// Search configuration - easily extensible
|
||||
interface SearchConfig {
|
||||
// ============================================================================
|
||||
// SEARCHABLE ENTITY CONFIGURATION
|
||||
// Add new searchable types here following the pattern
|
||||
// ============================================================================
|
||||
|
||||
interface SearchableEntityConfig {
|
||||
table: string;
|
||||
type: SearchResultType;
|
||||
fields: string[];
|
||||
type: SearchableType;
|
||||
titleField: string;
|
||||
subtitleField?: string;
|
||||
descriptionField?: string;
|
||||
snippetField?: string;
|
||||
statusField?: string;
|
||||
priorityField?: string;
|
||||
colorField?: string;
|
||||
urlGenerator: (item: any) => string;
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
searchFields: string[]; // Fields to search in
|
||||
searchFields: string[];
|
||||
// Generate URL for deep linking to full view
|
||||
getUrl: (item: any) => string;
|
||||
// Generate snippet from content
|
||||
getSnippet?: (item: any) => string | undefined;
|
||||
}
|
||||
|
||||
// Define searchable entities - add new ones here
|
||||
const searchConfigs: SearchConfig[] = [
|
||||
const searchableEntities: SearchableEntityConfig[] = [
|
||||
{
|
||||
table: "tasks",
|
||||
type: "task",
|
||||
fields: ["id", "title", "description", "status", "priority", "project_id", "type"],
|
||||
titleField: "title",
|
||||
subtitleField: "type",
|
||||
descriptionField: "description",
|
||||
snippetField: "description",
|
||||
statusField: "status",
|
||||
priorityField: "priority",
|
||||
urlGenerator: (item) => `https://gantt-board.vercel.app/tasks/${item.id}`,
|
||||
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",
|
||||
fields: ["id", "name", "description", "color", "status"],
|
||||
titleField: "name",
|
||||
descriptionField: "description",
|
||||
snippetField: "description",
|
||||
colorField: "color",
|
||||
statusField: "status",
|
||||
urlGenerator: (item) => `/projects`,
|
||||
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",
|
||||
fields: ["id", "name", "goal", "status", "start_date", "end_date", "project_id"],
|
||||
titleField: "name",
|
||||
subtitleField: "goal",
|
||||
snippetField: "goal",
|
||||
statusField: "status",
|
||||
urlGenerator: (item) => `https://gantt-board.vercel.app/sprints/${item.id}`,
|
||||
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",
|
||||
fields: ["id", "title", "content", "folder", "tags"],
|
||||
titleField: "title",
|
||||
subtitleField: "folder",
|
||||
descriptionField: "content",
|
||||
urlGenerator: (item) => `/documents`,
|
||||
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,
|
||||
},
|
||||
// Future: Add more search types here
|
||||
// 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);
|
||||
@ -122,7 +138,7 @@ export async function GET(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
// Create Supabase client with service role for full access
|
||||
// Create Supabase client
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
@ -134,73 +150,65 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
const results: SearchResult[] = [];
|
||||
const results: SearchableResult[] = [];
|
||||
|
||||
// Search each configured entity
|
||||
for (const config of searchConfigs) {
|
||||
if (!config.enabled) continue;
|
||||
// Search each enabled entity
|
||||
for (const entity of searchableEntities) {
|
||||
if (!entity.enabled) continue;
|
||||
|
||||
try {
|
||||
// Build OR filter dynamically based on searchFields
|
||||
const orConditions = config.searchFields
|
||||
// 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(config.table)
|
||||
.select(config.fields.join(", "))
|
||||
.from(entity.table)
|
||||
.select(selectFields.join(", "))
|
||||
.or(orConditions)
|
||||
.limit(10);
|
||||
|
||||
if (error) {
|
||||
console.error(`Search error in ${config.table}:`, error);
|
||||
console.error(`Search error in ${entity.table}:`, error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const mappedResults: SearchResult[] = data.map((item: any) => ({
|
||||
const mappedResults: SearchableResult[] = data.map((item: any) => ({
|
||||
id: item.id,
|
||||
type: config.type,
|
||||
title: item[config.titleField] || "Untitled",
|
||||
subtitle: config.subtitleField ? item[config.subtitleField] : undefined,
|
||||
description: config.descriptionField ? item[config.descriptionField] : undefined,
|
||||
status: config.statusField ? item[config.statusField] : undefined,
|
||||
priority: config.priorityField ? item[config.priorityField] : undefined,
|
||||
color: config.colorField ? item[config.colorField] : undefined,
|
||||
url: config.urlGenerator(item),
|
||||
icon: config.icon,
|
||||
metadata: {
|
||||
table: config.table,
|
||||
...Object.entries(item).reduce((acc, [key, value]) => {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
acc[key] = String(value);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
},
|
||||
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 ${config.table}:`, err);
|
||||
console.error(`Error searching ${entity.table}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort results by relevance (exact matches first, then partial)
|
||||
// Sort by relevance: exact match > starts with > contains
|
||||
results.sort((a, b) => {
|
||||
const aTitle = a.title.toLowerCase();
|
||||
const bTitle = b.title.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// Exact match gets highest priority
|
||||
if (aTitle === query && bTitle !== query) return -1;
|
||||
if (bTitle === query && aTitle !== query) return 1;
|
||||
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;
|
||||
|
||||
// Starts with query gets second priority
|
||||
if (aTitle.startsWith(query) && !bTitle.startsWith(query)) return -1;
|
||||
if (bTitle.startsWith(query) && !aTitle.startsWith(query)) return 1;
|
||||
|
||||
// Otherwise alphabetical
|
||||
return aTitle.localeCompare(bTitle);
|
||||
});
|
||||
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
} from "@/components/calendar";
|
||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||
import { format } from "date-fns";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
// Google OAuth Client ID - should be from environment variable
|
||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
@ -145,7 +146,7 @@ function CalendarContent() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<a
|
||||
href="https://calendar.google.com"
|
||||
href={siteUrls.googleCalendar}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
||||
@ -153,7 +154,7 @@ function CalendarContent() {
|
||||
Open Google Calendar →
|
||||
</a>
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/settings"
|
||||
href={siteUrls.googleCalendarSettings}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
||||
@ -161,7 +162,7 @@ function CalendarContent() {
|
||||
Calendar Settings →
|
||||
</a>
|
||||
<a
|
||||
href="https://gantt-board.vercel.app"
|
||||
href={siteUrls.ganttBoard}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@ -27,11 +28,11 @@ export const metadata: Metadata = {
|
||||
],
|
||||
authors: [{ name: "TopDogLabs" }],
|
||||
creator: "TopDogLabs",
|
||||
metadataBase: new URL("https://mission-control-rho-pink.vercel.app"),
|
||||
metadataBase: new URL(siteUrls.missionControl),
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: "https://mission-control-rho-pink.vercel.app",
|
||||
url: siteUrls.missionControl,
|
||||
title: "Mission Control | TopDogLabs",
|
||||
description:
|
||||
"Central hub for activity, tasks, goals, and tools. Build an iOS empire to achieve financial independence.",
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
Milestone,
|
||||
NextStep
|
||||
} from "@/lib/data/mission";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
// Revalidate every 5 minutes
|
||||
export const revalidate = 300;
|
||||
@ -387,7 +388,7 @@ export default async function MissionPage() {
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild className="shrink-0">
|
||||
<a href="https://gantt-board.vercel.app" target="_blank" rel="noopener noreferrer">
|
||||
<a href={siteUrls.ganttBoard} target="_blank" rel="noopener noreferrer">
|
||||
View All <ArrowRight className="w-4 h-4 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@ -36,6 +36,13 @@ import {
|
||||
Sprint,
|
||||
} from "@/lib/data/projects";
|
||||
import { Task } from "@/lib/data/tasks";
|
||||
import {
|
||||
getGanttProjectUrl,
|
||||
getGanttSprintUrl,
|
||||
getGanttTaskUrl,
|
||||
getGanttTasksUrl,
|
||||
siteUrls,
|
||||
} from "@/lib/config/sites";
|
||||
|
||||
// Force dynamic rendering to fetch fresh data on each request
|
||||
export const dynamic = "force-dynamic";
|
||||
@ -123,7 +130,7 @@ function TaskListItem({ task }: { task: Task }) {
|
||||
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
||||
href={getGanttTaskUrl(task.id)}
|
||||
target="_blank"
|
||||
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
||||
>
|
||||
@ -245,7 +252,7 @@ function ProjectCard({ stats }: { stats: ProjectStats }) {
|
||||
|
||||
{/* Action Link */}
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app?project=${project.id}`}
|
||||
href={getGanttProjectUrl(project.id)}
|
||||
target="_blank"
|
||||
className="flex items-center justify-between text-xs text-primary hover:underline mt-auto pt-2"
|
||||
>
|
||||
@ -314,7 +321,7 @@ function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectSta
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app?sprint=${sprint.id}`}
|
||||
href={getGanttSprintUrl(sprint.id)}
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full mt-2">
|
||||
@ -373,7 +380,7 @@ export default async function ProjectsOverviewPage() {
|
||||
<>
|
||||
Manage all projects and track progress. Edit projects in{" "}
|
||||
<Link
|
||||
href="https://gantt-board.vercel.app"
|
||||
href={siteUrls.ganttBoard}
|
||||
target="_blank"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
@ -383,7 +390,7 @@ export default async function ProjectsOverviewPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank">
|
||||
<Button size="sm">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Open Gantt Board
|
||||
@ -534,7 +541,7 @@ export default async function ProjectsOverviewPage() {
|
||||
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
||||
href={getGanttTaskUrl(task.id)}
|
||||
target="_blank"
|
||||
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
||||
>
|
||||
@ -577,7 +584,7 @@ export default async function ProjectsOverviewPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="block">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/15 mr-3 shrink-0">
|
||||
<ExternalLink className="w-4 h-4 text-blue-500" />
|
||||
@ -589,7 +596,7 @@ export default async function ProjectsOverviewPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="https://gantt-board.vercel.app/tasks?priority=urgent,high" target="_blank" className="block">
|
||||
<Link href={getGanttTasksUrl({ priority: "urgent,high" })} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||
<div className="p-2 rounded-lg bg-orange-500/15 mr-3 shrink-0">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||
@ -601,7 +608,7 @@ export default async function ProjectsOverviewPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="https://gantt-board.vercel.app/tasks?status=in-progress" target="_blank" className="block">
|
||||
<Link href={getGanttTasksUrl({ status: "in-progress" })} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/15 mr-3 shrink-0">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
@ -614,7 +621,7 @@ export default async function ProjectsOverviewPage() {
|
||||
</Link>
|
||||
|
||||
{activeSprint && (
|
||||
<Link href={`https://gantt-board.vercel.app?sprint=${activeSprint.id}`} target="_blank" className="block">
|
||||
<Link href={getGanttSprintUrl(activeSprint.id)} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||
<div className="p-2 rounded-lg bg-purple-500/15 mr-3 shrink-0">
|
||||
<Zap className="w-4 h-4 text-purple-500" />
|
||||
@ -637,7 +644,7 @@ export default async function ProjectsOverviewPage() {
|
||||
Mission Control is read-only. Create and edit projects in gantt-board.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="shrink-0">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank" className="shrink-0">
|
||||
<Button variant="secondary" size="sm">
|
||||
Go to gantt-board
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = "https://mission-control-rho-pink.vercel.app";
|
||||
const baseUrl = siteUrls.missionControl;
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@ -27,6 +27,11 @@ import {
|
||||
countOverdueTasks,
|
||||
Task,
|
||||
} from "@/lib/data/tasks";
|
||||
import {
|
||||
getGanttTaskUrl,
|
||||
getGanttTasksUrl,
|
||||
siteUrls,
|
||||
} from "@/lib/config/sites";
|
||||
|
||||
// Force dynamic rendering to fetch fresh data from Supabase on each request
|
||||
export const dynamic = "force-dynamic";
|
||||
@ -103,7 +108,7 @@ function TaskListItem({ task }: { task: Task }) {
|
||||
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
||||
href={getGanttTaskUrl(task.id)}
|
||||
target="_blank"
|
||||
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
||||
>
|
||||
@ -248,7 +253,7 @@ export default async function TasksOverviewPage() {
|
||||
<>
|
||||
Mission Control view of all tasks. Manage work in{" "}
|
||||
<Link
|
||||
href="https://gantt-board.vercel.app"
|
||||
href={siteUrls.ganttBoard}
|
||||
target="_blank"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
@ -258,7 +263,7 @@ export default async function TasksOverviewPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank">
|
||||
<Button size="sm" className="w-full sm:w-auto">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Open gantt-board
|
||||
@ -394,7 +399,7 @@ export default async function TasksOverviewPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="block">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||
<div className="p-2 rounded-lg bg-blue-500/15 mr-3 shrink-0">
|
||||
<ExternalLink className="w-4 h-4 sm:w-5 sm:h-5 text-blue-500" />
|
||||
@ -406,7 +411,7 @@ export default async function TasksOverviewPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="https://gantt-board.vercel.app/tasks" target="_blank" className="block">
|
||||
<Link href={getGanttTasksUrl()} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||
<div className="p-2 rounded-lg bg-purple-500/15 mr-3 shrink-0">
|
||||
<Layers className="w-4 h-4 sm:w-5 sm:h-5 text-purple-500" />
|
||||
@ -418,7 +423,7 @@ export default async function TasksOverviewPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="https://gantt-board.vercel.app/tasks?priority=high,urgent" target="_blank" className="block">
|
||||
<Link href={getGanttTasksUrl({ priority: "high,urgent" })} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||
<div className="p-2 rounded-lg bg-orange-500/15 mr-3 shrink-0">
|
||||
<AlertTriangle className="w-4 h-4 sm:w-5 sm:h-5 text-orange-500" />
|
||||
@ -430,7 +435,7 @@ export default async function TasksOverviewPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="https://gantt-board.vercel.app/tasks?status=in-progress" target="_blank" className="block">
|
||||
<Link href={getGanttTasksUrl({ status: "in-progress" })} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||
<div className="p-2 rounded-lg bg-green-500/15 mr-3 shrink-0">
|
||||
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-green-500" />
|
||||
@ -452,7 +457,7 @@ export default async function TasksOverviewPage() {
|
||||
Mission Control is read-only. All task creation and editing happens in gantt-board.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="shrink-0">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank" className="shrink-0">
|
||||
<Button variant="secondary" size="sm">
|
||||
Go to gantt-board
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
Check
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@ -43,35 +44,35 @@ const QUICK_LINKS: QuickLink[] = [
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
url: "https://github.com",
|
||||
url: siteUrls.github,
|
||||
icon: <Github className="w-4 h-4" />,
|
||||
color: "bg-slate-800"
|
||||
},
|
||||
{
|
||||
id: "vercel",
|
||||
name: "Vercel",
|
||||
url: "https://vercel.com",
|
||||
url: siteUrls.vercel,
|
||||
icon: <Cloud className="w-4 h-4" />,
|
||||
color: "bg-black"
|
||||
},
|
||||
{
|
||||
id: "supabase",
|
||||
name: "Supabase",
|
||||
url: "https://supabase.com",
|
||||
url: siteUrls.supabase,
|
||||
icon: <Database className="w-4 h-4" />,
|
||||
color: "bg-emerald-600"
|
||||
},
|
||||
{
|
||||
id: "gantt",
|
||||
name: "Gantt Board",
|
||||
url: "https://gantt-board.vercel.app",
|
||||
url: siteUrls.ganttBoard,
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
color: "bg-blue-600"
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
name: "Google",
|
||||
url: "https://google.com",
|
||||
url: siteUrls.google,
|
||||
icon: <Globe className="w-4 h-4" />,
|
||||
color: "bg-red-500"
|
||||
},
|
||||
|
||||
@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar, Clock, MapPin, ExternalLink, FileText, X } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
@ -103,7 +104,7 @@ function formatEventDate(event: CalendarEvent): string {
|
||||
export function EventDetailModal({ event, isOpen, onClose }: EventDetailModalProps) {
|
||||
if (!event) return null;
|
||||
|
||||
const eventUrl = event.htmlLink || `https://calendar.google.com/calendar/event?eid=${btoa(event.id)}`;
|
||||
const eventUrl = event.htmlLink || `${siteUrls.googleCalendar}/calendar/event?eid=${btoa(event.id)}`;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { format, isSameDay, parseISO } from "date-fns";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
function getEventDate(event: { start: { dateTime?: string; date?: string } }): Date {
|
||||
if (event.start.dateTime) {
|
||||
@ -185,7 +186,7 @@ function EventCard({
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`https://calendar.google.com/calendar/event?eid=${btoa(
|
||||
href={`${siteUrls.googleCalendar}/calendar/event?eid=${btoa(
|
||||
event.id
|
||||
)}`}
|
||||
target="_blank"
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getGanttTaskUrl, getGanttTasksUrl } from "@/lib/config/sites";
|
||||
|
||||
interface TaskWithDueDate {
|
||||
id: string;
|
||||
@ -169,7 +170,7 @@ export function TaskCalendarIntegration({ maxTasks = 10 }: TaskCalendarIntegrati
|
||||
{getStatusIcon(task.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
||||
href={getGanttTaskUrl(task.id)}
|
||||
target="_blank"
|
||||
className="font-medium text-sm hover:text-primary hover:underline block truncate"
|
||||
>
|
||||
@ -217,7 +218,7 @@ export function TaskCalendarIntegration({ maxTasks = 10 }: TaskCalendarIntegrati
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<Link
|
||||
href="https://gantt-board.vercel.app/tasks"
|
||||
href={getGanttTasksUrl()}
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="w-full gap-2">
|
||||
|
||||
@ -28,22 +28,20 @@ import {
|
||||
Timer,
|
||||
Circle,
|
||||
} from "lucide-react";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
// Search result type from API
|
||||
type SearchResultType = "task" | "project" | "document" | "sprint" | "activity";
|
||||
// Search result type from API - matches SearchableResult interface
|
||||
type SearchResultType = "task" | "project" | "document" | "sprint";
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
type: SearchResultType;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
metadata?: Record<string, string>;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
color?: string;
|
||||
url?: string;
|
||||
icon?: string;
|
||||
snippet?: string; // Brief preview text (replaces subtitle/description)
|
||||
url: string; // Deep link to full view
|
||||
icon: string;
|
||||
status?: string; // For visual badges
|
||||
color?: string; // For project/task colors
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
@ -58,9 +56,9 @@ const navItems = [
|
||||
];
|
||||
|
||||
const quickLinks = [
|
||||
{ name: "Gantt Board", url: "https://gantt-board.vercel.app", icon: ExternalLink },
|
||||
{ name: "Blog Backup", url: "https://blog-backup-two.vercel.app", icon: ExternalLink },
|
||||
{ name: "Gitea", url: "http://192.168.1.128:3000", icon: ExternalLink },
|
||||
{ name: "Gantt Board", url: siteUrls.ganttBoard, icon: ExternalLink },
|
||||
{ name: "Blog Backup", url: siteUrls.blogBackup, icon: ExternalLink },
|
||||
{ name: "Gitea", url: siteUrls.gitea, icon: ExternalLink },
|
||||
];
|
||||
|
||||
// Icon mapping for search result types
|
||||
@ -69,16 +67,6 @@ const typeIcons: Record<SearchResultType, React.ElementType> = {
|
||||
project: FolderKanban,
|
||||
document: FileText,
|
||||
sprint: Timer,
|
||||
activity: Activity,
|
||||
};
|
||||
|
||||
// Tag colors for documents
|
||||
const tagColors: Record<string, string> = {
|
||||
infrastructure: "bg-blue-500/20 text-blue-400",
|
||||
monitoring: "bg-green-500/20 text-green-400",
|
||||
security: "bg-red-500/20 text-red-400",
|
||||
urgent: "bg-orange-500/20 text-orange-400",
|
||||
guide: "bg-purple-500/20 text-purple-400",
|
||||
};
|
||||
|
||||
// Type labels
|
||||
@ -87,7 +75,6 @@ const typeLabels: Record<SearchResultType, string> = {
|
||||
project: "Project",
|
||||
document: "Document",
|
||||
sprint: "Sprint",
|
||||
activity: "Activity",
|
||||
};
|
||||
|
||||
function getStatusIcon(status?: string) {
|
||||
@ -105,19 +92,6 @@ function getStatusIcon(status?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityColor(priority?: string): string {
|
||||
switch (priority) {
|
||||
case "urgent":
|
||||
return "text-red-500";
|
||||
case "high":
|
||||
return "text-orange-500";
|
||||
case "medium":
|
||||
return "text-yellow-500";
|
||||
default:
|
||||
return "text-blue-500";
|
||||
}
|
||||
}
|
||||
|
||||
export function QuickSearch() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
@ -243,33 +217,13 @@ export function QuickSearch() {
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="truncate">{result.title}</span>
|
||||
{result.subtitle && (
|
||||
{result.snippet && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{result.subtitle}
|
||||
{result.snippet}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document tags */}
|
||||
{result.type === "document" && result.metadata?.tags && (
|
||||
<div className="flex gap-1 ml-2">
|
||||
{result.metadata.tags.split(",").slice(0, 2).map((tag: string) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded ${tagColors[tag.trim()] || "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.priority && (
|
||||
<span className={`text-xs ${getPriorityColor(result.priority)} ml-2`}>
|
||||
{result.priority}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{result.status && result.type !== "task" && (
|
||||
<span className="text-xs text-muted-foreground ml-2 capitalize">
|
||||
{result.status}
|
||||
|
||||
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 { Task, fetchAllTasks } from "./tasks";
|
||||
import { Project, fetchAllProjects } from "./projects";
|
||||
import { getGanttTaskUrl } from "@/lib/config/sites";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@ -370,7 +371,7 @@ export async function getNextMissionSteps(): Promise<NextStep[]> {
|
||||
priority: task.priority as "high" | "urgent",
|
||||
projectName: projectMap.get(task.project_id),
|
||||
dueDate: task.due_date,
|
||||
ganttBoardUrl: `https://gantt-board.vercel.app/?task=${task.id}`,
|
||||
ganttBoardUrl: getGanttTaskUrl(task.id),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user