Add icon support for tools and improve analytics chart display

Update API, database schema, and frontend components to allow optional icons for tools, and adjust analytics chart rendering for better visibility.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 0ad0b686-eebd-46d2-8314-67e9e874e224
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/1p7jhzu
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
cheffe01
2026-05-25 13:28:34 +00:00
parent 7abb048edc
commit b8ba53598d
13 changed files with 132 additions and 27 deletions
+2
View File
@@ -84,6 +84,7 @@ router.post("/tools", requireAuth, async (req, res): Promise<void> => {
description: parsed.data.description, description: parsed.data.description,
category: parsed.data.category, category: parsed.data.category,
websiteUrl: parsed.data.websiteUrl ?? null, websiteUrl: parsed.data.websiteUrl ?? null,
iconUrl: parsed.data.iconUrl ?? null,
features: parsed.data.features ?? [], features: parsed.data.features ?? [],
tags: parsed.data.tags ?? [], tags: parsed.data.tags ?? [],
}).returning(); }).returning();
@@ -130,6 +131,7 @@ router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
if (parsed.data.description !== undefined) updateData.description = parsed.data.description; if (parsed.data.description !== undefined) updateData.description = parsed.data.description;
if (parsed.data.category !== undefined) updateData.category = parsed.data.category; if (parsed.data.category !== undefined) updateData.category = parsed.data.category;
if (parsed.data.websiteUrl !== undefined) updateData.websiteUrl = parsed.data.websiteUrl; if (parsed.data.websiteUrl !== undefined) updateData.websiteUrl = parsed.data.websiteUrl;
if (parsed.data.iconUrl !== undefined) updateData.iconUrl = parsed.data.iconUrl;
if (parsed.data.features !== undefined) updateData.features = parsed.data.features; if (parsed.data.features !== undefined) updateData.features = parsed.data.features;
if (parsed.data.tags !== undefined) updateData.tags = parsed.data.tags; if (parsed.data.tags !== undefined) updateData.tags = parsed.data.tags;
@@ -1,7 +1,7 @@
import { ToolWithStats } from "@workspace/api-client-react"; import { ToolWithStats } from "@workspace/api-client-react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Star, MessageSquare } from "lucide-react"; import { Star, MessageSquare, Wrench } from "lucide-react";
import { Link } from "wouter"; import { Link } from "wouter";
export function ToolCard({ tool }: { tool: ToolWithStats }) { export function ToolCard({ tool }: { tool: ToolWithStats }) {
@@ -9,9 +9,25 @@ export function ToolCard({ tool }: { tool: ToolWithStats }) {
<Card className="hover-elevate transition-all flex flex-col h-full cursor-pointer hover:border-primary/50"> <Card className="hover-elevate transition-all flex flex-col h-full cursor-pointer hover:border-primary/50">
<Link href={`/tools/${tool.id}`} className="flex flex-col h-full p-0 m-0"> <Link href={`/tools/${tool.id}`} className="flex flex-col h-full p-0 m-0">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex justify-between items-start mb-2"> <div className="flex items-start gap-3 mb-2">
<CardTitle className="text-lg line-clamp-1">{tool.name}</CardTitle> <div className="shrink-0 w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden">
<Badge variant="outline" className="shrink-0">{tool.category}</Badge> {tool.iconUrl ? (
<img
src={tool.iconUrl}
alt={tool.name}
className="w-full h-full object-contain p-0.5"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<Wrench className="w-4 h-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start gap-2">
<CardTitle className="text-base line-clamp-1">{tool.name}</CardTitle>
<Badge variant="outline" className="shrink-0 text-xs">{tool.category}</Badge>
</div>
</div>
</div> </div>
<CardDescription className="line-clamp-2 min-h-[2.5rem]"> <CardDescription className="line-clamp-2 min-h-[2.5rem]">
{tool.description} {tool.description}
+26 -20
View File
@@ -164,30 +164,36 @@ export default function Analytics() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingDistribution ? ( {loadingDistribution ? (
<Skeleton className="h-[300px] w-full" /> <Skeleton className="h-[280px] w-full" />
) : ( ) : (
<div className="h-[300px] w-full flex flex-col gap-4"> <div className="space-y-5">
<div className="flex-1"> <div>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usefulness</span> <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usefulness</span>
<ResponsiveContainer width="100%" height="100%"> <div className="h-[110px] w-full">
<BarChart data={usefulnessData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}> <ResponsiveContainer width="100%" height="100%">
<XAxis dataKey="score" tickLine={false} axisLine={false} tick={{fontSize: 12}} /> <BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<YAxis hide /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} /> <XAxis dataKey="score" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} /> <YAxis hide />
</BarChart> <RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} contentStyle={{ borderRadius: '6px', border: '1px solid hsl(var(--border))' }} />
</ResponsiveContainer> <Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={40} />
</BarChart>
</ResponsiveContainer>
</div>
</div> </div>
<div className="flex-1"> <div>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usability</span> <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usability</span>
<ResponsiveContainer width="100%" height="100%"> <div className="h-[110px] w-full">
<BarChart data={usabilityData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}> <ResponsiveContainer width="100%" height="100%">
<XAxis dataKey="score" tickLine={false} axisLine={false} tick={{fontSize: 12}} /> <BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<YAxis hide /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} /> <XAxis dataKey="score" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
<Bar dataKey="count" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} /> <YAxis hide />
</BarChart> <RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} contentStyle={{ borderRadius: '6px', border: '1px solid hsl(var(--border))' }} />
</ResponsiveContainer> <Bar dataKey="count" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} maxBarSize={40} />
</BarChart>
</ResponsiveContainer>
</div>
</div> </div>
</div> </div>
)} )}
+19 -3
View File
@@ -142,9 +142,25 @@ export default function ToolDetail() {
<div className="flex flex-col md:flex-row justify-between gap-6 items-start"> <div className="flex flex-col md:flex-row justify-between gap-6 items-start">
<div className="space-y-4 flex-1"> <div className="space-y-4 flex-1">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<h1 className="text-3xl font-bold">{tool.name}</h1> <div className="w-12 h-12 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<Badge variant="outline" className="text-sm bg-background">{tool.category}</Badge> {tool.iconUrl ? (
<img
src={tool.iconUrl}
alt={tool.name}
className="w-full h-full object-contain p-1"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<span className="text-xl font-bold text-muted-foreground select-none">
{tool.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<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>
</div> </div>
<p className="text-lg text-muted-foreground max-w-3xl"> <p className="text-lg text-muted-foreground max-w-3xl">
+37
View File
@@ -23,6 +23,7 @@ const toolSchema = z.object({
description: z.string().min(10, "Description must be at least 10 characters"), description: z.string().min(10, "Description must be at least 10 characters"),
category: z.string().min(2, "Category is required"), category: z.string().min(2, "Category is required"),
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")), websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
features: z.array(z.object({ value: z.string() })).optional(), features: z.array(z.object({ value: z.string() })).optional(),
tags: z.array(z.object({ value: z.string() })).optional(), tags: z.array(z.object({ value: z.string() })).optional(),
}); });
@@ -43,6 +44,7 @@ export default function ToolNew() {
description: "", description: "",
category: "", category: "",
websiteUrl: "", websiteUrl: "",
iconUrl: "",
features: [{ value: "" }], features: [{ value: "" }],
tags: [{ value: "" }], tags: [{ value: "" }],
}, },
@@ -62,6 +64,7 @@ export default function ToolNew() {
const payload = { const payload = {
...data, ...data,
websiteUrl: data.websiteUrl || undefined, websiteUrl: data.websiteUrl || undefined,
iconUrl: data.iconUrl || undefined,
features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""), features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""),
tags: data.tags?.map((t) => t.value).filter((v) => v.trim() !== ""), tags: data.tags?.map((t) => t.value).filter((v) => v.trim() !== ""),
}; };
@@ -166,6 +169,40 @@ export default function ToolNew() {
)} )}
/> />
<FormField
control={form.control}
name="iconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
{field.value ? (
<img
src={field.value}
alt="icon preview"
className="w-full h-full object-contain p-0.5"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<span className="text-xs text-muted-foreground">img</span>
)}
</div>
<Input
placeholder="https://example.com/logo.png"
type="url"
{...field}
data-testid="input-tool-icon-url"
className="flex-1"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
@@ -16,6 +16,8 @@ export interface Tool {
category: string; category: string;
/** @nullable */ /** @nullable */
websiteUrl?: string | null; websiteUrl?: string | null;
/** @nullable */
iconUrl?: string | null;
features?: string[]; features?: string[];
tags?: string[]; tags?: string[];
createdAt: string; createdAt: string;
@@ -29,6 +31,8 @@ export interface ToolWithStats {
category: string; category: string;
/** @nullable */ /** @nullable */
websiteUrl?: string | null; websiteUrl?: string | null;
/** @nullable */
iconUrl?: string | null;
features?: string[]; features?: string[];
tags?: string[]; tags?: string[];
createdAt: string; createdAt: string;
@@ -50,6 +54,7 @@ export interface ToolInput {
/** @minLength 1 */ /** @minLength 1 */
category: string; category: string;
websiteUrl?: string; websiteUrl?: string;
iconUrl?: string;
features?: string[]; features?: string[];
tags?: string[]; tags?: string[];
} }
@@ -60,6 +65,7 @@ export interface ToolUpdate {
description?: string; description?: string;
category?: string; category?: string;
websiteUrl?: string; websiteUrl?: string;
iconUrl?: string;
features?: string[]; features?: string[];
tags?: string[]; tags?: string[];
} }
+8
View File
@@ -362,6 +362,8 @@ components:
type: string type: string
websiteUrl: websiteUrl:
type: ["string", "null"] type: ["string", "null"]
iconUrl:
type: ["string", "null"]
features: features:
type: array type: array
items: items:
@@ -391,6 +393,8 @@ components:
type: string type: string
websiteUrl: websiteUrl:
type: ["string", "null"] type: ["string", "null"]
iconUrl:
type: ["string", "null"]
features: features:
type: array type: array
items: items:
@@ -429,6 +433,8 @@ components:
minLength: 1 minLength: 1
websiteUrl: websiteUrl:
type: string type: string
iconUrl:
type: string
features: features:
type: array type: array
items: items:
@@ -450,6 +456,8 @@ components:
type: string type: string
websiteUrl: websiteUrl:
type: string type: string
iconUrl:
type: string
features: features:
type: array type: array
items: items:
+7
View File
@@ -32,6 +32,7 @@ export const ListToolsResponseItem = zod.object({
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
"websiteUrl": zod.string().nullish(), "websiteUrl": zod.string().nullish(),
"iconUrl": zod.string().nullish(),
"features": zod.array(zod.string()).optional(), "features": zod.array(zod.string()).optional(),
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
@@ -57,6 +58,7 @@ export const CreateToolBody = zod.object({
"description": zod.string().min(1), "description": zod.string().min(1),
"category": zod.string().min(1), "category": zod.string().min(1),
"websiteUrl": zod.string().optional(), "websiteUrl": zod.string().optional(),
"iconUrl": zod.string().optional(),
"features": zod.array(zod.string()).optional(), "features": zod.array(zod.string()).optional(),
"tags": zod.array(zod.string()).optional() "tags": zod.array(zod.string()).optional()
}) })
@@ -75,6 +77,7 @@ export const GetToolResponse = zod.object({
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
"websiteUrl": zod.string().nullish(), "websiteUrl": zod.string().nullish(),
"iconUrl": zod.string().nullish(),
"features": zod.array(zod.string()).optional(), "features": zod.array(zod.string()).optional(),
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
@@ -101,6 +104,7 @@ export const UpdateToolBody = zod.object({
"description": zod.string().optional(), "description": zod.string().optional(),
"category": zod.string().optional(), "category": zod.string().optional(),
"websiteUrl": zod.string().optional(), "websiteUrl": zod.string().optional(),
"iconUrl": zod.string().optional(),
"features": zod.array(zod.string()).optional(), "features": zod.array(zod.string()).optional(),
"tags": zod.array(zod.string()).optional() "tags": zod.array(zod.string()).optional()
}) })
@@ -111,6 +115,7 @@ export const UpdateToolResponse = zod.object({
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
"websiteUrl": zod.string().nullish(), "websiteUrl": zod.string().nullish(),
"iconUrl": zod.string().nullish(),
"features": zod.array(zod.string()).optional(), "features": zod.array(zod.string()).optional(),
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
@@ -188,6 +193,7 @@ export const GetAnalyticsSummaryResponse = zod.object({
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
"websiteUrl": zod.string().nullish(), "websiteUrl": zod.string().nullish(),
"iconUrl": zod.string().nullish(),
"features": zod.array(zod.string()).optional(), "features": zod.array(zod.string()).optional(),
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
@@ -215,6 +221,7 @@ export const GetTopToolsResponseItem = zod.object({
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
"websiteUrl": zod.string().nullish(), "websiteUrl": zod.string().nullish(),
"iconUrl": zod.string().nullish(),
"features": zod.array(zod.string()).optional(), "features": zod.array(zod.string()).optional(),
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
+2
View File
@@ -13,6 +13,8 @@ export interface Tool {
category: string; category: string;
/** @nullable */ /** @nullable */
websiteUrl?: string | null; websiteUrl?: string | null;
/** @nullable */
iconUrl?: string | null;
features?: string[]; features?: string[];
tags?: string[]; tags?: string[];
createdAt: Date; createdAt: Date;
@@ -14,6 +14,7 @@ export interface ToolInput {
/** @minLength 1 */ /** @minLength 1 */
category: string; category: string;
websiteUrl?: string; websiteUrl?: string;
iconUrl?: string;
features?: string[]; features?: string[];
tags?: string[]; tags?: string[];
} }
@@ -12,6 +12,7 @@ export interface ToolUpdate {
description?: string; description?: string;
category?: string; category?: string;
websiteUrl?: string; websiteUrl?: string;
iconUrl?: string;
features?: string[]; features?: string[];
tags?: string[]; tags?: string[];
} }
@@ -13,6 +13,8 @@ export interface ToolWithStats {
category: string; category: string;
/** @nullable */ /** @nullable */
websiteUrl?: string | null; websiteUrl?: string | null;
/** @nullable */
iconUrl?: string | null;
features?: string[]; features?: string[];
tags?: string[]; tags?: string[];
createdAt: Date; createdAt: Date;
+1
View File
@@ -8,6 +8,7 @@ export const toolsTable = pgTable("tools", {
description: text("description").notNull(), description: text("description").notNull(),
category: text("category").notNull(), category: text("category").notNull(),
websiteUrl: text("website_url"), websiteUrl: text("website_url"),
iconUrl: text("icon_url"),
features: text("features").array().notNull().default([]), features: text("features").array().notNull().default([]),
tags: text("tags").array().notNull().default([]), tags: text("tags").array().notNull().default([]),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),