diff --git a/docs/superpowers/specs/2026-04-03-user-menu-design.md b/docs/superpowers/specs/2026-04-03-user-menu-design.md new file mode 100644 index 0000000..33f5256 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-user-menu-design.md @@ -0,0 +1,35 @@ +# User Menu Design + +## Summary + +Replace the plain "Sign out" button in the header with a user icon that opens a dropdown menu containing Settings and Sign out options. This provides a way to navigate to the Settings page from the header. + +## Components + +### UserMenu (`src/client/components/UserMenu.tsx`) + +New component rendered by `TotalsBar` when authenticated. + +**Trigger:** Circular `CircleUser` icon button (Lucide). Styled consistently with surrounding header elements. + +**Dropdown:** Absolutely-positioned popover anchored to the right edge, appearing below the icon: + +1. **Settings** — `Settings` (gear) icon + "Settings" label, `` +2. **Divider** — thin horizontal line +3. **Sign out** — `LogOut` icon + "Sign out" label, calls `logout.mutate()` from `useLogout()` + +**Behavior:** +- Click icon toggles open/close +- Click outside closes (via `useEffect` with document click listener) +- Clicking a menu item closes the dropdown +- Dropdown anchored right so it doesn't overflow viewport + +### TotalsBar Changes (`src/client/components/TotalsBar.tsx`) + +- When `isAuthenticated`: render `` in place of the current "Sign out" button +- When not authenticated: keep the existing "Sign in" link unchanged +- Remove the `useLogout` hook usage from TotalsBar (moved into UserMenu) + +## No Backend Changes + +The existing `/api/auth/me` endpoint and `useAuth` hook are sufficient. No username display needed — using a generic user icon. diff --git a/src/client/components/TotalsBar.tsx b/src/client/components/TotalsBar.tsx index 1ad86d3..40e6aae 100644 --- a/src/client/components/TotalsBar.tsx +++ b/src/client/components/TotalsBar.tsx @@ -1,10 +1,11 @@ import { Link } from "@tanstack/react-router"; -import { useAuth, useLogout } from "../hooks/useAuth"; +import { useAuth } from "../hooks/useAuth"; import { useFormatters } from "../hooks/useFormatters"; import { useUpdateSetting } from "../hooks/useSettings"; import { useTotals } from "../hooks/useTotals"; import type { WeightUnit } from "../lib/formatters"; import { LucideIcon } from "../lib/iconData"; +import { UserMenu } from "./UserMenu"; const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"]; @@ -21,7 +22,6 @@ export function TotalsBar({ }: TotalsBarProps) { const { data } = useTotals(); const { data: auth } = useAuth(); - const logout = useLogout(); const isAuthenticated = !!auth?.user; const { weight, price, unit } = useFormatters(); const updateSetting = useUpdateSetting(); @@ -104,24 +104,16 @@ export function TotalsBar({ ))} )} -
- {isAuthenticated ? ( - - ) : ( - - Sign in - - )} -
+ {isAuthenticated ? ( + + ) : ( + + Sign in + + )} diff --git a/src/client/components/UserMenu.tsx b/src/client/components/UserMenu.tsx new file mode 100644 index 0000000..4704650 --- /dev/null +++ b/src/client/components/UserMenu.tsx @@ -0,0 +1,57 @@ +import { Link } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { useLogout } from "../hooks/useAuth"; +import { LucideIcon } from "../lib/iconData"; + +export function UserMenu() { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const logout = useLogout(); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + return ( +
+ + {open && ( +
+ setOpen(false)} + className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors" + > + + Settings + +
+ +
+ )} +
+ ); +} diff --git a/src/server/mcp/tools/categories.ts b/src/server/mcp/tools/categories.ts index 1ee2338..540b575 100644 --- a/src/server/mcp/tools/categories.ts +++ b/src/server/mcp/tools/categories.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { db as prodDb } from "../../../db/index.ts"; import { createCategory, @@ -24,24 +25,14 @@ export const categoryToolDefinitions = [ { name: "list_categories", description: "List all gear categories.", - inputSchema: { - type: "object" as const, - properties: {}, - }, + inputSchema: {}, }, { name: "create_category", description: "Create a new gear category.", inputSchema: { - type: "object" as const, - properties: { - name: { type: "string", description: "Category name" }, - icon: { - type: "string", - description: "Icon name (defaults to 'package')", - }, - }, - required: ["name"], + name: z.string().describe("Category name"), + icon: z.string().optional().describe("Icon name (defaults to 'package')"), }, }, ]; diff --git a/src/server/mcp/tools/images.ts b/src/server/mcp/tools/images.ts index aac55e1..5f52f36 100644 --- a/src/server/mcp/tools/images.ts +++ b/src/server/mcp/tools/images.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { fetchImageFromUrl } from "../../services/image.service.ts"; interface ToolResult { @@ -20,14 +21,9 @@ export const imageToolDefinitions = [ description: "Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.", inputSchema: { - type: "object" as const, - properties: { - url: { - type: "string", - description: "URL of the image to fetch (jpeg, png, or webp)", - }, - }, - required: ["url"], + url: z + .string() + .describe("URL of the image to fetch (jpeg, png, or webp)"), }, }, ]; diff --git a/src/server/mcp/tools/items.ts b/src/server/mcp/tools/items.ts index 63ee111..b4d454d 100644 --- a/src/server/mcp/tools/items.ts +++ b/src/server/mcp/tools/items.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { db as prodDb } from "../../../db/index.ts"; import { createItem, @@ -29,24 +30,14 @@ export const itemToolDefinitions = [ description: "List all items in the gear collection, optionally filtered by category.", inputSchema: { - type: "object" as const, - properties: { - categoryId: { - type: "number", - description: "Filter items by category ID", - }, - }, + categoryId: z.number().optional().describe("Filter items by category ID"), }, }, { name: "get_item", description: "Get a single item by its ID, including all details.", inputSchema: { - type: "object" as const, - properties: { - id: { type: "number", description: "The item ID" }, - }, - required: ["id"], + id: z.number().describe("The item ID"), }, }, { @@ -54,60 +45,48 @@ export const itemToolDefinitions = [ description: "Add a new item to the gear collection. Use this for items you've already decided on. For items you're still researching, use create_thread instead.", inputSchema: { - type: "object" as const, - properties: { - name: { type: "string", description: "Item name" }, - categoryId: { type: "number", description: "Category ID" }, - weightGrams: { type: "number", description: "Weight in grams" }, - priceCents: { type: "number", description: "Price in cents" }, - notes: { type: "string", description: "Notes about the item" }, - productUrl: { type: "string", description: "URL to the product page" }, - imageFilename: { - type: "string", - description: "Filename of an uploaded image", - }, - imageSourceUrl: { - type: "string", - description: "Original URL the image was fetched from", - }, - }, - required: ["name", "categoryId"], + name: z.string().describe("Item name"), + categoryId: z.number().describe("Category ID"), + weightGrams: z.number().optional().describe("Weight in grams"), + priceCents: z.number().optional().describe("Price in cents"), + notes: z.string().optional().describe("Notes about the item"), + productUrl: z.string().optional().describe("URL to the product page"), + imageFilename: z + .string() + .optional() + .describe("Filename of an uploaded image"), + imageSourceUrl: z + .string() + .optional() + .describe("Original URL the image was fetched from"), }, }, { name: "update_item", description: "Update an existing item's fields.", inputSchema: { - type: "object" as const, - properties: { - id: { type: "number", description: "The item ID to update" }, - name: { type: "string", description: "Item name" }, - categoryId: { type: "number", description: "Category ID" }, - weightGrams: { type: "number", description: "Weight in grams" }, - priceCents: { type: "number", description: "Price in cents" }, - notes: { type: "string", description: "Notes about the item" }, - productUrl: { type: "string", description: "URL to the product page" }, - imageFilename: { - type: "string", - description: "Filename of an uploaded image", - }, - imageSourceUrl: { - type: "string", - description: "Original URL the image was fetched from", - }, - }, - required: ["id"], + id: z.number().describe("The item ID to update"), + name: z.string().optional().describe("Item name"), + categoryId: z.number().optional().describe("Category ID"), + weightGrams: z.number().optional().describe("Weight in grams"), + priceCents: z.number().optional().describe("Price in cents"), + notes: z.string().optional().describe("Notes about the item"), + productUrl: z.string().optional().describe("URL to the product page"), + imageFilename: z + .string() + .optional() + .describe("Filename of an uploaded image"), + imageSourceUrl: z + .string() + .optional() + .describe("Original URL the image was fetched from"), }, }, { name: "delete_item", description: "Delete an item from the gear collection by ID.", inputSchema: { - type: "object" as const, - properties: { - id: { type: "number", description: "The item ID to delete" }, - }, - required: ["id"], + id: z.number().describe("The item ID to delete"), }, }, ]; diff --git a/src/server/mcp/tools/setups.ts b/src/server/mcp/tools/setups.ts index 65b7c95..ab3b9ac 100644 --- a/src/server/mcp/tools/setups.ts +++ b/src/server/mcp/tools/setups.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { db as prodDb } from "../../../db/index.ts"; import { createSetup, @@ -28,31 +29,20 @@ export const setupToolDefinitions = [ name: "list_setups", description: "List all gear setups with item counts and weight/cost totals.", - inputSchema: { - type: "object" as const, - properties: {}, - }, + inputSchema: {}, }, { name: "get_setup", description: "Get a setup with all its items and details.", inputSchema: { - type: "object" as const, - properties: { - id: { type: "number", description: "Setup ID" }, - }, - required: ["id"], + id: z.number().describe("Setup ID"), }, }, { name: "create_setup", description: "Create a new gear setup (e.g. 'Bikepacking weekend').", inputSchema: { - type: "object" as const, - properties: { - name: { type: "string", description: "Setup name" }, - }, - required: ["name"], + name: z.string().describe("Setup name"), }, }, { @@ -60,17 +50,12 @@ export const setupToolDefinitions = [ description: "Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.", inputSchema: { - type: "object" as const, - properties: { - id: { type: "number", description: "Setup ID" }, - name: { type: "string", description: "New setup name" }, - itemIds: { - type: "array", - items: { type: "number" }, - description: "Array of item IDs to include in the setup", - }, - }, - required: ["id"], + id: z.number().describe("Setup ID"), + name: z.string().optional().describe("New setup name"), + itemIds: z + .array(z.number()) + .optional() + .describe("Array of item IDs to include in the setup"), }, }, ]; diff --git a/src/server/mcp/tools/threads.ts b/src/server/mcp/tools/threads.ts index e6657e5..7c55606 100644 --- a/src/server/mcp/tools/threads.ts +++ b/src/server/mcp/tools/threads.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { db as prodDb } from "../../../db/index.ts"; import { createCandidate, @@ -31,14 +32,12 @@ export const threadToolDefinitions = [ description: "List research threads. Threads are the recommended way to evaluate gear purchases — each thread tracks multiple candidates for a single gear slot, making it easy to compare options before committing.", inputSchema: { - type: "object" as const, - properties: { - includeResolved: { - type: "boolean", - description: - "Include resolved threads (default: false, only active threads)", - }, - }, + includeResolved: z + .boolean() + .optional() + .describe( + "Include resolved threads (default: false, only active threads)", + ), }, }, { @@ -46,11 +45,7 @@ export const threadToolDefinitions = [ description: "Get a thread with all its candidates for detailed comparison.", inputSchema: { - type: "object" as const, - properties: { - id: { type: "number", description: "Thread ID" }, - }, - required: ["id"], + id: z.number().describe("Thread ID"), }, }, { @@ -58,15 +53,8 @@ export const threadToolDefinitions = [ description: "Start a new research thread for a gear slot. This is the preferred workflow: create a thread, add candidates with pros/cons/prices, compare them, then resolve the thread to add the winner to your collection.", inputSchema: { - type: "object" as const, - properties: { - name: { - type: "string", - description: "Thread name (e.g. 'Handlebar bag')", - }, - categoryId: { type: "number", description: "Category ID" }, - }, - required: ["name", "categoryId"], + name: z.string().describe("Thread name (e.g. 'Handlebar bag')"), + categoryId: z.number().describe("Category ID"), }, }, { @@ -74,35 +62,24 @@ export const threadToolDefinitions = [ description: "Resolve a research thread by picking the winning candidate. The winner is automatically added to the gear collection as a new item, and the thread is marked as resolved.", inputSchema: { - type: "object" as const, - properties: { - threadId: { type: "number", description: "Thread ID" }, - candidateId: { - type: "number", - description: "ID of the winning candidate", - }, - }, - required: ["threadId", "candidateId"], + threadId: z.number().describe("Thread ID"), + candidateId: z.number().describe("ID of the winning candidate"), }, }, { name: "add_candidate", description: "Add a candidate option to a research thread for comparison.", inputSchema: { - type: "object" as const, - properties: { - threadId: { type: "number", description: "Thread ID" }, - name: { type: "string", description: "Candidate name" }, - categoryId: { type: "number", description: "Category ID" }, - weightGrams: { type: "number", description: "Weight in grams" }, - priceCents: { type: "number", description: "Price in cents" }, - notes: { type: "string", description: "Notes" }, - productUrl: { type: "string", description: "Product URL" }, - imageFilename: { type: "string", description: "Image filename" }, - pros: { type: "string", description: "Pros of this candidate" }, - cons: { type: "string", description: "Cons of this candidate" }, - }, - required: ["threadId", "name", "categoryId"], + threadId: z.number().describe("Thread ID"), + name: z.string().describe("Candidate name"), + categoryId: z.number().describe("Category ID"), + weightGrams: z.number().optional().describe("Weight in grams"), + priceCents: z.number().optional().describe("Price in cents"), + notes: z.string().optional().describe("Notes"), + productUrl: z.string().optional().describe("Product URL"), + imageFilename: z.string().optional().describe("Image filename"), + pros: z.string().optional().describe("Pros of this candidate"), + cons: z.string().optional().describe("Cons of this candidate"), }, }, { @@ -110,36 +87,28 @@ export const threadToolDefinitions = [ description: "Update a candidate's details (name, price, pros, cons, etc.).", inputSchema: { - type: "object" as const, - properties: { - id: { type: "number", description: "Candidate ID" }, - name: { type: "string", description: "Candidate name" }, - weightGrams: { type: "number", description: "Weight in grams" }, - priceCents: { type: "number", description: "Price in cents" }, - categoryId: { type: "number", description: "Category ID" }, - notes: { type: "string", description: "Notes" }, - productUrl: { type: "string", description: "Product URL" }, - imageFilename: { type: "string", description: "Image filename" }, - imageSourceUrl: { type: "string", description: "Image source URL" }, - status: { - type: "string", - description: "Status: researching, ordered, or arrived", - }, - pros: { type: "string", description: "Pros" }, - cons: { type: "string", description: "Cons" }, - }, - required: ["id"], + id: z.number().describe("Candidate ID"), + name: z.string().optional().describe("Candidate name"), + weightGrams: z.number().optional().describe("Weight in grams"), + priceCents: z.number().optional().describe("Price in cents"), + categoryId: z.number().optional().describe("Category ID"), + notes: z.string().optional().describe("Notes"), + productUrl: z.string().optional().describe("Product URL"), + imageFilename: z.string().optional().describe("Image filename"), + imageSourceUrl: z.string().optional().describe("Image source URL"), + status: z + .string() + .optional() + .describe("Status: researching, ordered, or arrived"), + pros: z.string().optional().describe("Pros"), + cons: z.string().optional().describe("Cons"), }, }, { name: "remove_candidate", description: "Remove a candidate from a research thread.", inputSchema: { - type: "object" as const, - properties: { - id: { type: "number", description: "Candidate ID to remove" }, - }, - required: ["id"], + id: z.number().describe("Candidate ID to remove"), }, }, ];