fix: compact filter popover; feat: i18n (de/en)
Build & Push Docker Image / build (push) Successful in 2m31s
Build & Push Docker Image / build (push) Successful in 2m31s
This commit is contained in:
@@ -15,12 +15,14 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ShieldAlert, Trophy, Scale } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function fmt(v: number | null | undefined): string {
|
||||
return v != null ? v.toFixed(1) : "–";
|
||||
}
|
||||
|
||||
export default function Compare() {
|
||||
const { t } = useTranslation();
|
||||
const { hasFeature, isLoading: authLoading } = useAuth();
|
||||
const search = useSearch();
|
||||
|
||||
@@ -50,10 +52,10 @@ export default function Compare() {
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Compare requires a higher tier</h2>
|
||||
<p className="text-muted-foreground">Compare tools side-by-side is available to Premium and Enterprise users.</p>
|
||||
<h2 className="text-2xl font-bold">{t("compare.requiresPremium")}</h2>
|
||||
<p className="text-muted-foreground">{t("compare.requiresPremiumSub")}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Back to tools</a>
|
||||
<a href="/tools">{t("common.backToTools")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -77,10 +79,10 @@ export default function Compare() {
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Scale className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Nothing to compare</h2>
|
||||
<p className="text-muted-foreground">Select at least one tool to compare and come back here.</p>
|
||||
<h2 className="text-2xl font-bold">{t("compare.nothingToCompare")}</h2>
|
||||
<p className="text-muted-foreground">{t("compare.nothingToCompareSub")}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Browse tools</a>
|
||||
<a href="/tools">{t("common.browseTools")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -99,20 +101,20 @@ export default function Compare() {
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Compare Tools</h1>
|
||||
<p className="text-muted-foreground">Comparing {list.length} tool(s) side-by-side.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("compare.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Side-by-side comparison</CardTitle>
|
||||
<CardDescription>Best value in each row is highlighted.</CardDescription>
|
||||
<CardTitle>{t("compare.sideBySide")}</CardTitle>
|
||||
<CardDescription>{t("compare.bestValue")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-44">Attribute</TableHead>
|
||||
<TableHead className="w-44">{t("compare.attribute")}</TableHead>
|
||||
{list.map((t) => (
|
||||
<TableHead key={t.id} className="min-w-[12rem]">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -127,7 +129,7 @@ export default function Compare() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Rating</TableCell>
|
||||
<TableCell className="font-medium">{t("compare.rating")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className={cn(isBest(t.avgCombined, bestRating) && "bg-primary/5")}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -155,7 +157,7 @@ export default function Compare() {
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Reviews</TableCell>
|
||||
<TableCell className="font-medium">{t("compare.reviews")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className={cn(isBest(t.ratingCount, bestReviews) && "bg-primary/5")}>
|
||||
<span className="tabular-nums">{t.ratingCount}</span>
|
||||
@@ -163,7 +165,7 @@ export default function Compare() {
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Description</TableCell>
|
||||
<TableCell className="font-medium">{t("compare.description")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className="text-sm text-muted-foreground align-top">
|
||||
{t.description}
|
||||
@@ -197,7 +199,7 @@ export default function Compare() {
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Last updated</TableCell>
|
||||
<TableCell className="font-medium">{t("compare.lastUpdated")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className="text-sm text-muted-foreground">
|
||||
{new Date(t.updatedAt).toLocaleDateString()}
|
||||
@@ -210,7 +212,7 @@ export default function Compare() {
|
||||
</Card>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/tools">Back to browse</Link>
|
||||
<Link to="/tools">{t("compare.backToBrowse")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -11,8 +11,10 @@ import { ToolCard } from "@/components/tool-card";
|
||||
import { Star, Wrench, MessageSquare, ArrowRight } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
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" });
|
||||
@@ -21,8 +23,8 @@ export default function Home() {
|
||||
<Layout>
|
||||
<div className="space-y-8 pb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Welcome to toolr</h1>
|
||||
<p className="text-muted-foreground">The community hub where engineers honestly rate the tools they use daily.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
|
||||
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Banner */}
|
||||
@@ -33,7 +35,7 @@ export default function Home() {
|
||||
<Wrench className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">Total Tools</p>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">{t("home.totalTools")}</p>
|
||||
{loadingSummary ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
@@ -49,7 +51,7 @@ export default function Home() {
|
||||
<MessageSquare className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">Total Ratings</p>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">{t("home.totalRatings")}</p>
|
||||
{loadingSummary ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
@@ -65,7 +67,7 @@ export default function Home() {
|
||||
<Star className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">Avg Rating</p>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">{t("home.avgRating")}</p>
|
||||
{loadingSummary ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
@@ -80,12 +82,12 @@ export default function Home() {
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.topRated")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("home.topRatedSub")}</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" />
|
||||
{t("common.viewAll")} <ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -106,10 +108,10 @@ export default function Home() {
|
||||
<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>
|
||||
<h3 className="text-lg font-medium">{t("home.noRatingsYet")}</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noRatingsSub")}</p>
|
||||
<Button asChild>
|
||||
<Link href="/tools">Browse Tools</Link>
|
||||
<Link href="/tools">{t("common.browseTools")}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -120,12 +122,12 @@ export default function Home() {
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.recentlyAdded")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("home.recentlyAddedSub")}</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" />
|
||||
{t("common.viewAll")} <ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -146,10 +148,10 @@ export default function Home() {
|
||||
<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>
|
||||
<h3 className="text-lg font-medium">{t("home.noTools")}</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noToolsSub")}</p>
|
||||
<Button asChild>
|
||||
<Link href="/tools/new">Add a Tool</Link>
|
||||
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -8,8 +8,10 @@ import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Wrench, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Login() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const search = useSearch();
|
||||
const params = new URLSearchParams(search);
|
||||
@@ -33,7 +35,7 @@ export default function Login() {
|
||||
setLocation(returnTo);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.data?.error || err.message || "Invalid username or password");
|
||||
setError(err.data?.error || err.message || t("auth.invalidCredentials"));
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -50,18 +52,18 @@ export default function Login() {
|
||||
<Wrench className="w-7 h-7" />
|
||||
<span>toolr</span>
|
||||
</Link>
|
||||
<p className="text-muted-foreground text-sm">Sign in to your account</p>
|
||||
<p className="text-muted-foreground text-sm">{t("auth.loginSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">Sign in</CardTitle>
|
||||
<CardDescription>Enter your credentials to continue</CardDescription>
|
||||
<CardTitle className="text-lg">{t("auth.signIn")}</CardTitle>
|
||||
<CardDescription>{t("auth.loginDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor="username">{t("auth.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
@@ -73,7 +75,7 @@ export default function Login() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -95,7 +97,7 @@ export default function Login() {
|
||||
className="w-full"
|
||||
disabled={localLogin.isPending}
|
||||
>
|
||||
{localLogin.isPending ? "Signing in..." : "Sign in"}
|
||||
{localLogin.isPending ? t("common.loading") : t("auth.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -103,7 +105,7 @@ export default function Login() {
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
Back to Home
|
||||
{t("notFound.backHome")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Wrench, ArrowLeft } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
@@ -13,10 +15,10 @@ export default function NotFound() {
|
||||
<span>toolr</span>
|
||||
</Link>
|
||||
<h1 className="text-6xl font-bold text-muted-foreground/30 mb-2">404</h1>
|
||||
<p className="text-muted-foreground mb-6">This page doesn't exist.</p>
|
||||
<p className="text-muted-foreground mb-6">{t("notFound.text")}</p>
|
||||
<Link href="/">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Home
|
||||
<ArrowLeft className="w-4 h-4" /> {t("notFound.backHome")}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
||||
@@ -46,6 +46,7 @@ import { Link } from "wouter";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -79,6 +80,7 @@ export default function ToolDetail() {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const { user, isAdmin, hasFeature } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist();
|
||||
const canManageCosts = hasFeature("costs");
|
||||
const hasTrash = hasFeature("trash");
|
||||
@@ -355,7 +357,7 @@ export default function ToolDetail() {
|
||||
<span className="text-muted-foreground self-end mb-1">/ 5</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Based on {tool.ratingCount} reviews
|
||||
{t("detail.basedOnReview", { count: tool.ratingCount })}
|
||||
</div>
|
||||
|
||||
{canWatchlist && (
|
||||
@@ -365,14 +367,14 @@ export default function ToolDetail() {
|
||||
onClick={() => toggleWatchlist(Number(id))}
|
||||
>
|
||||
<Bookmark className={cn("w-4 h-4", isWatched(id) && "fill-current")} />
|
||||
{isWatched(id) ? "Saved to watchlist" : "Save to watchlist"}
|
||||
{isWatched(id) ? t("detail.savedToWatchlist") : t("detail.saveToWatchlist")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{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" />
|
||||
{t("detail.visitWebsite")} <ExternalLink className="w-4 h-4 ml-2" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
@@ -381,7 +383,7 @@ export default function ToolDetail() {
|
||||
<div className="flex gap-2 w-full mt-1">
|
||||
<Button asChild variant="outline" size="sm" className="flex-1 gap-2">
|
||||
<Link href={`/tools/${id}/edit`}>
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
<Pencil className="w-3.5 h-3.5" /> {t("detail.edit")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -390,7 +392,7 @@ export default function ToolDetail() {
|
||||
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
<Trash2 className="w-3.5 h-3.5" /> {t("detail.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -556,10 +558,10 @@ export default function ToolDetail() {
|
||||
{tool && (
|
||||
<div className="space-y-4 mt-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xl font-bold">Costs</h3>
|
||||
<h3 className="text-xl font-bold">{t("detail.costs")}</h3>
|
||||
{canManageCosts && (
|
||||
<Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2">
|
||||
<Plus className="w-4 h-4" /> Add Cost
|
||||
<Plus className="w-4 h-4" /> {t("detail.addCost")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -607,7 +609,7 @@ export default function ToolDetail() {
|
||||
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editCost ? "Edit Cost" : "Add Cost"}</DialogTitle>
|
||||
<DialogTitle>{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}</DialogTitle>
|
||||
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
@@ -674,19 +676,19 @@ export default function ToolDetail() {
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rating Breakdown</CardTitle>
|
||||
<CardTitle>{t("detail.ratingBreakdown")}</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-medium">{t("detail.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-medium">{t("detail.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" />
|
||||
@@ -696,7 +698,7 @@ export default function ToolDetail() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score Distribution</CardTitle>
|
||||
<CardTitle>{t("detail.scoreDistribution")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingDistribution ? (
|
||||
@@ -718,7 +720,7 @@ export default function ToolDetail() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score Trend</CardTitle>
|
||||
<CardTitle>{t("detail.scoreTrend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingRatingHistory ? (
|
||||
@@ -739,7 +741,7 @@ export default function ToolDetail() {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Not enough ratings yet to show a trend.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("detail.notEnoughRatings")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -748,16 +750,16 @@ export default function ToolDetail() {
|
||||
{/* 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>
|
||||
<h3 className="text-2xl font-bold">{t("detail.reviews")}</h3>
|
||||
{!isReviewFormOpen && user && (
|
||||
<Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button>
|
||||
<Button onClick={() => setIsReviewFormOpen(true)}>{t("detail.addReview")}</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isReviewFormOpen && (
|
||||
<Card className="border-primary shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Write a Review</CardTitle>
|
||||
<CardTitle>{t("detail.addReview")}</CardTitle>
|
||||
<CardDescription>Share your experience with {tool.name}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -769,7 +771,7 @@ export default function ToolDetail() {
|
||||
name="usefulness"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usefulness</FormLabel>
|
||||
<FormLabel>{t("detail.usefulness")}</FormLabel>
|
||||
<div className="py-2">
|
||||
<RatingStars
|
||||
value={field.value}
|
||||
@@ -787,7 +789,7 @@ export default function ToolDetail() {
|
||||
name="usability"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usability</FormLabel>
|
||||
<FormLabel>{t("detail.usability")}</FormLabel>
|
||||
<div className="py-2">
|
||||
<RatingStars
|
||||
value={field.value}
|
||||
@@ -844,7 +846,7 @@ export default function ToolDetail() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createRating.isPending}>
|
||||
{createRating.isPending ? "Submitting..." : "Submit Review"}
|
||||
{createRating.isPending ? t("common.loading") : t("detail.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -873,11 +875,11 @@ export default function ToolDetail() {
|
||||
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">{t("detail.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>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">{t("detail.usability")}</span>
|
||||
<RatingStars value={rating.usability} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -893,7 +895,7 @@ export default function ToolDetail() {
|
||||
) : (
|
||||
<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>
|
||||
<h4 className="text-lg font-medium">{t("detail.noReviews")}</h4>
|
||||
<p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||
import { CompareBar } from "@/components/compare-bar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -30,13 +31,13 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const SORT_VALUES = new Set<string>(Object.values(ListToolsSort));
|
||||
|
||||
const SORT_LABELS: Record<string, string> = {
|
||||
[ListToolsSort.newest]: "Newest",
|
||||
[ListToolsSort.top_rated]: "Top Rated",
|
||||
[ListToolsSort.most_reviewed]: "Most Reviewed",
|
||||
[ListToolsSort.name_asc]: "Name (A–Z)",
|
||||
[ListToolsSort.name_desc]: "Name (Z–A)",
|
||||
[ListToolsSort.recently_updated]: "Recently Updated",
|
||||
const SORT_KEYS: Record<string, string> = {
|
||||
[ListToolsSort.newest]: "browse.sortNewest",
|
||||
[ListToolsSort.top_rated]: "browse.sortTopRated",
|
||||
[ListToolsSort.most_reviewed]: "browse.sortMostReviewed",
|
||||
[ListToolsSort.name_asc]: "browse.sortNameAsc",
|
||||
[ListToolsSort.name_desc]: "browse.sortNameDesc",
|
||||
[ListToolsSort.recently_updated]: "browse.sortRecentlyUpdated",
|
||||
};
|
||||
|
||||
function SortHeader({
|
||||
@@ -77,6 +78,7 @@ function SortHeader({
|
||||
export default function ToolsBrowse() {
|
||||
const [, navigate] = useLocation();
|
||||
const urlSearch = useSearch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const initialParams = useMemo(() => new URLSearchParams(urlSearch), [urlSearch]);
|
||||
const initialSearch = initialParams.get("search") ?? "";
|
||||
@@ -228,11 +230,11 @@ export default function ToolsBrowse() {
|
||||
<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>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("nav.browseTools")}</h1>
|
||||
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/tools/new">Add a Tool</Link>
|
||||
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -242,7 +244,7 @@ export default function ToolsBrowse() {
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder='Search tools… (press "/" to focus)'
|
||||
placeholder={`${t("common.search")}… (press "/" to focus)`}
|
||||
className="pl-9 w-full"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
@@ -256,7 +258,7 @@ export default function ToolsBrowse() {
|
||||
<SelectValue placeholder="All Categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
<SelectItem value="all">{t("browse.allCategories")}</SelectItem>
|
||||
{!loadingCategories &&
|
||||
categories?.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
@@ -273,7 +275,7 @@ export default function ToolsBrowse() {
|
||||
<SelectContent>
|
||||
{Object.values(ListToolsSort).map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{SORT_LABELS[v]}
|
||||
{t(SORT_KEYS[v])}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -329,7 +331,7 @@ export default function ToolsBrowse() {
|
||||
)}
|
||||
{sort !== ListToolsSort.newest && (
|
||||
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||
{SORT_LABELS[sort]}
|
||||
{t(SORT_KEYS[sort])}
|
||||
<button
|
||||
onClick={() => setSort(ListToolsSort.newest)}
|
||||
className="rounded-sm hover:bg-muted p-0.5"
|
||||
@@ -385,7 +387,7 @@ export default function ToolsBrowse() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
{loadingTools ? "Loading tools…" : `Showing ${allTools.length} tool${allTools.length === 1 ? "" : "s"}`}
|
||||
{loadingTools ? t("common.loading") : t("browse.showingTool", { count: allTools.length })}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<DensityToggle value={density} onValueChange={setDensity} />
|
||||
@@ -433,20 +435,20 @@ export default function ToolsBrowse() {
|
||||
<div className={cn("grid items-center gap-3 border-b bg-muted sticky top-0 z-10 px-4", TABLE_GRID)}>
|
||||
<div className="py-2.5">
|
||||
<SortHeader active={sortActive(ListToolsSort.name_asc) || sortActive(ListToolsSort.name_desc)} direction={nameDir} onClick={toggleNameSort}>
|
||||
Tool
|
||||
{t("browse.tool")}
|
||||
</SortHeader>
|
||||
</div>
|
||||
<div className="py-2.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Category
|
||||
{t("browse.category")}
|
||||
</div>
|
||||
<div className="py-2.5">
|
||||
<SortHeader active={sortActive(ListToolsSort.top_rated)} onClick={() => setSort(ListToolsSort.top_rated)} align="right">
|
||||
Rating
|
||||
{t("browse.rating")}
|
||||
</SortHeader>
|
||||
</div>
|
||||
<div className="py-2.5">
|
||||
<SortHeader active={sortActive(ListToolsSort.most_reviewed)} onClick={() => setSort(ListToolsSort.most_reviewed)} align="right">
|
||||
Reviews
|
||||
{t("browse.reviews")}
|
||||
</SortHeader>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,19 +488,18 @@ export default function ToolsBrowse() {
|
||||
<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>
|
||||
<h3 className="text-xl font-medium mb-2">{t("browse.noToolsFound")}</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.
|
||||
{t("browse.noToolsMatch")}
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{hasFilters && (
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
{t("filter.clearFilters")}
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild>
|
||||
<Link href="/tools/new">Add Tool</Link>
|
||||
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,8 +27,10 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Trash2, RotateCcw, Search, ShieldAlert, Trash as TrashIcon, RefreshCcw } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Trash() {
|
||||
const { t } = useTranslation();
|
||||
const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -139,10 +141,10 @@ export default function Trash() {
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Trash requires a higher tier</h2>
|
||||
<p className="text-muted-foreground">The trash is available to Premium and Enterprise users.</p>
|
||||
<h2 className="text-2xl font-bold">{t("trash.requiresPremium")}</h2>
|
||||
<p className="text-muted-foreground">{t("trash.requiresPremiumSub")}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Back to tools</a>
|
||||
<a href="/tools">{t("common.backToTools")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -155,16 +157,16 @@ export default function Trash() {
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Trash</h1>
|
||||
<p className="text-muted-foreground">Deleted tools are kept here until they are restored or permanently removed.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("trash.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Trashed Tools</CardTitle>
|
||||
<CardTitle>{t("trash.trashedTools")}</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${trashed.length} tool(s) in trash`}
|
||||
{selectedIds.length > 0 ? t("trash.selected", { count: selectedIds.length }) : t("trash.toolsInTrash", { count: trashed.length })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -172,7 +174,7 @@ export default function Trash() {
|
||||
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8 w-56"
|
||||
placeholder="Search trash…"
|
||||
placeholder={`${t("common.search")}…`}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
@@ -183,7 +185,7 @@ export default function Trash() {
|
||||
disabled={selectedIds.length === 0 || restore.isPending}
|
||||
onClick={() => handleRestore(selectedIds)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" /> Restore ({selectedIds.length})
|
||||
<RotateCcw className="w-4 h-4 mr-2" /> {t("trash.restore")} ({selectedIds.length})
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
@@ -193,7 +195,7 @@ export default function Trash() {
|
||||
disabled={selectedIds.length === 0 || deletePermanent.isPending}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 mr-2" /> Delete permanently ({selectedIds.length})
|
||||
<TrashIcon className="w-4 h-4 mr-2" /> {t("trash.deletePermanently")} ({selectedIds.length})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -201,7 +203,7 @@ export default function Trash() {
|
||||
disabled={trashed.length === 0 || empty.isPending}
|
||||
onClick={() => setConfirmEmpty(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" /> Empty trash
|
||||
<Trash2 className="w-4 h-4 mr-2" /> {t("trash.emptyTrash")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -215,7 +217,7 @@ export default function Trash() {
|
||||
) : trashed.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center flex flex-col items-center gap-2">
|
||||
<RefreshCcw className="w-6 h-6 text-muted-foreground/50" />
|
||||
{search ? "No tools match your search." : "The trash is empty."}
|
||||
{search ? t("trash.noMatch") : t("trash.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
@@ -230,8 +232,8 @@ export default function Trash() {
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Deleted at</TableHead>
|
||||
<TableHead>Deleted by</TableHead>
|
||||
<TableHead>{t("trash.deletedAt")}</TableHead>
|
||||
<TableHead>{t("trash.deletedBy")}</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -294,7 +296,7 @@ export default function Trash() {
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => handleDeletePermanent(selectedIds)}
|
||||
>
|
||||
Delete permanently
|
||||
{t("trash.deletePermanently")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -314,7 +316,7 @@ export default function Trash() {
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleEmpty}
|
||||
>
|
||||
Empty trash
|
||||
{t("trash.emptyTrash")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -9,8 +9,10 @@ import { ToolCard } from "@/components/tool-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ShieldAlert, Bookmark } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Watchlist() {
|
||||
const { t } = useTranslation();
|
||||
const { hasFeature, isLoading: authLoading } = useAuth();
|
||||
const { toggle } = useWatchlist();
|
||||
const canWatchlist = hasFeature("watchlist");
|
||||
@@ -24,10 +26,10 @@ export default function Watchlist() {
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Watchlist requires a higher tier</h2>
|
||||
<p className="text-muted-foreground">Saving tools to your watchlist is available to Premium and Enterprise users.</p>
|
||||
<h2 className="text-2xl font-bold">{t("watchlist.requiresPremium")}</h2>
|
||||
<p className="text-muted-foreground">{t("watchlist.requiresPremiumSub")}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Back to tools</a>
|
||||
<a href="/tools">{t("common.backToTools")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -40,8 +42,8 @@ export default function Watchlist() {
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Watchlist</h1>
|
||||
<p className="text-muted-foreground">Tools you saved for later, with live scores.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("watchlist.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -51,9 +53,9 @@ export default function Watchlist() {
|
||||
) : list.length === 0 ? (
|
||||
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center gap-3">
|
||||
<Bookmark className="w-8 h-8 text-muted-foreground" />
|
||||
<h3 className="text-xl font-medium">Your watchlist is empty</h3>
|
||||
<h3 className="text-xl font-medium">{t("watchlist.empty")}</h3>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Browse tools and click the bookmark to save them here.
|
||||
{t("watchlist.emptySub")}
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Browse tools</a>
|
||||
|
||||
Reference in New Issue
Block a user