feat: Implement a new dashboard layout with sidebar, introduce project and data source management, and add various UI components.

This commit is contained in:
2026-02-03 15:11:53 +00:00
parent a795e92ef3
commit 7e3854d7d6
29 changed files with 2460 additions and 645 deletions

View File

@@ -1,516 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
Search,
Loader2,
ExternalLink,
RefreshCw,
Users,
Target,
Zap,
TrendingUp,
Lightbulb,
AlertCircle,
CheckCircle2,
BarChart3,
Sparkles
} from 'lucide-react'
import type { EnhancedProductAnalysis, Opportunity } from '@/lib/types'
export default function DashboardPage() {
const router = useRouter()
const searchParams = useSearchParams()
const productName = searchParams.get('product')
const [analysis, setAnalysis] = useState<EnhancedProductAnalysis | null>(null)
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
const [loadingOpps, setLoadingOpps] = useState(false)
const [stats, setStats] = useState<any>(null)
useEffect(() => {
const stored = localStorage.getItem('productAnalysis')
const storedStats = localStorage.getItem('analysisStats')
if (stored) {
setAnalysis(JSON.parse(stored))
}
if (storedStats) {
setStats(JSON.parse(storedStats))
}
}, [])
async function findOpportunities() {
if (!analysis) return
setLoadingOpps(true)
try {
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ analysis }),
})
if (!response.ok) throw new Error('Failed to search')
const data = await response.json()
setOpportunities(data.data.opportunities)
} catch (error) {
console.error('Search error:', error)
} finally {
setLoadingOpps(false)
}
}
function getScoreColor(score: number) {
if (score >= 0.8) return 'bg-green-500/20 text-green-400 border-green-500/30'
if (score >= 0.6) return 'bg-amber-500/20 text-amber-400 border-amber-500/30'
return 'bg-red-500/20 text-red-400 border-red-500/30'
}
function getIntentIcon(intent: string) {
switch (intent) {
case 'frustrated': return <AlertCircle className="h-4 w-4" />
case 'alternative': return <RefreshCw className="h-4 w-4" />
case 'comparison': return <BarChart3 className="h-4 w-4" />
case 'problem-solving': return <Lightbulb className="h-4 w-4" />
default: return <Search className="h-4 w-4" />
}
}
// Separate keywords by type for prioritization
const differentiatorKeywords = analysis?.keywords.filter(k =>
k.type === 'differentiator' ||
k.term.includes('vs') ||
k.term.includes('alternative') ||
k.term.includes('better')
) || []
const otherKeywords = analysis?.keywords.filter(k =>
!differentiatorKeywords.includes(k)
) || []
// Sort: single words first, then short phrases
const sortedKeywords = [...differentiatorKeywords, ...otherKeywords].sort((a, b) => {
const aWords = a.term.split(/\s+/).length
const bWords = b.term.split(/\s+/).length
return aWords - bWords || a.term.length - b.term.length
})
if (!analysis) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">No product analyzed yet.</p>
<Button onClick={() => router.push('/onboarding')}>
<Search className="mr-2 h-4 w-4" />
Analyze Product
</Button>
</div>
</div>
)
}
return (
<div className="p-8">
{/* Centered container */}
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold">{analysis.productName}</h1>
<p className="text-lg text-muted-foreground mt-1">{analysis.tagline}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.push('/onboarding')}>
<RefreshCw className="mr-2 h-4 w-4" />
New Analysis
</Button>
<Button onClick={findOpportunities} disabled={loadingOpps}>
{loadingOpps && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Search className="mr-2 h-4 w-4" />
Find Opportunities
</Button>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<Card className="p-4 text-center">
<Zap className="mx-auto h-5 w-5 text-blue-400 mb-2" />
<div className="text-2xl font-bold">{stats.features}</div>
<div className="text-xs text-muted-foreground">Features</div>
</Card>
<Card className="p-4 text-center">
<Target className="mx-auto h-5 w-5 text-purple-400 mb-2" />
<div className="text-2xl font-bold">{stats.keywords}</div>
<div className="text-xs text-muted-foreground">Keywords</div>
</Card>
<Card className="p-4 text-center">
<Users className="mx-auto h-5 w-5 text-green-400 mb-2" />
<div className="text-2xl font-bold">{stats.personas}</div>
<div className="text-xs text-muted-foreground">Personas</div>
</Card>
<Card className="p-4 text-center">
<Lightbulb className="mx-auto h-5 w-5 text-amber-400 mb-2" />
<div className="text-2xl font-bold">{stats.useCases}</div>
<div className="text-xs text-muted-foreground">Use Cases</div>
</Card>
<Card className="p-4 text-center">
<TrendingUp className="mx-auto h-5 w-5 text-red-400 mb-2" />
<div className="text-2xl font-bold">{stats.competitors}</div>
<div className="text-xs text-muted-foreground">Competitors</div>
</Card>
<Card className="p-4 text-center">
<Search className="mx-auto h-5 w-5 text-cyan-400 mb-2" />
<div className="text-2xl font-bold">{stats.dorkQueries}</div>
<div className="text-xs text-muted-foreground">Queries</div>
</Card>
</div>
)}
{/* Main Tabs */}
<Tabs defaultValue="overview" className="space-y-6">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="features">Features</TabsTrigger>
<TabsTrigger value="personas">Personas</TabsTrigger>
<TabsTrigger value="keywords">Keywords</TabsTrigger>
<TabsTrigger value="opportunities">
Opportunities {opportunities.length > 0 && `(${opportunities.length})`}
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Product Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground leading-relaxed">{analysis.description}</p>
</CardContent>
</Card>
<div className="grid md:grid-cols-2 gap-6">
{/* Problems Solved */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-red-400" />
Problems Solved
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{analysis.problemsSolved.slice(0, 5).map((problem, i) => (
<div key={i} className="border-l-2 border-red-500/30 pl-4">
<div className="flex items-center gap-2">
<span className="font-medium">{problem.problem}</span>
<Badge variant="outline" className={
problem.severity === 'high' ? 'border-red-500/30 text-red-400' :
problem.severity === 'medium' ? 'border-amber-500/30 text-amber-400' :
'border-green-500/30 text-green-400'
}>
{problem.severity}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Feeling: {problem.emotionalImpact}
</p>
</div>
))}
</CardContent>
</Card>
{/* Competitors */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-cyan-400" />
Competitive Landscape
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{analysis.competitors.slice(0, 4).map((comp, i) => (
<div key={i} className="border-l-2 border-cyan-500/30 pl-4">
<div className="font-medium text-lg">{comp.name}</div>
<p className="text-sm text-muted-foreground mt-1">
<span className="text-green-400">Your edge:</span> {comp.differentiator}
</p>
<p className="text-xs text-muted-foreground mt-1">
Their strength: {comp.theirStrength}
</p>
</div>
))}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Features Tab */}
<TabsContent value="features" className="space-y-4">
{analysis.features.map((feature, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{feature.name}
</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Benefits</h4>
<ul className="space-y-1">
{feature.benefits.map((b, j) => (
<li key={j} className="text-sm flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-400 mt-0.5 shrink-0" />
{b}
</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Use Cases</h4>
<ul className="space-y-1">
{feature.useCases.map((u, j) => (
<li key={j} className="text-sm text-muted-foreground">
{u}
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
))}
</TabsContent>
{/* Personas Tab */}
<TabsContent value="personas" className="space-y-4">
{analysis.personas.map((persona, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-primary" />
{persona.name}
</CardTitle>
<CardDescription>{persona.role} {persona.companySize}</CardDescription>
</div>
<Badge variant="outline">{persona.techSavvy} tech</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Pain Points</h4>
<div className="flex flex-wrap gap-2">
{persona.painPoints.map((p, j) => (
<Badge key={j} variant="secondary" className="bg-red-500/10 text-red-400">
{p}
</Badge>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Goals</h4>
<ul className="text-sm space-y-1">
{persona.goals.map((g, j) => (
<li key={j} className="text-muted-foreground"> {g}</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Search Behavior</h4>
<div className="space-y-1">
{persona.searchBehavior.map((s, j) => (
<div key={j} className="text-sm font-mono bg-muted px-2 py-1 rounded">
"{s}"
</div>
))}
</div>
</div>
</CardContent>
</Card>
))}
</TabsContent>
{/* Keywords Tab */}
<TabsContent value="keywords" className="space-y-6">
{/* Differentiation Keywords First */}
{differentiatorKeywords.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-primary" />
Differentiation Keywords
</CardTitle>
<CardDescription>
Keywords that highlight your competitive advantage
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{differentiatorKeywords.map((kw, i) => (
<Badge
key={i}
className="bg-primary/20 text-primary border-primary/30 text-sm px-3 py-1"
>
{kw.term}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* All Keywords */}
<Card>
<CardHeader>
<CardTitle>All Keywords ({sortedKeywords.length})</CardTitle>
<CardDescription>
Sorted by word count - single words first
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{sortedKeywords.map((kw, i) => (
<Badge
key={i}
variant={kw.type === 'differentiator' ? 'default' : 'outline'}
className={kw.type === 'differentiator' ? 'bg-primary/20 text-primary border-primary/30' : ''}
>
{kw.term}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* Keywords by Type */}
<div className="grid md:grid-cols-2 gap-4">
{['product', 'problem', 'solution', 'feature', 'competitor'].map(type => {
const typeKeywords = analysis.keywords.filter(k => k.type === type)
if (typeKeywords.length === 0) return null
return (
<Card key={type}>
<CardHeader className="pb-3">
<CardTitle className="text-sm uppercase tracking-wide">{type}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{typeKeywords.slice(0, 20).map((kw, i) => (
<Badge key={i} variant="secondary">
{kw.term}
</Badge>
))}
</div>
</CardContent>
</Card>
)
})}
</div>
</TabsContent>
{/* Opportunities Tab */}
<TabsContent value="opportunities" className="space-y-4">
{opportunities.length > 0 ? (
<>
{opportunities.map((opp) => (
<Card key={opp.url} className="hover:border-muted-foreground/50 transition-colors">
<CardContent className="pt-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-medium line-clamp-2">{opp.title}</h3>
</div>
<Badge className={getScoreColor(opp.relevanceScore)}>
{Math.round(opp.relevanceScore * 100)}%
</Badge>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline">{opp.source}</Badge>
<Badge variant="secondary" className="gap-1">
{getIntentIcon(opp.intent)}
{opp.intent}
</Badge>
{opp.matchedPersona && (
<Badge className="bg-blue-500/20 text-blue-400">
{opp.matchedPersona}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground line-clamp-3">
{opp.snippet}
</p>
{opp.matchedKeywords.length > 0 && (
<div className="flex flex-wrap gap-2">
{opp.matchedKeywords.map((k, i) => (
<Badge key={i} className="bg-purple-500/20 text-purple-400 text-xs">
{k}
</Badge>
))}
</div>
)}
<div className="bg-muted rounded-lg p-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide mb-1">
Suggested Approach
</p>
<p className="text-sm text-muted-foreground">{opp.suggestedApproach}</p>
</div>
<a
href={opp.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-primary hover:underline"
>
View Post
<ExternalLink className="ml-1 h-3 w-3" />
</a>
</CardContent>
</Card>
))}
</>
) : (
<Card className="bg-muted/50">
<CardContent className="py-12 text-center">
<Search className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">No opportunities yet</h3>
<p className="text-muted-foreground mb-4">
Click &quot;Find Opportunities&quot; to search for potential customers
</p>
<Button onClick={findOpportunities} disabled={loadingOpps}>
{loadingOpps && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Search className="mr-2 h-4 w-4" />
Find Opportunities
</Button>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
</div>
)
}

View File

@@ -3,20 +3,34 @@
import { SignIn } from "@/components/auth/SignIn";
import { Authenticated, Unauthenticated, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function AuthPage() {
const router = useRouter();
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="min-h-screen flex items-center justify-center bg-background">
<Unauthenticated>
<SignIn />
</Unauthenticated>
<Authenticated>
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">You are logged in!</h1>
<p>Redirecting...</p>
{/* You could add a redirect here or a button to go to dashboard */}
</div>
<RedirectToDashboard />
</Authenticated>
</div>
);
}
function RedirectToDashboard() {
const router = useRouter();
useEffect(() => {
router.push("/dashboard");
}, [router]);
return (
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">You are logged in!</h1>
<p>Redirecting to dashboard...</p>
</div>
);
}

49
app/dashboard/layout.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { AppSidebar } from "@/components/app-sidebar"
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
import { Separator } from "@/components/ui/separator"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">
Platform
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Dashboard</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
{children}
</div>
</SidebarInset>
</SidebarProvider>
)
}

17
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,17 @@
export default function Page() {
return (
<div className="flex flex-1 flex-col gap-4 p-4 lg:p-8">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50" >
<div className="flex items-center justify-center h-full text-muted-foreground p-10 text-center">
<h2 className="text-xl font-semibold">Your Leads will appear here</h2>
<p>Select data sources in the sidebar to start finding opportunities dorked from the web.</p>
</div>
</div>
</div>
)
}

View File

@@ -3,6 +3,7 @@
@tailwind utilities;
:root {
color-scheme: dark;
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
@@ -33,4 +34,24 @@
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
:root {
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}

View File

@@ -3,6 +3,7 @@ import { Inter } from 'next/font/google'
import './globals.css'
import ConvexClientProvider from './ConvexClientProvider'
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
import { ThemeProvider } from "@/components/theme-provider";
const inter = Inter({ subsets: ['latin'] })
@@ -18,9 +19,16 @@ export default function RootLayout({
}) {
return (
<ConvexAuthNextjsServerProvider>
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<ConvexClientProvider>{children}</ConvexClientProvider>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>{children}</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</ConvexAuthNextjsServerProvider>

View File

@@ -19,7 +19,7 @@ export default function LandingPage() {
<Link href="/dashboard" className="text-sm text-muted-foreground hover:text-foreground">
Dashboard
</Link>
<Link href="/onboarding">
<Link href="/auth">
<Button size="sm">Get Started</Button>
</Link>
</nav>
@@ -33,20 +33,20 @@ export default function LandingPage() {
<Sparkles className="mr-1 h-3 w-3" />
AI-Powered Research
</Badge>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl text-foreground">
Find Your Next Customers
<br />
<span className="text-muted-foreground">Before They Know They Need You</span>
</h1>
<p className="max-w-2xl text-lg text-muted-foreground">
AutoDork analyzes your product and finds people on Reddit, Hacker News, and forums
AutoDork analyzes your product and finds people on Reddit, Hacker News, and forums
who are actively expressing needs that your solution solves.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/onboarding">
<Link href="/auth">
<Button size="lg" className="gap-2">
Start Finding Opportunities
<ArrowRight className="h-4 w-4" />
@@ -69,7 +69,7 @@ export default function LandingPage() {
We scrape your website and use GPT-4 to extract features, pain points, and keywords automatically.
</p>
</div>
<div className="space-y-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
@@ -79,7 +79,7 @@ export default function LandingPage() {
Our system generates targeted Google dork queries to find high-intent posts across Reddit, HN, and more.
</p>
</div>
<div className="space-y-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Target className="h-5 w-5 text-primary" />
@@ -102,7 +102,7 @@ export default function LandingPage() {
<p className="text-muted-foreground mb-8 max-w-lg mx-auto">
Stop guessing. Start finding people who are already looking for solutions like yours.
</p>
<Link href="/onboarding">
<Link href="/auth">
<Button size="lg">Get Started Free</Button>
</Link>
</div>