feat: user menu dropdown + fix MCP tool schemas #10

Merged
makiolaj merged 2 commits from feature/user-menu-dropdown into Develop 2026-04-03 18:59:59 +00:00
8 changed files with 193 additions and 189 deletions

View 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.

View File

@@ -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>

View 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>
);
}

View File

@@ -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')"),
},
},
];

View File

@@ -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)"),
},
},
];

View File

@@ -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"),
},
},
];

View File

@@ -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"),
},
},
];

View File

@@ -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"),
},
},
];