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
@@ -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>
);
}