409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { use, useState, useEffect } from "react";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
FileText,
|
|
Plus,
|
|
Search,
|
|
Filter,
|
|
MoreHorizontal,
|
|
Star,
|
|
Info,
|
|
Share2,
|
|
Archive,
|
|
Loader2,
|
|
Target,
|
|
Lightbulb,
|
|
MessageSquare,
|
|
BookOpen,
|
|
} from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import Link from "next/link";
|
|
import { toast } from "sonner";
|
|
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
|
|
|
|
type DocType = "all" | "vision" | "features" | "research" | "chats";
|
|
type ViewType = "public" | "private" | "archived";
|
|
|
|
interface Document {
|
|
id: string;
|
|
title: string;
|
|
type: DocType;
|
|
owner: string;
|
|
dateModified: string;
|
|
visibility: ViewType;
|
|
starred: boolean;
|
|
chunkCount?: number;
|
|
}
|
|
|
|
export default function DocsPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ workspace: string; projectId: string }>;
|
|
}) {
|
|
const { workspace, projectId } = use(params);
|
|
const [activeView, setActiveView] = useState<ViewType>("public");
|
|
const [filterType, setFilterType] = useState<DocType>("all");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [documents, setDocuments] = useState<Document[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [sortBy, setSortBy] = useState<"modified" | "created">("modified");
|
|
|
|
useEffect(() => {
|
|
loadDocuments();
|
|
}, [projectId, activeView, filterType]);
|
|
|
|
const loadDocuments = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch(
|
|
`/api/projects/${projectId}/knowledge/items?visibility=${activeView}&type=${filterType}`
|
|
);
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
// Use returned items or mock data if empty
|
|
if (data.items && data.items.length > 0) {
|
|
// Transform knowledge items to document format
|
|
const docs = data.items.map((item: any, index: number) => ({
|
|
id: item.id,
|
|
title: item.title,
|
|
type: item.sourceType === 'vision' ? 'vision' :
|
|
item.sourceType === 'feature' ? 'features' :
|
|
item.sourceType === 'chat' ? 'chats' : 'research',
|
|
owner: "You",
|
|
dateModified: item.updatedAt || item.createdAt,
|
|
visibility: activeView,
|
|
starred: false,
|
|
chunkCount: item.chunkCount,
|
|
}));
|
|
setDocuments(docs);
|
|
} else {
|
|
// Show mock data when no real data exists
|
|
setDocuments([
|
|
{
|
|
id: "1",
|
|
title: "Project Vision & Mission",
|
|
type: "vision",
|
|
owner: "You",
|
|
dateModified: new Date().toISOString(),
|
|
visibility: "public",
|
|
starred: true,
|
|
chunkCount: 12,
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "Core Features Specification",
|
|
type: "features",
|
|
owner: "You",
|
|
dateModified: new Date(Date.now() - 86400000).toISOString(),
|
|
visibility: "public",
|
|
starred: false,
|
|
chunkCount: 24,
|
|
},
|
|
]);
|
|
}
|
|
} else {
|
|
// Fallback to mock data on error
|
|
setDocuments([
|
|
{
|
|
id: "1",
|
|
title: "Project Vision & Mission",
|
|
type: "vision",
|
|
owner: "You",
|
|
dateModified: new Date().toISOString(),
|
|
visibility: "public",
|
|
starred: true,
|
|
chunkCount: 12,
|
|
},
|
|
]);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading documents:", error);
|
|
// Show mock data on error
|
|
setDocuments([
|
|
{
|
|
id: "1",
|
|
title: "Project Vision & Mission",
|
|
type: "vision",
|
|
owner: "You",
|
|
dateModified: new Date().toISOString(),
|
|
visibility: "public",
|
|
starred: true,
|
|
chunkCount: 12,
|
|
},
|
|
]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getDocIcon = (type: DocType) => {
|
|
switch (type) {
|
|
case "vision":
|
|
return <Target className="h-4 w-4 text-blue-600" />;
|
|
case "features":
|
|
return <Lightbulb className="h-4 w-4 text-purple-600" />;
|
|
case "research":
|
|
return <BookOpen className="h-4 w-4 text-green-600" />;
|
|
case "chats":
|
|
return <MessageSquare className="h-4 w-4 text-orange-600" />;
|
|
default:
|
|
return <FileText className="h-4 w-4 text-gray-600" />;
|
|
}
|
|
};
|
|
|
|
const filteredDocuments = documents.filter((doc) => {
|
|
if (searchQuery && !doc.title.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
return (
|
|
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
|
{/* Left Sidebar */}
|
|
<CollapsibleSidebar>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold mb-2">Document Stats</h3>
|
|
<div className="space-y-2 text-xs">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Total Docs</span>
|
|
<span className="font-medium">{documents.length}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Public</span>
|
|
<span className="font-medium">{documents.filter(d => d.visibility === 'public').length}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Private</span>
|
|
<span className="font-medium">{documents.filter(d => d.visibility === 'private').length}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Starred</span>
|
|
<span className="font-medium">{documents.filter(d => d.starred).length}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CollapsibleSidebar>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* Header */}
|
|
<div className="border-b bg-background">
|
|
<div className="flex items-center justify-between p-4">
|
|
<div className="flex items-center gap-4">
|
|
<h1 className="text-xl font-bold">Docs</h1>
|
|
<Badge variant="secondary" className="font-normal">
|
|
{filteredDocuments.length} {filteredDocuments.length === 1 ? "doc" : "docs"}
|
|
</Badge>
|
|
</div>
|
|
<Button className="gap-2">
|
|
<Plus className="h-4 w-4" />
|
|
Add page
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex items-center gap-6 px-4">
|
|
<button
|
|
onClick={() => setActiveView("public")}
|
|
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
|
activeView === "public"
|
|
? "border-primary text-primary"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
Public
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveView("private")}
|
|
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
|
activeView === "private"
|
|
? "border-primary text-primary"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
Private
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveView("archived")}
|
|
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
|
activeView === "archived"
|
|
? "border-primary text-primary"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
Archived
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-4 p-4 border-b bg-muted/30">
|
|
<div className="relative flex-1 max-w-md">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search docs..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Sort by" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="modified">Date modified</SelectItem>
|
|
<SelectItem value="created">Date created</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={filterType} onValueChange={(value: any) => setFilterType(value)}>
|
|
<SelectTrigger className="w-[150px]">
|
|
<SelectValue placeholder="Filter" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All types</SelectItem>
|
|
<SelectItem value="vision">Vision</SelectItem>
|
|
<SelectItem value="features">Features</SelectItem>
|
|
<SelectItem value="research">Research</SelectItem>
|
|
<SelectItem value="chats">Chats</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Document List */}
|
|
<div className="flex-1 overflow-auto p-4">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : filteredDocuments.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-64 text-center">
|
|
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-semibold mb-2">No documents yet</h3>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Create your first document to get started
|
|
</p>
|
|
<Button>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add page
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filteredDocuments.map((doc) => (
|
|
<Link
|
|
key={doc.id}
|
|
href={`/${workspace}/project/${projectId}/docs/${doc.id}`}
|
|
className="block"
|
|
>
|
|
<Card className="p-4 hover:bg-accent/50 transition-colors cursor-pointer">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3 flex-1">
|
|
{getDocIcon(doc.type)}
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-medium text-sm">{doc.title}</h3>
|
|
{doc.starred && (
|
|
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1">
|
|
<span className="text-xs text-muted-foreground">{doc.owner}</span>
|
|
<span className="text-xs text-muted-foreground">•</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{new Date(doc.dateModified).toLocaleDateString()}
|
|
</span>
|
|
{doc.chunkCount && (
|
|
<>
|
|
<span className="text-xs text-muted-foreground">•</span>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{doc.chunkCount} chunks
|
|
</Badge>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
toast.info("Share functionality coming soon");
|
|
}}
|
|
>
|
|
<Share2 className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
toast.info("Info panel coming soon");
|
|
}}
|
|
>
|
|
<Info className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
// Toggle star
|
|
}}
|
|
>
|
|
<Star
|
|
className={`h-4 w-4 ${
|
|
doc.starred ? "fill-yellow-400 text-yellow-400" : ""
|
|
}`}
|
|
/>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
toast.info("More options coming soon");
|
|
}}
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* End Main Content */}
|
|
</div>
|
|
);
|
|
}
|
|
|