diff --git a/.replit b/.replit index 82bac8f..6281b48 100644 --- a/.replit +++ b/.replit @@ -1,4 +1,4 @@ -modules = ["nodejs-24"] +modules = ["nodejs-24", "postgresql-16"] [deployment] router = "application" @@ -18,3 +18,18 @@ expertMode = true [postMerge] path = "scripts/post-merge.sh" timeoutMs = 20000 + +[[ports]] +localPort = 8080 +externalPort = 80 + +[[ports]] +localPort = 8081 +externalPort = 8081 + +[[ports]] +localPort = 26015 +externalPort = 3000 + +[nix] +channel = "stable-25_05" diff --git a/artifacts/api-server/src/routes/analytics.ts b/artifacts/api-server/src/routes/analytics.ts new file mode 100644 index 0000000..447c149 --- /dev/null +++ b/artifacts/api-server/src/routes/analytics.ts @@ -0,0 +1,181 @@ +import { Router, type IRouter } from "express"; +import { eq, sql, desc } from "drizzle-orm"; +import { db, toolsTable, ratingsTable } from "@workspace/db"; +import { + GetTopToolsQueryParams, + GetRatingDistributionQueryParams, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +router.get("/analytics/summary", async (_req, res): Promise => { + const [toolStats] = await db + .select({ totalTools: sql`count(*)::int` }) + .from(toolsTable); + + const [ratingStats] = await db + .select({ + totalRatings: sql`count(*)::int`, + avgUsefulness: sql`avg(${ratingsTable.usefulness})`, + avgUsability: sql`avg(${ratingsTable.usability})`, + }) + .from(ratingsTable); + + const [catStats] = await db + .select({ categoriesCount: sql`count(distinct ${toolsTable.category})::int` }) + .from(toolsTable); + + const avgCombined = ratingStats.avgUsefulness != null && ratingStats.avgUsability != null + ? (Number(ratingStats.avgUsefulness) + Number(ratingStats.avgUsability)) / 2 + : null; + + // Most-rated tool + const [mostRatedRow] = await db + .select({ + toolId: ratingsTable.toolId, + ratingCount: sql`count(*)::int`, + }) + .from(ratingsTable) + .groupBy(ratingsTable.toolId) + .orderBy(desc(sql`count(*)`)) + .limit(1); + + let mostRatedTool = null; + if (mostRatedRow) { + const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, mostRatedRow.toolId)); + if (tool) { + const ratingRows = await db + .select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) + .from(ratingsTable) + .where(eq(ratingsTable.toolId, tool.id)); + const rc = ratingRows.length; + const au = rc > 0 ? ratingRows.reduce((s, r) => s + r.usefulness, 0) / rc : null; + const aus = rc > 0 ? ratingRows.reduce((s, r) => s + r.usability, 0) / rc : null; + const ac = au != null && aus != null ? (au + aus) / 2 : null; + mostRatedTool = { ...tool, ratingCount: rc, avgUsefulness: au, avgUsability: aus, avgCombined: ac }; + } + } + + res.json({ + totalTools: toolStats?.totalTools ?? 0, + totalRatings: ratingStats?.totalRatings ?? 0, + avgUsefulness: ratingStats?.avgUsefulness != null ? Number(ratingStats.avgUsefulness) : null, + avgUsability: ratingStats?.avgUsability != null ? Number(ratingStats.avgUsability) : null, + avgCombined, + categoriesCount: catStats?.categoriesCount ?? 0, + mostRatedTool, + }); +}); + +router.get("/analytics/top-tools", async (req, res): Promise => { + const parsed = GetTopToolsQueryParams.safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const limit = parsed.data.limit ?? 10; + const metric = parsed.data.metric ?? "combined"; + + const tools = await db.select().from(toolsTable); + const allRatings = await db + .select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) + .from(ratingsTable); + + const ratingsByTool = new Map(); + for (const r of allRatings) { + const arr = ratingsByTool.get(r.toolId) ?? []; + arr.push({ usefulness: r.usefulness, usability: r.usability }); + ratingsByTool.set(r.toolId, arr); + } + + const toolsWithStats = tools.map((t) => { + const ratings = ratingsByTool.get(t.id) ?? []; + const rc = ratings.length; + const au = rc > 0 ? ratings.reduce((s, r) => s + r.usefulness, 0) / rc : null; + const aus = rc > 0 ? ratings.reduce((s, r) => s + r.usability, 0) / rc : null; + const ac = au != null && aus != null ? (au + aus) / 2 : null; + return { ...t, ratingCount: rc, avgUsefulness: au, avgUsability: aus, avgCombined: ac }; + }); + + const scored = toolsWithStats + .filter((t) => t.ratingCount > 0) + .map((t) => { + let score = 0; + if (metric === "usefulness") score = t.avgUsefulness ?? 0; + else if (metric === "usability") score = t.avgUsability ?? 0; + else score = t.avgCombined ?? 0; + return { tool: t, score, ratingCount: t.ratingCount }; + }) + .sort((a, b) => b.score - a.score) + .slice(0, limit); + + res.json(scored); +}); + +router.get("/analytics/by-category", async (_req, res): Promise => { + const categories = await db + .selectDistinct({ category: toolsTable.category }) + .from(toolsTable); + + const result = await Promise.all( + categories.map(async ({ category }) => { + const [toolCount] = await db + .select({ count: sql`count(*)::int` }) + .from(toolsTable) + .where(eq(toolsTable.category, category)); + + const [ratingStats] = await db + .select({ + totalRatings: sql`count(*)::int`, + avgUsefulness: sql`avg(${ratingsTable.usefulness})`, + avgUsability: sql`avg(${ratingsTable.usability})`, + }) + .from(ratingsTable) + .innerJoin(toolsTable, eq(ratingsTable.toolId, toolsTable.id)) + .where(eq(toolsTable.category, category)); + + return { + category, + toolCount: toolCount?.count ?? 0, + totalRatings: ratingStats?.totalRatings ?? 0, + avgUsefulness: ratingStats?.avgUsefulness != null ? Number(ratingStats.avgUsefulness) : null, + avgUsability: ratingStats?.avgUsability != null ? Number(ratingStats.avgUsability) : null, + }; + }) + ); + + res.json(result); +}); + +router.get("/analytics/rating-distribution", async (req, res): Promise => { + const parsed = GetRatingDistributionQueryParams.safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const toolIdFilter = parsed.data.toolId; + + const baseQuery = toolIdFilter + ? db.select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) + .from(ratingsTable) + .where(eq(ratingsTable.toolId, toolIdFilter)) + : db.select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) + .from(ratingsTable); + + const allRatings = await baseQuery; + + const usefulnessBuckets = [1, 2, 3, 4, 5].map((score) => ({ + score, + count: allRatings.filter((r) => r.usefulness === score).length, + })); + + const usabilityBuckets = [1, 2, 3, 4, 5].map((score) => ({ + score, + count: allRatings.filter((r) => r.usability === score).length, + })); + + res.json({ usefulness: usefulnessBuckets, usability: usabilityBuckets }); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 5a1f77a..7b6c72b 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -1,8 +1,14 @@ import { Router, type IRouter } from "express"; import healthRouter from "./health"; +import toolsRouter from "./tools"; +import ratingsRouter from "./ratings"; +import analyticsRouter from "./analytics"; const router: IRouter = Router(); router.use(healthRouter); +router.use(toolsRouter); +router.use(ratingsRouter); +router.use(analyticsRouter); export default router; diff --git a/artifacts/api-server/src/routes/ratings.ts b/artifacts/api-server/src/routes/ratings.ts new file mode 100644 index 0000000..66dfc55 --- /dev/null +++ b/artifacts/api-server/src/routes/ratings.ts @@ -0,0 +1,64 @@ +import { Router, type IRouter } from "express"; +import { eq } from "drizzle-orm"; +import { db, toolsTable, ratingsTable } from "@workspace/db"; +import { + ListToolRatingsParams, + CreateRatingParams, + CreateRatingBody, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +router.get("/tools/:id/ratings", async (req, res): Promise => { + const params = ListToolRatingsParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, params.data.id)); + if (!tool) { + res.status(404).json({ error: "Tool not found" }); + return; + } + + const ratings = await db + .select() + .from(ratingsTable) + .where(eq(ratingsTable.toolId, params.data.id)) + .orderBy(ratingsTable.createdAt); + + res.json(ratings); +}); + +router.post("/tools/:id/ratings", async (req, res): Promise => { + const params = CreateRatingParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, params.data.id)); + if (!tool) { + res.status(404).json({ error: "Tool not found" }); + return; + } + + const parsed = CreateRatingBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [rating] = await db.insert(ratingsTable).values({ + toolId: params.data.id, + usefulness: parsed.data.usefulness, + usability: parsed.data.usability, + comment: parsed.data.comment ?? null, + reviewerName: parsed.data.reviewerName ?? null, + }).returning(); + + res.status(201).json(rating); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/tools.ts b/artifacts/api-server/src/routes/tools.ts new file mode 100644 index 0000000..21466dd --- /dev/null +++ b/artifacts/api-server/src/routes/tools.ts @@ -0,0 +1,173 @@ +import { Router, type IRouter } from "express"; +import { eq, ilike, desc, sql, avg, count } from "drizzle-orm"; +import { db, toolsTable, ratingsTable } from "@workspace/db"; +import { + ListToolsQueryParams, + CreateToolBody, + GetToolParams, + UpdateToolParams, + UpdateToolBody, + DeleteToolParams, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +function buildToolWithStats(tool: typeof toolsTable.$inferSelect, ratings: { usefulness: number; usability: number }[]) { + const ratingCount = ratings.length; + const avgUsefulness = ratingCount > 0 + ? ratings.reduce((s, r) => s + r.usefulness, 0) / ratingCount + : null; + const avgUsability = ratingCount > 0 + ? ratings.reduce((s, r) => s + r.usability, 0) / ratingCount + : null; + const avgCombined = avgUsefulness != null && avgUsability != null + ? (avgUsefulness + avgUsability) / 2 + : null; + return { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined }; +} + +router.get("/tools", async (req, res): Promise => { + const parsed = ListToolsQueryParams.safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const { category, search, sort } = parsed.data; + + let query = db.select().from(toolsTable).$dynamic(); + if (category) { + query = query.where(eq(toolsTable.category, category)); + } + if (search) { + query = query.where(ilike(toolsTable.name, `%${search}%`)); + } + + const tools = await query.orderBy(desc(toolsTable.createdAt)); + + const toolIds = tools.map((t) => t.id); + let allRatings: { toolId: number; usefulness: number; usability: number }[] = []; + if (toolIds.length > 0) { + allRatings = await db + .select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) + .from(ratingsTable) + .where(sql`${ratingsTable.toolId} = ANY(${sql`ARRAY[${sql.join(toolIds.map(id => sql`${id}`), sql`, `)}]::int[]`})`); + } + + const ratingsByTool = new Map(); + for (const r of allRatings) { + const arr = ratingsByTool.get(r.toolId) ?? []; + arr.push({ usefulness: r.usefulness, usability: r.usability }); + ratingsByTool.set(r.toolId, arr); + } + + let result = tools.map((t) => buildToolWithStats(t, ratingsByTool.get(t.id) ?? [])); + + if (sort === "top_rated") { + result = result.sort((a, b) => (b.avgCombined ?? 0) - (a.avgCombined ?? 0)); + } else if (sort === "most_reviewed") { + result = result.sort((a, b) => b.ratingCount - a.ratingCount); + } + + res.json(result); +}); + +router.post("/tools", async (req, res): Promise => { + const parsed = CreateToolBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [tool] = await db.insert(toolsTable).values({ + name: parsed.data.name, + description: parsed.data.description, + category: parsed.data.category, + websiteUrl: parsed.data.websiteUrl ?? null, + features: parsed.data.features ?? [], + tags: parsed.data.tags ?? [], + }).returning(); + + res.status(201).json(tool); +}); + +router.get("/tools/:id", async (req, res): Promise => { + const params = GetToolParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, params.data.id)); + if (!tool) { + res.status(404).json({ error: "Tool not found" }); + return; + } + + const ratings = await db + .select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) + .from(ratingsTable) + .where(eq(ratingsTable.toolId, params.data.id)); + + res.json(buildToolWithStats(tool, ratings)); +}); + +router.patch("/tools/:id", async (req, res): Promise => { + const params = UpdateToolParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const parsed = UpdateToolBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const updateData: Record = {}; + if (parsed.data.name !== undefined) updateData.name = parsed.data.name; + 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.features !== undefined) updateData.features = parsed.data.features; + if (parsed.data.tags !== undefined) updateData.tags = parsed.data.tags; + + const [tool] = await db + .update(toolsTable) + .set(updateData) + .where(eq(toolsTable.id, params.data.id)) + .returning(); + + if (!tool) { + res.status(404).json({ error: "Tool not found" }); + return; + } + + res.json(tool); +}); + +router.delete("/tools/:id", async (req, res): Promise => { + const params = DeleteToolParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [tool] = await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id)).returning(); + if (!tool) { + res.status(404).json({ error: "Tool not found" }); + return; + } + + res.sendStatus(204); +}); + +router.get("/categories", async (_req, res): Promise => { + const rows = await db + .selectDistinct({ category: toolsTable.category }) + .from(toolsTable) + .orderBy(toolsTable.category); + res.json(rows.map((r) => r.category)); +}); + +export default router; diff --git a/artifacts/toolrate/.replit-artifact/artifact.toml b/artifacts/toolrate/.replit-artifact/artifact.toml new file mode 100644 index 0000000..6afe456 --- /dev/null +++ b/artifacts/toolrate/.replit-artifact/artifact.toml @@ -0,0 +1,31 @@ +kind = "web" +previewPath = "/" +title = "ToolRate — Tool-Bewertungsplattform" +version = "1.0.0" +id = "artifacts/toolrate" +router = "path" + +[[integratedSkills]] +name = "react-vite" +version = "1.0.0" + +[[services]] +name = "web" +paths = [ "/" ] +localPort = 26015 + +[services.development] +run = "pnpm --filter @workspace/toolrate run dev" + +[services.production] +build = [ "pnpm", "--filter", "@workspace/toolrate", "run", "build" ] +publicDir = "artifacts/toolrate/dist/public" +serve = "static" + +[[services.production.rewrites]] +from = "/*" +to = "/index.html" + +[services.env] +PORT = "26015" +BASE_PATH = "/" diff --git a/artifacts/toolrate/components.json b/artifacts/toolrate/components.json new file mode 100644 index 0000000..3ff62cf --- /dev/null +++ b/artifacts/toolrate/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/artifacts/toolrate/index.html b/artifacts/toolrate/index.html new file mode 100644 index 0000000..987ecd7 --- /dev/null +++ b/artifacts/toolrate/index.html @@ -0,0 +1,24 @@ + + + + + + ToolRate — Tool-Bewertungsplattform + + + + + + + + + + + + + + +
+ + + diff --git a/artifacts/toolrate/package.json b/artifacts/toolrate/package.json new file mode 100644 index 0000000..fd5be53 --- /dev/null +++ b/artifacts/toolrate/package.json @@ -0,0 +1,77 @@ +{ + "name": "@workspace/toolrate", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "vite build --config vite.config.ts", + "serve": "vite preview --config vite.config.ts --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-aspect-ratio": "^1.1.3", + "@radix-ui/react-avatar": "^1.1.4", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.7", + "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-hover-card": "^1.1.7", + "@radix-ui/react-label": "^2.1.3", + "@radix-ui/react-menubar": "^1.1.7", + "@radix-ui/react-navigation-menu": "^1.2.6", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-progress": "^1.1.3", + "@radix-ui/react-radio-group": "^1.2.4", + "@radix-ui/react-scroll-area": "^1.2.4", + "@radix-ui/react-select": "^2.1.7", + "@radix-ui/react-separator": "^1.1.3", + "@radix-ui/react-slider": "^1.2.4", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-toast": "^1.2.7", + "@radix-ui/react-toggle": "^1.1.3", + "@radix-ui/react-toggle-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.0", + "@replit/vite-plugin-cartographer": "catalog:", + "@replit/vite-plugin-dev-banner": "catalog:", + "@replit/vite-plugin-runtime-error-modal": "catalog:", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@workspace/api-client-react": "workspace:*", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "catalog:", + "input-otp": "^1.4.2", + "lucide-react": "catalog:", + "next-themes": "^0.4.6", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "react-hook-form": "^7.55.0", + "react-icons": "^5.4.0", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.2", + "sonner": "^2.0.7", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "vite": "catalog:", + "wouter": "^3.3.5", + "zod": "catalog:" + } +} diff --git a/artifacts/toolrate/public/favicon.svg b/artifacts/toolrate/public/favicon.svg new file mode 100644 index 0000000..4373d3c --- /dev/null +++ b/artifacts/toolrate/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/artifacts/toolrate/public/opengraph.jpg b/artifacts/toolrate/public/opengraph.jpg new file mode 100644 index 0000000..bbbdfe8 Binary files /dev/null and b/artifacts/toolrate/public/opengraph.jpg differ diff --git a/artifacts/toolrate/public/robots.txt b/artifacts/toolrate/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/artifacts/toolrate/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/artifacts/toolrate/src/App.tsx b/artifacts/toolrate/src/App.tsx new file mode 100644 index 0000000..6628fc5 --- /dev/null +++ b/artifacts/toolrate/src/App.tsx @@ -0,0 +1,49 @@ +import { Switch, Route, Router as WouterRouter } from "wouter"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +// Pages +import Home from "@/pages/home"; +import ToolsBrowse from "@/pages/tools-browse"; +import ToolDetail from "@/pages/tool-detail"; +import ToolNew from "@/pages/tool-new"; +import Analytics from "@/pages/analytics"; +import NotFound from "@/pages/not-found"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 5, // 5 minutes + }, + }, +}); + +function Router() { + return ( + + + + + + + + + ); +} + +function App() { + return ( + + + + + + + + + ); +} + +export default App; diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx new file mode 100644 index 0000000..d999c5d --- /dev/null +++ b/artifacts/toolrate/src/components/layout.tsx @@ -0,0 +1,49 @@ +import { Link, useLocation } from "wouter"; +import { LayoutDashboard, Wrench, PlusCircle, BarChart3 } from "lucide-react"; + +export function Layout({ children }: { children: React.ReactNode }) { + const [location] = useLocation(); + + const links = [ + { href: "/", label: "Dashboard", icon: LayoutDashboard }, + { href: "/tools", label: "Browse Tools", icon: Wrench }, + { href: "/tools/new", label: "Add Tool", icon: PlusCircle }, + { href: "/analytics", label: "Analytics", icon: BarChart3 }, + ]; + + return ( +
+ +
+
+
+ + ToolRate +
+
+
+ {children} +
+
+
+ ); +} diff --git a/artifacts/toolrate/src/components/rating-stars.tsx b/artifacts/toolrate/src/components/rating-stars.tsx new file mode 100644 index 0000000..41cbd71 --- /dev/null +++ b/artifacts/toolrate/src/components/rating-stars.tsx @@ -0,0 +1,41 @@ +import { Star } from "lucide-react"; + +interface RatingStarsProps { + value: number; + max?: number; + size?: "sm" | "md" | "lg"; + interactive?: boolean; + onChange?: (value: number) => void; +} + +export function RatingStars({ value, max = 5, size = "md", interactive = false, onChange }: RatingStarsProps) { + const sizeClasses = { + sm: "w-3 h-3", + md: "w-5 h-5", + lg: "w-7 h-7" + }; + + return ( +
+ {Array.from({ length: max }).map((_, i) => { + const starValue = i + 1; + const isFilled = value >= starValue; + + return ( + + ); + })} +
+ ); +} diff --git a/artifacts/toolrate/src/components/tool-card.tsx b/artifacts/toolrate/src/components/tool-card.tsx new file mode 100644 index 0000000..22253fd --- /dev/null +++ b/artifacts/toolrate/src/components/tool-card.tsx @@ -0,0 +1,51 @@ +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 { Link } from "wouter"; + +export function ToolCard({ tool }: { tool: ToolWithStats }) { + return ( + + + +
+ {tool.name} + {tool.category} +
+ + {tool.description} + +
+ +
+ {tool.tags?.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {(tool.tags?.length || 0) > 3 && ( + + +{(tool.tags?.length || 0) - 3} + + )} +
+
+ +
+
+ + + {tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"} + +
+
+ + {tool.ratingCount} +
+
+
+ +
+ ); +} diff --git a/artifacts/toolrate/src/components/ui/accordion.tsx b/artifacts/toolrate/src/components/ui/accordion.tsx new file mode 100644 index 0000000..e1797c9 --- /dev/null +++ b/artifacts/toolrate/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/artifacts/toolrate/src/components/ui/alert-dialog.tsx b/artifacts/toolrate/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..fa2b442 --- /dev/null +++ b/artifacts/toolrate/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/artifacts/toolrate/src/components/ui/alert.tsx b/artifacts/toolrate/src/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/artifacts/toolrate/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/artifacts/toolrate/src/components/ui/aspect-ratio.tsx b/artifacts/toolrate/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c4abbf3 --- /dev/null +++ b/artifacts/toolrate/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/artifacts/toolrate/src/components/ui/avatar.tsx b/artifacts/toolrate/src/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/artifacts/toolrate/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/artifacts/toolrate/src/components/ui/badge.tsx b/artifacts/toolrate/src/components/ui/badge.tsx new file mode 100644 index 0000000..3f03665 --- /dev/null +++ b/artifacts/toolrate/src/components/ui/badge.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + // @replit + // Whitespace-nowrap: Badges should never wrap. + "whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + + " hover-elevate ", + { + variants: { + variant: { + default: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-primary text-primary-foreground shadow-xs", + secondary: + // @replit no hover because we use hover-elevate + "border-transparent bg-secondary text-secondary-foreground", + destructive: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-destructive text-destructive-foreground shadow-xs", + // @replit shadow-xs" - use badge outline variable + outline: "text-foreground border [border-color:var(--badge-outline)]", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/artifacts/toolrate/src/components/ui/breadcrumb.tsx b/artifacts/toolrate/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/artifacts/toolrate/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>