Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-27 13:22:00 -06:00
parent bca83c682d
commit 7caf290332
3 changed files with 310 additions and 0 deletions

50
Dockerfile Normal file
View File

@ -0,0 +1,50 @@
FROM node:22-bookworm-slim
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Build-time NEXT_PUBLIC vars for client bundles.
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_MISSION_CONTROL_URL
ARG NEXT_PUBLIC_GANTT_BOARD_URL
ARG NEXT_PUBLIC_BLOG_BACKUP_URL
ARG NEXT_PUBLIC_GITEA_URL
ARG NEXT_PUBLIC_GITHUB_URL
ARG NEXT_PUBLIC_VERCEL_URL
ARG NEXT_PUBLIC_SUPABASE_SITE_URL
ARG NEXT_PUBLIC_GOOGLE_URL
ARG NEXT_PUBLIC_GOOGLE_CALENDAR_URL
ARG NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL
ARG NEXT_PUBLIC_GANTT_API_BASE_URL
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL \
NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY \
NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID \
NEXT_PUBLIC_MISSION_CONTROL_URL=$NEXT_PUBLIC_MISSION_CONTROL_URL \
NEXT_PUBLIC_GANTT_BOARD_URL=$NEXT_PUBLIC_GANTT_BOARD_URL \
NEXT_PUBLIC_BLOG_BACKUP_URL=$NEXT_PUBLIC_BLOG_BACKUP_URL \
NEXT_PUBLIC_GITEA_URL=$NEXT_PUBLIC_GITEA_URL \
NEXT_PUBLIC_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL \
NEXT_PUBLIC_VERCEL_URL=$NEXT_PUBLIC_VERCEL_URL \
NEXT_PUBLIC_SUPABASE_SITE_URL=$NEXT_PUBLIC_SUPABASE_SITE_URL \
NEXT_PUBLIC_GOOGLE_URL=$NEXT_PUBLIC_GOOGLE_URL \
NEXT_PUBLIC_GOOGLE_CALENDAR_URL=$NEXT_PUBLIC_GOOGLE_CALENDAR_URL \
NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL=$NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL \
NEXT_PUBLIC_GANTT_API_BASE_URL=$NEXT_PUBLIC_GANTT_API_BASE_URL \
NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
RUN mkdir -p /app/data
EXPOSE 8301
CMD ["npm", "run", "start", "--", "-H", "0.0.0.0", "-p", "8301"]

193
app/documents/[id]/page.tsx Normal file
View File

@ -0,0 +1,193 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { createClient } from '@supabase/supabase-js';
import { DashboardLayout } from '@/components/layout/sidebar';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, FileText, FileType, FileIcon, FileCode, Edit3, Trash2, Clock, Folder } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Document, DocumentType, DOCUMENT_TYPE_COLORS } from '@/types/documents';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
const typeIcons: Record<DocumentType, typeof FileText> = {
markdown: FileType,
text: FileText,
pdf: FileIcon,
code: FileCode,
};
function getSupabaseServerClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error('Server configuration error: Missing Supabase credentials');
}
return createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
async function getDocument(id: string): Promise<Document | null> {
try {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('mission_control_documents')
.select('*')
.eq('id', id)
.single();
if (error || !data) {
console.error('Error fetching document:', error);
return null;
}
return {
id: data.id,
title: data.title,
content: data.content,
type: data.type as DocumentType,
folder: data.folder || 'General',
tags: data.tags || [],
createdAt: data.created_at,
updatedAt: data.updated_at,
size: data.size || 0,
description: data.description,
};
} catch (err) {
console.error('Failed to fetch document:', err);
return null;
}
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const document = await getDocument(id);
return {
title: document ? `${document.title} - Mission Control` : 'Document - Mission Control',
};
}
export default async function DocumentPage({ params }: PageProps) {
const { id } = await params;
const document = await getDocument(id);
if (!document) {
notFound();
}
const Icon = typeIcons[document.type];
const colorClass = DOCUMENT_TYPE_COLORS[document.type];
return (
<DashboardLayout>
<div className="space-y-6">
{/* Header with back button */}
<div className="flex items-center gap-4">
<Link href="/documents">
<Button variant="outline" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Documents
</Button>
</Link>
</div>
{/* Document Header */}
<div className="border rounded-lg p-6 bg-card">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className={cn('p-3 rounded-lg shrink-0', colorClass)}>
<Icon className="w-6 h-6" />
</div>
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl font-semibold">{document.title}</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground mt-2">
<span className="flex items-center gap-1">
<Folder className="w-4 h-4" />
{document.folder}
</span>
<span></span>
<span>{formatFileSize(document.size)}</span>
<span></span>
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
Updated {formatDate(document.updatedAt)}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Link href={`/documents?id=${document.id}&edit=true`}>
<Button variant="outline" size="sm">
<Edit3 className="w-4 h-4 mr-2" />
Edit
</Button>
</Link>
</div>
</div>
{/* Tags */}
{document.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4 pt-4 border-t">
{document.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
#{tag}
</Badge>
))}
</div>
)}
</div>
{/* Document Content */}
<div className="border rounded-lg p-6 bg-card">
<div className="prose prose-base dark:prose-invert max-w-none prose-headings:mt-6 prose-headings:mb-4 prose-p:my-3 prose-ul:my-3 prose-ol:my-3">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{document.content || '*No content*'}
</ReactMarkdown>
</div>
</div>
{/* Footer with back button */}
<div className="flex items-center justify-center pt-4">
<Link href="/documents">
<Button variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Documents
</Button>
</Link>
</div>
</div>
</DashboardLayout>
);
}

