320 lines
15 KiB
TypeScript
320 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { useQuery } from "convex/react"
|
|
import { useState } from "react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import { useProject } from "@/components/project-context"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
|
import { Button } from "@/components/ui/button"
|
|
import { useMutation } from "convex/react"
|
|
import { Progress } from "@/components/ui/progress"
|
|
|
|
export default function Page() {
|
|
const { selectedProjectId } = useProject()
|
|
const projects = useQuery(api.projects.getProjects)
|
|
const dataSources = useQuery(
|
|
api.dataSources.getProjectDataSources,
|
|
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
|
)
|
|
const searchContext = useQuery(
|
|
api.projects.getSearchContext,
|
|
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
|
)
|
|
const analysisJobs = useQuery(
|
|
api.analysisJobs.listByProject,
|
|
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
|
)
|
|
const updateDataSourceStatus = useMutation(api.dataSources.updateDataSourceStatus)
|
|
const createAnalysis = useMutation(api.analyses.createAnalysis)
|
|
const createAnalysisJob = useMutation(api.analysisJobs.create)
|
|
const [reanalyzingId, setReanalyzingId] = useState<string | null>(null)
|
|
const analysis = useQuery(
|
|
api.analyses.getLatestByProject,
|
|
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
|
)
|
|
|
|
const selectedProject = projects?.find((project) => project._id === selectedProjectId)
|
|
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || []
|
|
const isLoading = selectedProjectId && analysis === undefined
|
|
|
|
if (!selectedProjectId && projects && projects.length === 0) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center p-10 text-center">
|
|
<div className="space-y-2">
|
|
<h2 className="text-xl font-semibold">No projects yet</h2>
|
|
<p className="text-muted-foreground">Complete onboarding to create your first project.</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center p-10 text-center text-muted-foreground">
|
|
Loading analysis...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!analysis) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center p-10 text-center">
|
|
<div className="space-y-2">
|
|
<h2 className="text-xl font-semibold">No analysis yet</h2>
|
|
<p className="text-muted-foreground">Run onboarding to analyze a product for this project.</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const handleReanalyze = async (source: any) => {
|
|
if (!selectedProjectId) return
|
|
|
|
setReanalyzingId(source._id)
|
|
await updateDataSourceStatus({
|
|
dataSourceId: source._id,
|
|
analysisStatus: "pending",
|
|
lastError: undefined,
|
|
lastAnalyzedAt: undefined,
|
|
})
|
|
|
|
try {
|
|
const jobId = await createAnalysisJob({
|
|
projectId: selectedProjectId as any,
|
|
dataSourceId: source._id,
|
|
})
|
|
|
|
const response = await fetch("/api/analyze", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url: source.url, jobId }),
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (!response.ok) {
|
|
await updateDataSourceStatus({
|
|
dataSourceId: source._id,
|
|
analysisStatus: "failed",
|
|
lastError: data.error || "Analysis failed",
|
|
lastAnalyzedAt: Date.now(),
|
|
})
|
|
return
|
|
}
|
|
|
|
await createAnalysis({
|
|
projectId: selectedProjectId as any,
|
|
dataSourceId: source._id,
|
|
analysis: data.data,
|
|
})
|
|
|
|
await updateDataSourceStatus({
|
|
dataSourceId: source._id,
|
|
analysisStatus: "completed",
|
|
lastError: undefined,
|
|
lastAnalyzedAt: Date.now(),
|
|
})
|
|
} finally {
|
|
setReanalyzingId(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-8">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-2xl font-semibold">
|
|
{selectedProject?.name || analysis.productName}
|
|
</h1>
|
|
<Badge variant="outline">{analysis.productName}</Badge>
|
|
</div>
|
|
<p className="text-muted-foreground">{analysis.tagline}</p>
|
|
<p className="max-w-3xl text-sm text-muted-foreground">{analysis.description}</p>
|
|
</div>
|
|
|
|
{searchContext?.missingSources?.length > 0 && (
|
|
<Alert>
|
|
<AlertDescription>
|
|
Some selected sources don't have analysis yet:{" "}
|
|
{searchContext.missingSources
|
|
.map((missing: any) =>
|
|
dataSources?.find((source: any) => source._id === missing.sourceId)?.name ||
|
|
dataSources?.find((source: any) => source._id === missing.sourceId)?.url ||
|
|
missing.sourceId
|
|
)
|
|
.join(", ")}
|
|
. Run onboarding or re-analyze them for best results.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Features</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-2xl font-semibold">{analysis.features.length}</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Keywords</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-2xl font-semibold">{analysis.keywords.length}</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Personas</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-2xl font-semibold">{analysis.personas.length}</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Competitors</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-2xl font-semibold">{analysis.competitors.length}</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Use Cases</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-2xl font-semibold">{analysis.useCases.length}</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Data Sources</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
|
{dataSources && dataSources.length > 0 ? (
|
|
dataSources.map((source: any) => (
|
|
<div key={source._id} className="flex items-center justify-between gap-3">
|
|
<span className="truncate">{source.name || source.url}</span>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={selectedSourceIds.includes(source._id) ? "secondary" : "outline"}>
|
|
{selectedSourceIds.includes(source._id) ? "active" : "inactive"}
|
|
</Badge>
|
|
<Badge variant={source.analysisStatus === "completed" ? "secondary" : "outline"}>
|
|
{source.analysisStatus}
|
|
</Badge>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleReanalyze(source)}
|
|
disabled={reanalyzingId === source._id}
|
|
>
|
|
{reanalyzingId === source._id ? "Analyzing..." : "Re-analyze"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p>No data sources yet. Add a source during onboarding.</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{analysisJobs && analysisJobs.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Analysis Jobs</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
|
{analysisJobs.slice(0, 5).map((job: any) => {
|
|
const sourceName = dataSources?.find((source: any) => source._id === job.dataSourceId)?.name
|
|
|| dataSources?.find((source: any) => source._id === job.dataSourceId)?.url
|
|
|| "Unknown source"
|
|
return (
|
|
<div key={job._id} className="space-y-2 rounded-md border border-border p-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="truncate">
|
|
<span className="font-medium text-foreground">{sourceName}</span>{" "}
|
|
<span className="text-muted-foreground">
|
|
({job.status})
|
|
</span>
|
|
</div>
|
|
{job.status === "failed" && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const source = dataSources?.find((s: any) => s._id === job.dataSourceId)
|
|
if (source) void handleReanalyze(source)
|
|
}}
|
|
>
|
|
Retry
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{(job.status === "running" || job.status === "pending") && (
|
|
<div className="space-y-1">
|
|
<Progress value={typeof job.progress === "number" ? job.progress : 10} />
|
|
<div className="text-xs text-muted-foreground">
|
|
{typeof job.progress === "number" ? `${job.progress}% complete` : "Starting..."}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{job.status === "failed" && job.error && (
|
|
<div className="text-xs text-destructive">{job.error}</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{searchContext?.context && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Aggregated Context</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 text-sm text-muted-foreground md:grid-cols-2">
|
|
<div>Keywords: <span className="text-foreground font-medium">{searchContext.context.keywords.length}</span></div>
|
|
<div>Problems: <span className="text-foreground font-medium">{searchContext.context.problemsSolved.length}</span></div>
|
|
<div>Competitors: <span className="text-foreground font-medium">{searchContext.context.competitors.length}</span></div>
|
|
<div>Use Cases: <span className="text-foreground font-medium">{searchContext.context.useCases.length}</span></div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Top Features</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{analysis.features.slice(0, 6).map((feature, index) => (
|
|
<div key={`${feature.name}-${index}`} className="flex items-start gap-2">
|
|
<span className="mt-1 h-2 w-2 rounded-full bg-primary" />
|
|
<div>
|
|
<p className="text-sm font-medium">{feature.name}</p>
|
|
<p className="text-xs text-muted-foreground">{feature.description}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Top Problems Solved</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{analysis.problemsSolved.slice(0, 6).map((problem, index) => (
|
|
<div key={`${problem.problem}-${index}`} className="flex items-start gap-2">
|
|
<span className="mt-1 h-2 w-2 rounded-full bg-primary" />
|
|
<div>
|
|
<p className="text-sm font-medium">{problem.problem}</p>
|
|
<p className="text-xs text-muted-foreground">{problem.emotionalImpact}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|