Add API endpoints and frontend components for tool management and analytics
Implement CRUD operations for tools and ratings, introduce analytics endpoints, and develop frontend components for displaying tools, ratings, and analytics data. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: feaa4ce1-5aed-4cc0-bcea-47855b615b48 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/rx9K7bW Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
useGetAnalyticsSummary,
|
||||
useGetTopTools,
|
||||
useGetAnalyticsByCategory,
|
||||
useGetRatingDistribution,
|
||||
GetTopToolsMetric
|
||||
} from "@workspace/api-client-react";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer,
|
||||
RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend,
|
||||
LineChart, Line
|
||||
} from "recharts";
|
||||
import { BarChart3, TrendingUp, Layers, Activity } from "lucide-react";
|
||||
|
||||
export default function Analytics() {
|
||||
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
||||
|
||||
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({
|
||||
limit: 8,
|
||||
metric: GetTopToolsMetric.combined
|
||||
});
|
||||
|
||||
const { data: categoryStats, isLoading: loadingCategories } = useGetAnalyticsByCategory();
|
||||
|
||||
const { data: distribution, isLoading: loadingDistribution } = useGetRatingDistribution();
|
||||
|
||||
const topToolsChartData = topTools?.map(t => ({
|
||||
name: t.tool.name,
|
||||
score: Number((t.score).toFixed(2)),
|
||||
ratings: t.ratingCount
|
||||
})) || [];
|
||||
|
||||
const categoryChartData = categoryStats?.map(c => ({
|
||||
category: c.category,
|
||||
tools: c.toolCount,
|
||||
avgScore: c.avgCombined ? Number(c.avgCombined.toFixed(2)) : 0
|
||||
})) || [];
|
||||
|
||||
const usefulnessData = distribution?.usefulness.map(b => ({ score: `${b.score} Star`, count: b.count })) || [];
|
||||
const usabilityData = distribution?.usability.map(b => ({ score: `${b.score} Star`, count: b.count })) || [];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Platform Analytics</h1>
|
||||
<p className="text-muted-foreground">Macro-level insights into tool performance and community engagement.</p>
|
||||
</div>
|
||||
|
||||
{/* Top KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Total Tools Indexed</span>
|
||||
<Layers className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||
<div className="text-3xl font-bold">{summary?.totalTools || 0}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Total Ratings Cast</span>
|
||||
<Activity className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||
<div className="text-3xl font-bold">{summary?.totalRatings || 0}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Active Categories</span>
|
||||
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||
<div className="text-3xl font-bold">{summary?.categoriesCount || 0}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Avg Global Score</span>
|
||||
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||
<div className="text-3xl font-bold">{summary?.avgCombined?.toFixed(2) || "0.00"}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Top Tools Chart */}
|
||||
<Card className="col-span-1 lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Top 8 Tools by Combined Score</CardTitle>
|
||||
<CardDescription>Highest rated tools across the platform</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingTopTools ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : (
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topToolsChartData} margin={{ top: 20, right: 30, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
<XAxis dataKey="name" tickLine={false} axisLine={false} />
|
||||
<YAxis domain={[0, 5]} tickLine={false} axisLine={false} tickFormatter={(v) => `${v}`} />
|
||||
<RechartsTooltip
|
||||
cursor={{ fill: 'hsl(var(--muted))' }}
|
||||
contentStyle={{ borderRadius: '8px', border: '1px solid hsl(var(--border))' }}
|
||||
/>
|
||||
<Bar dataKey="score" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={50} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tools by Category</CardTitle>
|
||||
<CardDescription>Distribution of tools across categories</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingCategories ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : (
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={categoryChartData}>
|
||||
<PolarGrid stroke="hsl(var(--border))" />
|
||||
<PolarAngleAxis dataKey="category" tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 'auto']} tick={false} axisLine={false} />
|
||||
<Radar name="Tools" dataKey="tools" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.3} />
|
||||
<RechartsTooltip />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rating Distributions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Global Rating Distribution</CardTitle>
|
||||
<CardDescription>How users are voting across all tools</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingDistribution ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : (
|
||||
<div className="h-[300px] w-full flex flex-col gap-4">
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usefulness</span>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={usefulnessData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||
<XAxis dataKey="score" tickLine={false} axisLine={false} tick={{fontSize: 12}} />
|
||||
<YAxis hide />
|
||||
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} />
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usability</span>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={usabilityData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||
<XAxis dataKey="score" tickLine={false} axisLine={false} tick={{fontSize: 12}} />
|
||||
<YAxis hide />
|
||||
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} />
|
||||
<Bar dataKey="count" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
useGetAnalyticsSummary,
|
||||
useGetTopTools,
|
||||
useListTools,
|
||||
GetTopToolsMetric
|
||||
} from "@workspace/api-client-react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { ToolCard } from "@/components/tool-card";
|
||||
import { Star, Wrench, MessageSquare, ArrowRight } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function Home() {
|
||||
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
||||
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({ limit: 4, metric: GetTopToolsMetric.combined });
|
||||
const { data: recentTools, isLoading: loadingRecent } = useListTools({ sort: "newest" });
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-8 pb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Welcome to ToolRate</h1>
|
||||
<p className="text-muted-foreground">The community hub where engineers honestly rate the tools they use daily.</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Banner */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="p-6 flex items-center gap-4">
|
||||
<div className="p-3 bg-primary/10 rounded-lg text-primary">
|
||||
<Wrench className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">Total Tools</p>
|
||||
{loadingSummary ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<h3 className="text-3xl font-bold">{summary?.totalTools || 0}</h3>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="p-6 flex items-center gap-4">
|
||||
<div className="p-3 bg-primary/10 rounded-lg text-primary">
|
||||
<MessageSquare className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">Total Ratings</p>
|
||||
{loadingSummary ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<h3 className="text-3xl font-bold">{summary?.totalRatings || 0}</h3>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="p-6 flex items-center gap-4">
|
||||
<div className="p-3 bg-primary/10 rounded-lg text-primary">
|
||||
<Star className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">Avg Rating</p>
|
||||
{loadingSummary ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<h3 className="text-3xl font-bold">{summary?.avgCombined ? summary.avgCombined.toFixed(1) : "0.0"}</h3>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Top Tools Section */}
|
||||
<section>
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-1">Top Rated Tools</h2>
|
||||
<p className="text-muted-foreground text-sm">Highest combined usefulness and usability.</p>
|
||||
</div>
|
||||
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
||||
<Link href="/tools?sort=top_rated">
|
||||
View all <ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadingTopTools ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<Skeleton key={`sk-top-${i}`} className="h-[200px] w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : topTools && topTools.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{topTools.map(({ tool }) => (
|
||||
<ToolCard key={tool.id} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="bg-muted/30 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
|
||||
<Star className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
||||
<h3 className="text-lg font-medium">No ratings yet</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">Be the first to rate a tool and help the community.</p>
|
||||
<Button asChild>
|
||||
<Link href="/tools">Browse Tools</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recently Added Section */}
|
||||
<section>
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-1">Recently Added</h2>
|
||||
<p className="text-muted-foreground text-sm">The latest additions to the catalog.</p>
|
||||
</div>
|
||||
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
||||
<Link href="/tools?sort=newest">
|
||||
View all <ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadingRecent ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<Skeleton key={`sk-recent-${i}`} className="h-[200px] w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : recentTools && recentTools.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{recentTools.slice(0, 4).map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="bg-muted/30 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
|
||||
<Wrench className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
||||
<h3 className="text-lg font-medium">No tools found</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">There are no tools in the database yet.</p>
|
||||
<Button asChild>
|
||||
<Link href="/tools/new">Add a Tool</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex mb-4 gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Did you forget to add the page to the router?
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useState } from "react";
|
||||
import { useRoute, useLocation } from "wouter";
|
||||
import {
|
||||
useGetTool,
|
||||
getGetToolQueryKey,
|
||||
useListToolRatings,
|
||||
getListToolRatingsQueryKey,
|
||||
useGetRatingDistribution,
|
||||
getGetRatingDistributionQueryKey,
|
||||
useCreateRating
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { Layout } from "@/components/layout";
|
||||
import { RatingStars } from "@/components/rating-stars";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import { ExternalLink, Star, ArrowLeft, Plus } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
const ratingSchema = z.object({
|
||||
usefulness: z.number().min(1).max(5),
|
||||
usability: z.number().min(1).max(5),
|
||||
comment: z.string().optional(),
|
||||
reviewerName: z.string().optional(),
|
||||
});
|
||||
|
||||
type RatingFormValues = z.infer<typeof ratingSchema>;
|
||||
|
||||
export default function ToolDetail() {
|
||||
const [match, params] = useRoute("/tools/:id");
|
||||
const [, setLocation] = useLocation();
|
||||
const id = parseInt(params?.id || "0", 10);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [isReviewFormOpen, setIsReviewFormOpen] = useState(false);
|
||||
|
||||
const { data: tool, isLoading: loadingTool } = useGetTool(id, {
|
||||
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
|
||||
});
|
||||
|
||||
const { data: ratings, isLoading: loadingRatings } = useListToolRatings(id, {
|
||||
query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) }
|
||||
});
|
||||
|
||||
const { data: distribution, isLoading: loadingDistribution } = useGetRatingDistribution({ toolId: id }, {
|
||||
query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }
|
||||
});
|
||||
|
||||
const createRating = useCreateRating();
|
||||
|
||||
const form = useForm<RatingFormValues>({
|
||||
resolver: zodResolver(ratingSchema),
|
||||
defaultValues: {
|
||||
usefulness: 0,
|
||||
usability: 0,
|
||||
comment: "",
|
||||
reviewerName: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: RatingFormValues) => {
|
||||
if (data.usefulness === 0 || data.usability === 0) {
|
||||
toast({
|
||||
title: "Missing ratings",
|
||||
description: "Please rate both usefulness and usability.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createRating.mutate({ id, data }, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Rating submitted",
|
||||
description: "Thank you for your feedback!",
|
||||
});
|
||||
setIsReviewFormOpen(false);
|
||||
form.reset();
|
||||
|
||||
// Invalidate queries
|
||||
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
|
||||
queryClient.invalidateQueries({ queryKey: getListToolRatingsQueryKey(id) });
|
||||
queryClient.invalidateQueries({ queryKey: getGetRatingDistributionQueryKey({ toolId: id }) });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Failed to submit rating",
|
||||
description: error.error || "An unexpected error occurred.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!match || isNaN(id)) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<h2 className="text-2xl font-bold">Invalid Tool ID</h2>
|
||||
<Button variant="link" asChild className="mt-4">
|
||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to tools</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const usefulnessData = distribution?.usefulness.map(b => ({ score: b.score, count: b.count })).reverse() || [];
|
||||
const usabilityData = distribution?.usability.map(b => ({ score: b.score, count: b.count })).reverse() || [];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to browse</Link>
|
||||
</Button>
|
||||
|
||||
{/* Header Section */}
|
||||
{loadingTool ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-1/3" />
|
||||
<Skeleton className="h-6 w-1/4" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : tool ? (
|
||||
<div className="bg-card border rounded-xl p-6 md:p-8 space-y-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-bl-full -z-10" />
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between gap-6 items-start">
|
||||
<div className="space-y-4 flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-3xl font-bold">{tool.name}</h1>
|
||||
<Badge variant="outline" className="text-sm bg-background">{tool.category}</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-muted-foreground max-w-3xl">
|
||||
{tool.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{tool.tags?.map(tag => (
|
||||
<Badge key={tag} variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 shrink-0 md:items-end w-full md:w-auto bg-muted/30 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-6 h-6 fill-primary text-primary" />
|
||||
<span className="text-3xl font-bold">{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"}</span>
|
||||
<span className="text-muted-foreground self-end mb-1">/ 5</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Based on {tool.ratingCount} reviews
|
||||
</div>
|
||||
|
||||
{tool.websiteUrl && (
|
||||
<Button asChild className="w-full mt-2" variant="outline">
|
||||
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
|
||||
Visit Website <ExternalLink className="w-4 h-4 ml-2" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tool.features && tool.features.length > 0 && (
|
||||
<div className="pt-6 border-t">
|
||||
<h3 className="text-lg font-semibold mb-3">Key Features</h3>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{tool.features.map((feature, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<div className="mt-1 bg-primary/20 p-0.5 rounded text-primary">
|
||||
<Plus className="w-3 h-3" />
|
||||
</div>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10">Tool not found.</div>
|
||||
)}
|
||||
|
||||
{/* Ratings & Reviews Section */}
|
||||
{tool && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Left Column: Stats */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rating Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">Usefulness</span>
|
||||
<span className="font-bold">{tool.avgUsefulness ? tool.avgUsefulness.toFixed(1) : "0.0"}</span>
|
||||
</div>
|
||||
<Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">Usability</span>
|
||||
<span className="font-bold">{tool.avgUsability ? tool.avgUsability.toFixed(1) : "0.0"}</span>
|
||||
</div>
|
||||
<Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingDistribution ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={usefulnessData} layout="vertical" margin={{ top: 0, right: 0, bottom: 0, left: -20 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis dataKey="score" type="category" axisLine={false} tickLine={false} tickFormatter={(val) => `${val} ★`} />
|
||||
<Tooltip cursor={{ fill: 'transparent' }} />
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} barSize={12} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Reviews */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-2xl font-bold">Reviews</h3>
|
||||
{!isReviewFormOpen && (
|
||||
<Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isReviewFormOpen && (
|
||||
<Card className="border-primary shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Write a Review</CardTitle>
|
||||
<CardDescription>Share your experience with {tool.name}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="usefulness"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usefulness</FormLabel>
|
||||
<div className="py-2">
|
||||
<RatingStars
|
||||
value={field.value}
|
||||
interactive
|
||||
onChange={field.onChange}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="usability"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usability</FormLabel>
|
||||
<div className="py-2">
|
||||
<RatingStars
|
||||
value={field.value}
|
||||
interactive
|
||||
onChange={field.onChange}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="comment"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What do you think about this tool?"
|
||||
className="resize-none min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reviewerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Anonymous" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsReviewFormOpen(false)}
|
||||
disabled={createRating.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createRating.isPending}>
|
||||
{createRating.isPending ? "Submitting..." : "Submit Review"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{loadingRatings ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}><CardContent className="p-6"><Skeleton className="h-24 w-full" /></CardContent></Card>
|
||||
))
|
||||
) : ratings && ratings.length > 0 ? (
|
||||
ratings.map((rating) => (
|
||||
<Card key={rating.id} className="bg-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">{rating.reviewerName || "Anonymous Engineer"}</span>
|
||||
<span className="text-muted-foreground text-sm ml-2">
|
||||
{format(new Date(rating.createdAt), "MMM d, yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 mb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Usefulness</span>
|
||||
<RatingStars value={rating.usefulness} size="sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Usability</span>
|
||||
<RatingStars value={rating.usability} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rating.comment && (
|
||||
<p className="text-foreground leading-relaxed">
|
||||
{rating.comment}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
|
||||
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<h4 className="text-lg font-medium">No reviews yet</h4>
|
||||
<p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { useCreateTool, getListToolsQueryKey } from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Layout } from "@/components/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@/components/ui/form";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Wrench, Plus, X, ArrowLeft } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
const toolSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
description: z.string().min(10, "Description must be at least 10 characters"),
|
||||
category: z.string().min(2, "Category is required"),
|
||||
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
||||
features: z.array(z.object({ value: z.string() })).optional(),
|
||||
tags: z.array(z.object({ value: z.string() })).optional(),
|
||||
});
|
||||
|
||||
type ToolFormValues = z.infer<typeof toolSchema>;
|
||||
|
||||
export default function ToolNew() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const createTool = useCreateTool();
|
||||
|
||||
const form = useForm<ToolFormValues>({
|
||||
resolver: zodResolver(toolSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
category: "",
|
||||
websiteUrl: "",
|
||||
features: [{ value: "" }],
|
||||
tags: [{ value: "" }]
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: featureFields, append: appendFeature, remove: removeFeature } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "features",
|
||||
});
|
||||
|
||||
const { fields: tagFields, append: appendTag, remove: removeTag } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "tags",
|
||||
});
|
||||
|
||||
const onSubmit = (data: ToolFormValues) => {
|
||||
// Transform arrays back to strings
|
||||
const payload = {
|
||||
...data,
|
||||
websiteUrl: data.websiteUrl || undefined,
|
||||
features: data.features?.map(f => f.value).filter(v => v.trim() !== ""),
|
||||
tags: data.tags?.map(t => t.value).filter(v => v.trim() !== "")
|
||||
};
|
||||
|
||||
createTool.mutate({ data: payload }, {
|
||||
onSuccess: (newTool) => {
|
||||
toast({
|
||||
title: "Tool added successfully",
|
||||
description: "Your tool is now available for review.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
setLocation(`/tools/${newTool.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Failed to add tool",
|
||||
description: error.error || "An unexpected error occurred",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to browse</Link>
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Add a New Tool</h1>
|
||||
<p className="text-muted-foreground">Submit a tool you use to let the community rate and review it.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
Tool Details
|
||||
</CardTitle>
|
||||
<CardDescription>Provide the basic information about the tool.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. React, Next.js, Postgres" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Framework, Database, CI/CD" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="websiteUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Website URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." type="url" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What does this tool do? Why do people use it?"
|
||||
className="min-h-[120px] resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Features</h3>
|
||||
<p className="text-sm text-muted-foreground">List key capabilities of the tool.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendFeature({ value: "" })}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{featureFields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`features.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-start gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Real-time collaboration" {...field} />
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFeature(index)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{featureFields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">No features added.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Tags</h3>
|
||||
<p className="text-sm text-muted-foreground">Keywords to help find this tool.</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendTag({ value: "" })}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tagFields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`tags.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-y-0 relative w-[150px]">
|
||||
<FormControl>
|
||||
<Input placeholder="Tag" className="pr-8 h-9 text-sm" {...field} />
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-9 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeTag(index)}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t flex justify-end">
|
||||
<Button type="submit" disabled={createTool.isPending} className="w-full sm:w-auto">
|
||||
{createTool.isPending ? "Adding Tool..." : "Submit Tool"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useListTools,
|
||||
useListCategories,
|
||||
ListToolsSort
|
||||
} from "@workspace/api-client-react";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { ToolCard } from "@/components/tool-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Search, Wrench, SlidersHorizontal, X } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
export default function ToolsBrowse() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [category, setCategory] = useState<string>("all");
|
||||
const [sort, setSort] = useState<ListToolsSort>(ListToolsSort.newest);
|
||||
|
||||
const { data: categories, isLoading: loadingCategories } = useListCategories();
|
||||
|
||||
const queryParams = {
|
||||
...(search ? { search } : {}),
|
||||
...(category && category !== "all" ? { category } : {}),
|
||||
...(sort ? { sort } : {})
|
||||
};
|
||||
|
||||
const { data: tools, isLoading: loadingTools } = useListTools(queryParams);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSearch(searchInput);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch("");
|
||||
setSearchInput("");
|
||||
setCategory("all");
|
||||
setSort(ListToolsSort.newest);
|
||||
};
|
||||
|
||||
const hasFilters = search !== "" || category !== "all" || sort !== ListToolsSort.newest;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Browse Tools</h1>
|
||||
<p className="text-muted-foreground">Discover and evaluate the best tools for your stack.</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/tools/new">Add a Tool</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-card border rounded-xl p-4 flex flex-col md:flex-row gap-4">
|
||||
<form onSubmit={handleSearch} className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tools..."
|
||||
className="pl-9 w-full"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
data-testid="input-search"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap sm:flex-nowrap gap-4 shrink-0">
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-category">
|
||||
<SelectValue placeholder="All Categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{!loadingCategories && categories?.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sort} onValueChange={(val) => setSort(val as ListToolsSort)}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-sort">
|
||||
<SelectValue placeholder="Sort By" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ListToolsSort.newest}>Newest</SelectItem>
|
||||
<SelectItem value={ListToolsSort.top_rated}>Top Rated</SelectItem>
|
||||
<SelectItem value={ListToolsSort.most_reviewed}>Most Reviewed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="icon" onClick={clearFilters} className="shrink-0" title="Clear filters">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div>
|
||||
<div className="flex items-center text-sm text-muted-foreground mb-4">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
{loadingTools ? "Loading tools..." : `Showing ${tools?.length || 0} tools`}
|
||||
</div>
|
||||
|
||||
{loadingTools ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map(i => (
|
||||
<Skeleton key={`sk-tools-${i}`} className="h-[200px] w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : tools && tools.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tools.map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center">
|
||||
<div className="bg-muted p-4 rounded-full mb-4">
|
||||
<Wrench className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-2">No tools found</h3>
|
||||
<p className="text-muted-foreground max-w-md mx-auto mb-6">
|
||||
We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a new tool.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{hasFilters && (
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild>
|
||||
<Link href="/tools/new">Add Tool</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user