Major cleanup: remove all dead pages and components
Components deleted (~27 files): - components/ai/ (9 files — collector, extraction, vision, phase sidebar) - components/assistant-ui/ (thread.tsx, markdown-text.tsx) - components/mission/, sidebar/, extension/, icons/ - layout/app-shell, left-rail, right-panel, connect-sources-modal, mcp-connect-modal, page-header, page-template, project-sidebar, workspace-left-rail, PAGE_TEMPLATE_GUIDE - chatgpt-import-card, mcp-connection-card, mcp-playground Project pages deleted (~20 dirs): - analytics, api-map, architecture, associate-sessions, audit, audit-test, automation, code, context, design-old, docs, features, getting-started, journey, market, mission, money, plan, product, progress, sandbox, sessions, tech, timeline-plan Workspace routes deleted (~12 dirs): - connections, costs, debug-projects, debug-sessions, keys, mcp, new-project, projects/new, test-api-key, test-auth, test-sessions, users Remaining: 5 components, 2 layout files, 8 project tabs, 3 workspace routes Made-with: Cursor
This commit is contained in:
@@ -1,385 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Upload, Github, Plug, FileText, Download, Copy, Check } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import { toast } from "sonner";
|
||||
import { GitHubRepoPicker } from "./github-repo-picker";
|
||||
import { CursorIcon } from "@/components/icons/custom-icons";
|
||||
|
||||
interface CollectorActionsProps {
|
||||
projectId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CollectorActions({ projectId, className }: CollectorActionsProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [showGithubPicker, setShowGithubPicker] = useState(false);
|
||||
const [showPasteDialog, setShowPasteDialog] = useState(false);
|
||||
const [showCursorImportDialog, setShowCursorImportDialog] = useState(false);
|
||||
const [pasteTitle, setPasteTitle] = useState("");
|
||||
const [pasteContent, setPasteContent] = useState("");
|
||||
const [isPasting, setIsPasting] = useState(false);
|
||||
const [copiedConfig, setCopiedConfig] = useState(false);
|
||||
const [copiedCommand, setCopiedCommand] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error("Please sign in to upload files");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/knowledge/upload-document`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload ${file.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Uploaded ${files.length} file(s)`);
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
toast.error("Failed to upload files");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtensionClick = () => {
|
||||
window.open("https://chrome.google.com/webstore", "_blank");
|
||||
toast.info("Install the Vibn browser extension and link it to this project");
|
||||
};
|
||||
|
||||
const handleCopyConfig = () => {
|
||||
const vibnConfig = {
|
||||
projectId: projectId,
|
||||
version: "1.0.0"
|
||||
};
|
||||
|
||||
const content = JSON.stringify(vibnConfig, null, 2);
|
||||
navigator.clipboard.writeText(content);
|
||||
setCopiedConfig(true);
|
||||
toast.success("Configuration copied to clipboard!");
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
};
|
||||
|
||||
const handleCopyCommand = () => {
|
||||
navigator.clipboard.writeText("Vibn: Import Historical Conversations");
|
||||
setCopiedCommand(true);
|
||||
toast.success("Command copied to clipboard!");
|
||||
setTimeout(() => setCopiedCommand(false), 2000);
|
||||
};
|
||||
|
||||
const handlePasteSubmit = async () => {
|
||||
if (!pasteTitle.trim() || !pasteContent.trim()) {
|
||||
toast.error("Please provide both title and content");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPasting(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error("Please sign in to save content");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/knowledge/import-ai-chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: pasteTitle,
|
||||
transcript: pasteContent,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to import chat");
|
||||
}
|
||||
|
||||
toast.success("AI chat imported successfully!");
|
||||
setShowPasteDialog(false);
|
||||
setPasteTitle("");
|
||||
setPasteContent("");
|
||||
} catch (error) {
|
||||
console.error("Paste error:", error);
|
||||
toast.error("Failed to import chat");
|
||||
} finally {
|
||||
setIsPasting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".txt,.md,.json,.pdf,.doc,.docx"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Upload Documents */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start h-auto py-2 px-3"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span className="text-xs">
|
||||
{uploading ? "Uploading..." : "Upload Documents"}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* Connect GitHub */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start h-auto py-2 px-3"
|
||||
onClick={() => setShowGithubPicker(true)}
|
||||
>
|
||||
<Github className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span className="text-xs">Connect GitHub</span>
|
||||
</Button>
|
||||
|
||||
{/* Get Extension */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start h-auto py-2 px-3"
|
||||
onClick={handleExtensionClick}
|
||||
>
|
||||
<Plug className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span className="text-xs">Get Extension</span>
|
||||
</Button>
|
||||
|
||||
{/* Paste AI Chat */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start h-auto py-2 px-3"
|
||||
onClick={() => setShowPasteDialog(true)}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span className="text-xs">Paste AI Chat</span>
|
||||
</Button>
|
||||
|
||||
{/* Import Cursor History */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start h-auto py-2 px-3"
|
||||
onClick={() => setShowCursorImportDialog(true)}
|
||||
>
|
||||
<CursorIcon className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span className="text-xs">Import Cursor History</span>
|
||||
</Button>
|
||||
|
||||
{/* GitHub Picker Dialog */}
|
||||
{showGithubPicker && (
|
||||
<GitHubRepoPicker
|
||||
projectId={projectId}
|
||||
onClose={() => setShowGithubPicker(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Paste AI Chat Dialog */}
|
||||
<Dialog open={showPasteDialog} onOpenChange={setShowPasteDialog}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import AI Chat</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste a conversation from ChatGPT, Claude, or any other AI tool
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="paste-title">Title</Label>
|
||||
<Input
|
||||
id="paste-title"
|
||||
placeholder="e.g., Product Vision Discussion"
|
||||
value={pasteTitle}
|
||||
onChange={(e) => setPasteTitle(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="paste-content">Chat Content</Label>
|
||||
<Textarea
|
||||
id="paste-content"
|
||||
placeholder="Paste your entire conversation here..."
|
||||
value={pasteContent}
|
||||
onChange={(e) => setPasteContent(e.target.value)}
|
||||
className="mt-1 min-h-[300px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPasteDialog(false)}
|
||||
disabled={isPasting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePasteSubmit}
|
||||
disabled={isPasting || !pasteTitle.trim() || !pasteContent.trim()}
|
||||
>
|
||||
{isPasting ? "Importing..." : "Import Chat"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Cursor Import Dialog */}
|
||||
<Dialog open={showCursorImportDialog} onOpenChange={setShowCursorImportDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CursorIcon className="h-5 w-5" />
|
||||
Import Cursor Conversation History
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import all conversations from Cursor related to this project
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Step 1: Create .vibn file</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
In your project root directory (where you open Cursor), create a file named <code className="text-xs bg-background px-1 py-0.5 rounded">.vibn</code>
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium">Paste this content into the file:</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCopyConfig}
|
||||
className="h-7 gap-2"
|
||||
>
|
||||
{copiedConfig ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-background p-3 rounded border overflow-x-auto">
|
||||
<code>{JSON.stringify({ projectId: projectId, version: "1.0.0" }, null, 2)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Step 2: Reload Cursor window</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
After creating the .vibn file, reload Cursor to register the new configuration:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs text-muted-foreground ml-2">
|
||||
<li>Command Palette → <strong>Developer: Reload Window</strong></li>
|
||||
<li>Or: Close and reopen Cursor</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Step 3: Run import command</p>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground ml-2">
|
||||
<li>Open Command Palette (Cmd+Shift+P / Ctrl+Shift+P)</li>
|
||||
<li>Run this command:</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<code className="flex-1 text-xs bg-background px-3 py-2 rounded border">
|
||||
Vibn: Import Historical Conversations
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCopyCommand}
|
||||
className="h-8"
|
||||
>
|
||||
{copiedCommand ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-400">
|
||||
💡 <strong>Note:</strong> The Cursor Monitor extension must be installed and configured with your Vibn API key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">What gets imported:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1 ml-4">
|
||||
<li>• All chat sessions from the workspace</li>
|
||||
<li>• User prompts and AI responses</li>
|
||||
<li>• File edit history and context</li>
|
||||
<li>• Conversation timestamps and metadata</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCursorImportDialog(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CheckCircle2, Circle, Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { db } from "@/lib/firebase/config";
|
||||
import { doc, onSnapshot, collection, query, where } from "firebase/firestore";
|
||||
|
||||
interface CollectorChecklistProps {
|
||||
projectId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CollectorChecklist({ projectId, className }: CollectorChecklistProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPhase, setCurrentPhase] = useState<string | null>(null);
|
||||
const [hasDocuments, setHasDocuments] = useState(false);
|
||||
const [documentCount, setDocumentCount] = useState(0);
|
||||
const [githubConnected, setGithubConnected] = useState(false);
|
||||
const [githubRepo, setGithubRepo] = useState<string | null>(null);
|
||||
const [extensionLinked, setExtensionLinked] = useState(false);
|
||||
const [readyForNextPhase, setReadyForNextPhase] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
const unsubscribe = onSnapshot(
|
||||
doc(db, "projects", projectId),
|
||||
(snapshot) => {
|
||||
if (snapshot.exists()) {
|
||||
const data = snapshot.data();
|
||||
const phase = data?.currentPhase || 'collector';
|
||||
setCurrentPhase(phase);
|
||||
|
||||
// Get actual state from project data
|
||||
const repo = data?.githubRepo || null;
|
||||
|
||||
setGithubConnected(!!repo);
|
||||
setGithubRepo(repo);
|
||||
|
||||
// Check handoff for readiness
|
||||
const handoff = data?.phaseData?.phaseHandoffs?.collector;
|
||||
setReadyForNextPhase(handoff?.readyForNextPhase || false);
|
||||
|
||||
console.log("[CollectorChecklist] Project state:", {
|
||||
currentPhase: phase,
|
||||
githubRepo: repo,
|
||||
readyForNextPhase: handoff?.readyForNextPhase,
|
||||
userId: data?.userId, // Log the user ID
|
||||
});
|
||||
|
||||
// Also log it prominently for easy copying
|
||||
console.log(`🆔 YOUR USER ID: ${data?.userId}`);
|
||||
} else {
|
||||
console.log("[CollectorChecklist] Project not found:", projectId);
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
(error) => {
|
||||
console.error("[CollectorChecklist] Error loading checklist:", error);
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [projectId]);
|
||||
|
||||
// Separate effect to check for linked sessions (extension linked)
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
console.log(`[CollectorChecklist] Querying sessions for projectId: "${projectId}"`);
|
||||
|
||||
const sessionsRef = collection(db, 'sessions');
|
||||
const q = query(sessionsRef, where('projectId', '==', projectId));
|
||||
|
||||
const unsubscribe = onSnapshot(q, (snapshot) => {
|
||||
const hasLinkedSessions = !snapshot.empty;
|
||||
setExtensionLinked(hasLinkedSessions);
|
||||
console.log("[CollectorChecklist] Extension linked (sessions found):", hasLinkedSessions, `(${snapshot.size} sessions)`);
|
||||
|
||||
// Debug: Show session IDs if found
|
||||
if (snapshot.size > 0) {
|
||||
const sessionIds = snapshot.docs.map(doc => doc.id);
|
||||
console.log("[CollectorChecklist] Found session IDs:", sessionIds);
|
||||
}
|
||||
}, (error) => {
|
||||
console.error("[CollectorChecklist] Error querying sessions:", error);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [projectId]);
|
||||
|
||||
// Separate effect to count documents from knowledge_items collection
|
||||
// Count both uploaded documents AND pasted AI chat content
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
const q = query(
|
||||
collection(db, 'knowledge_items'),
|
||||
where('projectId', '==', projectId)
|
||||
);
|
||||
|
||||
const unsubscribe = onSnapshot(q, (snapshot) => {
|
||||
// Filter for document types (uploaded files and pasted content)
|
||||
const docs = snapshot.docs.filter(doc => {
|
||||
const sourceType = doc.data().sourceType;
|
||||
return sourceType === 'imported_document' || sourceType === 'imported_ai_chat';
|
||||
});
|
||||
|
||||
const count = docs.length;
|
||||
setHasDocuments(count > 0);
|
||||
setDocumentCount(count);
|
||||
console.log("[CollectorChecklist] Document count:", count, "(documents + pasted content)");
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [projectId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold">Setup Progress</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const checklist = [
|
||||
{
|
||||
id: "documents",
|
||||
label: "Documents uploaded",
|
||||
checked: hasDocuments,
|
||||
count: documentCount,
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
label: "GitHub connected",
|
||||
checked: githubConnected,
|
||||
repo: githubRepo,
|
||||
},
|
||||
{
|
||||
id: "extension",
|
||||
label: "Extension linked",
|
||||
checked: extensionLinked,
|
||||
},
|
||||
];
|
||||
|
||||
const completedCount = checklist.filter((item) => item.checked).length;
|
||||
const progress = (completedCount / checklist.length) * 100;
|
||||
|
||||
// If phase is beyond collector, show completed state
|
||||
const isCompleted = currentPhase === 'analyzed' || currentPhase === 'vision' || currentPhase === 'mvp';
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{isCompleted ? '✓ Setup Complete' : 'Setup Progress'}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${isCompleted ? 100 : progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isCompleted ? '3 of 3 complete' : `${completedCount} of ${checklist.length} complete`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checklist Items */}
|
||||
<div className="space-y-2">
|
||||
{checklist.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center gap-2 py-2 px-3 rounded-md border ${
|
||||
(item.checked || isCompleted)
|
||||
? "bg-green-50 border-green-200"
|
||||
: "bg-muted/50 border-muted text-muted-foreground opacity-60"
|
||||
}`}
|
||||
>
|
||||
{(item.checked || isCompleted) ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium">
|
||||
{item.label}
|
||||
{item.checked && item.count !== undefined && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({item.count})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{item.checked && item.repo && (
|
||||
<p className="text-[10px] text-muted-foreground truncate">
|
||||
{item.repo}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ready indicator */}
|
||||
{readyForNextPhase && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<p className="text-xs text-green-600 font-medium">
|
||||
✓ Ready for extraction phase
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CheckCircle2, AlertTriangle, HelpCircle, Users, Lightbulb, Wrench, AlertCircle, Sparkles, X, Plus, Save, Edit2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ExtractionHandoff {
|
||||
phase: string;
|
||||
readyForNextPhase: boolean;
|
||||
confidence: number;
|
||||
confirmed: {
|
||||
problems?: string[];
|
||||
targetUsers?: string[];
|
||||
features?: string[];
|
||||
constraints?: string[];
|
||||
opportunities?: string[];
|
||||
};
|
||||
uncertain: Record<string, any>;
|
||||
missing: string[];
|
||||
questionsForUser: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ExtractionResultsEditableProps {
|
||||
projectId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExtractionResultsEditable({ projectId, className }: ExtractionResultsEditableProps) {
|
||||
const [extraction, setExtraction] = useState<ExtractionHandoff | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Local editable state
|
||||
const [editedProblems, setEditedProblems] = useState<string[]>([]);
|
||||
const [editedUsers, setEditedUsers] = useState<string[]>([]);
|
||||
const [editedFeatures, setEditedFeatures] = useState<string[]>([]);
|
||||
const [editedConstraints, setEditedConstraints] = useState<string[]>([]);
|
||||
const [editedOpportunities, setEditedOpportunities] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchExtraction = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/extraction-handoff`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setExtraction(null);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Failed to fetch extraction: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const handoff = data.handoff;
|
||||
setExtraction(handoff);
|
||||
|
||||
// Initialize editable state
|
||||
setEditedProblems(handoff.confirmed.problems || []);
|
||||
setEditedUsers(handoff.confirmed.targetUsers || []);
|
||||
setEditedFeatures(handoff.confirmed.features || []);
|
||||
setEditedConstraints(handoff.confirmed.constraints || []);
|
||||
setEditedOpportunities(handoff.confirmed.opportunities || []);
|
||||
} catch (err) {
|
||||
console.error("[ExtractionResults] Error:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load extraction");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
fetchExtraction();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error("Please sign in to save changes");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/extraction-handoff`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confirmed: {
|
||||
problems: editedProblems.filter(p => p.trim()),
|
||||
targetUsers: editedUsers.filter(u => u.trim()),
|
||||
features: editedFeatures.filter(f => f.trim()),
|
||||
constraints: editedConstraints.filter(c => c.trim()),
|
||||
opportunities: editedOpportunities.filter(o => o.trim()),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to save changes");
|
||||
}
|
||||
|
||||
// Update local state
|
||||
if (extraction) {
|
||||
setExtraction({
|
||||
...extraction,
|
||||
confirmed: {
|
||||
problems: editedProblems.filter(p => p.trim()),
|
||||
targetUsers: editedUsers.filter(u => u.trim()),
|
||||
features: editedFeatures.filter(f => f.trim()),
|
||||
constraints: editedConstraints.filter(c => c.trim()),
|
||||
opportunities: editedOpportunities.filter(o => o.trim()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
toast.success("Changes saved");
|
||||
} catch (err) {
|
||||
console.error("[ExtractionResults] Save error:", err);
|
||||
toast.error("Failed to save changes");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset to original values
|
||||
if (extraction) {
|
||||
setEditedProblems(extraction.confirmed.problems || []);
|
||||
setEditedUsers(extraction.confirmed.targetUsers || []);
|
||||
setEditedFeatures(extraction.confirmed.features || []);
|
||||
setEditedConstraints(extraction.confirmed.constraints || []);
|
||||
setEditedOpportunities(extraction.confirmed.opportunities || []);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const renderEditableList = (
|
||||
items: string[],
|
||||
setItems: (items: string[]) => void,
|
||||
Icon: any,
|
||||
iconColor: string,
|
||||
title: string
|
||||
) => {
|
||||
const safeItems = items || [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Icon className={cn("h-4 w-4", iconColor)} />
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{safeItems.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Input
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = e.target.value;
|
||||
setItems(newItems);
|
||||
}}
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
setItems(newItems);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{item}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isEditing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-2"
|
||||
onClick={() => setItems([...items, ""])}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
{!isEditing && items.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground italic">None</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={cn("border-destructive", className)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!extraction) {
|
||||
return (
|
||||
<Card className={cn("border-dashed", className)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Sparkles className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No extraction results yet</p>
|
||||
<p className="text-xs mt-1">Upload documents and trigger extraction to see insights</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Extracted Insights</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Review and edit the extracted information
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={extraction.readyForNextPhase ? "default" : "secondary"}>
|
||||
{Math.round(extraction.confidence * 100)}% confidence
|
||||
</Badge>
|
||||
{!isEditing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Editable Lists */}
|
||||
{renderEditableList(editedProblems, setEditedProblems, AlertTriangle, "text-orange-600", "Problems & Pain Points")}
|
||||
{renderEditableList(editedUsers, setEditedUsers, Users, "text-blue-600", "Target Users")}
|
||||
{renderEditableList(editedFeatures, setEditedFeatures, Lightbulb, "text-yellow-600", "Key Features")}
|
||||
{renderEditableList(editedConstraints, setEditedConstraints, Wrench, "text-purple-600", "Constraints & Requirements")}
|
||||
{renderEditableList(editedOpportunities, setEditedOpportunities, Sparkles, "text-green-600", "Opportunities")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CheckCircle2, AlertTriangle, HelpCircle, Users, Lightbulb, Wrench, AlertCircle, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ExtractionHandoff {
|
||||
phase: string;
|
||||
readyForNextPhase: boolean;
|
||||
confidence: number;
|
||||
confirmed: {
|
||||
problems?: string[];
|
||||
targetUsers?: string[];
|
||||
features?: string[];
|
||||
constraints?: string[];
|
||||
opportunities?: string[];
|
||||
};
|
||||
uncertain: Record<string, any>;
|
||||
missing: string[];
|
||||
questionsForUser: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ExtractionResultsProps {
|
||||
projectId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExtractionResults({ projectId, className }: ExtractionResultsProps) {
|
||||
const [extraction, setExtraction] = useState<ExtractionHandoff | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchExtraction = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/extraction-handoff`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setExtraction(null);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Failed to fetch extraction: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setExtraction(data.handoff);
|
||||
} catch (err) {
|
||||
console.error("[ExtractionResults] Error:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load extraction");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
fetchExtraction();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={cn("border-destructive", className)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!extraction) {
|
||||
return (
|
||||
<Card className={cn("border-dashed", className)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Sparkles className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No extraction results yet</p>
|
||||
<p className="text-xs mt-1">Upload documents and trigger extraction to see insights</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { confirmed } = extraction;
|
||||
const confidencePercent = Math.round(extraction.confidence * 100);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Header with Confidence Score */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Extraction Results</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={extraction.readyForNextPhase ? "default" : "secondary"}>
|
||||
{extraction.readyForNextPhase ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Ready
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HelpCircle className="h-3 w-3 mr-1" />
|
||||
Incomplete
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={cn(
|
||||
confidencePercent >= 70 ? "border-green-500 text-green-600" :
|
||||
confidencePercent >= 40 ? "border-yellow-500 text-yellow-600" :
|
||||
"border-red-500 text-red-600"
|
||||
)}>
|
||||
{confidencePercent}% confidence
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Extracted on {new Date(extraction.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Problems / Pain Points */}
|
||||
{confirmed.problems && confirmed.problems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
Problems & Pain Points
|
||||
<Badge variant="secondary">{confirmed.problems.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{confirmed.problems.map((problem, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-orange-500 mt-1">•</span>
|
||||
<span className="text-sm">{problem}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Target Users */}
|
||||
{confirmed.targetUsers && confirmed.targetUsers.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
Target Users
|
||||
<Badge variant="secondary">{confirmed.targetUsers.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{confirmed.targetUsers.map((user, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">•</span>
|
||||
<span className="text-sm">{user}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
{confirmed.features && confirmed.features.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4 text-yellow-500" />
|
||||
Key Features
|
||||
<Badge variant="secondary">{confirmed.features.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{confirmed.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-yellow-500 mt-1">•</span>
|
||||
<span className="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Constraints */}
|
||||
{confirmed.constraints && confirmed.constraints.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4 text-purple-500" />
|
||||
Constraints & Requirements
|
||||
<Badge variant="secondary">{confirmed.constraints.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{confirmed.constraints.map((constraint, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-purple-500 mt-1">•</span>
|
||||
<span className="text-sm">{constraint}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Opportunities */}
|
||||
{confirmed.opportunities && confirmed.opportunities.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-green-500" />
|
||||
Opportunities
|
||||
<Badge variant="secondary">{confirmed.opportunities.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{confirmed.opportunities.map((opportunity, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-green-500 mt-1">•</span>
|
||||
<span className="text-sm">{opportunity}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Missing Information */}
|
||||
{extraction.missing && extraction.missing.length > 0 && (
|
||||
<Card className="border-yellow-200 bg-yellow-50/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2 text-yellow-800">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
Missing Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-1">
|
||||
{extraction.missing.map((item, idx) => (
|
||||
<li key={idx} className="text-sm text-yellow-800 flex items-center gap-2">
|
||||
<span>-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Questions for User */}
|
||||
{extraction.questionsForUser && extraction.questionsForUser.length > 0 && (
|
||||
<Card className="border-blue-200 bg-blue-50/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2 text-blue-800">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
Questions for Clarification
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{extraction.questionsForUser.map((question, idx) => (
|
||||
<li key={idx} className="text-sm text-blue-800 flex items-start gap-2">
|
||||
<span className="font-medium">{idx + 1}.</span>
|
||||
<span>{question}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, Circle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { db } from "@/lib/firebase/config";
|
||||
import { doc, onSnapshot } from "firebase/firestore";
|
||||
|
||||
interface ExtractionReviewChecklistProps {
|
||||
projectId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExtractionReviewChecklist({ projectId, className }: ExtractionReviewChecklistProps) {
|
||||
const [hasProblems, setHasProblems] = useState(false);
|
||||
const [hasUsers, setHasUsers] = useState(false);
|
||||
const [hasFeatures, setHasFeatures] = useState(false);
|
||||
const [reviewedWithAI, setReviewedWithAI] = useState(false);
|
||||
const [readyForVision, setReadyForVision] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
const unsubscribe = onSnapshot(
|
||||
doc(db, "projects", projectId),
|
||||
(snapshot) => {
|
||||
if (snapshot.exists()) {
|
||||
const data = snapshot.data();
|
||||
const extraction = data?.phaseData?.phaseHandoffs?.extraction;
|
||||
|
||||
if (extraction) {
|
||||
// Check if we have key data
|
||||
setHasProblems((extraction.confirmed?.problems?.length || 0) > 0);
|
||||
setHasUsers((extraction.confirmed?.targetUsers?.length || 0) > 0);
|
||||
setHasFeatures((extraction.confirmed?.features?.length || 0) > 0);
|
||||
setReadyForVision(extraction.readyForNextPhase || false);
|
||||
}
|
||||
|
||||
// Check if user has reviewed with AI (simplified: check if there are any messages in chat)
|
||||
// This is a placeholder - you might track this differently
|
||||
setReviewedWithAI(data?.lastChatAt ? true : false);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [projectId]);
|
||||
|
||||
const checklist = [
|
||||
{
|
||||
id: "problems",
|
||||
label: "Problems identified",
|
||||
checked: hasProblems,
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "Target users defined",
|
||||
checked: hasUsers,
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
label: "Key features extracted",
|
||||
checked: hasFeatures,
|
||||
},
|
||||
{
|
||||
id: "reviewed",
|
||||
label: "Reviewed with AI",
|
||||
checked: reviewedWithAI,
|
||||
},
|
||||
];
|
||||
|
||||
const completedCount = checklist.filter((item) => item.checked).length;
|
||||
const progress = (completedCount / checklist.length) * 100;
|
||||
const isCompleted = completedCount === checklist.length || readyForVision;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{isCompleted ? '✓ Review Complete' : 'Extraction Review'}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${isCompleted ? 100 : progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isCompleted ? `${checklist.length} of ${checklist.length} complete` : `${completedCount} of ${checklist.length} complete`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checklist Items */}
|
||||
<div className="space-y-2">
|
||||
{checklist.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center gap-2 py-2 px-3 rounded-md border ${
|
||||
(item.checked || isCompleted)
|
||||
? "bg-green-50 border-green-200"
|
||||
: "bg-muted/50 border-muted text-muted-foreground opacity-60"
|
||||
}`}
|
||||
>
|
||||
{(item.checked || isCompleted) ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium">{item.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ready indicator */}
|
||||
{readyForVision && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<p className="text-xs text-green-600 font-medium">
|
||||
✓ Ready for Vision phase
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Github, Loader2, CheckCircle2, ExternalLink, X } from 'lucide-react';
|
||||
import { auth } from '@/lib/firebase/config';
|
||||
import { doc, updateDoc, serverTimestamp } from 'firebase/firestore';
|
||||
import { db } from '@/lib/firebase/config';
|
||||
import { toast } from 'sonner';
|
||||
import { initiateGitHubOAuth } from '@/lib/github/oauth';
|
||||
|
||||
interface GitHubRepoPickerProps {
|
||||
projectId: string;
|
||||
onRepoSelected?: (repo: any) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function GitHubRepoPicker({ projectId, onRepoSelected, onClose }: GitHubRepoPickerProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [repos, setRepos] = useState<any[]>([]);
|
||||
const [selectedRepo, setSelectedRepo] = useState<any>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkConnection();
|
||||
}, []);
|
||||
|
||||
const checkConnection = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
const token = await user.getIdToken();
|
||||
|
||||
const statusResponse = await fetch('/api/github/connect', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (statusResponse.ok) {
|
||||
const statusData = await statusResponse.json();
|
||||
setConnected(statusData.connected);
|
||||
|
||||
if (statusData.connected) {
|
||||
const reposResponse = await fetch('/api/github/repos', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (reposResponse.ok) {
|
||||
const reposData = await reposResponse.json();
|
||||
setRepos(reposData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking GitHub connection:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
const redirectUri = `${window.location.origin}/api/github/oauth/callback`;
|
||||
initiateGitHubOAuth(redirectUri);
|
||||
};
|
||||
|
||||
const handleSelectRepo = async (repo: any) => {
|
||||
setSelectedRepo(repo);
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update project with GitHub info
|
||||
await updateDoc(doc(db, 'projects', projectId), {
|
||||
githubRepo: repo.full_name,
|
||||
githubRepoId: repo.id,
|
||||
githubRepoUrl: repo.html_url,
|
||||
githubDefaultBranch: repo.default_branch,
|
||||
hasGithub: true,
|
||||
updatedAt: serverTimestamp(),
|
||||
});
|
||||
|
||||
// Try to automatically associate existing sessions with this repo
|
||||
try {
|
||||
const token = await user.getIdToken();
|
||||
const associateResponse = await fetch(`/api/projects/${projectId}/associate-github-sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
githubRepo: repo.full_name,
|
||||
githubRepoUrl: repo.html_url,
|
||||
}),
|
||||
});
|
||||
|
||||
if (associateResponse.ok) {
|
||||
const data = await associateResponse.json();
|
||||
console.log('🔗 Session association result:', data);
|
||||
|
||||
if (data.sessionsAssociated > 0) {
|
||||
toast.success(`Connected to ${repo.full_name}!`, {
|
||||
description: `Found and linked ${data.sessionsAssociated} existing chat sessions from this repository`,
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
// No sessions found - show helpful message
|
||||
toast.success(`Connected to ${repo.full_name}!`, {
|
||||
description: `Repository linked! Future chat sessions from this repo will be automatically tracked here.`,
|
||||
duration: 5000,
|
||||
});
|
||||
console.log(`ℹ️ No matching sessions found. This could mean:
|
||||
- No chat sessions exist yet for this repo
|
||||
- Sessions are already linked to other projects
|
||||
- Workspace folder name doesn't match repo name (${repo.name})`);
|
||||
}
|
||||
} else {
|
||||
// Connection succeeded but session association failed - still show success
|
||||
toast.success(`Connected to ${repo.full_name}!`);
|
||||
console.warn('Session association failed but connection succeeded');
|
||||
}
|
||||
} catch (associateError) {
|
||||
// Don't fail the whole operation if association fails
|
||||
console.error('Error associating sessions:', associateError);
|
||||
toast.success(`Connected to ${repo.full_name}!`);
|
||||
}
|
||||
|
||||
// Notify parent component
|
||||
if (onRepoSelected) {
|
||||
onRepoSelected(repo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error connecting repo:', error);
|
||||
toast.error('Failed to connect repository');
|
||||
setSelectedRepo(null);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="my-2">
|
||||
<CardContent className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<Card className="my-2 border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
Connect GitHub
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your GitHub account to select a repository
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={handleConnect} className="w-full">
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
Connect GitHub Account
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedRepo) {
|
||||
return (
|
||||
<Card className="my-2 border-green-500/50 bg-green-50/50 dark:bg-green-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{selectedRepo.full_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Repository connected!</p>
|
||||
</div>
|
||||
<a
|
||||
href={selectedRepo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View
|
||||
</a>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 ml-2"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="my-2">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
Select Repository
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose which repository to connect to this project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{repos.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No repositories found
|
||||
</p>
|
||||
) : (
|
||||
repos.map((repo) => (
|
||||
<button
|
||||
key={repo.id}
|
||||
onClick={() => handleSelectRepo(repo)}
|
||||
disabled={saving}
|
||||
className="w-full text-left p-3 rounded-lg border-2 border-border hover:border-primary transition-all disabled:opacity-50"
|
||||
>
|
||||
<div className="font-medium">{repo.full_name}</div>
|
||||
{repo.description && (
|
||||
<div className="text-sm text-muted-foreground truncate mt-1">
|
||||
{repo.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{repo.language && (
|
||||
<span className="text-xs bg-muted px-2 py-0.5 rounded">
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
{repo.private && (
|
||||
<span className="text-xs bg-yellow-500/10 text-yellow-600 px-2 py-0.5 rounded">
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { db } from "@/lib/firebase/config";
|
||||
import { doc, onSnapshot } from "firebase/firestore";
|
||||
import { CollectorChecklist } from "./collector-checklist";
|
||||
import { ExtractionReviewChecklist } from "./extraction-review-checklist";
|
||||
import { ExtractionResults } from "./extraction-results";
|
||||
import { CollectorActions } from "./collector-actions";
|
||||
import { ProjectConfigGenerator } from "./project-config-generator";
|
||||
|
||||
interface PhaseSidebarProps {
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PhaseSidebar({ projectId, projectName, className }: PhaseSidebarProps) {
|
||||
const [currentPhase, setCurrentPhase] = useState<string>("collector");
|
||||
const [projectNameState, setProjectNameState] = useState<string>(projectName || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
const unsubscribe = onSnapshot(
|
||||
doc(db, "projects", projectId),
|
||||
(snapshot) => {
|
||||
if (snapshot.exists()) {
|
||||
const data = snapshot.data();
|
||||
const phase = data?.currentPhase || "collector";
|
||||
setCurrentPhase(phase);
|
||||
setProjectNameState(data?.name || data?.productName || "");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [projectId]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Top: Show checklist based on current phase */}
|
||||
{currentPhase === "collector" && (
|
||||
<CollectorChecklist projectId={projectId} />
|
||||
)}
|
||||
|
||||
{(currentPhase === "extraction_review" || currentPhase === "analyzed") && (
|
||||
<ExtractionReviewChecklist projectId={projectId} />
|
||||
)}
|
||||
|
||||
{/* Bottom: Phase-specific content */}
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
{currentPhase === "collector" && (
|
||||
<>
|
||||
<h3 className="text-sm font-semibold mb-3">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<ProjectConfigGenerator projectId={projectId} projectName={projectNameState} />
|
||||
<CollectorActions projectId={projectId} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(currentPhase === "extraction_review" || currentPhase === "analyzed") && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review the extracted insights in the chat area above.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{currentPhase === "vision" && (
|
||||
<>
|
||||
<h3 className="text-sm font-semibold mb-3">Vision Phase</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defining your product vision and MVP...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === "mvp" && (
|
||||
<>
|
||||
<h3 className="text-sm font-semibold mb-3">MVP Planning</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Planning your minimum viable product...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Download, Search, CheckCircle2, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ProjectConfigGeneratorProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export function ProjectConfigGenerator({ projectId, projectName }: ProjectConfigGeneratorProps) {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [fileFound, setFileFound] = useState<boolean | null>(null);
|
||||
|
||||
const vibnConfig = {
|
||||
projectId,
|
||||
projectName,
|
||||
version: "1.0",
|
||||
};
|
||||
|
||||
const configContent = JSON.stringify(vibnConfig, null, 2);
|
||||
|
||||
const handleSearchFile = async () => {
|
||||
setSearching(true);
|
||||
setFileFound(null);
|
||||
|
||||
try {
|
||||
// Prompt user to select the .vibn file
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.vibn,.json';
|
||||
|
||||
input.onchange = async (e: any) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (!file) {
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const config = JSON.parse(text);
|
||||
|
||||
if (config.projectId === projectId) {
|
||||
setFileFound(true);
|
||||
toast.success("File verified!", {
|
||||
description: "Your .vibn file is correctly configured for this project",
|
||||
});
|
||||
} else {
|
||||
setFileFound(false);
|
||||
toast.error("Project ID mismatch", {
|
||||
description: "This .vibn file is for a different project",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setFileFound(false);
|
||||
toast.error("Invalid file", {
|
||||
description: "Could not read or parse the .vibn file",
|
||||
});
|
||||
}
|
||||
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
input.click();
|
||||
} catch (error) {
|
||||
toast.error("Failed to search for file");
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([configContent], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = ".vibn";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Configuration downloaded!", {
|
||||
description: "Save it to your project root and restart Cursor",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start h-auto py-2 px-3"
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span className="text-xs">Link Workspace</span>
|
||||
</Button>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Link Your Workspace
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a <code className="text-xs bg-muted px-1 py-0.5 rounded">.vibn</code> file to your project root so the Cursor extension automatically tracks sessions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Primary Action */}
|
||||
<Button onClick={handleDownload} className="w-full" size="lg">
|
||||
<Download className="h-5 w-5 mr-2" />
|
||||
Download .vibn
|
||||
</Button>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="text-sm space-y-3 bg-muted/50 p-3 rounded-md">
|
||||
<p className="font-medium">Setup Steps:</p>
|
||||
<ol className="text-xs text-muted-foreground space-y-1 list-decimal ml-4">
|
||||
<li>Download the file above</li>
|
||||
<li>Save it to your project root as <code className="bg-background px-1 rounded">.vibn</code></li>
|
||||
<li>Commit and push to GitHub</li>
|
||||
<li>Extension will auto-link sessions to this project</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Verification */}
|
||||
<div className="pt-2 space-y-2">
|
||||
<Button
|
||||
onClick={handleSearchFile}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={searching}
|
||||
>
|
||||
{searching ? (
|
||||
<>
|
||||
<Search className="h-4 w-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : fileFound === true ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2 text-green-600" />
|
||||
✓ File Found
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Verify File Added
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{fileFound === false && (
|
||||
<p className="text-xs text-red-600 text-center">File not found or incorrect projectId</p>
|
||||
)}
|
||||
{fileFound === true && (
|
||||
<p className="text-xs text-green-600 text-center">Ready! Push to GitHub and you're all set.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2, CheckCircle2, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface VisionFormProps {
|
||||
projectId: string;
|
||||
workspace?: string;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function VisionForm({ projectId, workspace, onComplete }: VisionFormProps) {
|
||||
const [currentQuestion, setCurrentQuestion] = useState(1);
|
||||
const [answers, setAnswers] = useState({
|
||||
q1: '',
|
||||
q2: '',
|
||||
q3: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const questions = [
|
||||
{
|
||||
number: 1,
|
||||
key: 'q1' as const,
|
||||
question: 'Who has the problem you want to fix and what is it?',
|
||||
placeholder: 'E.g., Fantasy hockey GMs need an advantage over their competitors...',
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
key: 'q2' as const,
|
||||
question: 'Tell me a story of this person using your tool and experiencing your vision?',
|
||||
placeholder: 'E.g., The user connects their hockey pool site...',
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
key: 'q3' as const,
|
||||
question: 'How much did that improve things for them?',
|
||||
placeholder: 'E.g., They feel relieved and excited...',
|
||||
},
|
||||
];
|
||||
|
||||
const currentQ = questions[currentQuestion - 1];
|
||||
|
||||
const handleNext = () => {
|
||||
if (!answers[currentQ.key].trim()) {
|
||||
toast.error('Please answer the question before continuing');
|
||||
return;
|
||||
}
|
||||
setCurrentQuestion(currentQuestion + 1);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentQuestion(currentQuestion - 1);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!answers.q3.trim()) {
|
||||
toast.error('Please answer the question');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Save vision answers to Firestore
|
||||
const response = await fetch(`/api/projects/${projectId}/vision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
visionAnswers: {
|
||||
q1: answers.q1,
|
||||
q2: answers.q2,
|
||||
q3: answers.q3,
|
||||
allAnswered: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save vision answers');
|
||||
}
|
||||
|
||||
toast.success('Vision captured! Generating your MVP plan...');
|
||||
setSaving(false);
|
||||
setGenerating(true);
|
||||
|
||||
// Trigger MVP generation
|
||||
const mvpResponse = await fetch(`/api/projects/${projectId}/mvp-checklist`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!mvpResponse.ok) {
|
||||
throw new Error('Failed to generate MVP plan');
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
toast.success('MVP plan generated! Redirecting to plan page...');
|
||||
|
||||
// Redirect to plan page
|
||||
if (workspace) {
|
||||
setTimeout(() => {
|
||||
window.location.href = `/${workspace}/project/${projectId}/plan`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
onComplete?.();
|
||||
} catch (error) {
|
||||
console.error('Error saving vision:', error);
|
||||
toast.error('Failed to save vision and generate plan');
|
||||
setSaving(false);
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (generating) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[400px] space-y-4">
|
||||
<Sparkles className="h-12 w-12 text-primary animate-pulse" />
|
||||
<h3 className="text-xl font-semibold">Generating your MVP plan...</h3>
|
||||
<p className="text-muted-foreground">This may take up to a minute</p>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 space-y-6">
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center justify-center space-x-4 mb-8">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.number} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full border-2 font-semibold text-sm transition-all',
|
||||
currentQuestion > q.number
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: currentQuestion === q.number
|
||||
? 'border-primary text-primary'
|
||||
: 'border-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{currentQuestion > q.number ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
q.number
|
||||
)}
|
||||
</div>
|
||||
{idx < questions.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-12 h-0.5 mx-2 transition-all',
|
||||
currentQuestion > q.number ? 'bg-primary' : 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Current question card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">
|
||||
Question {currentQ.number} of 3
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-lg font-medium leading-relaxed">
|
||||
{currentQ.question}
|
||||
</label>
|
||||
<Textarea
|
||||
value={answers[currentQ.key]}
|
||||
onChange={(e) =>
|
||||
setAnswers({ ...answers, [currentQ.key]: e.target.value })
|
||||
}
|
||||
placeholder={currentQ.placeholder}
|
||||
className="min-h-[120px] text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={currentQuestion === 1}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{currentQuestion < 3 ? (
|
||||
<Button onClick={handleNext}>
|
||||
Next Question →
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Generate MVP Plan
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Show all answers summary */}
|
||||
{currentQuestion > 1 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Your Vision So Far
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{questions.slice(0, currentQuestion - 1).map((q) => (
|
||||
<div key={q.number} className="text-sm">
|
||||
<p className="font-medium text-muted-foreground mb-1">
|
||||
Q{q.number}: {q.question}
|
||||
</p>
|
||||
<p className="text-foreground">{answers[q.key]}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const MarkdownText: FC = () => {
|
||||
return (
|
||||
<MarkdownTextPrimitive
|
||||
remarkPlugins={[remarkGfm]}
|
||||
className="prose prose-sm dark:prose-invert max-w-none leading-relaxed"
|
||||
components={{
|
||||
h1: ({ className, ...props }) => (
|
||||
<h1 className={cn("text-base font-bold mt-3 mb-1", className)} {...props} />
|
||||
),
|
||||
h2: ({ className, ...props }) => (
|
||||
<h2 className={cn("text-sm font-bold mt-3 mb-1", className)} {...props} />
|
||||
),
|
||||
h3: ({ className, ...props }) => (
|
||||
<h3 className={cn("text-sm font-semibold mt-2 mb-1", className)} {...props} />
|
||||
),
|
||||
p: ({ className, ...props }) => (
|
||||
<p className={cn("mb-2 last:mb-0", className)} {...props} />
|
||||
),
|
||||
ul: ({ className, ...props }) => (
|
||||
<ul className={cn("list-disc list-outside ml-4 mb-2 space-y-0.5", className)} {...props} />
|
||||
),
|
||||
ol: ({ className, ...props }) => (
|
||||
<ol className={cn("list-decimal list-outside ml-4 mb-2 space-y-0.5", className)} {...props} />
|
||||
),
|
||||
li: ({ className, ...props }) => (
|
||||
<li className={cn("leading-relaxed", className)} {...props} />
|
||||
),
|
||||
strong: ({ className, ...props }) => (
|
||||
<strong className={cn("font-semibold", className)} {...props} />
|
||||
),
|
||||
code: ({ className, ...props }) => (
|
||||
<code className={cn("bg-muted px-1 py-0.5 rounded text-xs font-mono", className)} {...props} />
|
||||
),
|
||||
pre: ({ className, ...props }) => (
|
||||
<pre className={cn("bg-muted rounded-lg p-3 overflow-x-auto text-xs my-2", className)} {...props} />
|
||||
),
|
||||
blockquote: ({ className, ...props }) => (
|
||||
<blockquote className={cn("border-l-2 border-muted-foreground/30 pl-3 italic text-muted-foreground my-2", className)} {...props} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,312 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActionBarPrimitive,
|
||||
BranchPickerPrimitive,
|
||||
ComposerPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
RefreshCwIcon,
|
||||
SquareIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { MarkdownText } from "./markdown-text";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread root — Stackless style: flat on beige, no card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Thread: FC<{ userInitial?: string }> = ({ userInitial = "Y" }) => (
|
||||
<ThreadPrimitive.Root
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
background: "#f6f4f0",
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* Empty state */}
|
||||
<ThreadPrimitive.Empty>
|
||||
<div style={{
|
||||
display: "flex", height: "100%",
|
||||
flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||||
gap: 12, padding: "40px 32px",
|
||||
}}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 11, background: "#1a1a1a",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 500,
|
||||
color: "#fff",
|
||||
}}>
|
||||
A
|
||||
</div>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Atlas</p>
|
||||
<p style={{ fontSize: "0.78rem", color: "#a09a90", maxWidth: 260, lineHeight: 1.5 }}>
|
||||
Your product strategist. Let's define what you're building.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ width: "100%", maxWidth: 600 }}>
|
||||
<Composer userInitial={userInitial} />
|
||||
</div>
|
||||
</div>
|
||||
</ThreadPrimitive.Empty>
|
||||
|
||||
{/* Messages */}
|
||||
<ThreadPrimitive.Viewport style={{ flex: 1, overflowY: "auto", padding: "28px 32px" }}>
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
UserMessage: (props) => <UserMessage {...props} userInitial={userInitial} />,
|
||||
AssistantMessage,
|
||||
}}
|
||||
/>
|
||||
</ThreadPrimitive.Viewport>
|
||||
|
||||
{/* Input bar */}
|
||||
<div style={{ padding: "14px 32px 22px", flexShrink: 0 }}>
|
||||
<Composer userInitial={userInitial} />
|
||||
</div>
|
||||
</ThreadPrimitive.Root>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composer — Stackless white pill input bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Composer: FC<{ userInitial?: string }> = () => (
|
||||
<ComposerPrimitive.Root style={{ width: "100%" }}>
|
||||
<div style={{
|
||||
display: "flex", gap: 8,
|
||||
padding: "5px 5px 5px 16px",
|
||||
background: "#fff",
|
||||
border: "1px solid #e0dcd4",
|
||||
borderRadius: 10,
|
||||
alignItems: "center",
|
||||
boxShadow: "0 1px 4px #1a1a1a06",
|
||||
}}>
|
||||
<ComposerPrimitive.Input
|
||||
placeholder="Describe your thinking..."
|
||||
rows={1}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
background: "none",
|
||||
fontSize: "0.86rem",
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
color: "#1a1a1a",
|
||||
padding: "8px 0",
|
||||
resize: "none",
|
||||
outline: "none",
|
||||
minHeight: 24,
|
||||
maxHeight: 120,
|
||||
}}
|
||||
/>
|
||||
<ThreadPrimitive.If running={false}>
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<button
|
||||
style={{
|
||||
padding: "9px 16px",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
background: "#1a1a1a",
|
||||
color: "#fff",
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: 600,
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
cursor: "pointer",
|
||||
transition: "opacity 0.15s",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = "0.8")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = "1")}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</ComposerPrimitive.Send>
|
||||
</ThreadPrimitive.If>
|
||||
<ThreadPrimitive.If running>
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<button
|
||||
style={{
|
||||
padding: "9px 16px",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
background: "#eae6de",
|
||||
color: "#8a8478",
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: 600,
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<SquareIcon style={{ width: 10, height: 10 }} />
|
||||
Stop
|
||||
</button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
</ThreadPrimitive.If>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant message — black avatar, "Atlas" label, plain text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AssistantMessage: FC = () => (
|
||||
<MessagePrimitive.Root style={{ display: "flex", gap: 12, marginBottom: 22 }} className="group">
|
||||
{/* Avatar */}
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2,
|
||||
background: "#1a1a1a",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.68rem", fontWeight: 700, color: "#fff",
|
||||
fontFamily: "Newsreader, serif",
|
||||
}}>
|
||||
A
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Sender label */}
|
||||
<div style={{
|
||||
fontSize: "0.68rem", fontWeight: 600, color: "#a09a90",
|
||||
marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.04em",
|
||||
}}>
|
||||
Atlas
|
||||
</div>
|
||||
{/* Message content */}
|
||||
<div style={{ fontSize: "0.88rem", color: "#2a2824", lineHeight: 1.72 }}>
|
||||
<MessagePrimitive.Content components={{ Text: AssistantText }} />
|
||||
</div>
|
||||
<AssistantActionBar />
|
||||
<BranchPicker />
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
|
||||
const AssistantText: FC = () => <MarkdownText />;
|
||||
|
||||
const AssistantActionBar: FC = () => (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
className="group-hover:opacity-100"
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, opacity: 0, transition: "opacity 0.15s" }}
|
||||
>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<button style={{
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
fontSize: "0.68rem", color: "#b5b0a6",
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
fontFamily: "Outfit, sans-serif", padding: "2px 6px", borderRadius: 4,
|
||||
}}>
|
||||
<MessagePrimitive.If copied>
|
||||
<CheckIcon style={{ width: 10, height: 10 }} /> Copied
|
||||
</MessagePrimitive.If>
|
||||
<MessagePrimitive.If copied={false}>
|
||||
<CopyIcon style={{ width: 10, height: 10 }} /> Copy
|
||||
</MessagePrimitive.If>
|
||||
</button>
|
||||
</ActionBarPrimitive.Copy>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<button style={{
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
fontSize: "0.68rem", color: "#b5b0a6",
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
fontFamily: "Outfit, sans-serif", padding: "2px 6px", borderRadius: 4,
|
||||
}}>
|
||||
<RefreshCwIcon style={{ width: 10, height: 10 }} /> Retry
|
||||
</button>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User message — warm avatar, "You" label, same layout as Atlas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UserMessage: FC<{ userInitial?: string }> = ({ userInitial = "Y" }) => (
|
||||
<MessagePrimitive.Root style={{ display: "flex", gap: 12, marginBottom: 22 }} className="group">
|
||||
{/* Avatar */}
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2,
|
||||
background: "#e8e4dc",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.68rem", fontWeight: 700, color: "#8a8478",
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
}}>
|
||||
{userInitial}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Sender label */}
|
||||
<div style={{
|
||||
fontSize: "0.68rem", fontWeight: 600, color: "#a09a90",
|
||||
marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.04em",
|
||||
}}>
|
||||
You
|
||||
</div>
|
||||
{/* Message content */}
|
||||
<div style={{ fontSize: "0.88rem", color: "#2a2824", lineHeight: 1.72, whiteSpace: "pre-wrap" }}>
|
||||
<MessagePrimitive.Content components={{ Text: UserText }} />
|
||||
</div>
|
||||
<UserActionBar />
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
|
||||
const UserText: FC<{ text: string }> = ({ text }) => <span>{text}</span>;
|
||||
|
||||
const UserActionBar: FC = () => (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
className="group-hover:opacity-100"
|
||||
style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 4, opacity: 0, transition: "opacity 0.15s" }}
|
||||
>
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button style={{
|
||||
fontSize: "0.68rem", color: "#b5b0a6",
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
fontFamily: "Outfit, sans-serif", padding: "2px 6px", borderRadius: 4,
|
||||
}}>
|
||||
Edit
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branch picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BranchPicker: FC = () => (
|
||||
<BranchPickerPrimitive.Root
|
||||
hideWhenSingleBranch
|
||||
className="group-hover:opacity-100"
|
||||
style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 4, opacity: 0, transition: "opacity 0.15s" }}
|
||||
>
|
||||
<BranchPickerPrimitive.Previous asChild>
|
||||
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#b5b0a6" }}>
|
||||
<ChevronLeftIcon style={{ width: 12, height: 12 }} />
|
||||
</button>
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span style={{ fontSize: "0.68rem", color: "#b5b0a6" }}>
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next asChild>
|
||||
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#b5b0a6" }}>
|
||||
<ChevronRightIcon style={{ width: 12, height: 12 }} />
|
||||
</button>
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
);
|
||||
@@ -1,643 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { auth } from '@/lib/firebase/config';
|
||||
import { toast } from 'sonner';
|
||||
import { Download, ExternalLink, Eye, EyeOff, MessageSquare, FolderKanban, Bot, Upload } from 'lucide-react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { OpenAIIcon } from '@/components/icons/custom-icons';
|
||||
|
||||
interface ChatGPTImportCardProps {
|
||||
projectId?: string;
|
||||
onImportComplete?: (importData: any) => void;
|
||||
}
|
||||
|
||||
export function ChatGPTImportCard({ projectId, onImportComplete }: ChatGPTImportCardProps) {
|
||||
const [conversationUrl, setConversationUrl] = useState('');
|
||||
const [openaiApiKey, setOpenaiApiKey] = useState('');
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importedData, setImportedData] = useState<any>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [hasStoredKey, setHasStoredKey] = useState(false);
|
||||
const [checkingKey, setCheckingKey] = useState(true);
|
||||
const [importType, setImportType] = useState<'conversation' | 'project' | 'gpt' | 'file'>('file');
|
||||
|
||||
// Check for stored OpenAI key on mount
|
||||
useEffect(() => {
|
||||
const checkStoredKey = async () => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
setCheckingKey(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const response = await fetch('/api/keys', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const hasOpenAI = data.keys.some((k: any) => k.service === 'openai');
|
||||
setHasStoredKey(hasOpenAI);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for stored key:', error);
|
||||
} finally {
|
||||
setCheckingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkStoredKey();
|
||||
}, []);
|
||||
|
||||
const extractConversationId = (urlOrId: string): { id: string | null; isShareLink: boolean } => {
|
||||
// If it's already just an ID
|
||||
if (!urlOrId.includes('/') && !urlOrId.includes('http')) {
|
||||
const trimmed = urlOrId.trim();
|
||||
// Check if it's a share link ID (UUID format)
|
||||
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed);
|
||||
return { id: trimmed, isShareLink: isUUID };
|
||||
}
|
||||
|
||||
// Extract from URL patterns:
|
||||
// https://chat.openai.com/c/{id} - regular conversation (old)
|
||||
// https://chatgpt.com/c/{id} - regular conversation (new)
|
||||
// https://chat.openai.com/share/{id} - shared conversation (not supported)
|
||||
// https://chatgpt.com/share/{id} - shared conversation (not supported)
|
||||
|
||||
// Check for share links first
|
||||
const sharePatterns = [
|
||||
/chat\.openai\.com\/share\/([a-zA-Z0-9-]+)/,
|
||||
/chatgpt\.com\/share\/([a-zA-Z0-9-]+)/,
|
||||
];
|
||||
|
||||
for (const pattern of sharePatterns) {
|
||||
const match = urlOrId.match(pattern);
|
||||
if (match) {
|
||||
return { id: match[1], isShareLink: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Regular conversation patterns
|
||||
const conversationPatterns = [
|
||||
/chat\.openai\.com\/c\/([a-zA-Z0-9-]+)/,
|
||||
/chatgpt\.com\/c\/([a-zA-Z0-9-]+)/,
|
||||
// GPT project conversations: https://chatgpt.com/g/g-p-[id]-[name]/c/[conversation-id]
|
||||
/chatgpt\.com\/g\/g-p-[a-zA-Z0-9-]+\/c\/([a-zA-Z0-9-]+)/,
|
||||
];
|
||||
|
||||
for (const pattern of conversationPatterns) {
|
||||
const match = urlOrId.match(pattern);
|
||||
if (match) {
|
||||
return { id: match[1], isShareLink: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { id: null, isShareLink: false };
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!conversationUrl.trim()) {
|
||||
toast.error('Please enter a conversation ID or project ID');
|
||||
return;
|
||||
}
|
||||
|
||||
// If no stored key and no manual key provided, show error
|
||||
if (!hasStoredKey && !openaiApiKey) {
|
||||
toast.error('Please enter your OpenAI API key or add one in Keys page');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in to import');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
|
||||
if (importType === 'file') {
|
||||
// Import from uploaded conversations.json file
|
||||
try {
|
||||
const conversations = JSON.parse(conversationUrl);
|
||||
|
||||
const response = await fetch('/api/chatgpt/import-file', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversations,
|
||||
projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.details || error.error || 'Import failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
toast.success(`✅ Imported ${data.imported} conversations!`);
|
||||
setImportedData({
|
||||
messageCount: data.imported,
|
||||
title: `${data.imported} conversations`
|
||||
});
|
||||
|
||||
if (onImportComplete) {
|
||||
onImportComplete(data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(`File import failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setConversationUrl('');
|
||||
setDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which key to use
|
||||
let keyToUse = openaiApiKey;
|
||||
|
||||
// If no manual key provided, try to get stored key
|
||||
if (!keyToUse && hasStoredKey) {
|
||||
const keyResponse = await fetch('/api/keys/get', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ service: 'openai' }),
|
||||
});
|
||||
|
||||
if (keyResponse.ok) {
|
||||
const keyData = await keyResponse.json();
|
||||
keyToUse = keyData.keyValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (importType === 'project') {
|
||||
// Import OpenAI Project
|
||||
const response = await fetch('/api/openai/projects', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
openaiApiKey: keyToUse,
|
||||
projectId: conversationUrl.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.details || error.error || 'Failed to fetch project');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
toast.success(`Retrieved OpenAI Project: ${data.data.name || data.data.id}`);
|
||||
setImportedData(data.data);
|
||||
|
||||
if (onImportComplete) {
|
||||
onImportComplete(data.data);
|
||||
}
|
||||
} else {
|
||||
// Import ChatGPT Conversation
|
||||
const { id: conversationId, isShareLink } = extractConversationId(conversationUrl);
|
||||
|
||||
if (!conversationId) {
|
||||
toast.error('Invalid ChatGPT URL or conversation ID');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a share link
|
||||
if (isShareLink) {
|
||||
toast.error('Share links are not supported. Please use the direct conversation URL from your ChatGPT chat history.', {
|
||||
description: 'Look for URLs like: chatgpt.com/c/... or chat.openai.com/c/...',
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/chatgpt/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId,
|
||||
openaiApiKey: keyToUse,
|
||||
projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.details || error.error || 'Import failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setImportedData(data);
|
||||
toast.success(`Imported: ${data.title} (${data.messageCount} messages)`);
|
||||
|
||||
if (onImportComplete) {
|
||||
onImportComplete(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setConversationUrl('');
|
||||
setDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to import');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Import from OpenAI</CardTitle>
|
||||
<CardDescription>
|
||||
Import ChatGPT conversations or OpenAI Projects
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Import from OpenAI
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import from OpenAI</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import ChatGPT conversations or OpenAI Projects
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={importType} onValueChange={(v) => setImportType(v as 'conversation' | 'project' | 'gpt' | 'file')} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="file" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload File
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="conversation" className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Single Chat
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="file" className="space-y-4 mt-4">
|
||||
{/* File Upload */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conversations-file">conversations.json File</Label>
|
||||
<Input
|
||||
id="conversations-file"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const conversations = JSON.parse(event.target?.result as string);
|
||||
setConversationUrl(JSON.stringify(conversations)); // Store in existing state
|
||||
toast.success(`Loaded ${conversations.length} conversations`);
|
||||
} catch (error) {
|
||||
toast.error('Invalid JSON file');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload your ChatGPT exported conversations.json file
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<p className="text-sm font-medium mb-2">How to export your ChatGPT data:</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
<li>Go to <a href="https://chatgpt.com/settings/data-controls" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ChatGPT Settings → Data Controls</a></li>
|
||||
<li>Click "Export data"</li>
|
||||
<li>Wait for the email from OpenAI (can take up to 24 hours)</li>
|
||||
<li>Download and extract the ZIP file</li>
|
||||
<li>Upload the <code className="text-xs">conversations.json</code> file here</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4">
|
||||
<p className="text-sm font-medium text-blue-700 dark:text-blue-400 mb-2">
|
||||
💡 Privacy-friendly import
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your conversations are processed locally and only stored in your Vibn account.
|
||||
We never send your data to third parties.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="conversation" className="space-y-4 mt-4">
|
||||
{/* Show stored key status */}
|
||||
{hasStoredKey && (
|
||||
<div className="rounded-lg border border-green-500/20 bg-green-500/5 p-3">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
✓ Using your stored OpenAI API key
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenAI API Key (optional if stored) */}
|
||||
{!hasStoredKey && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openai-key">OpenAI API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="openai-key"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="sk-..."
|
||||
value={openaiApiKey}
|
||||
onChange={(e) => setOpenaiApiKey(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your API key <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<a
|
||||
href="/keys"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Or save it in Keys page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversation URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conversation-url">ChatGPT Conversation URL or ID</Label>
|
||||
<Input
|
||||
id="conversation-url"
|
||||
placeholder="https://chatgpt.com/c/abc-123 or just abc-123"
|
||||
value={conversationUrl}
|
||||
onChange={(e) => setConversationUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy the URL from your ChatGPT conversation or paste the conversation ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<p className="text-sm font-medium mb-2">How to find your conversation URL:</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
<li>Open the ChatGPT conversation you want to import</li>
|
||||
<li>Look at the URL in your browser (must show <code className="text-xs">/c/</code>)</li>
|
||||
<li>Copy the full URL: <code className="text-xs">chatgpt.com/c/...</code></li>
|
||||
<li><strong>Note:</strong> Share links (<code className="text-xs">/share/</code>) won't work - you need the direct conversation URL</li>
|
||||
</ol>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gpt" className="space-y-4 mt-4">
|
||||
{/* GPT URL Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gpt-url">ChatGPT GPT URL</Label>
|
||||
<Input
|
||||
id="gpt-url"
|
||||
placeholder="https://chatgpt.com/g/g-p-abc123-your-gpt-name/project"
|
||||
value={conversationUrl}
|
||||
onChange={(e) => setConversationUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste the full URL of your custom GPT
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<p className="text-sm font-medium mb-2">How to find your GPT URL:</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
<li>Open your custom GPT in ChatGPT</li>
|
||||
<li>Copy the full URL from your browser</li>
|
||||
<li>Paste it here</li>
|
||||
<li>To import conversations with this GPT, switch to the "Chat" tab</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4">
|
||||
<p className="text-sm font-medium text-blue-700 dark:text-blue-400 mb-2">
|
||||
💡 About GPT imports
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This saves a reference to your custom GPT. To import actual conversations
|
||||
with this GPT, go to a specific chat and use the "Chat" tab to import
|
||||
that conversation.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="project" className="space-y-4 mt-4">
|
||||
{/* Show stored key status */}
|
||||
{hasStoredKey && (
|
||||
<div className="rounded-lg border border-green-500/20 bg-green-500/5 p-3">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
✓ Using your stored OpenAI API key
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenAI API Key (optional if stored) */}
|
||||
{!hasStoredKey && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openai-key-project">OpenAI API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="openai-key-project"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="sk-..."
|
||||
value={openaiApiKey}
|
||||
onChange={(e) => setOpenaiApiKey(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your API key <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<a
|
||||
href="/keys"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Or save it in Keys page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project ID */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-id">OpenAI Project ID</Label>
|
||||
<Input
|
||||
id="project-id"
|
||||
placeholder="proj_abc123..."
|
||||
value={conversationUrl}
|
||||
onChange={(e) => setConversationUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter your OpenAI Project ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<p className="text-sm font-medium mb-2">How to find your Project ID:</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
<li>Go to <a href="https://platform.openai.com/settings/organization/projects" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">OpenAI Projects</a></li>
|
||||
<li>Click on the project you want to import</li>
|
||||
<li>Copy the Project ID (starts with <code className="text-xs">proj_</code>)</li>
|
||||
<li>Or use the API to list all projects</li>
|
||||
</ol>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={loading || !conversationUrl || (importType !== 'file' && !hasStoredKey && !openaiApiKey)}
|
||||
>
|
||||
{loading
|
||||
? (importType === 'file' ? 'Importing...' : importType === 'project' ? 'Fetching Project...' : 'Importing Conversation...')
|
||||
: (importType === 'file' ? 'Import Conversations' : importType === 'project' ? 'Fetch Project' : 'Import Conversation')
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">What you can import:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li><strong>Conversations:</strong> Full ChatGPT chat history with planning & brainstorming</li>
|
||||
<li><strong>Projects:</strong> OpenAI Platform projects with API keys, usage, and settings</li>
|
||||
<li>Requirements, specifications, and design decisions</li>
|
||||
<li>Architecture notes and technical discussions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{importedData && (
|
||||
<div className="rounded-lg border border-green-500/20 bg-green-500/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{importedData.messageCount ? (
|
||||
<MessageSquare className="h-5 w-5 text-green-600 mt-0.5" />
|
||||
) : (
|
||||
<FolderKanban className="h-5 w-5 text-green-600 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||
Recently imported: {importedData.title || importedData.name || importedData.id}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{importedData.messageCount
|
||||
? `${importedData.messageCount} messages • Conversation ID: ${importedData.conversationId}`
|
||||
: `Project ID: ${importedData.id}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4">
|
||||
<p className="text-sm font-medium text-blue-700 dark:text-blue-400 mb-2">
|
||||
💡 Why import from OpenAI?
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect your planning discussions from ChatGPT and your OpenAI Platform projects
|
||||
with your actual coding sessions in Vibn. Our AI can reference everything to provide
|
||||
better context and insights.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import { Link2, CheckCircle2, Copy } from "lucide-react";
|
||||
|
||||
interface ProjectLinkerProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export function ProjectLinker({ projectId, projectName }: ProjectLinkerProps) {
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
const [isLinking, setIsLinking] = useState(false);
|
||||
const [isLinked, setIsLinked] = useState(false);
|
||||
|
||||
const handleLink = async () => {
|
||||
if (!workspacePath.trim()) {
|
||||
toast.error("Please enter a workspace path");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLinking(true);
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error("Not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
const idToken = await user.getIdToken();
|
||||
|
||||
const response = await fetch("/api/extension/link-project", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${idToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
workspacePath: workspacePath.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to link extension");
|
||||
}
|
||||
|
||||
setIsLinked(true);
|
||||
toast.success("Extension linked successfully!");
|
||||
} catch (error) {
|
||||
console.error("Failed to link extension:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to link extension");
|
||||
} finally {
|
||||
setIsLinking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyProjectId = () => {
|
||||
navigator.clipboard.writeText(projectId);
|
||||
toast.success("Project ID copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link2 className="h-5 w-5" />
|
||||
Link Browser Extension
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your Cursor Monitor extension to this project so AI chats are automatically
|
||||
captured.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLinked ? (
|
||||
<div className="flex items-center gap-2 text-green-600 bg-green-50 p-4 rounded-lg">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span className="font-medium">Extension linked to {projectName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="projectId">Project ID</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="projectId"
|
||||
value={projectId}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyProjectId}
|
||||
title="Copy project ID"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy this ID and add it to your Cursor Monitor extension settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspacePath">Workspace Path</Label>
|
||||
<Input
|
||||
id="workspacePath"
|
||||
placeholder="/Users/yourname/projects/my-app"
|
||||
value={workspacePath}
|
||||
onChange={(e) => setWorkspacePath(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the full path to your Cursor workspace directory.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleLink} disabled={isLinking} className="w-full">
|
||||
{isLinking ? "Linking..." : "Link Extension"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
|
||||
<p className="font-medium">How it works:</p>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>Copy the Project ID above</li>
|
||||
<li>Open Cursor Monitor extension settings</li>
|
||||
<li>Paste the Project ID in the "Vibn Project ID" field</li>
|
||||
<li>Enter your workspace path here and click "Link Extension"</li>
|
||||
<li>All AI chats from Cursor will now be captured in this project</li>
|
||||
</ol>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CursorIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function V0Icon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>v0</title>
|
||||
<path d="M14.066 6.028v2.22h5.729q.075-.001.148.005l-5.853 5.752a2 2 0 0 1-.024-.309V8.247h-2.353v5.45c0 2.322 1.935 4.222 4.258 4.222h5.675v-2.22h-5.675q-.03 0-.059-.003l5.729-5.629q.006.082.006.166v5.465H24v-5.465a4.204 4.204 0 0 0-4.205-4.205zM0 8.245l8.28 9.266c.839.94 2.396.346 2.396-.914V8.245H8.19v5.44l-4.86-5.44Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenAIIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RailwayIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Railway</title>
|
||||
<path d="M.113 10.27A13.026 13.026 0 000 11.48h18.23c-.064-.125-.15-.237-.235-.347-3.117-4.027-4.793-3.677-7.19-3.78-.8-.034-1.34-.048-4.524-.048-1.704 0-3.555.005-5.358.01-.234.63-.459 1.24-.567 1.737h9.342v1.216H.113v.002zm18.26 2.426H.009c.02.326.05.645.094.961h16.955c.754 0 1.179-.429 1.315-.96zm-17.318 4.28s2.81 6.902 10.93 7.024c4.855 0 9.027-2.883 10.92-7.024H1.056zM11.988 0C7.5 0 3.593 2.466 1.531 6.108l4.75-.005v-.002c3.71 0 3.849.016 4.573.047l.448.016c1.563.052 3.485.22 4.996 1.364.82.621 2.007 1.99 2.712 2.965.654.902.842 1.94.396 2.934-.408.914-1.289 1.458-2.353 1.458H.391s.099.42.249.886h22.748A12.026 12.026 0 0024 12.005C24 5.377 18.621 0 11.988 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadsIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Threads</title>
|
||||
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function XIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>X</title>
|
||||
<path d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstagramIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Instagram</title>
|
||||
<path d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FacebookIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Facebook</title>
|
||||
<path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkedInIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>LinkedIn</title>
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickBooksIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>QuickBooks</title>
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm.642 4.1335c.9554 0 1.7296.776 1.7296 1.7332v9.0667h1.6c1.614 0 2.9275-1.3156 2.9275-2.933 0-1.6173-1.3136-2.9333-2.9276-2.9333h-.6654V7.3334h.6654c2.5722 0 4.6577 2.0897 4.6577 4.667 0 2.5774-2.0855 4.6666-4.6577 4.6666H12.642zM7.9837 7.333h3.3291v12.533c-.9555 0-1.73-.7759-1.73-1.7332V9.0662H7.9837c-1.6146 0-2.9277 1.316-2.9277 2.9334 0 1.6175 1.3131 2.9333 2.9277 2.9333h.6654v1.7332h-.6654c-2.5725 0-4.6577-2.0892-4.6577-4.6665 0-2.5771 2.0852-4.6666 4.6577-4.6666Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function XeroIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Xero</title>
|
||||
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm6.585 14.655c-1.485 0-2.69-1.206-2.69-2.689 0-1.485 1.207-2.691 2.69-2.691 1.485 0 2.69 1.207 2.69 2.691s-1.207 2.689-2.69 2.689zM7.53 14.644c-.099 0-.192-.041-.267-.116l-2.043-2.04-2.052 2.047c-.069.068-.16.108-.258.108-.202 0-.368-.166-.368-.368 0-.099.04-.191.111-.263l2.04-2.05-2.038-2.047c-.075-.069-.113-.162-.113-.261 0-.203.166-.366.368-.366.098 0 .188.037.258.105l2.055 2.048 2.048-2.045c.069-.071.162-.108.26-.108.211 0 .375.165.375.366 0 .098-.029.188-.104.258l-2.056 2.055 2.055 2.051c.068.069.104.16.104.258 0 .202-.165.368-.365.368h-.01zm8.017-4.591c-.796.101-.882.476-.882 1.404v2.787c0 .202-.165.366-.366.366-.203 0-.367-.165-.368-.366v-4.53c0-.204.16-.366.362-.366.166 0 .316.125.346.289.27-.209.6-.317.93-.317h.105c.195 0 .359.165.359.368 0 .201-.164.352-.375.359 0 0-.09 0-.164.008l.053-.002zm-3.091 2.205H8.625c0 .019.003.037.006.057.02.105.045.211.083.31.194.531.765 1.275 1.829 1.29.33-.003.631-.086.9-.229.21-.12.391-.271.525-.428.045-.058.09-.112.12-.168.18-.229.405-.186.54-.083.164.135.18.391.045.57l-.016.016c-.21.27-.435.495-.689.66-.255.164-.525.284-.811.345-.33.09-.645.104-.975.06-1.095-.135-2.01-.93-2.28-2.01-.06-.21-.09-.42-.09-.645 0-.855.421-1.695 1.125-2.205.885-.615 2.085-.66 3-.075.63.405 1.035 1.021 1.185 1.771.075.419-.21.794-.734.81l.068-.046zm6.129-2.223c-1.064 0-1.931.865-1.931 1.931 0 1.064.866 1.931 1.931 1.931s1.931-.867 1.931-1.931c0-1.065-.866-1.933-1.931-1.933v.002zm0 2.595c-.367 0-.666-.297-.666-.666 0-.367.3-.665.666-.665.367 0 .667.299.667.665 0 .369-.3.667-.667.666zm-8.04-2.603c-.91 0-1.672.623-1.886 1.466v.03h3.776c-.203-.855-.973-1.494-1.891-1.494v-.002z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnthropicIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Anthropic</title>
|
||||
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoogleGeminiIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Google Gemini</title>
|
||||
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoogleCloudIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Google Cloud</title>
|
||||
<path d="M12.19 2.38a9.344 9.344 0 0 0-9.234 6.893c.053-.02-.055.013 0 0-3.875 2.551-3.922 8.11-.247 10.941l.006-.007-.007.03a6.717 6.717 0 0 0 4.077 1.356h5.173l.03.03h5.192c6.687.053 9.376-8.605 3.835-12.35a9.365 9.365 0 0 0-2.821-4.552l-.043.043.006-.05A9.344 9.344 0 0 0 12.19 2.38zm-.358 4.146c1.244-.04 2.518.368 3.486 1.15a5.186 5.186 0 0 1 1.862 4.078v.518c3.53-.07 3.53 5.262 0 5.193h-5.193l-.008.009v-.04H6.785a2.59 2.59 0 0 1-1.067-.23h.001a2.597 2.597 0 1 1 3.437-3.437l3.013-3.012A6.747 6.747 0 0 0 8.11 8.24c.018-.01.04-.026.054-.023a5.186 5.186 0 0 1 3.67-1.69z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
# Page Template Guide
|
||||
|
||||
A consistent, reusable page layout system for all pages in the application.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Consistent Layout**: Left rail + sidebar + main content area
|
||||
- ✅ **Responsive Design**: Works on all screen sizes
|
||||
- ✅ **Sidebar Navigation**: Built-in support for left sidebar with active states
|
||||
- ✅ **Hero Section**: Optional hero banner with icon, title, description, and actions
|
||||
- ✅ **Utility Components**: Pre-built sections, cards, grids, and empty states
|
||||
- ✅ **Type-safe**: Full TypeScript support
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { PageTemplate } from "@/components/layout/page-template";
|
||||
import { Home } from "lucide-react";
|
||||
|
||||
export default function MyPage() {
|
||||
return (
|
||||
<PageTemplate
|
||||
hero={{
|
||||
icon: Home,
|
||||
title: "My Page Title",
|
||||
description: "A brief description of what this page does",
|
||||
}}
|
||||
>
|
||||
<div>Your page content here</div>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## With Sidebar Navigation
|
||||
|
||||
```tsx
|
||||
import { PageTemplate } from "@/components/layout/page-template";
|
||||
import { Home, Settings, Users } from "lucide-react";
|
||||
|
||||
export default function MyPage() {
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
title: "My Section",
|
||||
description: "Navigate through options",
|
||||
items: [
|
||||
{ title: "Home", icon: Home, href: "/home", isActive: true },
|
||||
{ title: "Settings", icon: Settings, href: "/settings" },
|
||||
{ title: "Users", icon: Users, href: "/users", badge: "3" },
|
||||
],
|
||||
footer: <p className="text-xs">Footer content</p>,
|
||||
}}
|
||||
hero={{
|
||||
icon: Home,
|
||||
title: "My Page",
|
||||
description: "Page description",
|
||||
actions: [
|
||||
{
|
||||
label: "Create New",
|
||||
onClick: () => console.log("Create"),
|
||||
icon: Plus,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div>Your content</div>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using Utility Components
|
||||
|
||||
### PageSection
|
||||
|
||||
Organized content sections with optional titles and actions:
|
||||
|
||||
```tsx
|
||||
import { PageSection } from "@/components/layout/page-template";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
<PageSection
|
||||
title="Recent Activity"
|
||||
description="Your latest updates"
|
||||
headerAction={<Button size="sm">View All</Button>}
|
||||
>
|
||||
<div>Section content</div>
|
||||
</PageSection>
|
||||
```
|
||||
|
||||
### PageCard
|
||||
|
||||
Styled cards with consistent padding and hover effects:
|
||||
|
||||
```tsx
|
||||
import { PageCard } from "@/components/layout/page-template";
|
||||
|
||||
<PageCard padding="lg" hover>
|
||||
<h3>Card Title</h3>
|
||||
<p>Card content</p>
|
||||
</PageCard>
|
||||
```
|
||||
|
||||
### PageGrid
|
||||
|
||||
Responsive grid layouts:
|
||||
|
||||
```tsx
|
||||
import { PageGrid } from "@/components/layout/page-template";
|
||||
|
||||
<PageGrid cols={3}>
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
<div>Item 3</div>
|
||||
</PageGrid>
|
||||
```
|
||||
|
||||
### PageEmptyState
|
||||
|
||||
Empty states with icon, title, description, and action:
|
||||
|
||||
```tsx
|
||||
import { PageEmptyState } from "@/components/layout/page-template";
|
||||
import { Inbox } from "lucide-react";
|
||||
|
||||
<PageEmptyState
|
||||
icon={Inbox}
|
||||
title="No messages yet"
|
||||
description="Start a conversation to see messages here"
|
||||
action={{
|
||||
label: "New Message",
|
||||
onClick: () => console.log("Create message"),
|
||||
icon: Plus,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a full example combining all components:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Home, Settings, Users, Plus, Mail } from "lucide-react";
|
||||
import {
|
||||
PageTemplate,
|
||||
PageSection,
|
||||
PageCard,
|
||||
PageGrid,
|
||||
PageEmptyState,
|
||||
} from "@/components/layout/page-template";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
const projectId = params.projectId as string;
|
||||
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
title: "Dashboard",
|
||||
description: "Overview and insights",
|
||||
items: [
|
||||
{
|
||||
title: "Overview",
|
||||
icon: Home,
|
||||
href: `/${workspace}/project/${projectId}/overview`,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
href: `/${workspace}/project/${projectId}/settings`,
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
icon: Users,
|
||||
href: `/${workspace}/project/${projectId}/users`,
|
||||
badge: "12",
|
||||
},
|
||||
],
|
||||
footer: (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last updated 5 minutes ago
|
||||
</p>
|
||||
),
|
||||
}}
|
||||
hero={{
|
||||
icon: Home,
|
||||
title: "Dashboard",
|
||||
description: "Welcome back! Here's what's happening.",
|
||||
actions: [
|
||||
{
|
||||
label: "Create Report",
|
||||
onClick: () => console.log("Create report"),
|
||||
icon: Plus,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{/* Stats Grid */}
|
||||
<PageSection title="Quick Stats">
|
||||
<PageGrid cols={4}>
|
||||
<PageCard>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Total Users
|
||||
</h3>
|
||||
<p className="text-3xl font-bold mt-2">1,234</p>
|
||||
</PageCard>
|
||||
<PageCard>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Active Sessions
|
||||
</h3>
|
||||
<p className="text-3xl font-bold mt-2">89</p>
|
||||
</PageCard>
|
||||
<PageCard>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Revenue
|
||||
</h3>
|
||||
<p className="text-3xl font-bold mt-2">$12,345</p>
|
||||
</PageCard>
|
||||
<PageCard>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Conversion
|
||||
</h3>
|
||||
<p className="text-3xl font-bold mt-2">3.2%</p>
|
||||
</PageCard>
|
||||
</PageGrid>
|
||||
</PageSection>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<PageSection
|
||||
title="Recent Activity"
|
||||
description="Latest updates from your team"
|
||||
>
|
||||
<PageCard>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">User signed up</p>
|
||||
<p className="text-xs text-muted-foreground">2 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Empty State Example */}
|
||||
<PageSection title="Messages">
|
||||
<PageCard>
|
||||
<PageEmptyState
|
||||
icon={Mail}
|
||||
title="No messages yet"
|
||||
description="When you receive messages, they'll appear here"
|
||||
action={{
|
||||
label: "Send Message",
|
||||
onClick: () => console.log("Send"),
|
||||
icon: Plus,
|
||||
}}
|
||||
/>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Props Reference
|
||||
|
||||
### PageTemplate
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `children` | `ReactNode` | Main content |
|
||||
| `sidebar` | `object` | Optional sidebar configuration |
|
||||
| `hero` | `object` | Optional hero section configuration |
|
||||
| `containerWidth` | `"default" \| "wide" \| "full"` | Content container width (default: "default") |
|
||||
| `className` | `string` | Custom wrapper class |
|
||||
| `contentClassName` | `string` | Custom content class |
|
||||
|
||||
### Sidebar Config
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `title` | `string` | Sidebar title |
|
||||
| `description` | `string` | Optional subtitle |
|
||||
| `items` | `array` | Navigation items |
|
||||
| `footer` | `ReactNode` | Optional footer content |
|
||||
|
||||
### Hero Config
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `icon` | `LucideIcon` | Optional icon |
|
||||
| `title` | `string` | Page title |
|
||||
| `description` | `string` | Optional description |
|
||||
| `actions` | `array` | Optional action buttons |
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Consistent Icons**: Always use Lucide icons for consistency
|
||||
2. **Active States**: Pass `isActive` to sidebar items to highlight current page
|
||||
3. **Responsive**: Grid and card components are responsive by default
|
||||
4. **Accessibility**: All components include proper ARIA attributes
|
||||
5. **Performance**: Use "use client" only when you need client-side interactivity
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
To migrate an existing page:
|
||||
|
||||
1. Import `PageTemplate` and utility components
|
||||
2. Wrap content in `<PageTemplate>`
|
||||
3. Move navigation to `sidebar` prop
|
||||
4. Move page header to `hero` prop
|
||||
5. Replace div sections with `<PageSection>`
|
||||
6. Replace card divs with `<PageCard>`
|
||||
7. Use `<PageGrid>` for responsive grids
|
||||
|
||||
Before:
|
||||
```tsx
|
||||
<div className="flex h-full">
|
||||
<div className="w-64 border-r">
|
||||
{/* sidebar */}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{/* content */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
After:
|
||||
```tsx
|
||||
<PageTemplate sidebar={{...}} hero={{...}}>
|
||||
{/* content */}
|
||||
</PageTemplate>
|
||||
```
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState, useMemo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LeftRail } from "./left-rail";
|
||||
import { RightPanel } from "./right-panel";
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export function AppShell({ children, workspace, projectId, projectName }: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const [activeSection, setActiveSection] = useState<string>("home");
|
||||
|
||||
// Derive active section from pathname
|
||||
const derivedSection = useMemo(() => {
|
||||
if (pathname.includes('/overview')) return 'home';
|
||||
if (pathname.includes('/design')) return 'design';
|
||||
return activeSection;
|
||||
}, [pathname, activeSection]);
|
||||
|
||||
const displayProjectName = projectName || "Product";
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Left Rail - App Navigation */}
|
||||
<LeftRail
|
||||
key={projectId} // Force re-render when projectId changes
|
||||
activeSection={derivedSection}
|
||||
onSectionChange={setActiveSection}
|
||||
projectName={displayProjectName}
|
||||
projectId={projectId}
|
||||
workspace={workspace}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - Activity & AI Chat */}
|
||||
<RightPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Github, Sparkles, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { CursorIcon } from "@/components/icons/custom-icons";
|
||||
|
||||
interface ConnectSourcesModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
// Mock connection states - these would come from your database in production
|
||||
const useConnectionStates = () => {
|
||||
const [connections, setConnections] = useState({
|
||||
vibn: false,
|
||||
chatgpt: false,
|
||||
github: false,
|
||||
v0: false,
|
||||
});
|
||||
|
||||
return { connections, setConnections };
|
||||
};
|
||||
|
||||
export function ConnectSourcesModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
}: ConnectSourcesModalProps) {
|
||||
const { connections, setConnections } = useConnectionStates();
|
||||
|
||||
const handleConnect = async (source: "vibn" | "chatgpt" | "github" | "v0") => {
|
||||
// Mock connection logic - replace with actual OAuth/API integration
|
||||
const sourceName = source === "vibn" ? "Vib'n Extension" : source === "chatgpt" ? "ChatGPT" : source === "github" ? "GitHub" : "v0";
|
||||
toast.success(`Connecting to ${sourceName}...`);
|
||||
|
||||
// Simulate connection delay
|
||||
setTimeout(() => {
|
||||
setConnections((prev) => ({ ...prev, [source]: true }));
|
||||
toast.success(`${sourceName} connected successfully!`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleDisconnect = (source: "vibn" | "chatgpt" | "github" | "v0") => {
|
||||
const sourceName = source === "vibn" ? "Vib'n Extension" : source === "chatgpt" ? "ChatGPT" : source === "github" ? "GitHub" : "v0";
|
||||
setConnections((prev) => ({ ...prev, [source]: false }));
|
||||
toast.success(`${sourceName} disconnected`);
|
||||
};
|
||||
|
||||
const sources = [
|
||||
{
|
||||
id: "vibn" as const,
|
||||
name: "Vib'n Extension",
|
||||
description: "Connect the Vib'n extension with Cursor for seamless development tracking",
|
||||
icon: CursorIcon,
|
||||
color: "text-foreground",
|
||||
bgColor: "bg-primary/10",
|
||||
},
|
||||
{
|
||||
id: "chatgpt" as const,
|
||||
name: "ChatGPT",
|
||||
description: "Connect your ChatGPT project for AI-powered insights and context",
|
||||
icon: Sparkles,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-500/10",
|
||||
},
|
||||
{
|
||||
id: "github" as const,
|
||||
name: "GitHub",
|
||||
description: "Sync your repository to track code changes and generate screens",
|
||||
icon: Github,
|
||||
color: "text-foreground",
|
||||
bgColor: "bg-foreground/10",
|
||||
},
|
||||
{
|
||||
id: "v0" as const,
|
||||
name: "v0",
|
||||
description: "Connect v0 to generate and iterate on UI designs",
|
||||
icon: Sparkles,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect Sources</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect external sources to enhance your product development workflow
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 mt-4">
|
||||
{sources.map((source) => {
|
||||
const Icon = source.icon;
|
||||
const isConnected = connections[source.id];
|
||||
|
||||
return (
|
||||
<Card key={source.id} className={isConnected ? "border-primary" : ""}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-2.5 rounded-lg ${source.bgColor} shrink-0`}>
|
||||
<Icon className={`h-5 w-5 ${source.color}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-sm">{source.name}</h3>
|
||||
{isConnected && (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600 bg-green-500/10 px-2 py-0.5 rounded-full">
|
||||
<Check className="h-3 w-3" />
|
||||
Connected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{source.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(source.id)}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleConnect(source.id)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Settings,
|
||||
HelpCircle,
|
||||
User,
|
||||
Key,
|
||||
Palette,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type NavSection = "home" | "ai-chat" | "docs" | "plan" | "design" | "tech" | "journey" | "settings";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: "home" as NavSection, icon: Sparkles, label: "AI Chat", href: "/project/{projectId}/overview" },
|
||||
{ id: "design" as NavSection, icon: Palette, label: "Design", href: "/project/{projectId}/design" },
|
||||
];
|
||||
|
||||
interface LeftRailProps {
|
||||
activeSection: string;
|
||||
onSectionChange: (section: string) => void;
|
||||
projectName?: string;
|
||||
projectId?: string;
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
export function LeftRail({ activeSection, onSectionChange, projectName, projectId, workspace = 'marks-account' }: LeftRailProps) {
|
||||
return (
|
||||
<div className="flex w-16 flex-col items-center border-r bg-card">
|
||||
{/* Vib'n Logo */}
|
||||
<Link href={`/${workspace}/projects`} className="flex h-14 w-16 items-center justify-center border-b">
|
||||
<img
|
||||
src="/vibn-black-circle-logo.png"
|
||||
alt="Vib'n"
|
||||
className="h-10 w-10 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="w-full flex flex-col items-center">
|
||||
{/* Main Navigation Items */}
|
||||
<div className="flex flex-col gap-2 w-full items-center">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
if (!projectId) return null;
|
||||
const fullHref = `/${workspace}${item.href.replace('{projectId}', projectId)}`;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={fullHref}
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 w-full py-2 px-2 transition-all relative",
|
||||
activeSection === item.id
|
||||
? "text-primary bg-primary/5"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
{activeSection === item.id && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 h-10 w-1 bg-primary" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Bottom Items */}
|
||||
<div className="mt-auto flex flex-col gap-1 w-full items-center pb-4">
|
||||
<Separator className="w-8 mb-1" />
|
||||
|
||||
<Link href={`/${workspace}/keys`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
title="API Keys"
|
||||
>
|
||||
<Key className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/${workspace}/connections`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
title="Settings & Connections"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
title="Help - Coming Soon"
|
||||
onClick={() => toast.info("Help Center - Coming Soon")}
|
||||
>
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full"
|
||||
title="Profile - Coming Soon"
|
||||
onClick={() => toast.info("Profile Settings - Coming Soon")}
|
||||
>
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ExternalLink, Copy, CheckCircle2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface MCPConnectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function MCPConnectModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
}: MCPConnectModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const mcpUrl = `https://api.vibn.co/mcp/projects/${projectId}`;
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(mcpUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-green-500/20 to-emerald-500/20">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"
|
||||
fill="currentColor"
|
||||
className="text-green-600"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl">Connect ChatGPT</DialogTitle>
|
||||
<Badge variant="secondary" className="mt-1 text-xs">MCP Protocol</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Link your ChatGPT to this project for real-time sync and AI-powered updates
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* Step 1 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-sm font-medium">
|
||||
1
|
||||
</div>
|
||||
<h3 className="font-semibold">Copy your MCP Server URL</h3>
|
||||
</div>
|
||||
<div className="ml-8 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This unique URL connects ChatGPT to your project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-sm font-medium">
|
||||
2
|
||||
</div>
|
||||
<h3 className="font-semibold">Add Connector in ChatGPT</h3>
|
||||
</div>
|
||||
<div className="ml-8 space-y-2">
|
||||
<ol className="text-sm space-y-2 text-muted-foreground list-decimal list-inside">
|
||||
<li>Open ChatGPT settings</li>
|
||||
<li>Navigate to <strong className="text-foreground">Connectors</strong></li>
|
||||
<li>Click <strong className="text-foreground">New Connector</strong></li>
|
||||
<li>Paste the MCP Server URL</li>
|
||||
<li>Select <strong className="text-foreground">OAuth</strong> authentication</li>
|
||||
</ol>
|
||||
<Button variant="outline" size="sm" className="mt-3" asChild>
|
||||
<a href="https://chatgpt.com/settings" target="_blank" rel="noopener noreferrer">
|
||||
Open ChatGPT Settings
|
||||
<ExternalLink className="ml-2 h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-sm font-medium">
|
||||
3
|
||||
</div>
|
||||
<h3 className="font-semibold">Authorize Access</h3>
|
||||
</div>
|
||||
<div className="ml-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
ChatGPT will request permission to:
|
||||
</p>
|
||||
<ul className="mt-2 text-sm space-y-1 text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-green-600">✓</span>
|
||||
Read your project vision and progress
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-green-600">✓</span>
|
||||
Add features and tasks
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-green-600">✓</span>
|
||||
Update documentation
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="text-blue-600 shrink-0">ℹ️</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="font-medium text-foreground">What happens after connecting?</p>
|
||||
<p className="text-muted-foreground">
|
||||
You'll be able to chat with ChatGPT about your project, and it will automatically
|
||||
sync updates to your Vib'n workspace. Plan features, discuss architecture, and track
|
||||
progress - all seamlessly connected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href="https://platform.openai.com/docs/mcp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View MCP Docs
|
||||
<ExternalLink className="ml-2 h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface PageHeaderProps {
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
projectEmoji?: string;
|
||||
pageName: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
projectId,
|
||||
projectName = "AI Proxy",
|
||||
projectEmoji = "🤖",
|
||||
pageName,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex h-12 items-center justify-between border-b bg-card/50 px-6">
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-base">{projectEmoji}</span>
|
||||
<span className="font-medium">{projectName}</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{pageName}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
interface PageTemplateProps {
|
||||
children: ReactNode;
|
||||
|
||||
// Sidebar configuration
|
||||
sidebar?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
items: Array<{
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
href: string;
|
||||
isActive?: boolean;
|
||||
badge?: string | number;
|
||||
}>;
|
||||
footer?: ReactNode;
|
||||
customContent?: ReactNode;
|
||||
};
|
||||
|
||||
// Hero section configuration
|
||||
hero?: {
|
||||
icon?: LucideIcon;
|
||||
iconBgColor?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "outline" | "ghost" | "secondary";
|
||||
icon?: LucideIcon;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Container width
|
||||
containerWidth?: "default" | "wide" | "full";
|
||||
|
||||
// Custom classes
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
export function PageTemplate({
|
||||
children,
|
||||
sidebar,
|
||||
hero,
|
||||
containerWidth = "default",
|
||||
className,
|
||||
contentClassName,
|
||||
}: PageTemplateProps) {
|
||||
const maxWidthClass = {
|
||||
default: "max-w-5xl",
|
||||
wide: "max-w-7xl",
|
||||
full: "max-w-none",
|
||||
}[containerWidth];
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full w-full overflow-hidden", className)}>
|
||||
{/* Left Sidebar Navigation (if provided) */}
|
||||
{sidebar && (
|
||||
<div className="w-64 border-r bg-muted/30 py-4">
|
||||
{/* Sidebar Header */}
|
||||
{sidebar.title && (
|
||||
<div className="pb-4 border-b mb-4">
|
||||
<h2 className="text-lg font-semibold">{sidebar.title}</h2>
|
||||
{sidebar.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{sidebar.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Div: Navigation Items */}
|
||||
<div className="px-3 py-4 space-y-1">
|
||||
{sidebar.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.isActive;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 px-2 py-1.5 rounded-md text-sm transition-all group",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{item.title}</span>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary shrink-0 font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t my-4" />
|
||||
|
||||
{/* Bottom Div: Custom Content */}
|
||||
{sidebar.customContent && (
|
||||
<div className="px-3 py-4">
|
||||
{sidebar.customContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Hero Section (if provided) */}
|
||||
{hero && (
|
||||
<div className="border-b bg-gradient-to-br from-primary/5 to-background">
|
||||
<div className={cn(maxWidthClass, "mx-auto px-8 py-12")}>
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
{hero.icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-12 w-12 rounded-xl flex items-center justify-center shrink-0",
|
||||
hero.iconBgColor || "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<hero.icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-3xl font-bold truncate">{hero.title}</h1>
|
||||
{hero.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{hero.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Actions */}
|
||||
{hero.actions && hero.actions.length > 0 && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hero.actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant || "default"}
|
||||
size="sm"
|
||||
>
|
||||
{action.icon && <action.icon className="h-4 w-4 mr-2" />}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className={cn(maxWidthClass, "mx-auto px-6 py-6", contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility Components for common page patterns
|
||||
|
||||
interface PageSectionProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
headerAction?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
headerAction,
|
||||
}: PageSectionProps) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{(title || description || headerAction) && (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
{title && <h2 className="text-lg font-semibold">{title}</h2>}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{headerAction && <div>{headerAction}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
padding?: "sm" | "md" | "lg";
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export function PageCard({
|
||||
children,
|
||||
className,
|
||||
padding = "md",
|
||||
hover = false,
|
||||
}: PageCardProps) {
|
||||
const paddingClass = {
|
||||
sm: "p-4",
|
||||
md: "p-6",
|
||||
lg: "p-8",
|
||||
}[padding];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-lg bg-card",
|
||||
paddingClass,
|
||||
hover && "hover:border-primary hover:shadow-md transition-all",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageGridProps {
|
||||
children: ReactNode;
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageGrid({ children, cols = 2, className }: PageGridProps) {
|
||||
const colsClass = {
|
||||
1: "grid-cols-1",
|
||||
2: "md:grid-cols-2",
|
||||
3: "md:grid-cols-3",
|
||||
4: "md:grid-cols-2 lg:grid-cols-4",
|
||||
}[cols];
|
||||
|
||||
return (
|
||||
<div className={cn("grid grid-cols-1 gap-4", colsClass, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageEmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
}
|
||||
|
||||
export function PageEmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: PageEmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||
<div className="rounded-full bg-muted p-6 mb-4">
|
||||
<Icon className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground mb-6 max-w-md">{description}</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button onClick={action.onClick}>
|
||||
{action.icon && <action.icon className="h-4 w-4 mr-2" />}
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,655 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Target,
|
||||
ListChecks,
|
||||
Palette,
|
||||
Code2,
|
||||
Server,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Github,
|
||||
MessageSquare,
|
||||
Image,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
Inbox,
|
||||
Users,
|
||||
Eye,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { MCPConnectModal } from "./mcp-connect-modal";
|
||||
import { ConnectSourcesModal } from "./connect-sources-modal";
|
||||
import { OpenAIIcon, V0Icon, CursorIcon } from "@/components/icons/custom-icons";
|
||||
|
||||
interface ProjectSidebarProps {
|
||||
projectId: string;
|
||||
activeSection?: string; // From left rail: 'projects', 'inbox', 'clients', etc.
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
// Map section IDs to display names
|
||||
const SECTION_NAMES: Record<string, string> = {
|
||||
home: 'Home',
|
||||
product: 'Product',
|
||||
site: 'Site',
|
||||
pricing: 'Pricing',
|
||||
content: 'Content',
|
||||
social: 'Social',
|
||||
inbox: 'Inbox',
|
||||
people: 'People',
|
||||
settings: 'Settings',
|
||||
};
|
||||
|
||||
// Section-specific navigation items
|
||||
const SECTION_ITEMS: Record<string, Array<{title: string; icon: any; href: string}>> = {
|
||||
home: [
|
||||
// { title: "Vision", icon: Eye, href: "/vision" }, // Hidden per user request
|
||||
{ title: "Context", icon: FolderOpen, href: "/context" },
|
||||
],
|
||||
product: [
|
||||
{ title: "Product Vision", icon: Target, href: "/plan" },
|
||||
{ title: "Progress", icon: ListChecks, href: "/progress" },
|
||||
{ title: "UI UX", icon: Palette, href: "/design" },
|
||||
{ title: "Code", icon: Code2, href: "/code" },
|
||||
{ title: "Deployment", icon: Server, href: "/deployment" },
|
||||
{ title: "Automation", icon: Zap, href: "/automation" },
|
||||
],
|
||||
site: [
|
||||
{ title: "Pages", icon: Globe, href: "/site/pages" },
|
||||
{ title: "Templates", icon: Palette, href: "/site/templates" },
|
||||
{ title: "Settings", icon: Target, href: "/site/settings" },
|
||||
],
|
||||
pricing: [
|
||||
{ title: "Plans", icon: Target, href: "/pricing/plans" },
|
||||
{ title: "Billing", icon: Code2, href: "/pricing/billing" },
|
||||
{ title: "Invoices", icon: ListChecks, href: "/pricing/invoices" },
|
||||
],
|
||||
content: [
|
||||
{ title: "Blog Posts", icon: Target, href: "/content/blog" },
|
||||
{ title: "Case Studies", icon: Code2, href: "/content/cases" },
|
||||
{ title: "Documentation", icon: ListChecks, href: "/content/docs" },
|
||||
],
|
||||
social: [
|
||||
{ title: "Posts", icon: MessageSquare, href: "/social/posts" },
|
||||
{ title: "Analytics", icon: Target, href: "/social/analytics" },
|
||||
{ title: "Schedule", icon: ListChecks, href: "/social/schedule" },
|
||||
],
|
||||
inbox: [
|
||||
{ title: "All", icon: Inbox, href: "/inbox/all" },
|
||||
{ title: "Unread", icon: Target, href: "/inbox/unread" },
|
||||
{ title: "Archived", icon: ListChecks, href: "/inbox/archived" },
|
||||
],
|
||||
people: [
|
||||
{ title: "Team", icon: Users, href: "/people/team" },
|
||||
{ title: "Clients", icon: Users, href: "/people/clients" },
|
||||
{ title: "Contacts", icon: Users, href: "/people/contacts" },
|
||||
],
|
||||
};
|
||||
|
||||
type ConnectionStatus = 'inactive' | 'connected' | 'live';
|
||||
|
||||
export function ProjectSidebar({ projectId, activeSection = 'projects', workspace = 'marks-account' }: ProjectSidebarProps) {
|
||||
const minWidth = 200;
|
||||
const maxWidth = 500;
|
||||
const [width, setWidth] = useState(minWidth);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false);
|
||||
const [connectModalOpen, setConnectModalOpen] = useState(false);
|
||||
const [isUserFlowsExpanded, setIsUserFlowsExpanded] = useState(true);
|
||||
const [isProductScreensExpanded, setIsProductScreensExpanded] = useState(true);
|
||||
const [getStartedCompleted, setGetStartedCompleted] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Connection states - mock data, would come from API/database in production
|
||||
const [connectionStates, setConnectionStates] = useState<{
|
||||
github: ConnectionStatus;
|
||||
openai: ConnectionStatus;
|
||||
v0: ConnectionStatus;
|
||||
cursor: ConnectionStatus;
|
||||
}>({
|
||||
github: 'connected',
|
||||
openai: 'live',
|
||||
v0: 'inactive',
|
||||
cursor: 'connected',
|
||||
});
|
||||
|
||||
// Helper function to get icon classes based on connection status
|
||||
const getIconClasses = (status: ConnectionStatus) => {
|
||||
switch (status) {
|
||||
case 'inactive':
|
||||
return 'text-muted-foreground/40';
|
||||
case 'connected':
|
||||
return 'text-muted-foreground';
|
||||
case 'live':
|
||||
return 'text-foreground';
|
||||
default:
|
||||
return 'text-muted-foreground/40';
|
||||
}
|
||||
};
|
||||
|
||||
const startResizing = useCallback((e: React.MouseEvent) => {
|
||||
setIsResizing(true);
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const stopResizing = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
}, []);
|
||||
|
||||
const resize = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isResizing) {
|
||||
const newWidth = e.clientX - 64; // Subtract left rail width (64px)
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
setWidth(newWidth);
|
||||
}
|
||||
}
|
||||
},
|
||||
[isResizing]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("mousemove", resize);
|
||||
window.addEventListener("mouseup", stopResizing);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", resize);
|
||||
window.removeEventListener("mouseup", stopResizing);
|
||||
};
|
||||
}, [resize, stopResizing]);
|
||||
|
||||
// Determine header title based on active section
|
||||
const isVAIPage = pathname?.includes('/v_ai_chat');
|
||||
const headerTitle = isVAIPage ? 'v_ai' : (SECTION_NAMES[activeSection] || 'Home');
|
||||
|
||||
// Get section-specific items
|
||||
const currentSectionItems = SECTION_ITEMS[activeSection] || SECTION_ITEMS['home'];
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
style={{ width: `${width}px` }}
|
||||
className="relative flex flex-col border-r bg-card/50"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex h-14 items-center justify-between px-4 border-b">
|
||||
<h2 className="font-semibold text-sm">{headerTitle}</h2>
|
||||
{/* Connection icons - only show for Product section */}
|
||||
{activeSection === 'product' && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* GitHub */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 relative"
|
||||
onClick={() => setConnectModalOpen(true)}
|
||||
>
|
||||
<Github className={cn("h-4 w-4", getIconClasses(connectionStates.github))} />
|
||||
{connectionStates.github === 'live' && (
|
||||
<span className="absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* OpenAI/ChatGPT */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 relative"
|
||||
onClick={() => setConnectModalOpen(true)}
|
||||
>
|
||||
<OpenAIIcon className={cn("h-4 w-4", getIconClasses(connectionStates.openai))} />
|
||||
{connectionStates.openai === 'live' && (
|
||||
<span className="absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* v0 */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 relative"
|
||||
onClick={() => setConnectModalOpen(true)}
|
||||
>
|
||||
<V0Icon className={cn("h-4 w-4", getIconClasses(connectionStates.v0))} />
|
||||
{connectionStates.v0 === 'live' && (
|
||||
<span className="absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Cursor */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 relative"
|
||||
onClick={() => setConnectModalOpen(true)}
|
||||
>
|
||||
<CursorIcon className={cn("h-4 w-4", getIconClasses(connectionStates.cursor))} />
|
||||
{connectionStates.cursor === 'live' && (
|
||||
<span className="absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section-Specific Navigation */}
|
||||
<div className="px-2 py-1.5 space-y-0.5">
|
||||
{/* v_ai - Persistent AI Chat - Always show for Home section */}
|
||||
{activeSection === 'home' && (
|
||||
<div className="mb-2">
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/v_ai_chat`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1 text-sm transition-colors",
|
||||
pathname === `/${workspace}/project/${projectId}/v_ai_chat`
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<img src="/vibn-logo-circle.png" alt="v_ai" className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate font-medium">v_ai</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentSectionItems.map((item) => {
|
||||
const href = `/${workspace}/project/${projectId}${item.href}`;
|
||||
const isActive = pathname === href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{item.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<Separator className="my-1.5" />
|
||||
|
||||
{/* Context Section - Only show in Home section */}
|
||||
{activeSection === 'home' && !isVAIPage && (
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex items-center justify-between px-2 mb-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Context</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
||||
onClick={() => {
|
||||
// Navigate to context page
|
||||
window.location.href = `/${workspace}/project/${projectId}/context`;
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context Section - Shows items based on selected project section */}
|
||||
{!isVAIPage && activeSection !== 'home' && (
|
||||
<ScrollArea className="flex-1 px-2">
|
||||
<div className="space-y-1 py-1.5">
|
||||
{/* Show context based on current page */}
|
||||
{pathname.includes('/plan') && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground">VISION SOURCES</h3>
|
||||
<button
|
||||
onClick={() => setMcpModalOpen(true)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{/* OpenAI Icon (SVG) */}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="opacity-60"
|
||||
>
|
||||
<path
|
||||
d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span>Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span className="text-xs">📄</span>
|
||||
<span className="truncate">chatgpt-brainstorm.json</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span className="text-xs">📄</span>
|
||||
<span className="truncate">product-notes.md</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm border border-dashed border-primary/30 text-primary hover:bg-primary/5 transition-colors">
|
||||
<span className="text-xs">+</span>
|
||||
<span className="truncate">Upload new file</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pathname.includes('/progress') && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-2 text-xs font-semibold text-muted-foreground">FILTERS</h3>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span>All Tasks</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>In Progress</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>Completed</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pathname.includes('/design') && (
|
||||
<div className="space-y-1.5">
|
||||
{/* Sandbox Indicator */}
|
||||
<div className="px-2 py-1">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground">YOUR SANDBOX</h3>
|
||||
</div>
|
||||
|
||||
{/* User Flows */}
|
||||
<button
|
||||
onClick={() => setIsUserFlowsExpanded(!isUserFlowsExpanded)}
|
||||
className="flex w-full items-center justify-between px-2 py-1 rounded hover:bg-accent transition-colors"
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground">USER FLOWS</h3>
|
||||
{isUserFlowsExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{isUserFlowsExpanded && (
|
||||
<div className="space-y-0.5">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>🚀 Onboarding</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>✍️ Signup</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>👋 New User</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Screens */}
|
||||
<button
|
||||
onClick={() => setIsProductScreensExpanded(!isProductScreensExpanded)}
|
||||
className="flex w-full items-center justify-between px-2 py-1 rounded hover:bg-accent transition-colors mt-2.5"
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground">PRODUCT SCREENS</h3>
|
||||
{isProductScreensExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{isProductScreensExpanded && (
|
||||
<div className="space-y-0.5">
|
||||
<Link
|
||||
href={`/${projectId}/design/landing-hero`}
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between rounded-md px-2 py-1 text-sm transition-colors",
|
||||
pathname === `/${projectId}/design/landing-hero`
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>✨</span>
|
||||
<span>Landing Hero</span>
|
||||
</div>
|
||||
<span className="text-xs opacity-0 group-hover:opacity-100 transition-opacity">3</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${projectId}/design/dashboard`}
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between rounded-md px-2 py-1 text-sm transition-colors",
|
||||
pathname === `/${projectId}/design/dashboard`
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>📊</span>
|
||||
<span>Dashboard</span>
|
||||
</div>
|
||||
<span className="text-xs opacity-0 group-hover:opacity-100 transition-opacity">1</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${projectId}/design/pricing`}
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between rounded-md px-2 py-1 text-sm transition-colors",
|
||||
pathname === `/${projectId}/design/pricing`
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>💳</span>
|
||||
<span>Pricing</span>
|
||||
</div>
|
||||
<span className="text-xs opacity-0 group-hover:opacity-100 transition-opacity">2</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${projectId}/design/user-profile`}
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between rounded-md px-2 py-1 text-sm transition-colors",
|
||||
pathname === `/${projectId}/design/user-profile`
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>👤</span>
|
||||
<span>User Profile</span>
|
||||
</div>
|
||||
<span className="text-xs opacity-0 group-hover:opacity-100 transition-opacity">1</span>
|
||||
</Link>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-2 pt-1.5">
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed border-primary/30 px-2 py-1.5 text-sm text-primary hover:bg-primary/5 transition-colors">
|
||||
<span>+</span>
|
||||
<span>New Screen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pathname.includes('/code') && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-2 text-xs font-semibold text-muted-foreground">QUICK LINKS</h3>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>📦 Repository</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>🌿 Branches</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>🔀 Pull Requests</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pathname.includes('/deployment') && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-2 text-xs font-semibold text-muted-foreground">ENVIRONMENTS</h3>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>🟢 Production</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>🟡 Staging</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>🔵 Development</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pathname.includes('/automation') && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-2 text-xs font-semibold text-muted-foreground">WORKFLOWS</h3>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>⚡ Active Jobs</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>⏰ Scheduled</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors text-muted-foreground">
|
||||
<span>📋 Logs</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default: Left rail context sections */}
|
||||
{activeSection === 'inbox' && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-2 text-xs font-semibold text-muted-foreground">INBOX</h3>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span className="text-muted-foreground">No new items</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'clients' && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-2 text-xs font-semibold text-muted-foreground">PEOPLE</h3>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span>Personal Projects</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span>VIBN</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'invoices' && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-2 text-xs font-semibold text-muted-foreground">GROW</h3>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span>No growth strategies yet</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'site' && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-2 text-xs font-semibold text-muted-foreground">SITE</h3>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span>Pages</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'content' && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-2 text-xs font-semibold text-muted-foreground">CONTENT</h3>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span>Blog Posts</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span>Case Studies</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'social' && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-2 text-xs font-semibold text-muted-foreground">SOCIAL</h3>
|
||||
<div className="space-y-1">
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span>Posts</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors">
|
||||
<span>Analytics</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex h-10 items-center justify-between border-t px-4 text-xs text-muted-foreground">
|
||||
<span>v1.0.0</span>
|
||||
<button className="hover:text-foreground transition-colors">
|
||||
Help
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
onMouseDown={startResizing}
|
||||
className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/40 transition-colors"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<MCPConnectModal
|
||||
open={mcpModalOpen}
|
||||
onOpenChange={setMcpModalOpen}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
<ConnectSourcesModal
|
||||
open={connectModalOpen}
|
||||
onOpenChange={setConnectModalOpen}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Sparkles,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function RightPanel() {
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="relative flex w-12 flex-col items-center border-l bg-card/50 py-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="relative flex w-80 flex-col border-l bg-card/50">
|
||||
{/* Header */}
|
||||
<div className="flex h-12 items-center justify-between px-4 border-b">
|
||||
<h2 className="font-semibold text-sm">AI Chat</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4">
|
||||
{/* Empty State */}
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="mb-3 rounded-full bg-primary/10 p-3">
|
||||
<Sparkles className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-medium mb-1">AI Assistant</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-[250px]">
|
||||
Ask questions about your project, get code suggestions, or
|
||||
request documentation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Example Chat Messages (for when conversation exists) */}
|
||||
{/*
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="text-sm bg-muted rounded-lg p-3">
|
||||
How can I help you with your project today?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-row-reverse">
|
||||
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground">
|
||||
You
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="text-sm bg-primary text-primary-foreground rounded-lg p-3">
|
||||
What's the current token usage?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Chat Input */}
|
||||
<div className="border-t p-4 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
placeholder="Ask AI about your project..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="min-h-[60px] resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
// TODO: Send message
|
||||
setMessage("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="icon" className="shrink-0">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press Enter to send, Shift+Enter for new line
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Cable,
|
||||
Key,
|
||||
Users,
|
||||
Settings,
|
||||
DollarSign,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { signOut } from "next-auth/react";
|
||||
interface WorkspaceLeftRailProps {
|
||||
activeSection?: string;
|
||||
onSectionChange: (section: string) => void;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: 'projects',
|
||||
label: 'Projects',
|
||||
icon: LayoutGrid,
|
||||
href: '/projects',
|
||||
},
|
||||
{
|
||||
id: 'connections',
|
||||
label: 'Connect',
|
||||
icon: Cable,
|
||||
href: '/connections',
|
||||
},
|
||||
{
|
||||
id: 'keys',
|
||||
label: 'Keys',
|
||||
icon: Key,
|
||||
href: '/keys',
|
||||
},
|
||||
{
|
||||
id: 'costs',
|
||||
label: 'Costs',
|
||||
icon: DollarSign,
|
||||
href: '/costs',
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Users',
|
||||
icon: Users,
|
||||
href: '/users',
|
||||
},
|
||||
];
|
||||
|
||||
export function WorkspaceLeftRail({ activeSection = 'projects', onSectionChange }: WorkspaceLeftRailProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Extract workspace from pathname (e.g., /marks-account/projects -> marks-account)
|
||||
const workspace = pathname?.split('/')[1] || 'marks-account';
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut({ callbackUrl: "/auth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-16 flex-col items-center border-r bg-card">
|
||||
{/* Vib'n Logo */}
|
||||
<Link
|
||||
href={`/${workspace}/projects`}
|
||||
onClick={() => onSectionChange('projects')}
|
||||
className="flex h-14 w-16 items-center justify-center border-b"
|
||||
>
|
||||
<img
|
||||
src="/vibn-black-circle-logo.png"
|
||||
alt="Vib'n"
|
||||
className="h-10 w-10 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="pt-4 w-full flex flex-col items-center gap-2">
|
||||
{/* Navigation Items */}
|
||||
<div className="flex flex-col gap-3 w-full items-center">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const fullHref = `/${workspace}${item.href}`;
|
||||
const isActive = activeSection === item.id || pathname?.includes(item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={fullHref}
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 w-full py-2 px-2 transition-all relative",
|
||||
isActive
|
||||
? "text-primary bg-primary/5"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 h-10 w-1 bg-primary" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Bottom Items */}
|
||||
<div className="mt-auto flex flex-col gap-1 w-full items-center pb-4">
|
||||
<Separator className="w-8 mb-1" />
|
||||
|
||||
<Link
|
||||
href={`/${workspace}/settings`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 w-full py-2 px-2 transition-all",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
<span className="text-[10px] font-medium">Settings</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 w-full py-2 px-2 transition-all",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span className="text-[10px] font-medium">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { auth } from '@/lib/firebase/config';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Eye, EyeOff, RefreshCw, Trash2, ExternalLink } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export function MCPConnectionCard() {
|
||||
const [apiKey, setApiKey] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
|
||||
const mcpServerUrl = typeof window !== 'undefined'
|
||||
? `${window.location.origin}/api/mcp`
|
||||
: 'https://vibnai.com/api/mcp';
|
||||
|
||||
useEffect(() => {
|
||||
loadExistingKey();
|
||||
}, []);
|
||||
|
||||
const loadExistingKey = async () => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const response = await fetch('/api/mcp/generate-key', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setApiKey(data.apiKey);
|
||||
setHasKey(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading MCP key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const generateKey = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in to generate an API key');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const response = await fetch('/api/mcp/generate-key', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate API key');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setApiKey(data.apiKey);
|
||||
setHasKey(true);
|
||||
toast.success('MCP API key generated!');
|
||||
} catch (error) {
|
||||
console.error('Error generating API key:', error);
|
||||
toast.error('Failed to generate API key');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeKey = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const response = await fetch('/api/mcp/generate-key', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to revoke API key');
|
||||
}
|
||||
|
||||
setApiKey('');
|
||||
setHasKey(false);
|
||||
toast.success('MCP API key revoked');
|
||||
} catch (error) {
|
||||
console.error('Error revoking API key:', error);
|
||||
toast.error('Failed to revoke API key');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
};
|
||||
|
||||
const copyAllSettings = () => {
|
||||
const settings = `Name: Vibn
|
||||
Description: Access your Vibn coding projects, sessions, and AI conversation history
|
||||
MCP Server URL: ${mcpServerUrl}
|
||||
Authentication: Bearer
|
||||
API Key: ${apiKey}`;
|
||||
|
||||
navigator.clipboard.writeText(settings);
|
||||
toast.success('All settings copied! Paste into ChatGPT');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>ChatGPT Integration (MCP)</CardTitle>
|
||||
<CardDescription>
|
||||
Connect ChatGPT to your Vibn data using the Model Context Protocol
|
||||
</CardDescription>
|
||||
</div>
|
||||
<a
|
||||
href="https://help.openai.com/en/articles/10206189-connecting-mcp-servers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
Setup Guide <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{!hasKey ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate an API key to connect ChatGPT to your Vibn projects. This key allows
|
||||
ChatGPT to access your project data, coding sessions, and conversation history.
|
||||
</p>
|
||||
<Button onClick={generateKey} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
'Generate MCP API Key'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* MCP Server URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mcp-url">MCP Server URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="mcp-url"
|
||||
value={mcpServerUrl}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(mcpServerUrl, 'MCP Server URL')}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mcp-key">API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="mcp-key"
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
readOnly
|
||||
className="font-mono text-sm pr-10"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
>
|
||||
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(apiKey, 'API Key')}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This key doesn't expire. Keep it secure and never share it publicly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Copy Button */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<p className="text-sm font-medium mb-2">Quick Setup for ChatGPT</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Click below to copy all settings, then paste them into ChatGPT's "New Connector" form
|
||||
</p>
|
||||
<Button onClick={copyAllSettings} className="w-full">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy All Settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Setup Instructions */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Setup Steps:</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Click "Copy All Settings" above</li>
|
||||
<li>Open ChatGPT and go to Settings → Personalization → Custom Tools</li>
|
||||
<li>Click "Add New Connector"</li>
|
||||
<li>Fill in the form with the copied settings</li>
|
||||
<li>Set Authentication to "Bearer" and paste the API Key</li>
|
||||
<li>Check "I understand" and click Create</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Try It */}
|
||||
<div className="rounded-lg border border-green-500/20 bg-green-500/5 p-4">
|
||||
<p className="text-sm font-medium text-green-700 dark:text-green-400 mb-2">
|
||||
✨ Try asking ChatGPT:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• "Show me my Vibn projects"</li>
|
||||
<li>• "What are my recent coding sessions?"</li>
|
||||
<li>• "How much have I spent on AI this month?"</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Revoke Key */}
|
||||
<div className="pt-4 border-t">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" disabled={loading}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Revoke API Key
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke MCP API Key?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will immediately disconnect ChatGPT from your Vibn data. You'll need
|
||||
to generate a new key to reconnect.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={revokeKey}>Revoke Key</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MCP Playground Component
|
||||
*
|
||||
* Interactive demo of Vibn's MCP capabilities
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { auth } from '@/lib/firebase/config';
|
||||
import { toast } from 'sonner';
|
||||
import { Code2, Database, MessageSquare, Zap } from 'lucide-react';
|
||||
|
||||
interface MCPRequest {
|
||||
action: string;
|
||||
params?: any;
|
||||
}
|
||||
|
||||
export function MCPPlayground() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [selectedTool, setSelectedTool] = useState<string | null>(null);
|
||||
|
||||
const callMCP = async (request: MCPRequest) => {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in to use MCP');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
|
||||
const response = await fetch('/api/mcp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'MCP request failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
toast.success('MCP request completed');
|
||||
} catch (error) {
|
||||
console.error('MCP error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'MCP request failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tools = [
|
||||
{
|
||||
id: 'list_resources',
|
||||
name: 'List Resources',
|
||||
description: 'View all available MCP resources',
|
||||
icon: Database,
|
||||
action: () => callMCP({ action: 'list_resources' }),
|
||||
},
|
||||
{
|
||||
id: 'read_projects',
|
||||
name: 'Read Projects',
|
||||
description: 'Get all your projects via MCP',
|
||||
icon: Code2,
|
||||
action: () => {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in');
|
||||
return;
|
||||
}
|
||||
callMCP({
|
||||
action: 'read_resource',
|
||||
params: { uri: `vibn://projects/${user.uid}` },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'read_sessions',
|
||||
name: 'Read Sessions',
|
||||
description: 'Get all your coding sessions',
|
||||
icon: Zap,
|
||||
action: () => {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in');
|
||||
return;
|
||||
}
|
||||
callMCP({
|
||||
action: 'read_resource',
|
||||
params: { uri: `vibn://sessions/${user.uid}` },
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-bold">MCP Playground</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Test Vibn's Model Context Protocol capabilities. This is what AI assistants see when they
|
||||
query your project data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tool Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{tools.map((tool) => (
|
||||
<Card
|
||||
key={tool.id}
|
||||
className="cursor-pointer transition-all hover:shadow-md"
|
||||
onClick={() => {
|
||||
setSelectedTool(tool.id);
|
||||
tool.action();
|
||||
}}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<tool.icon className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">{tool.name}</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-xs">{tool.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="text-sm text-muted-foreground">Processing MCP request...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MCP Response</CardTitle>
|
||||
<CardDescription>
|
||||
This is the data that would be sent to an AI assistant
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={JSON.stringify(result, null, 2)}
|
||||
readOnly
|
||||
className="min-h-[400px] font-mono text-xs"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">About MCP</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
The Model Context Protocol (MCP) is a standard that allows AI assistants to access your
|
||||
project data in a secure and structured way.
|
||||
</p>
|
||||
<p>
|
||||
With Vibn's MCP server, AI assistants like Claude or ChatGPT can:
|
||||
</p>
|
||||
<ul className="ml-6 list-disc space-y-1">
|
||||
<li>View your projects and coding sessions</li>
|
||||
<li>Analyze your development patterns</li>
|
||||
<li>Reference past AI conversations for context</li>
|
||||
<li>Provide insights based on your actual work</li>
|
||||
</ul>
|
||||
<p className="pt-2">
|
||||
<a
|
||||
href="/MCP_SETUP.md"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Learn how to set up MCP with your AI assistant →
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Search
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
|
||||
interface ContextItem {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'insight' | 'file' | 'chat' | 'image';
|
||||
timestamp?: Date;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface InsightTheme {
|
||||
theme: string;
|
||||
description: string;
|
||||
insights: ContextItem[];
|
||||
}
|
||||
|
||||
interface CategorySection {
|
||||
id: 'insights' | 'files' | 'chats' | 'images';
|
||||
label: string;
|
||||
items: ContextItem[];
|
||||
themes?: InsightTheme[];
|
||||
}
|
||||
|
||||
interface MissionContextTreeProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function MissionContextTree({ projectId }: MissionContextTreeProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [sections, setSections] = useState<CategorySection[]>([
|
||||
{ id: 'insights', label: 'Insights', items: [] },
|
||||
{ id: 'files', label: 'Files', items: [] },
|
||||
{ id: 'chats', label: 'Chats', items: [] },
|
||||
{ id: 'images', label: 'Images', items: [] },
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchContextData();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const fetchContextData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (user) {
|
||||
const token = await user.getIdToken();
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.log('[MissionContextTree] No user logged in, attempting unauthenticated fetch (development mode)');
|
||||
}
|
||||
|
||||
// Fetch insights from AlloyDB knowledge chunks
|
||||
console.log('[MissionContextTree] Fetching insights from:', `/api/projects/${projectId}/knowledge/chunks`);
|
||||
const insightsResponse = await fetch(
|
||||
`/api/projects/${projectId}/knowledge/chunks`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!insightsResponse.ok) {
|
||||
console.error('[MissionContextTree] Insights fetch failed:', insightsResponse.status, await insightsResponse.text());
|
||||
}
|
||||
|
||||
const insightsData = insightsResponse.ok ? await insightsResponse.json() : { chunks: [] };
|
||||
console.log('[MissionContextTree] Insights data:', insightsData);
|
||||
|
||||
const insights: ContextItem[] = insightsData.chunks?.map((chunk: any) => ({
|
||||
id: chunk.id,
|
||||
title: chunk.content?.substring(0, 50) || 'Untitled',
|
||||
content: chunk.content,
|
||||
type: 'insight' as const,
|
||||
timestamp: chunk.created_at ? new Date(chunk.created_at) : undefined,
|
||||
})) || [];
|
||||
|
||||
// Group insights into themes using AI
|
||||
let insightThemes: InsightTheme[] = [];
|
||||
if (insights.length > 0) {
|
||||
console.log('[MissionContextTree] Grouping insights into themes...');
|
||||
try {
|
||||
const themesResponse = await fetch(
|
||||
`/api/projects/${projectId}/knowledge/themes`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ insights }),
|
||||
}
|
||||
);
|
||||
|
||||
if (themesResponse.ok) {
|
||||
const themesData = await themesResponse.json();
|
||||
console.log('[MissionContextTree] Got', themesData.themes?.length || 0, 'themes');
|
||||
|
||||
// Map themes to insights
|
||||
insightThemes = (themesData.themes || []).map((theme: any) => ({
|
||||
theme: theme.theme,
|
||||
description: theme.description,
|
||||
insights: insights.filter(i => theme.insightIds.includes(i.id)),
|
||||
}));
|
||||
} else {
|
||||
console.error('[MissionContextTree] Themes fetch failed:', themesResponse.status);
|
||||
}
|
||||
} catch (themeError) {
|
||||
console.error('[MissionContextTree] Error grouping themes:', themeError);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch files from Firebase Storage
|
||||
console.log('[MissionContextTree] Fetching files from:', `/api/projects/${projectId}/storage/files`);
|
||||
const filesResponse = await fetch(
|
||||
`/api/projects/${projectId}/storage/files`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!filesResponse.ok) {
|
||||
console.error('[MissionContextTree] Files fetch failed:', filesResponse.status, await filesResponse.text());
|
||||
}
|
||||
|
||||
const filesData = filesResponse.ok ? await filesResponse.json() : { files: [] };
|
||||
console.log('[MissionContextTree] Files data:', filesData);
|
||||
|
||||
const files: ContextItem[] = filesData.files?.map((file: any) => ({
|
||||
id: file.name,
|
||||
title: file.name,
|
||||
type: 'file' as const,
|
||||
timestamp: file.timeCreated ? new Date(file.timeCreated) : undefined,
|
||||
})) || [];
|
||||
|
||||
// Fetch chats and images from Firestore knowledge collection via API
|
||||
console.log('[MissionContextTree] Fetching knowledge items from:', `/api/projects/${projectId}/knowledge/items`);
|
||||
const knowledgeResponse = await fetch(
|
||||
`/api/projects/${projectId}/knowledge/items`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!knowledgeResponse.ok) {
|
||||
console.error('[MissionContextTree] Knowledge items fetch failed:', knowledgeResponse.status, await knowledgeResponse.text());
|
||||
}
|
||||
|
||||
const knowledgeData = knowledgeResponse.ok ? await knowledgeResponse.json() : { items: [] };
|
||||
console.log('[MissionContextTree] Knowledge items count:', knowledgeData.items?.length || 0);
|
||||
|
||||
const chats: ContextItem[] = [];
|
||||
const images: ContextItem[] = [];
|
||||
|
||||
(knowledgeData.items || []).forEach((item: any) => {
|
||||
const contextItem: ContextItem = {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: 'chat',
|
||||
timestamp: item.createdAt ? new Date(item.createdAt) : undefined,
|
||||
};
|
||||
|
||||
// Categorize based on sourceType
|
||||
if (item.sourceType === 'imported_ai_chat' || item.sourceType === 'imported_chat' || item.sourceType === 'user_chat') {
|
||||
chats.push({ ...contextItem, type: 'chat' });
|
||||
} else if (item.sourceMeta?.filename?.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) {
|
||||
images.push({ ...contextItem, type: 'image' });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[MissionContextTree] Final counts - Insights:', insights.length, 'Files:', files.length, 'Chats:', chats.length, 'Images:', images.length, 'Themes:', insightThemes.length);
|
||||
|
||||
setSections([
|
||||
{ id: 'insights', label: 'Insights', items: insights, themes: insightThemes },
|
||||
{ id: 'files', label: 'Files', items: files },
|
||||
{ id: 'chats', label: 'Chats', items: chats },
|
||||
{ id: 'images', label: 'Images', items: images },
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching context data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
const newExpanded = new Set(expandedSections);
|
||||
if (newExpanded.has(sectionId)) {
|
||||
newExpanded.delete(sectionId);
|
||||
} else {
|
||||
newExpanded.add(sectionId);
|
||||
}
|
||||
setExpandedSections(newExpanded);
|
||||
};
|
||||
|
||||
const filteredSections = sections.map(section => ({
|
||||
...section,
|
||||
items: section.items.filter(item =>
|
||||
!searchQuery || item.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search */}
|
||||
<div className="mb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-7 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Tree */}
|
||||
<div className="flex-1 overflow-auto space-y-0.5">
|
||||
{filteredSections.map((section) => {
|
||||
const isExpanded = expandedSections.has(section.id);
|
||||
|
||||
return (
|
||||
<div key={section.id}>
|
||||
{/* Section Header */}
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate">{section.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Section Items */}
|
||||
{isExpanded && (
|
||||
<div className="ml-6 space-y-0.5">
|
||||
{section.items.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground italic">
|
||||
No {section.label.toLowerCase()} yet
|
||||
</div>
|
||||
) : section.id === 'insights' && section.themes && section.themes.length > 0 ? (
|
||||
// Render insights grouped by themes
|
||||
section.themes.map((theme) => {
|
||||
const themeKey = `theme-${section.id}-${theme.theme}`;
|
||||
const isThemeExpanded = expandedSections.has(themeKey);
|
||||
|
||||
return (
|
||||
<div key={themeKey} className="space-y-0.5">
|
||||
{/* Theme Header */}
|
||||
<button
|
||||
onClick={() => toggleSection(themeKey)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
{isThemeExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<FolderOpen className="h-3 w-3 shrink-0 text-amber-500" />
|
||||
<span className="truncate font-medium text-xs">{theme.theme}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({theme.insights.length})</span>
|
||||
</button>
|
||||
|
||||
{/* Theme Insights */}
|
||||
{isThemeExpanded && (
|
||||
<div className="ml-5 space-y-0.5">
|
||||
{theme.insights.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs hover:bg-muted rounded transition-colors text-left",
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
title={item.content || item.title}
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Render items normally for non-insights sections
|
||||
section.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted rounded transition-colors text-left",
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
title={item.title}
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Clock, History, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { auth, db } from "@/lib/firebase/config";
|
||||
import { doc, getDoc, collection, query, where, orderBy, getDocs } from "firebase/firestore";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface MissionRevision {
|
||||
id: string;
|
||||
content: string;
|
||||
updatedAt: Date;
|
||||
updatedBy: string;
|
||||
source: 'ai' | 'user';
|
||||
}
|
||||
|
||||
interface MissionIdeaSectionProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function MissionIdeaSection({ projectId }: MissionIdeaSectionProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [content, setContent] = useState("");
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [revisions, setRevisions] = useState<MissionRevision[]>([]);
|
||||
const [loadingRevisions, setLoadingRevisions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchMissionIdea();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const fetchMissionIdea = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
// Fetch current mission idea from project document
|
||||
const projectRef = doc(db, 'projects', projectId);
|
||||
const projectSnap = await getDoc(projectRef);
|
||||
|
||||
if (projectSnap.exists()) {
|
||||
const data = projectSnap.data();
|
||||
setContent(
|
||||
data.missionIdea ||
|
||||
"Help solo founders build and launch their products 10x faster by turning conversations into production-ready code, designs, and marketing."
|
||||
);
|
||||
setLastUpdated(data.missionIdeaUpdatedAt?.toDate() || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching mission idea:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRevisions = async () => {
|
||||
setLoadingRevisions(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
// Fetch revision history
|
||||
const revisionsRef = collection(db, 'missionRevisions');
|
||||
const revisionsQuery = query(
|
||||
revisionsRef,
|
||||
where('projectId', '==', projectId),
|
||||
orderBy('updatedAt', 'desc')
|
||||
);
|
||||
const revisionsSnap = await getDocs(revisionsQuery);
|
||||
|
||||
const revisionsList: MissionRevision[] = [];
|
||||
revisionsSnap.forEach((doc) => {
|
||||
const data = doc.data();
|
||||
revisionsList.push({
|
||||
id: doc.id,
|
||||
content: data.content,
|
||||
updatedAt: data.updatedAt?.toDate(),
|
||||
updatedBy: data.updatedBy || 'AI',
|
||||
source: data.source || 'ai',
|
||||
});
|
||||
});
|
||||
|
||||
setRevisions(revisionsList);
|
||||
} catch (error) {
|
||||
console.error('Error fetching revisions:', error);
|
||||
} finally {
|
||||
setLoadingRevisions(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Content Card */}
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<p className="text-xl font-medium leading-relaxed">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Meta Information */}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
{lastUpdated
|
||||
? `Last updated ${formatDistanceToNow(lastUpdated, { addSuffix: true })} by AI`
|
||||
: 'Not yet updated'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Revision History */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchRevisions}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
View History
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] sm:w-[600px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Revision History</SheetTitle>
|
||||
<SheetDescription>
|
||||
See how your mission idea has evolved over time
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-120px)] mt-6">
|
||||
{loadingRevisions ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : revisions.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<History className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No revision history yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{revisions.map((revision, index) => (
|
||||
<div
|
||||
key={revision.id}
|
||||
className="rounded-lg border bg-card p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{revision.source === 'ai' ? 'AI Update' : 'Manual Edit'}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-primary/10 text-primary text-[10px] font-medium">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>
|
||||
{formatDistanceToNow(revision.updatedAt, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">
|
||||
{revision.content}
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{revision.updatedAt.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
Box,
|
||||
Map,
|
||||
FileCode,
|
||||
BarChart3,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ProjectSidebarProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: "Overview",
|
||||
icon: LayoutDashboard,
|
||||
href: "/overview",
|
||||
description: "Project dashboard and stats",
|
||||
},
|
||||
{
|
||||
title: "Sessions",
|
||||
icon: Activity,
|
||||
href: "/sessions",
|
||||
description: "AI coding sessions",
|
||||
},
|
||||
{
|
||||
title: "Features",
|
||||
icon: Box,
|
||||
href: "/features",
|
||||
description: "Feature planning and tracking",
|
||||
},
|
||||
{
|
||||
title: "API Map",
|
||||
icon: Map,
|
||||
href: "/api-map",
|
||||
description: "Auto-generated API docs",
|
||||
},
|
||||
{
|
||||
title: "Architecture",
|
||||
icon: FileCode,
|
||||
href: "/architecture",
|
||||
description: "Living architecture docs",
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
icon: BarChart3,
|
||||
href: "/analytics",
|
||||
description: "Costs and metrics",
|
||||
},
|
||||
];
|
||||
|
||||
export function ProjectSidebar({ projectId }: ProjectSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Project Header */}
|
||||
<div className="flex h-14 items-center justify-between border-b px-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">AI Proxy</h2>
|
||||
<p className="text-xs text-muted-foreground">Project Dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<ScrollArea className="flex-1 px-3 py-4">
|
||||
<div className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const href = `/${projectId}${item.href}`;
|
||||
const isActive = pathname === href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-all hover:bg-accent",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-accent-foreground"
|
||||
)}
|
||||
title={item.description}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{item.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* Settings Section */}
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
href={`/${projectId}/settings`}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-all hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex h-14 items-center justify-between border-t px-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>v1.0.0</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span>Powered by AI</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ResizableSidebarProps {
|
||||
children: ReactNode;
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
export function ResizableSidebar({
|
||||
children,
|
||||
defaultWidth = 250,
|
||||
minWidth = 200,
|
||||
maxWidth = 400,
|
||||
}: ResizableSidebarProps) {
|
||||
const [width, setWidth] = useState(defaultWidth);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [showPeek, setShowPeek] = useState(false);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const initialXRef = useRef<number>(0);
|
||||
const initialWidthRef = useRef<number>(defaultWidth);
|
||||
|
||||
const startResizing = useCallback((e: React.MouseEvent) => {
|
||||
setIsResizing(true);
|
||||
initialXRef.current = e.clientX;
|
||||
initialWidthRef.current = width;
|
||||
e.preventDefault();
|
||||
}, [width]);
|
||||
|
||||
const stopResizing = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
}, []);
|
||||
|
||||
const resize = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isResizing) {
|
||||
const deltaX = e.clientX - initialXRef.current;
|
||||
const newWidth = initialWidthRef.current + deltaX;
|
||||
const clampedWidth = Math.min(Math.max(newWidth, minWidth), maxWidth);
|
||||
setWidth(clampedWidth);
|
||||
}
|
||||
},
|
||||
[isResizing, minWidth, maxWidth]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResizing) {
|
||||
window.addEventListener("mousemove", resize);
|
||||
window.addEventListener("mouseup", stopResizing);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", resize);
|
||||
window.removeEventListener("mouseup", stopResizing);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [isResizing, resize, stopResizing]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (isCollapsed) {
|
||||
setShowPeek(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (isCollapsed) {
|
||||
setTimeout(() => setShowPeek(false), 300);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Peek trigger when collapsed */}
|
||||
{isCollapsed && (
|
||||
<div
|
||||
className="absolute left-0 top-0 z-40 h-full w-2"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
style={{ width: isCollapsed ? 0 : `${width}px` }}
|
||||
className={cn(
|
||||
"relative flex flex-col border-r bg-card transition-all duration-300",
|
||||
isCollapsed && "w-0 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{!isCollapsed && children}
|
||||
|
||||
{/* Resize Handle */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
onMouseDown={startResizing}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-1 cursor-col-resize transition-colors hover:bg-primary/20",
|
||||
isResizing && "bg-primary/40"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Collapse/Expand Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className={cn(
|
||||
"absolute -right-3 top-4 z-10 h-6 w-6 rounded-full shadow-sm",
|
||||
isCollapsed && "left-2"
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</aside>
|
||||
|
||||
{/* Peek Sidebar (when collapsed) */}
|
||||
{isCollapsed && showPeek && (
|
||||
<div
|
||||
className="absolute left-0 top-0 z-30 h-full w-64 border-r bg-card shadow-lg"
|
||||
style={{ width: `${defaultWidth}px` }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user