From 78244c31972617f104211e4b387b3716ae4e2d28 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 2 Aug 2026 09:34:53 +0200 Subject: [PATCH] feat: user-chosen browse views with grid/list toggle and profile sync - view modes grid | table | rows + density cozy/compact, persisted via localStorage and shareable ?view=?density= URL params (URL wins) - table view: sortable columns (name, rating, reviews), new sort options name_asc/name_desc/recently_updated (backend enum + handler) - live debounced search, removable filter chips, '/' focuses search - virtualization via @tanstack/react-virtual for table and rows views - profile sync: users.preferences jsonb + GET/PUT /api/auth/me/preferences; preference precedence URL > server profile > localStorage > default - add local rollup/lightningcss/tailwindcss oxide native binaries for macos dev --- artifacts/api-server/src/routes/auth.ts | 58 +++ artifacts/api-server/src/routes/tools.ts | 6 + artifacts/toolrate/package.json | 1 + .../src/components/density-toggle.tsx | 28 ++ .../src/components/tool-card-wide.tsx | 65 +++ .../toolrate/src/components/tool-row.tsx | 68 +++ .../toolrate/src/components/view-toggle.tsx | 37 ++ .../src/hooks/use-browse-preferences.ts | 89 ++++ artifacts/toolrate/src/pages/tools-browse.tsx | 394 +++++++++++++++--- .../src/generated/api.schemas.ts | 25 ++ lib/api-client-react/src/generated/api.ts | 149 +++++++ lib/api-spec/openapi.yaml | 54 ++- lib/api-zod/src/generated/api.ts | 25 +- lib/api-zod/src/generated/types/index.ts | 3 + .../src/generated/types/listToolsSort.ts | 3 + .../src/generated/types/userPreferences.ts | 14 + .../generated/types/userPreferencesDensity.ts | 15 + .../generated/types/userPreferencesView.ts | 16 + lib/db/src/schema/users.ts | 8 +- package.json | 15 +- pnpm-lock.yaml | 60 +++ pnpm-workspace.yaml | 1 + 22 files changed, 1061 insertions(+), 73 deletions(-) create mode 100644 artifacts/toolrate/src/components/density-toggle.tsx create mode 100644 artifacts/toolrate/src/components/tool-card-wide.tsx create mode 100644 artifacts/toolrate/src/components/tool-row.tsx create mode 100644 artifacts/toolrate/src/components/view-toggle.tsx create mode 100644 artifacts/toolrate/src/hooks/use-browse-preferences.ts create mode 100644 lib/api-zod/src/generated/types/userPreferences.ts create mode 100644 lib/api-zod/src/generated/types/userPreferencesDensity.ts create mode 100644 lib/api-zod/src/generated/types/userPreferencesView.ts diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index 4cc4fa4..daa58d7 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -2,6 +2,7 @@ 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 { z } from "zod"; import { db, usersTable } from "@workspace/db"; import { logger } from "../lib/logger"; import { getEntitlements } from "../middleware/feature"; @@ -278,4 +279,61 @@ router.get("/auth/me", async (req, res): Promise => { }); }); +type SessionUser = NonNullable; + +async function resolveDbUser(u: SessionUser) { + if (u.isLocal) { + const id = Number(u.sub); + if (Number.isFinite(id)) { + const [byId] = await db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1); + if (byId) return byId; + } + if (u.preferred_username) { + const [byName] = await db.select().from(usersTable).where(eq(usersTable.username, u.preferred_username)).limit(1); + if (byName) return byName; + } + return null; + } + const [byProvider] = await db.select().from(usersTable).where(eq(usersTable.authProviderId, u.sub)).limit(1); + return byProvider ?? null; +} + +const PreferenceSchema = z.object({ + view: z.enum(["grid", "table", "rows"]).optional(), + density: z.enum(["cozy", "compact"]).optional(), +}); + +router.get("/auth/me/preferences", async (req, res): Promise => { + if (!req.session.user) { + res.status(401).json({ error: "Not authenticated" }); + return; + } + const dbUser = await resolveDbUser(req.session.user); + if (!dbUser) { + res.status(401).json({ error: "User not found" }); + return; + } + res.json(dbUser.preferences ?? {}); +}); + +router.put("/auth/me/preferences", async (req, res): Promise => { + if (!req.session.user) { + res.status(401).json({ error: "Not authenticated" }); + return; + } + const parsed = PreferenceSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const dbUser = await resolveDbUser(req.session.user); + if (!dbUser) { + res.status(401).json({ error: "User not found" }); + return; + } + const merged = { ...(dbUser.preferences ?? {}), ...parsed.data }; + await db.update(usersTable).set({ preferences: merged }).where(eq(usersTable.id, dbUser.id)); + res.json(merged); +}); + export default router; diff --git a/artifacts/api-server/src/routes/tools.ts b/artifacts/api-server/src/routes/tools.ts index bf7a053..36a4801 100644 --- a/artifacts/api-server/src/routes/tools.ts +++ b/artifacts/api-server/src/routes/tools.ts @@ -87,6 +87,12 @@ router.get("/tools", async (req, res): Promise => { 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); + } else if (sort === "name_asc") { + result = result.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + } else if (sort === "name_desc") { + result = result.sort((a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: "base" })); + } else if (sort === "recently_updated") { + result = result.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); } res.json(result); diff --git a/artifacts/toolrate/package.json b/artifacts/toolrate/package.json index fd5be53..33c9bdf 100644 --- a/artifacts/toolrate/package.json +++ b/artifacts/toolrate/package.json @@ -44,6 +44,7 @@ "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "catalog:", "@tanstack/react-query": "catalog:", + "@tanstack/react-virtual": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/artifacts/toolrate/src/components/density-toggle.tsx b/artifacts/toolrate/src/components/density-toggle.tsx new file mode 100644 index 0000000..d16fb40 --- /dev/null +++ b/artifacts/toolrate/src/components/density-toggle.tsx @@ -0,0 +1,28 @@ +import { Slider } from "@/components/ui/slider"; +import { Maximize2, Minimize2 } from "lucide-react"; + +export type Density = "cozy" | "compact"; + +export function DensityToggle({ + value, + onValueChange, +}: { + value: Density; + onValueChange: (d: Density) => void; +}) { + return ( +
+ + onValueChange(v === 1 ? "compact" : "cozy")} + aria-label="List density" + /> + +
+ ); +} diff --git a/artifacts/toolrate/src/components/tool-card-wide.tsx b/artifacts/toolrate/src/components/tool-card-wide.tsx new file mode 100644 index 0000000..645f663 --- /dev/null +++ b/artifacts/toolrate/src/components/tool-card-wide.tsx @@ -0,0 +1,65 @@ +import { Link } from "wouter"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Star, MessageSquare, Wrench, ChevronRight } from "lucide-react"; +import { ToolWithStats } from "@workspace/api-client-react"; + +export function ToolCardWide({ tool, density }: { tool: ToolWithStats; density: "cozy" | "compact" }) { + const compact = density === "compact"; + return ( + + +
+ {tool.iconUrl ? ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + )} +
+
+
+ {tool.name} + + {tool.category} + +
+ {!compact &&

{tool.description}

} + {!compact && tool.tags && tool.tags.length > 0 && ( +
+ {tool.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {tool.tags.length > 3 && ( + + +{tool.tags.length - 3} + + )} +
+ )} +
+
+
+ + + {tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"} + +
+
+ + {tool.ratingCount} +
+ +
+ +
+ ); +} diff --git a/artifacts/toolrate/src/components/tool-row.tsx b/artifacts/toolrate/src/components/tool-row.tsx new file mode 100644 index 0000000..bf7d523 --- /dev/null +++ b/artifacts/toolrate/src/components/tool-row.tsx @@ -0,0 +1,68 @@ +import { Link } from "wouter"; +import { Badge } from "@/components/ui/badge"; +import { Star, MessageSquare, Wrench } from "lucide-react"; +import { ToolWithStats } from "@workspace/api-client-react"; +import { cn } from "@/lib/utils"; + +export const TABLE_GRID = + "grid-cols-[minmax(0,2fr)_minmax(0,1fr)_80px_80px]"; + +export function ToolRow({ + tool, + density, + className, +}: { + tool: ToolWithStats; + density: "cozy" | "compact"; + className?: string; +}) { + const compact = density === "compact"; + return ( +
+
+
+ {tool.iconUrl ? ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + )} +
+
+ + {tool.name} + + {!compact && ( +
{tool.description}
+ )} +
+
+
+ + {tool.category} + +
+
+ + {tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"} +
+
+ + {tool.ratingCount} +
+
+ ); +} diff --git a/artifacts/toolrate/src/components/view-toggle.tsx b/artifacts/toolrate/src/components/view-toggle.tsx new file mode 100644 index 0000000..a0f6319 --- /dev/null +++ b/artifacts/toolrate/src/components/view-toggle.tsx @@ -0,0 +1,37 @@ +import { LayoutGrid, List, Rows3 } from "lucide-react"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; + +export type ViewMode = "grid" | "table" | "rows"; + +const OPTIONS: { value: ViewMode; label: string; Icon: typeof LayoutGrid }[] = [ + { value: "grid", label: "Grid", Icon: LayoutGrid }, + { value: "table", label: "Table", Icon: List }, + { value: "rows", label: "Rows", Icon: Rows3 }, +]; + +export function ViewToggle({ + value, + onValueChange, +}: { + value: ViewMode; + onValueChange: (v: ViewMode) => void; +}) { + return ( + { + if (v) onValueChange(v as ViewMode); + }} + variant="outline" + size="sm" + aria-label="View mode" + > + {OPTIONS.map(({ value: v, label, Icon }) => ( + + + + ))} + + ); +} diff --git a/artifacts/toolrate/src/hooks/use-browse-preferences.ts b/artifacts/toolrate/src/hooks/use-browse-preferences.ts new file mode 100644 index 0000000..d6d7667 --- /dev/null +++ b/artifacts/toolrate/src/hooks/use-browse-preferences.ts @@ -0,0 +1,89 @@ +import { useState, useRef, useEffect } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { + useGetMePreferences, + useUpdateMePreferences, + getGetMePreferencesQueryKey, + type UserPreferences, +} from "@workspace/api-client-react"; + +export type ViewMode = "grid" | "table" | "rows"; +export type Density = "cozy" | "compact"; + +const LS_KEY = "toolrate:browse-preferences"; +const VIEWS = new Set(["grid", "table", "rows"]); +const DENSITIES = new Set(["cozy", "compact"]); + +export function isViewMode(v: string | null): v is ViewMode { + return v !== null && VIEWS.has(v); +} + +export function isDensity(v: string | null): v is Density { + return v !== null && DENSITIES.has(v); +} + +function readLocal(): Partial { + try { + const raw = window.localStorage.getItem(LS_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return { + ...(isViewMode(typeof parsed.view === "string" ? parsed.view : null) ? { view: parsed.view as ViewMode } : {}), + ...(isDensity(typeof parsed.density === "string" ? parsed.density : null) ? { density: parsed.density as Density } : {}), + }; + } catch { + return {}; + } +} + +function writeLocal(prefs: Partial) { + try { + window.localStorage.setItem(LS_KEY, JSON.stringify(prefs)); + } catch { + /* ignore */ + } +} + +export function useBrowsePreferences() { + const { isAuthenticated } = useAuth(); + const [localPrefs, setLocalPrefs] = useState(readLocal); + const syncTimer = useRef | null>(null); + + const { data: serverPrefs } = useGetMePreferences({ + query: { + queryKey: getGetMePreferencesQueryKey(), + enabled: isAuthenticated, + retry: false, + staleTime: 5 * 60 * 1000, + }, + }); + const { mutate: updatePrefs } = useUpdateMePreferences(); + + function persist(view: ViewMode, density: Density) { + const prefs: Partial = { view, density }; + setLocalPrefs(prefs); + writeLocal(prefs); + if (isAuthenticated) { + if (syncTimer.current) clearTimeout(syncTimer.current); + syncTimer.current = setTimeout(() => updatePrefs({ data: prefs }), 400); + } + } + + useEffect( + () => () => { + if (syncTimer.current) clearTimeout(syncTimer.current); + }, + [], + ); + + const serverView = isViewMode(serverPrefs?.view ?? null) ? serverPrefs!.view : undefined; + const serverDensity = isDensity(serverPrefs?.density ?? null) ? serverPrefs!.density : undefined; + + return { + serverView, + serverDensity, + localView: localPrefs.view, + localDensity: localPrefs.density, + persist, + }; +} diff --git a/artifacts/toolrate/src/pages/tools-browse.tsx b/artifacts/toolrate/src/pages/tools-browse.tsx index 82c24a2..cbb9c56 100644 --- a/artifacts/toolrate/src/pages/tools-browse.tsx +++ b/artifacts/toolrate/src/pages/tools-browse.tsx @@ -1,25 +1,77 @@ -import { useState, useEffect } from "react"; -import { - useListTools, +import { useState, useEffect, useRef, useMemo } from "react"; +import { + useListTools, useListCategories, - ListToolsSort + ListToolsSort, } from "@workspace/api-client-react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { Layout } from "@/components/layout"; import { ToolCard } from "@/components/tool-card"; +import { ToolCardWide } from "@/components/tool-card-wide"; +import { ToolRow, TABLE_GRID } from "@/components/tool-row"; +import { ViewToggle, type ViewMode } from "@/components/view-toggle"; +import { DensityToggle, type Density } from "@/components/density-toggle"; +import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-preferences"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; -import { Search, Wrench, SlidersHorizontal, X } from "lucide-react"; +import { Search, Wrench, X, ArrowUp, ArrowDown, ArrowUpDown, SlidersHorizontal } from "lucide-react"; import { Link, useLocation, useSearch } from "wouter"; +import { cn } from "@/lib/utils"; -const SORT_VALUES = new Set([ListToolsSort.newest, ListToolsSort.top_rated, ListToolsSort.most_reviewed]); +const SORT_VALUES = new Set(Object.values(ListToolsSort)); + +const SORT_LABELS: Record = { + [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", +}; + +function SortHeader({ + active, + direction, + onClick, + children, + align = "left", +}: { + active: boolean; + direction?: "asc" | "desc"; + onClick: () => void; + children: React.ReactNode; + align?: "left" | "right"; +}) { + return ( + + ); +} export default function ToolsBrowse() { const [, navigate] = useLocation(); const urlSearch = useSearch(); - const initialParams = new URLSearchParams(urlSearch); + const initialParams = useMemo(() => new URLSearchParams(urlSearch), [urlSearch]); const initialSearch = initialParams.get("search") ?? ""; const initialCategory = initialParams.get("category") ?? "all"; const initialSortParam = initialParams.get("sort") ?? ""; @@ -27,34 +79,76 @@ export default function ToolsBrowse() { ? (initialSortParam as ListToolsSort) : ListToolsSort.newest; + const { serverView, serverDensity, localView, localDensity, persist } = useBrowsePreferences(); + const [search, setSearch] = useState(initialSearch); const [searchInput, setSearchInput] = useState(initialSearch); const [category, setCategory] = useState(initialCategory); const [sort, setSort] = useState(initialSort); + const [overrideView, setOverrideView] = useState(null); + const [overrideDensity, setOverrideDensity] = useState(null); + + const urlView = initialParams.get("view"); + const urlDensity = initialParams.get("density"); + const view: ViewMode = + (isViewMode(urlView) ? urlView : null) ?? overrideView ?? serverView ?? localView ?? "grid"; + const density: Density = + (isDensity(urlDensity) ? urlDensity : null) ?? overrideDensity ?? serverDensity ?? localDensity ?? "cozy"; useEffect(() => { const p = new URLSearchParams(); if (search) p.set("search", search); if (category && category !== "all") p.set("category", category); if (sort && sort !== ListToolsSort.newest) p.set("sort", sort); + if (view !== "grid") p.set("view", view); + if (density !== "cozy") p.set("density", density); const qs = p.toString(); navigate(qs ? `/tools?${qs}` : "/tools", { replace: true }); - }, [search, category, sort]); + }, [search, category, sort, view, density, navigate]); + + useEffect(() => { + const t = setTimeout(() => setSearch(searchInput), 300); + return () => clearTimeout(t); + }, [searchInput]); + + const searchInputRef = useRef(null); + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const isTyping = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable; + if (e.key === "/" && !isTyping) { + e.preventDefault(); + searchInputRef.current?.focus(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); const { data: categories, isLoading: loadingCategories } = useListCategories(); - + const queryParams = { ...(search ? { search } : {}), ...(category && category !== "all" ? { category } : {}), - ...(sort ? { sort } : {}) + ...(sort ? { sort } : {}), }; - - const { data: tools, isLoading: loadingTools } = useListTools(queryParams); - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); - setSearch(searchInput); - }; + const { data: tools, isLoading: loadingTools } = useListTools(queryParams); + const allTools = tools ?? []; + + function setView(v: ViewMode) { + setOverrideView(v); + persist(v, density); + } + + function setDensity(d: Density) { + setOverrideDensity(d); + persist(view, d); + } const clearFilters = () => { setSearch(""); @@ -65,6 +159,34 @@ export default function ToolsBrowse() { const hasFilters = search !== "" || category !== "all" || sort !== ListToolsSort.newest; + function toggleNameSort() { + setSort(sort === ListToolsSort.name_asc ? ListToolsSort.name_desc : ListToolsSort.name_asc); + } + + // ---- virtualization ---- + const tableParentRef = useRef(null); + const rowsParentRef = useRef(null); + + const tableVirtualizer = useVirtualizer({ + count: allTools.length, + getScrollElement: () => tableParentRef.current, + estimateSize: () => (density === "compact" ? 44 : 64), + overscan: 8, + }); + + const rowsVirtualizer = useVirtualizer({ + count: allTools.length, + getScrollElement: () => rowsParentRef.current, + estimateSize: () => (density === "compact" ? 72 : 116), + overscan: 4, + }); + + const gridPad = density === "compact" ? "gap-3" : "gap-4"; + + const sortActive = (v: ListToolsSort) => sort === v; + const nameDir: "asc" | "desc" | undefined = + sort === ListToolsSort.name_asc ? "asc" : sort === ListToolsSort.name_desc ? "desc" : undefined; + return (
@@ -80,17 +202,18 @@ export default function ToolsBrowse() { {/* Filters */}
-
+
- setSearchInput(e.target.value)} data-testid="input-search" /> - - +
+
- - {hasFilters && ( - +
+
+ + {hasFilters && ( +
+ {search && ( + + “{search}” + + )} + {category !== "all" && ( + + {category} + + + )} + {sort !== ListToolsSort.newest && ( + + {SORT_LABELS[sort]} + + + )} + +
+ )} + + {/* Results toolbar */} +
+
+ + {loadingTools ? "Loading tools…" : `Showing ${allTools.length} tool${allTools.length === 1 ? "" : "s"}`} +
+
+ +
{/* Results */} -
-
- - {loadingTools ? "Loading tools..." : `Showing ${tools?.length || 0} tools`} -
- - {loadingTools ? ( -
- {[1, 2, 3, 4, 5, 6, 7, 8].map(i => ( - - ))} -
- ) : tools && tools.length > 0 ? ( -
- {tools.map((tool) => ( + {loadingTools ? ( + + ) : allTools.length > 0 ? ( + view === "grid" ? ( +
+ {allTools.map((tool) => ( ))}
- ) : ( -
-
- -
-

No tools found

-

- We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a new tool. -

-
- {hasFilters && ( - - )} - + ) : view === "rows" ? ( +
+
+ {rowsVirtualizer.getVirtualItems().map((vi) => ( +
+ +
+ ))}
- )} -
+ ) : ( +
+
+
+ + Tool + +
+
+ Category +
+
+ setSort(ListToolsSort.top_rated)} align="right"> + Rating + +
+
+ setSort(ListToolsSort.most_reviewed)} align="right"> + Reviews + +
+
+
+ {tableVirtualizer.getVirtualItems().map((vi) => { + const tool = allTools[vi.index]; + return ( +
+ +
+ ); + })} +
+
+ ) + ) : ( +
+
+ +
+

No tools found

+

+ We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a + new tool. +

+
+ {hasFilters && ( + + )} + +
+
+ )}
); } + +function Skeletons({ view, density }: { view: ViewMode; density: Density }) { + const compact = density === "compact"; + if (view === "grid") { + return ( +
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( + + ))} +
+ ); + } + if (view === "rows") { + return ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ); + } + return ( +
+ + {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ); +} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 134a95a..dbe296d 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -308,6 +308,28 @@ export interface AuthUser { isLocal?: boolean; } +export type UserPreferencesView = typeof UserPreferencesView[keyof typeof UserPreferencesView]; + + +export const UserPreferencesView = { + grid: 'grid', + table: 'table', + rows: 'rows', +} as const; + +export type UserPreferencesDensity = typeof UserPreferencesDensity[keyof typeof UserPreferencesDensity]; + + +export const UserPreferencesDensity = { + cozy: 'cozy', + compact: 'compact', +} as const; + +export interface UserPreferences { + view?: UserPreferencesView; + density?: UserPreferencesDensity; +} + export interface ErrorResponse { error: string; } @@ -325,6 +347,9 @@ export const ListToolsSort = { newest: 'newest', top_rated: 'top_rated', most_reviewed: 'most_reviewed', + name_asc: 'name_asc', + name_desc: 'name_desc', + recently_updated: 'recently_updated', } as const; export type ListTrashedToolsParams = { diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 2f8b7c2..06f01b6 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -47,6 +47,7 @@ import type { TrashToolsInput, User, UserCreateInput, + UserPreferences, UserRoleUpdate, VersionInfo } from './api.schemas'; @@ -1887,6 +1888,154 @@ export function useGetMe>, TError = Err +export const getGetMePreferencesUrl = () => { + + + + + return `/api/auth/me/preferences` +} + +/** + * @summary Get current user's browse preferences + */ +export const getMePreferences = async ( options?: RequestInit): Promise => { + + return customFetch(getGetMePreferencesUrl(), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetMePreferencesQueryKey = () => { + return [ + `/api/auth/me/preferences` + ] as const; + } + + +export const getGetMePreferencesQueryOptions = >, TError = ErrorType>( options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetMePreferencesQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getMePreferences({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: QueryKey } +} + +export type GetMePreferencesQueryResult = NonNullable>> +export type GetMePreferencesQueryError = ErrorType + + +/** + * @summary Get current user's browse preferences + */ + +export function useGetMePreferences>, TError = ErrorType>( + options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} + + ): UseQueryResult & { queryKey: QueryKey } { + + const queryOptions = getGetMePreferencesQueryOptions(options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + + +export const getUpdateMePreferencesUrl = () => { + + + + + return `/api/auth/me/preferences` +} + +/** + * @summary Update current user's browse preferences + */ +export const updateMePreferences = async (userPreferences: UserPreferences, options?: RequestInit): Promise => { + + return customFetch(getUpdateMePreferencesUrl(), + { + ...options, + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + userPreferences,) + } +);} + + + + +export const getUpdateMePreferencesMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: BodyType}, TContext> => { + +const mutationKey = ['updateMePreferences']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: BodyType}> = (props) => { + const {data} = props ?? {}; + + return updateMePreferences(data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateMePreferencesMutationResult = NonNullable>> + export type UpdateMePreferencesMutationBody = BodyType + export type UpdateMePreferencesMutationError = ErrorType + + /** + * @summary Update current user's browse preferences + */ +export const useUpdateMePreferences = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} + ): UseMutationResult< + Awaited>, + TError, + {data: BodyType}, + TContext + > => { + return useMutation(getUpdateMePreferencesMutationOptions(options)); + } + export const getListUsersUrl = () => { diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index fad5a1b..ad2aa53 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -72,7 +72,7 @@ paths: required: false schema: type: string - enum: [newest, top_rated, most_reviewed] + enum: [newest, top_rated, most_reviewed, name_asc, name_desc, recently_updated] responses: "200": description: List of tools @@ -533,6 +533,48 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /auth/me/preferences: + get: + operationId: getMePreferences + tags: [auth] + summary: Get current user's browse preferences + responses: + "200": + description: User preferences + content: + application/json: + schema: + $ref: "#/components/schemas/UserPreferences" + "401": + description: Not authenticated + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + put: + operationId: updateMePreferences + tags: [auth] + summary: Update current user's browse preferences + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserPreferences" + responses: + "200": + description: Updated preferences + content: + application/json: + schema: + $ref: "#/components/schemas/UserPreferences" + "401": + description: Not authenticated + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /users: get: operationId: listUsers @@ -1047,6 +1089,16 @@ components: isLocal: type: boolean + UserPreferences: + type: object + properties: + view: + type: string + enum: [grid, table, rows] + density: + type: string + enum: [cozy, compact] + ErrorResponse: type: object required: [error] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index e8e5aba..28d3f84 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -35,7 +35,7 @@ export const GetVersionResponse = zod.object({ export const ListToolsQueryParams = zod.object({ "category": zod.coerce.string().optional(), "search": zod.coerce.string().optional(), - "sort": zod.enum(['newest', 'top_rated', 'most_reviewed']).optional() + "sort": zod.enum(['newest', 'top_rated', 'most_reviewed', 'name_asc', 'name_desc', 'recently_updated']).optional() }) export const ListToolsResponseItem = zod.object({ @@ -429,6 +429,29 @@ export const GetMeResponse = zod.object({ }) +/** + * @summary Get current user's browse preferences + */ +export const GetMePreferencesResponse = zod.object({ + "view": zod.enum(['grid', 'table', 'rows']).optional(), + "density": zod.enum(['cozy', 'compact']).optional() +}) + + +/** + * @summary Update current user's browse preferences + */ +export const UpdateMePreferencesBody = zod.object({ + "view": zod.enum(['grid', 'table', 'rows']).optional(), + "density": zod.enum(['cozy', 'compact']).optional() +}) + +export const UpdateMePreferencesResponse = zod.object({ + "view": zod.enum(['grid', 'table', 'rows']).optional(), + "density": zod.enum(['cozy', 'compact']).optional() +}) + + /** * @summary List all local users (admin only) */ diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index 2b8b151..709c19a 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -41,6 +41,9 @@ export * from './user'; export * from './userCreateInput'; export * from './userCreateInputRole'; export * from './userCreateInputTier'; +export * from './userPreferences'; +export * from './userPreferencesDensity'; +export * from './userPreferencesView'; export * from './userRole'; export * from './userRoleUpdate'; export * from './userRoleUpdateRole'; diff --git a/lib/api-zod/src/generated/types/listToolsSort.ts b/lib/api-zod/src/generated/types/listToolsSort.ts index 0f463fe..05fb22b 100644 --- a/lib/api-zod/src/generated/types/listToolsSort.ts +++ b/lib/api-zod/src/generated/types/listToolsSort.ts @@ -13,4 +13,7 @@ export const ListToolsSort = { newest: 'newest', top_rated: 'top_rated', most_reviewed: 'most_reviewed', + name_asc: 'name_asc', + name_desc: 'name_desc', + recently_updated: 'recently_updated', } as const; diff --git a/lib/api-zod/src/generated/types/userPreferences.ts b/lib/api-zod/src/generated/types/userPreferences.ts new file mode 100644 index 0000000..fc07e10 --- /dev/null +++ b/lib/api-zod/src/generated/types/userPreferences.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ +import type { UserPreferencesDensity } from './userPreferencesDensity'; +import type { UserPreferencesView } from './userPreferencesView'; + +export interface UserPreferences { + view?: UserPreferencesView; + density?: UserPreferencesDensity; +} diff --git a/lib/api-zod/src/generated/types/userPreferencesDensity.ts b/lib/api-zod/src/generated/types/userPreferencesDensity.ts new file mode 100644 index 0000000..b399793 --- /dev/null +++ b/lib/api-zod/src/generated/types/userPreferencesDensity.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ + +export type UserPreferencesDensity = typeof UserPreferencesDensity[keyof typeof UserPreferencesDensity]; + + +export const UserPreferencesDensity = { + cozy: 'cozy', + compact: 'compact', +} as const; diff --git a/lib/api-zod/src/generated/types/userPreferencesView.ts b/lib/api-zod/src/generated/types/userPreferencesView.ts new file mode 100644 index 0000000..5d1efbf --- /dev/null +++ b/lib/api-zod/src/generated/types/userPreferencesView.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ + +export type UserPreferencesView = typeof UserPreferencesView[keyof typeof UserPreferencesView]; + + +export const UserPreferencesView = { + grid: 'grid', + table: 'table', + rows: 'rows', +} as const; diff --git a/lib/db/src/schema/users.ts b/lib/db/src/schema/users.ts index e99a926..b1ff137 100644 --- a/lib/db/src/schema/users.ts +++ b/lib/db/src/schema/users.ts @@ -1,7 +1,12 @@ -import { pgTable, text, serial, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, text, serial, timestamp, jsonb } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod/v4"; +export type UserPreferences = { + view?: "grid" | "table" | "rows"; + density?: "cozy" | "compact"; +}; + export const usersTable = pgTable("users", { id: serial("id").primaryKey(), username: text("username").notNull().unique(), @@ -12,6 +17,7 @@ export const usersTable = pgTable("users", { authProvider: text("auth_provider").notNull().default("local"), authProviderId: text("auth_provider_id"), displayName: text("display_name"), + preferences: jsonb("preferences").$type().notNull().default({}), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/package.json b/package.json index f764366..4f770cd 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,19 @@ }, "pnpm": { "supportedArchitectures": { - "os": ["current", "linux"], - "cpu": ["current", "x64"] + "os": [ + "current", + "linux" + ], + "cpu": [ + "current", + "x64" + ] } + }, + "optionalDependencies": { + "@rollup/rollup-darwin-arm64": "^4.62.4", + "@tailwindcss/oxide-darwin-arm64": "^4.3.3", + "lightningcss-darwin-arm64": "^1.33.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 346262f..53a009f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ catalogs: '@tanstack/react-query': specifier: ^5.90.21 version: 5.100.9 + '@tanstack/react-virtual': + specifier: ^3.13.6 + version: 3.14.9 '@types/node': specifier: ^25.3.3 version: 25.6.2 @@ -164,6 +167,16 @@ importers: typescript: specifier: ~5.9.3 version: 5.9.3 + optionalDependencies: + '@rollup/rollup-darwin-arm64': + specifier: ^4.62.4 + version: 4.62.4 + '@tailwindcss/oxide-darwin-arm64': + specifier: ^4.3.3 + version: 4.3.3 + lightningcss-darwin-arm64: + specifier: ^1.33.0 + version: 1.33.0 artifacts/api-server: dependencies: @@ -528,6 +541,9 @@ importers: '@tanstack/react-query': specifier: 'catalog:' version: 5.100.9(react@19.1.0) + '@tanstack/react-virtual': + specifier: 'catalog:' + version: 3.14.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/node': specifier: 'catalog:' version: 25.6.2 @@ -1534,6 +1550,11 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-linux-x64-gnu@4.60.3': resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} cpu: [x64] @@ -1597,6 +1618,12 @@ packages: '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} @@ -1638,6 +1665,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.14.9': + resolution: {integrity: sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.7': + resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2456,6 +2492,12 @@ packages: resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -3065,6 +3107,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -4274,6 +4317,9 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + '@rollup/rollup-linux-x64-gnu@4.60.3': optional: true @@ -4348,6 +4394,9 @@ snapshots: source-map-js: 1.2.1 tailwindcss: 4.3.0 + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': optional: true @@ -4378,6 +4427,14 @@ snapshots: '@tanstack/query-core': 5.100.9 react: 19.1.0 + '@tanstack/react-virtual@3.14.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/virtual-core': 3.17.7 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/virtual-core@3.17.7': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.3 @@ -5098,6 +5155,9 @@ snapshots: leven@4.1.0: {} + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1b4aca6..f0b5563 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,7 @@ catalog: '@replit/vite-plugin-runtime-error-modal': ^0.0.6 '@tailwindcss/vite': ^4.1.14 '@tanstack/react-query': ^5.90.21 + '@tanstack/react-virtual': ^3.13.6 '@types/node': ^25.3.3 '@types/react': ^19.2.0 '@types/react-dom': ^19.2.0