From d033b20dfbb830af788fad93f3ba71849cfb8767 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 2 Aug 2026 13:00:10 +0200 Subject: [PATCH] feat: watchlist (premium), mobile bottom nav --- artifacts/api-server/src/routes/auth.ts | 47 ++++++++++- artifacts/toolrate/src/App.tsx | 2 + artifacts/toolrate/src/components/layout.tsx | 32 +++++++- .../toolrate/src/components/tool-card.tsx | 27 ++++++- artifacts/toolrate/src/hooks/use-watchlist.ts | 49 ++++++++++++ artifacts/toolrate/src/pages/tool-detail.tsx | 16 +++- artifacts/toolrate/src/pages/tools-browse.tsx | 3 + artifacts/toolrate/src/pages/watchlist.tsx | 76 ++++++++++++++++++ .../src/generated/api.schemas.ts | 1 + lib/api-client-react/src/generated/api.ts | 77 +++++++++++++++++++ lib/api-spec/openapi.yaml | 31 ++++++++ lib/api-zod/src/generated/api.ts | 32 +++++++- .../src/generated/types/userPreferences.ts | 1 + lib/db/src/schema/users.ts | 1 + 14 files changed, 383 insertions(+), 12 deletions(-) create mode 100644 artifacts/toolrate/src/hooks/use-watchlist.ts create mode 100644 artifacts/toolrate/src/pages/watchlist.tsx diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index daa58d7..c3f8923 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -1,11 +1,11 @@ import { Router, type IRouter, type Request } from "express"; import { Issuer, generators, type Client } from "openid-client"; import bcrypt from "bcryptjs"; -import { eq } from "drizzle-orm"; +import { eq, and, inArray, isNull } from "drizzle-orm"; import { z } from "zod"; -import { db, usersTable } from "@workspace/db"; +import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db"; import { logger } from "../lib/logger"; -import { getEntitlements } from "../middleware/feature"; +import { getEntitlements, requireFeature } from "../middleware/feature"; const router: IRouter = Router(); @@ -301,6 +301,7 @@ async function resolveDbUser(u: SessionUser) { const PreferenceSchema = z.object({ view: z.enum(["grid", "table", "rows"]).optional(), density: z.enum(["cozy", "compact"]).optional(), + watchlist: z.array(z.number().int().positive()).max(50).optional(), }); router.get("/auth/me/preferences", async (req, res): Promise => { @@ -316,6 +317,46 @@ router.get("/auth/me/preferences", async (req, res): Promise => { res.json(dbUser.preferences ?? {}); }); +router.get("/auth/me/watchlist", requireFeature("watchlist"), async (req, res): Promise => { + const dbUser = await resolveDbUser(req.session.user!); + if (!dbUser) { + res.status(401).json({ error: "User not found" }); + return; + } + const ids = (dbUser.preferences?.watchlist ?? []).filter((id) => Number.isInteger(id)); + if (ids.length === 0) { + res.json([]); + return; + } + const tools = await db + .select() + .from(toolsTable) + .where(and(isNull(toolsTable.deletedAt), inArray(toolsTable.id, ids))); + const ratings = await db + .select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) + .from(ratingsTable) + .where(inArray(ratingsTable.toolId, tools.map((t) => t.id))); + const byTool = new Map(); + for (const r of ratings) { + const list = byTool.get(r.toolId) ?? []; + list.push({ usefulness: r.usefulness, usability: r.usability }); + byTool.set(r.toolId, list); + } + const byId = new Map(tools.map((t) => [t.id, t])); + const ordered = ids + .filter((id) => byId.has(id)) + .map((id) => { + const tool = byId.get(id)!; + const toolRatings = byTool.get(id) ?? []; + const ratingCount = toolRatings.length; + const avgUsefulness = ratingCount > 0 ? toolRatings.reduce((s, r) => s + r.usefulness, 0) / ratingCount : null; + const avgUsability = ratingCount > 0 ? toolRatings.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 }; + }); + res.json(ordered); +}); + router.put("/auth/me/preferences", async (req, res): Promise => { if (!req.session.user) { res.status(401).json({ error: "Not authenticated" }); diff --git a/artifacts/toolrate/src/App.tsx b/artifacts/toolrate/src/App.tsx index aad3b60..0699a0f 100644 --- a/artifacts/toolrate/src/App.tsx +++ b/artifacts/toolrate/src/App.tsx @@ -14,6 +14,7 @@ import Admin from "@/pages/admin"; import Redundancy from "@/pages/redundancy"; import Trash from "@/pages/trash"; import Compare from "@/pages/compare"; +import Watchlist from "@/pages/watchlist"; import Login from "@/pages/login"; import NotFound from "@/pages/not-found"; @@ -36,6 +37,7 @@ function Router() { + diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index 2b7d55d..a247371 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from "wouter"; -import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2 } from "lucide-react"; +import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2, Bookmark } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react"; import { Button } from "@/components/ui/button"; @@ -19,6 +19,7 @@ export function Layout({ children }: { children: React.ReactNode }) { { href: "/tools", label: "Browse Tools", icon: Wrench }, { href: "/tools/new", label: "Add Tool", icon: PlusCircle }, { href: "/analytics", label: "Analytics", icon: BarChart3 }, + ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: "Watchlist", icon: Bookmark }] : []), ...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []), ...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []), ...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []), @@ -28,6 +29,14 @@ export function Layout({ children }: { children: React.ReactNode }) { (link) => location === link.href || (link.href !== "/" && location.startsWith(link.href)), ); + const mobileLinks = [ + { href: "/", label: "Home", icon: LayoutDashboard }, + { href: "/tools", label: "Tools", icon: Wrench }, + ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: "Watchlist", icon: Bookmark }] : []), + ...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []), + { href: "/analytics", label: "Analytics", icon: BarChart3 }, + ].slice(0, 5); + return (
@@ -149,10 +158,29 @@ export function Layout({ children }: { children: React.ReactNode }) { )}
-
+
{children}
+ +
); } diff --git a/artifacts/toolrate/src/components/tool-card.tsx b/artifacts/toolrate/src/components/tool-card.tsx index c1fe2ac..e49d3e4 100644 --- a/artifacts/toolrate/src/components/tool-card.tsx +++ b/artifacts/toolrate/src/components/tool-card.tsx @@ -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, Wrench, Scale } from "lucide-react"; +import { Star, MessageSquare, Wrench, Scale, Bookmark } from "lucide-react"; import { Link } from "wouter"; import { MiniBars } from "@/components/mini-bars"; import { cn } from "@/lib/utils"; @@ -9,9 +9,11 @@ import { cn } from "@/lib/utils"; export function ToolCard({ tool, compare, + watchlist, }: { tool: ToolWithStats; compare?: { selected: boolean; onToggle: () => void }; + watchlist?: { watched: boolean; onToggle: () => void }; }) { return ( @@ -70,8 +72,27 @@ export function ToolCard({ -
- +
+ + {watchlist && ( + + )} {compare && (
+ {canWatchlist && ( + + )} + {tool.websiteUrl && (
diff --git a/artifacts/toolrate/src/pages/watchlist.tsx b/artifacts/toolrate/src/pages/watchlist.tsx new file mode 100644 index 0000000..3931242 --- /dev/null +++ b/artifacts/toolrate/src/pages/watchlist.tsx @@ -0,0 +1,76 @@ +import { + useGetMeWatchlist, + getGetMeWatchlistQueryKey, +} from "@workspace/api-client-react"; +import { useAuth } from "@/hooks/use-auth"; +import { useWatchlist } from "@/hooks/use-watchlist"; +import { Layout } from "@/components/layout"; +import { ToolCard } from "@/components/tool-card"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ShieldAlert, Bookmark } from "lucide-react"; + +export default function Watchlist() { + const { hasFeature, isLoading: authLoading } = useAuth(); + const { toggle } = useWatchlist(); + const canWatchlist = hasFeature("watchlist"); + + const { data: tools, isLoading: loading } = useGetMeWatchlist({ + query: { queryKey: getGetMeWatchlistQueryKey(), enabled: canWatchlist }, + }); + + if (!authLoading && !canWatchlist) { + return ( + + + + ); + } + + const list = tools ?? []; + + return ( + +
+
+

Watchlist

+

Tools you saved for later, with live scores.

+
+ + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => )} +
+ ) : list.length === 0 ? ( +
+ +

Your watchlist is empty

+

+ Browse tools and click the bookmark to save them here. +

+ +
+ ) : ( +
+ {list.map((tool) => ( + toggle(tool.id) }} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 5dbca83..ab4aa68 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -335,6 +335,7 @@ export const UserPreferencesDensity = { export interface UserPreferences { view?: UserPreferencesView; density?: UserPreferencesDensity; + watchlist?: number[]; } export interface ErrorResponse { diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 815bc8e..a8ef830 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -2199,6 +2199,83 @@ export const useUpdateMePreferences = , return useMutation(getUpdateMePreferencesMutationOptions(options)); } +export const getGetMeWatchlistUrl = () => { + + + + + return `/api/auth/me/watchlist` +} + +/** + * @summary Get current user's watchlist tools (premium) + */ +export const getMeWatchlist = async ( options?: RequestInit): Promise => { + + return customFetch(getGetMeWatchlistUrl(), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetMeWatchlistQueryKey = () => { + return [ + `/api/auth/me/watchlist` + ] as const; + } + + +export const getGetMeWatchlistQueryOptions = >, TError = ErrorType>( options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetMeWatchlistQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getMeWatchlist({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: QueryKey } +} + +export type GetMeWatchlistQueryResult = NonNullable>> +export type GetMeWatchlistQueryError = ErrorType + + +/** + * @summary Get current user's watchlist tools (premium) + */ + +export function useGetMeWatchlist>, TError = ErrorType>( + options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} + + ): UseQueryResult & { queryKey: QueryKey } { + + const queryOptions = getGetMeWatchlistQueryOptions(options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + + export const getListUsersUrl = () => { diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 0cc6e18..84e4910 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -656,6 +656,33 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /auth/me/watchlist: + get: + operationId: getMeWatchlist + tags: [auth] + summary: Get current user's watchlist tools (premium) + responses: + "200": + description: Watchlist tools in saved order + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ToolWithStats" + "401": + description: Not authenticated + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "403": + description: Premium feature required + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /users: get: operationId: listUsers @@ -1193,6 +1220,10 @@ components: density: type: string enum: [cozy, compact] + watchlist: + type: array + items: + type: integer ErrorResponse: type: object diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index e13639f..e2b22da 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -485,7 +485,8 @@ export const GetMeResponse = zod.object({ */ export const GetMePreferencesResponse = zod.object({ "view": zod.enum(['grid', 'table', 'rows']).optional(), - "density": zod.enum(['cozy', 'compact']).optional() + "density": zod.enum(['cozy', 'compact']).optional(), + "watchlist": zod.array(zod.number()).optional() }) @@ -494,15 +495,40 @@ export const GetMePreferencesResponse = zod.object({ */ export const UpdateMePreferencesBody = zod.object({ "view": zod.enum(['grid', 'table', 'rows']).optional(), - "density": zod.enum(['cozy', 'compact']).optional() + "density": zod.enum(['cozy', 'compact']).optional(), + "watchlist": zod.array(zod.number()).optional() }) export const UpdateMePreferencesResponse = zod.object({ "view": zod.enum(['grid', 'table', 'rows']).optional(), - "density": zod.enum(['cozy', 'compact']).optional() + "density": zod.enum(['cozy', 'compact']).optional(), + "watchlist": zod.array(zod.number()).optional() }) +/** + * @summary Get current user's watchlist tools (premium) + */ +export const GetMeWatchlistResponseItem = zod.object({ + "id": zod.number(), + "name": zod.string(), + "description": zod.string(), + "category": zod.string(), + "websiteUrl": zod.string().nullish(), + "iconUrl": zod.string().nullish(), + "createdBy": zod.string().nullish(), + "features": zod.array(zod.string()).optional(), + "tags": zod.array(zod.string()).optional(), + "createdAt": zod.coerce.date(), + "updatedAt": zod.coerce.date(), + "ratingCount": zod.number(), + "avgUsefulness": zod.number().nullable(), + "avgUsability": zod.number().nullable(), + "avgCombined": zod.number().nullable() +}) +export const GetMeWatchlistResponse = zod.array(GetMeWatchlistResponseItem) + + /** * @summary List all local users (admin only) */ diff --git a/lib/api-zod/src/generated/types/userPreferences.ts b/lib/api-zod/src/generated/types/userPreferences.ts index fc07e10..92ce86e 100644 --- a/lib/api-zod/src/generated/types/userPreferences.ts +++ b/lib/api-zod/src/generated/types/userPreferences.ts @@ -11,4 +11,5 @@ import type { UserPreferencesView } from './userPreferencesView'; export interface UserPreferences { view?: UserPreferencesView; density?: UserPreferencesDensity; + watchlist?: number[]; } diff --git a/lib/db/src/schema/users.ts b/lib/db/src/schema/users.ts index b1ff137..e97f7c1 100644 --- a/lib/db/src/schema/users.ts +++ b/lib/db/src/schema/users.ts @@ -5,6 +5,7 @@ import { z } from "zod/v4"; export type UserPreferences = { view?: "grid" | "table" | "rows"; density?: "cozy" | "compact"; + watchlist?: number[]; }; export const usersTable = pgTable("users", {