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:
cheffe01
2026-05-25 13:04:53 +00:00
parent 1de22a6979
commit 036973ea32
111 changed files with 11006 additions and 96 deletions
+201
View File
@@ -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>
);
}
+161
View File
@@ -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>
);
}
+273
View File
@@ -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>
);
}