feat: user menu dropdown + fix MCP tool schemas #10
35
docs/superpowers/specs/2026-04-03-user-menu-design.md
Normal file
35
docs/superpowers/specs/2026-04-03-user-menu-design.md
Normal file
@@ -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, `<Link to="/settings">`
|
||||
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 `<UserMenu />` 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.
|
||||
@@ -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({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout.mutate()}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{isAuthenticated ? (
|
||||
<UserMenu />
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
57
src/client/components/UserMenu.tsx
Normal file
57
src/client/components/UserMenu.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<LucideIcon name="circle-user" size={22} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 w-40 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<LucideIcon name="settings" size={16} className="text-gray-400" />
|
||||
Settings
|
||||
</Link>
|
||||
<div className="border-t border-gray-100 my-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
logout.mutate();
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<LucideIcon name="log-out" size={16} className="text-gray-400" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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')"),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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)"),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user