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:
@@ -84,6 +84,7 @@ router.post("/tools", requireAuth, async (req, res): Promise<void> => {
|
||||
description: parsed.data.description,
|
||||
category: parsed.data.category,
|
||||
websiteUrl: parsed.data.websiteUrl ?? null,
|
||||
iconUrl: parsed.data.iconUrl ?? null,
|
||||
features: parsed.data.features ?? [],
|
||||
tags: parsed.data.tags ?? [],
|
||||
}).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.category !== undefined) updateData.category = parsed.data.category;
|
||||
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.tags !== undefined) updateData.tags = parsed.data.tags;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ToolWithStats } from "@workspace/api-client-react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Star, MessageSquare } from "lucide-react";
|
||||
import { Star, MessageSquare, Wrench } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
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">
|
||||
<Link href={`/tools/${tool.id}`} className="flex flex-col h-full p-0 m-0">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<CardTitle className="text-lg line-clamp-1">{tool.name}</CardTitle>
|
||||
<Badge variant="outline" className="shrink-0">{tool.category}</Badge>
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="shrink-0 w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden">
|
||||
{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>
|
||||
<CardDescription className="line-clamp-2 min-h-[2.5rem]">
|
||||
{tool.description}
|
||||
|
||||
@@ -164,32 +164,38 @@ export default function Analytics() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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="flex-1">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usefulness</span>
|
||||
<div className="h-[110px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={usefulnessData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
<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]} />
|
||||
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} contentStyle={{ borderRadius: '6px', border: '1px solid hsl(var(--border))' }} />
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={40} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usability</span>
|
||||
<div className="h-[110px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={usabilityData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
<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]} />
|
||||
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} contentStyle={{ borderRadius: '6px', border: '1px solid hsl(var(--border))' }} />
|
||||
<Bar dataKey="count" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} maxBarSize={40} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -142,10 +142,26 @@ export default function ToolDetail() {
|
||||
|
||||
<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-4 flex-wrap">
|
||||
<div className="w-12 h-12 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||
{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>
|
||||
|
||||
<p className="text-lg text-muted-foreground max-w-3xl">
|
||||
{tool.description}
|
||||
|
||||
@@ -23,6 +23,7 @@ const toolSchema = z.object({
|
||||
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("")),
|
||||
iconUrl: 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(),
|
||||
});
|
||||
@@ -43,6 +44,7 @@ export default function ToolNew() {
|
||||
description: "",
|
||||
category: "",
|
||||
websiteUrl: "",
|
||||
iconUrl: "",
|
||||
features: [{ value: "" }],
|
||||
tags: [{ value: "" }],
|
||||
},
|
||||
@@ -62,6 +64,7 @@ export default function ToolNew() {
|
||||
const payload = {
|
||||
...data,
|
||||
websiteUrl: data.websiteUrl || undefined,
|
||||
iconUrl: data.iconUrl || undefined,
|
||||
features: data.features?.map((f) => f.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
|
||||
control={form.control}
|
||||
name="description"
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Tool {
|
||||
category: string;
|
||||
/** @nullable */
|
||||
websiteUrl?: string | null;
|
||||
/** @nullable */
|
||||
iconUrl?: string | null;
|
||||
features?: string[];
|
||||
tags?: string[];
|
||||
createdAt: string;
|
||||
@@ -29,6 +31,8 @@ export interface ToolWithStats {
|
||||
category: string;
|
||||
/** @nullable */
|
||||
websiteUrl?: string | null;
|
||||
/** @nullable */
|
||||
iconUrl?: string | null;
|
||||
features?: string[];
|
||||
tags?: string[];
|
||||
createdAt: string;
|
||||
@@ -50,6 +54,7 @@ export interface ToolInput {
|
||||
/** @minLength 1 */
|
||||
category: string;
|
||||
websiteUrl?: string;
|
||||
iconUrl?: string;
|
||||
features?: string[];
|
||||
tags?: string[];
|
||||
}
|
||||
@@ -60,6 +65,7 @@ export interface ToolUpdate {
|
||||
description?: string;
|
||||
category?: string;
|
||||
websiteUrl?: string;
|
||||
iconUrl?: string;
|
||||
features?: string[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@@ -362,6 +362,8 @@ components:
|
||||
type: string
|
||||
websiteUrl:
|
||||
type: ["string", "null"]
|
||||
iconUrl:
|
||||
type: ["string", "null"]
|
||||
features:
|
||||
type: array
|
||||
items:
|
||||
@@ -391,6 +393,8 @@ components:
|
||||
type: string
|
||||
websiteUrl:
|
||||
type: ["string", "null"]
|
||||
iconUrl:
|
||||
type: ["string", "null"]
|
||||
features:
|
||||
type: array
|
||||
items:
|
||||
@@ -429,6 +433,8 @@ components:
|
||||
minLength: 1
|
||||
websiteUrl:
|
||||
type: string
|
||||
iconUrl:
|
||||
type: string
|
||||
features:
|
||||
type: array
|
||||
items:
|
||||
@@ -450,6 +456,8 @@ components:
|
||||
type: string
|
||||
websiteUrl:
|
||||
type: string
|
||||
iconUrl:
|
||||
type: string
|
||||
features:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@@ -32,6 +32,7 @@ export const ListToolsResponseItem = zod.object({
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
"websiteUrl": zod.string().nullish(),
|
||||
"iconUrl": zod.string().nullish(),
|
||||
"features": zod.array(zod.string()).optional(),
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": zod.coerce.date(),
|
||||
@@ -57,6 +58,7 @@ export const CreateToolBody = zod.object({
|
||||
"description": zod.string().min(1),
|
||||
"category": zod.string().min(1),
|
||||
"websiteUrl": zod.string().optional(),
|
||||
"iconUrl": zod.string().optional(),
|
||||
"features": zod.array(zod.string()).optional(),
|
||||
"tags": zod.array(zod.string()).optional()
|
||||
})
|
||||
@@ -75,6 +77,7 @@ export const GetToolResponse = zod.object({
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
"websiteUrl": zod.string().nullish(),
|
||||
"iconUrl": zod.string().nullish(),
|
||||
"features": zod.array(zod.string()).optional(),
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": zod.coerce.date(),
|
||||
@@ -101,6 +104,7 @@ export const UpdateToolBody = zod.object({
|
||||
"description": zod.string().optional(),
|
||||
"category": zod.string().optional(),
|
||||
"websiteUrl": zod.string().optional(),
|
||||
"iconUrl": zod.string().optional(),
|
||||
"features": zod.array(zod.string()).optional(),
|
||||
"tags": zod.array(zod.string()).optional()
|
||||
})
|
||||
@@ -111,6 +115,7 @@ export const UpdateToolResponse = zod.object({
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
"websiteUrl": zod.string().nullish(),
|
||||
"iconUrl": zod.string().nullish(),
|
||||
"features": zod.array(zod.string()).optional(),
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": zod.coerce.date(),
|
||||
@@ -188,6 +193,7 @@ export const GetAnalyticsSummaryResponse = zod.object({
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
"websiteUrl": zod.string().nullish(),
|
||||
"iconUrl": zod.string().nullish(),
|
||||
"features": zod.array(zod.string()).optional(),
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": zod.coerce.date(),
|
||||
@@ -215,6 +221,7 @@ export const GetTopToolsResponseItem = zod.object({
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
"websiteUrl": zod.string().nullish(),
|
||||
"iconUrl": zod.string().nullish(),
|
||||
"features": zod.array(zod.string()).optional(),
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": zod.coerce.date(),
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface Tool {
|
||||
category: string;
|
||||
/** @nullable */
|
||||
websiteUrl?: string | null;
|
||||
/** @nullable */
|
||||
iconUrl?: string | null;
|
||||
features?: string[];
|
||||
tags?: string[];
|
||||
createdAt: Date;
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ToolInput {
|
||||
/** @minLength 1 */
|
||||
category: string;
|
||||
websiteUrl?: string;
|
||||
iconUrl?: string;
|
||||
features?: string[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ToolUpdate {
|
||||
description?: string;
|
||||
category?: string;
|
||||
websiteUrl?: string;
|
||||
iconUrl?: string;
|
||||
features?: string[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ToolWithStats {
|
||||
category: string;
|
||||
/** @nullable */
|
||||
websiteUrl?: string | null;
|
||||
/** @nullable */
|
||||
iconUrl?: string | null;
|
||||
features?: string[];
|
||||
tags?: string[];
|
||||
createdAt: Date;
|
||||
|
||||
@@ -8,6 +8,7 @@ export const toolsTable = pgTable("tools", {
|
||||
description: text("description").notNull(),
|
||||
category: text("category").notNull(),
|
||||
websiteUrl: text("website_url"),
|
||||
iconUrl: text("icon_url"),
|
||||
features: text("features").array().notNull().default([]),
|
||||
tags: text("tags").array().notNull().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
Reference in New Issue
Block a user