67
docker-compose.yml Normal file
View File

@ -0,0 +1,67 @@
services:
mission-control:
image: mission-control:latest
pull_policy: build
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:-}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:-}
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
NEXT_PUBLIC_MISSION_CONTROL_URL: ${NEXT_PUBLIC_MISSION_CONTROL_URL:-}
NEXT_PUBLIC_GANTT_BOARD_URL: ${NEXT_PUBLIC_GANTT_BOARD_URL:-}
NEXT_PUBLIC_BLOG_BACKUP_URL: ${NEXT_PUBLIC_BLOG_BACKUP_URL:-}
NEXT_PUBLIC_GITEA_URL: ${NEXT_PUBLIC_GITEA_URL:-}
NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-}
NEXT_PUBLIC_VERCEL_URL: ${NEXT_PUBLIC_VERCEL_URL:-}
NEXT_PUBLIC_SUPABASE_SITE_URL: ${NEXT_PUBLIC_SUPABASE_SITE_URL:-}
NEXT_PUBLIC_GOOGLE_URL: ${NEXT_PUBLIC_GOOGLE_URL:-}
NEXT_PUBLIC_GOOGLE_CALENDAR_URL: ${NEXT_PUBLIC_GOOGLE_CALENDAR_URL:-}
NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL: ${NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL:-}
NEXT_PUBLIC_GANTT_API_BASE_URL: ${NEXT_PUBLIC_GANTT_API_BASE_URL:-}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-}
restart: unless-stopped
ports:
- "${APP_PORT:-8301}:8301"
environment:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: "1"
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:-}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:-}
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
NEXT_PUBLIC_MISSION_CONTROL_URL: ${NEXT_PUBLIC_MISSION_CONTROL_URL:-}
NEXT_PUBLIC_GANTT_BOARD_URL: ${NEXT_PUBLIC_GANTT_BOARD_URL:-}
NEXT_PUBLIC_BLOG_BACKUP_URL: ${NEXT_PUBLIC_BLOG_BACKUP_URL:-}
NEXT_PUBLIC_GITEA_URL: ${NEXT_PUBLIC_GITEA_URL:-}
NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-}
NEXT_PUBLIC_VERCEL_URL: ${NEXT_PUBLIC_VERCEL_URL:-}
NEXT_PUBLIC_SUPABASE_SITE_URL: ${NEXT_PUBLIC_SUPABASE_SITE_URL:-}
NEXT_PUBLIC_GOOGLE_URL: ${NEXT_PUBLIC_GOOGLE_URL:-}
NEXT_PUBLIC_GOOGLE_CALENDAR_URL: ${NEXT_PUBLIC_GOOGLE_CALENDAR_URL:-}
NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL: ${NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL:-}
NEXT_PUBLIC_GANTT_API_BASE_URL: ${NEXT_PUBLIC_GANTT_API_BASE_URL:-}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:-}
SUPABASE_URL: ${SUPABASE_URL:-}
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY:-}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
GANTT_API_BASE_URL: ${GANTT_API_BASE_URL:-}
GANTT_API_REVALIDATE_SECONDS: ${GANTT_API_REVALIDATE_SECONDS:-}
GANTT_API_BEARER_TOKEN: ${GANTT_API_BEARER_TOKEN:-}
GANTT_API_COOKIE: ${GANTT_API_COOKIE:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
EMAIL_FROM: ${EMAIL_FROM:-}
VERCEL_URL: ${VERCEL_URL:-}
volumes:
- mission_control_data:/app/data
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8301/login').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 5s
retries: 5
start_period: 45s
volumes:
mission_control_data: