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