16 Commits

Author SHA1 Message Date
d9ec330aca feat(catalog): searchable tag filter in global catalog overlay
All checks were successful
CI / ci (push) Successful in 1m56s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 16s
Adds a typing-to-filter input above the tag chip list in the filter
sidebar, rendered only when there are more than eight tags. Case-
insensitive substring match; shows "No tags match" when the query
empties the list. Selected tags filtered out of the sidebar remain
active as header pills. Resets with the rest of overlay state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:52:47 +02:00
7890de141e docs(plan): tag selector search implementation
Six-step inline plan per the approved spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:46:59 +02:00
b41aa9301e docs(spec): tag selector search in CatalogSearchOverlay
Captures approved design: in-sidebar typing-to-filter input,
case-insensitive substring match, hidden when tags <= 8,
selected tags filtered out stay active as header pills.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:44:15 +02:00
076616cd1b ci: switch release workflow to tag-driven
Trigger on `push: tags` instead of `workflow_dispatch`. The pushed tag
name is the version — removes the bump-input + compute-and-create-tag
dance, avoiding accidental major bumps from the manual dispatch form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:44:09 +02:00
0202d0bb5c docs: add superpowers-friendly state and backlog summary
All checks were successful
CI / ci (push) Successful in 2m0s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 15s
Summarize current v2.4 state and forward-looking backlog in docs/ as a
parallel entry point to the active .planning/ GSD workspace. Lets
superpowers skills (brainstorming → writing-plans → executing-plans)
work from a clean starting point without retiring the existing workflow.

Also ignore .idea/ so JetBrains project files stay local.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:16:15 +02:00
1f2e8e18c4 docs(quick-260420-vk0): Fix UAT issues: image fetch-from-URL, image cropping, tag routing, duplicate tag error, tag form UX
All checks were successful
CI / ci (push) Successful in 1m56s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:52:12 +02:00
ddf9b9554f chore: merge quick task worktree (worktree-agent-accd63c4) 2026-04-20 22:51:44 +02:00
113e689932 fix(admin): handle duplicate tag name with 409 + polish inline tag form
- admin-tags.ts: wrap createTag in try/catch, detect UNIQUE constraint violations and return 409 with friendly message
- tags/index.tsx: surface server error message in catch block via err.message (ApiError carries the message from response body)
- tags/index.tsx: replace bare form row with card-style wrapper — label for Name and Parent, card border/bg, shrink-0 submit button
2026-04-20 22:50:35 +02:00
b41b8329bc fix(admin): return presignedUrl from from-url endpoint and update image preview after fetch
- images.ts: import getImageUrl from storage service, call after fetchImageFromUrl and include presignedUrl in response
- $itemId.tsx: update handleFetchFromUrl to use presignedUrl and dominantColor from response, set imageUrl in form state so ImageUpload component shows preview immediately
2026-04-20 22:49:34 +02:00
e4c0298a08 docs: capture todo - Make tag selector in global search searchable 2026-04-20 22:34:03 +02:00
2f39a7241a fix: persist crop preview in ImageUpload via initialCrop prop
All checks were successful
CI / ci (push) Successful in 1m52s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 16s
Crop values were stored in DB but never passed back to ImageUpload on reload,
causing images to revert to object-contain. Now both admin and user item pages
pass persisted crop data so the cropped view displays correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 22:29:19 +02:00
f1825fc722 docs(37): add image fetch + crop issues to UAT
All checks were successful
CI / ci (push) Successful in 1m52s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 22:09:48 +02:00
8b60428b3b feat(admin): replace image URL input with ImageUpload component + fetch-from-URL
All checks were successful
CI / ci (push) Successful in 1m56s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 17s
Admin item edit page now uses the same ImageUpload component as the rest
of the app (file upload, preview, crop editor). Also adds a "Fetch from URL"
input that uses /api/images/from-url. Renames "Source URL" to "Product Page URL".

Backend updated to accept imageFilename, dominantColor, cropZoom/X/Y fields
and return presignedImageUrl for display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 22:01:39 +02:00
31a9e3c1ff fix(admin): move detail routes to directory structure to fix rendering
All checks were successful
CI / ci (push) Successful in 1m54s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 18s
Admin item/tag edit pages weren't rendering because TanStack Router treated
them as children of the list route (which had no Outlet). Moving to
directory-based routing (items/index.tsx + items/$itemId.tsx) makes them
siblings that render directly in the admin layout.

Also adds UAT results for phases 35-38 and backlog item 999.12 (Admin UX Polish).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 21:40:57 +02:00
88c5339b98 chore: commit missing drizzle-pg journal and snapshot for migration 0010
All checks were successful
CI / ci (push) Successful in 2m10s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 20:18:15 +02:00
e044547121 chore: fix lint errors — auto-format, isNaN, unused imports, button type
Some checks failed
CI / ci (push) Failing after 1m41s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:54:37 +02:00
49 changed files with 3484 additions and 274 deletions

View File

@@ -1,17 +1,9 @@
name: Release name: Release
on: on:
workflow_dispatch: push:
inputs: tags:
bump: - 'v*'
description: "Version bump type"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
jobs: jobs:
ci: ci:
@@ -45,25 +37,17 @@ jobs:
cd repo cd repo
git checkout ${{ gitea.ref_name }} git checkout ${{ gitea.ref_name }}
- name: Compute version - name: Resolve version from tag
working-directory: repo working-directory: repo
run: | run: |
LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -n1) VERSION="${{ gitea.ref_name }}"
if [ -z "$LATEST_TAG" ]; then PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -vxF "$VERSION" | head -n1)
LATEST_TAG="v0.0.0" if [ -z "$PREV_TAG" ]; then
PREV_TAG="v0.0.0"
fi fi
MAJOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f1) echo "VERSION=$VERSION" >> "$GITHUB_ENV"
MINOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f2) echo "PREV_TAG=$PREV_TAG" >> "$GITHUB_ENV"
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f3) echo "Releasing $VERSION (previous: $PREV_TAG)"
case "${{ gitea.event.inputs.bump }}" in
major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR+1)); PATCH=0 ;;
patch) PATCH=$((PATCH+1)) ;;
esac
NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_ENV"
echo "PREV_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
echo "New version: $NEW_VERSION"
- name: Generate changelog - name: Generate changelog
working-directory: repo working-directory: repo
@@ -77,14 +61,6 @@ jobs:
echo "$CHANGELOG" >> "$GITHUB_ENV" echo "$CHANGELOG" >> "$GITHUB_ENV"
echo "CHANGELOG_EOF" >> "$GITHUB_ENV" echo "CHANGELOG_EOF" >> "$GITHUB_ENV"
- name: Create and push tag
working-directory: repo
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.jeanlucmakiola.de"
git tag -a "$VERSION" -m "Release $VERSION"
git push origin "$VERSION"
- name: Build and push Docker image - name: Build and push Docker image
working-directory: repo working-directory: repo
run: | run: |

3
.gitignore vendored
View File

@@ -233,6 +233,9 @@ e2e/pgdata
test-results/ test-results/
playwright-report/ playwright-report/
# JetBrains IDEs (full directory)
.idea/
# Obsidian # Obsidian
.obsidian/ .obsidian/

View File

@@ -401,3 +401,11 @@ Plans:
Plans: Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready) - [ ] TBD (promote with /gsd:review-backlog when ready)
### Phase 999.12: Admin UX Polish (BACKLOG)
**Goal**: Overhaul admin panel UX with TanStack Table (sortable/groupable columns) and cmdk (GitLab-style composable filter bar with field→operator→value token input). Hide FAB on /admin/* pages. Replace tag inline form with popup modal. Show tags expanded on item rows (collapse to +N when tight). Group items by brand. Prominent search bar on both admin list pages.
**Requirements**: TBD
**Plans**: TBD
Plans:
- [ ] TBD (promote with /gsd:review-backlog when ready)

View File

@@ -5,7 +5,7 @@ milestone_name: Admin Foundation
status: executing status: executing
stopped_at: Completed 38-02-PLAN.md — admin tag management client UI stopped_at: Completed 38-02-PLAN.md — admin tag management client UI
last_updated: "2026-04-19T20:32:22Z" last_updated: "2026-04-19T20:32:22Z"
last_activity: 2026-04-19 last_activity: 2026-04-20
progress: progress:
total_phases: 20 total_phases: 20
completed_phases: 10 completed_phases: 10
@@ -80,6 +80,7 @@ Phase 35 decisions (35-02):
### Pending Todos ### Pending Todos
- Cursor pointer on all clickable links — Phase 35 (FIX-05, plan 35-03) - Cursor pointer on all clickable links — Phase 35 (FIX-05, plan 35-03)
- Make tag selector in global search searchable — `CatalogSearchOverlay.tsx`
Resolved in 35-01: Resolved in 35-01:
@@ -95,6 +96,12 @@ Resolved in 35-02:
None. None.
### Quick Tasks Completed
| # | Description | Date | Commit | Directory |
|---|-------------|------|--------|-----------|
| 260420-vk0 | Fix UAT issues: image fetch-from-URL, image cropping, tag routing, duplicate tag error, tag form UX | 2026-04-20 | ddf9b95 | [260420-vk0-fix-uat-issues-image-fetch-from-url-imag](./quick/260420-vk0-fix-uat-issues-image-fetch-from-url-imag/) |
## Deferred Items ## Deferred Items
Items carried forward from v2.3: Items carried forward from v2.3:

View File

@@ -0,0 +1,74 @@
---
status: complete
phase: 35-bug-fixes
source: [35-01-SUMMARY.md, 35-02-SUMMARY.md, 35-03-SUMMARY.md]
started: 2026-04-20T00:00:00.000Z
updated: 2026-04-20T00:01:00.000Z
---
## Current Test
[testing complete]
## Tests
### 1. Thread Add Candidate Opens Catalog Search
expected: On a thread detail page, clicking "Add Candidate" opens the CatalogSearchOverlay (same overlay used elsewhere), not a local modal form.
result: pass
### 2. Image Skeleton and Fade-In on Cards
expected: On collection, thread candidates, or catalog pages, images show a gray pulsing skeleton placeholder while loading. Once the image loads, it fades in smoothly (opacity transition). Cards without images show the category icon placeholder as before.
result: pass
### 3. Login Page Redirects to OIDC
expected: Navigating to /login immediately redirects to the Logto OIDC provider. No sign-in card or button is shown — at most a brief "Signing in..." text before the redirect.
result: pass
### 4. Cursor Pointer on Interactive Elements
expected: Hovering over clickable ItemCards (in collection), FabMenu buttons, and BottomTabBar tab buttons shows a pointer cursor. Non-navigable ItemCards (e.g., in setup view) keep the default cursor.
result: issue
reported: "cursor-pointer missing on add-to-collection and thread buttons on item details page, and the small instant-add button in catalog search"
severity: minor
## Summary
total: 4
passed: 3
issues: 1
pending: 0
skipped: 0
blocked: 0
## Gaps
- truth: "All interactive elements show cursor-pointer on hover"
status: failed
reason: "User reported: cursor-pointer missing on add-to-collection and thread buttons on item details page, and the small instant-add button in catalog search"
severity: minor
test: 4
artifacts: []
missing: []
- truth: "CatalogSearchOverlay z-index should not cover the UserMenu dropdown"
status: failed
reason: "User reported: the add candidate global search lays above the context menu which opens when clicking the avatar"
severity: minor
test: bonus
artifacts: []
missing: []
- truth: "Catalog search instant-add button persists after inspecting an item and returning"
status: failed
reason: "User reported: when adding an item, you click on one to inspect it, then go back to the search, the small instant-add button isn't there"
severity: major
test: bonus
artifacts: []
missing: []
- truth: "Thread creation dialog uses the CategoryPicker component"
status: failed
reason: "User reported: new thread creation dialogue doesn't use the category selector component but instead uses its own"
severity: minor
test: bonus
artifacts: []
missing: []

View File

@@ -0,0 +1,42 @@
---
status: complete
phase: 36-admin-role-panel-foundation
source: [36-01-SUMMARY.md, 36-02-SUMMARY.md]
started: 2026-04-20T00:02:00.000Z
updated: 2026-04-20T00:03:00.000Z
---
## Current Test
[testing complete]
## Tests
### 1. Admin Link in UserMenu
expected: As an admin user, clicking the avatar/user menu shows an "Admin" link at the top of the dropdown (with a shield icon). Non-admin users do not see this link.
result: pass
### 2. Admin Panel Access and Guard
expected: Navigating to /admin as an admin user shows the admin panel with a sidebar. Non-admin users are redirected to /.
result: pass
### 3. Admin Sidebar Navigation
expected: The admin sidebar shows "Items" and "Tags" links. Both are clickable (not disabled/greyed out).
result: pass
### 4. Admin API Protection
expected: Hitting /api/admin/ as a non-admin user returns 403 Forbidden. Unauthenticated requests return 401.
result: pass
## Summary
total: 4
passed: 4
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps
[none]

View File

@@ -0,0 +1,81 @@
---
status: complete
phase: 37-admin-global-item-management
source: [37-01-SUMMARY.md, 37-02-SUMMARY.md]
started: 2026-04-20T00:04:00.000Z
updated: 2026-04-20T00:05:00.000Z
---
## Current Test
[testing complete]
## Tests
### 1. Admin Items List Page
expected: Navigating to /admin/items shows a data table of all global catalog items with search input, tag filter chips, skeleton loading state, and infinite scroll when there are many items.
result: pass
### 2. Admin Items Search and Filter
expected: Typing in the search input filters items by name. Clicking tag filter chips narrows results to items with that tag. Both work together.
result: pass
### 3. Admin Item Edit Page
expected: Clicking an item in the list navigates to its edit page showing all fields (name, manufacturer, weight, price, etc.), a manufacturer dropdown, and a TagInput chip component for managing tags.
result: issue
reported: "nothing happening when clicking an item in the list"
severity: major
### 4. Admin Item Delete with Confirmation
expected: On the edit page, clicking "Delete" shows a confirmation dialog that mentions how many users have this item in their collection (ownerCount). Confirming deletes the item and returns to the list.
result: issue
reported: "navigating directly to /admin/items/1 still shows the catalogue list, edit page doesn't render"
severity: major
## Summary
total: 4
passed: 2
issues: 2
pending: 0
skipped: 0
blocked: 0
## Gaps
- truth: "Clicking an item in the admin items list navigates to its edit page"
status: fixed
reason: "Route nesting issue — fixed by moving to directory-based routing"
severity: major
- truth: "Admin item edit page renders at /admin/items/$itemId"
status: fixed
reason: "Same route nesting fix"
severity: major
- truth: "Fetch from URL on admin item edit page works"
status: failed
reason: "User reported: image fetching doesn't work"
severity: major
test: post-fix
artifacts: []
missing: []
- truth: "Image cropping on admin item edit page works correctly"
status: failed
reason: "User reported: cropping has issues"
severity: major
test: post-fix
artifacts: []
missing: []
test: 3
artifacts: []
missing: []
- truth: "Admin item edit page renders at /admin/items/$itemId"
status: failed
reason: "User reported: navigating directly to /admin/items/1 still shows the catalogue list, edit page doesn't render"
severity: major
test: 4
artifacts: []
missing: []

View File

@@ -0,0 +1,70 @@
---
status: complete
phase: 38-admin-tag-management
source: [38-01-SUMMARY.md, 38-02-SUMMARY.md]
started: 2026-04-20T00:06:00.000Z
updated: 2026-04-20T00:07:00.000Z
---
## Current Test
[testing complete]
## Tests
### 1. Tag List with Tree View
expected: Navigating to /admin/tags shows a collapsible tree view of all tags. Tags with children have a chevron to expand/collapse. Indentation shows hierarchy. Search input filters the tree in place.
result: pass
### 2. Quick-Add Tag
expected: An inline form at the top lets you type a tag name, optionally pick a parent from a dropdown (default: "No parent (top-level)"), and click "Add Tag" to create it. The new tag appears in the tree immediately.
result: issue
reported: "creating a duplicate tag shows unknown error because backend returns 500 instead of proper error. also the inline form is ugly and hard to navigate"
severity: minor
### 3. Tag Edit Page — Rename and Reparent
expected: Clicking a tag navigates to /admin/tags/$tagId showing a name field and a parent picker dropdown. The parent picker excludes the current tag and all its descendants (cycle prevention). Saving updates the tag.
result: issue
reported: "navigates to correct URL (e.g. /admin/tags/4) but still shows the tag list view, edit page doesn't render"
severity: major
### 4. Tag Delete with Impact Confirmation
expected: On the tag edit page, clicking "Delete Tag" shows a confirmation dialog mentioning how many items use this tag and how many child tags it has. Confirming deletes the tag and returns to the list.
result: blocked
blocked_by: other
reason: "edit page doesn't render due to same routing issue as Phase 37"
## Summary
total: 4
passed: 1
issues: 2
pending: 0
skipped: 0
blocked: 1
## Gaps
- truth: "Duplicate tag name returns a user-friendly error message"
status: failed
reason: "User reported: backend returns 500, UI shows unknown error"
severity: minor
test: 2
artifacts: []
missing: []
- truth: "Inline tag creation form is usable and visually consistent"
status: failed
reason: "User reported: the inline form is ugly and hard to navigate"
severity: cosmetic
test: 2
artifacts: []
missing: []
- truth: "Admin tag edit page renders at /admin/tags/$tagId"
status: failed
reason: "User reported: navigates to correct URL but still shows the tag list view, edit page doesn't render"
severity: major
test: 3
artifacts: []
missing: []

View File

@@ -0,0 +1,267 @@
---
phase: quick
plan: 260420-vk0
type: execute
wave: 1
depends_on: []
files_modified:
- src/server/routes/images.ts
- src/client/routes/admin/items/$itemId.tsx
- src/server/routes/admin-tags.ts
- src/client/routes/admin/tags/index.tsx
- src/client/routes/admin/tags/$tagId.tsx
autonomous: true
requirements: []
must_haves:
truths:
- "Fetch-from-URL on admin item edit page shows the fetched image preview immediately"
- "Image cropping works on admin item edit page after fetch-from-URL"
- "Admin tag edit page renders at /admin/tags/$tagId"
- "Duplicate tag name returns user-friendly error (not 500)"
- "Inline tag creation form is visually polished and easy to use"
artifacts:
- path: "src/server/routes/images.ts"
provides: "from-url endpoint returns presignedUrl for immediate display"
- path: "src/client/routes/admin/items/$itemId.tsx"
provides: "Updates imageUrl in form state after fetch-from-URL"
- path: "src/server/routes/admin-tags.ts"
provides: "Catches unique constraint violations, returns 409"
- path: "src/client/routes/admin/tags/index.tsx"
provides: "Polished inline tag creation form"
key_links:
- from: "src/client/routes/admin/items/$itemId.tsx"
to: "/api/images/from-url"
via: "apiPost then updates form.imageUrl with returned presignedUrl"
pattern: "setForm.*imageUrl.*result\\.presignedUrl"
---
<objective>
Fix 5 UAT issues across Phase 37 (admin item management) and Phase 38 (admin tag management).
Purpose: Close remaining UAT gaps so admin pages are fully functional.
Output: Working image fetch-from-URL with preview, working tag routing, proper error handling for duplicate tags, polished tag form.
</objective>
<execution_context>
@.planning/quick/260420-vk0-fix-uat-issues-image-fetch-from-url-imag/260420-vk0-PLAN.md
</execution_context>
<context>
@CLAUDE.md
@src/server/routes/images.ts
@src/client/routes/admin/items/$itemId.tsx
@src/server/routes/admin-tags.ts
@src/client/routes/admin/tags/index.tsx
@src/client/routes/admin/tags/$tagId.tsx
@src/client/components/ImageUpload.tsx
<interfaces>
From src/server/services/image.service.ts:
```typescript
interface FetchImageResult {
filename: string;
sourceUrl: string;
dominantColor: string | null;
}
export async function fetchImageFromUrl(url: string): Promise<FetchImageResult>;
```
From src/server/services/storage.service.ts:
```typescript
export async function getImageUrl(filename: string): Promise<string>;
```
From src/client/components/ImageUpload.tsx:
```typescript
interface ImageUploadProps {
value: string | null;
imageUrl?: string | null;
dominantColor?: string | null;
initialCrop?: { zoom: number; x: number; y: number } | null;
onChange: (filename: string | null, dominantColor?: string | null) => void;
onCropChange?: (crop: { zoom: number; x: number; y: number }) => void;
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Fix image fetch-from-URL preview and cropping</name>
<files>src/server/routes/images.ts, src/client/routes/admin/items/$itemId.tsx</files>
<action>
**Server side** (`src/server/routes/images.ts`):
After `fetchImageFromUrl(url)` returns successfully on line 21, call `getImageUrl(result.filename)` to generate a presigned URL. Return it alongside the existing fields:
```typescript
const result = await fetchImageFromUrl(url);
const presignedUrl = await getImageUrl(result.filename);
return c.json({ ...result, presignedUrl }, 201);
```
Import `getImageUrl` from `"../services/storage.service"`.
**Client side** (`src/client/routes/admin/items/$itemId.tsx`):
In `handleFetchFromUrl` (line 159), update the type annotation to include `presignedUrl: string` and `dominantColor: string | null` in the response type. After a successful fetch, also update `imageUrl` and `dominantColor` in form state:
```typescript
const result = await apiPost<{ filename: string; sourceUrl: string; presignedUrl: string; dominantColor: string | null }>(
"/api/images/from-url",
{ url: fetchUrl.trim() },
);
setForm((prev) => ({
...prev,
imageFilename: result.filename,
imageUrl: result.presignedUrl,
dominantColor: result.dominantColor ?? "",
imageSourceUrl: fetchUrl.trim(),
}));
```
This ensures the `ImageUpload` component receives the presigned URL via `imageUrl` prop and displays the fetched image immediately. With the image displaying, the crop button becomes visible and functional (cropping already works via `ImageCropEditor` — the issue was just that no image was shown to crop).
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint</automated>
</verify>
<done>After fetching an image from URL on the admin item edit page, the image preview displays immediately and the crop button is visible/functional.</done>
</task>
<task type="auto">
<name>Task 2: Fix tag edit page routing</name>
<files>src/client/routes/admin/tags/$tagId.tsx</files>
<action>
The tag edit route file exists at the correct path and the route tree correctly registers it. Investigate why it renders the list instead of the edit form:
1. First, regenerate the route tree: run `bunx tsr generate` (TanStack Router CLI) to ensure the route tree is fresh.
2. If that doesn't resolve it, check if the `createFileRoute` path string in `$tagId.tsx` matches exactly what TanStack Router expects. Currently it's `createFileRoute("/admin/tags/$tagId")` — verify this matches the route tree's registered path pattern. If the route tree expects a different format (e.g., relative path), update accordingly.
3. If the route tree is correct but the page still shows the list, the issue may be that TanStack Router is matching the index route instead of the param route. The fix (same as was done for items in commit 31a9e3c) may involve ensuring the route file path conventions match TanStack Router's expectations for the directory-based approach. Check if items has any difference in file structure or route declaration that makes it work while tags doesn't.
4. After investigation, ensure navigating to `/admin/tags/4` renders `AdminTagEditPage` (not the tag list).
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bunx tsr generate && bun run lint</automated>
</verify>
<done>Navigating to /admin/tags/$tagId renders the tag edit form, not the tag list page.</done>
</task>
<task type="auto">
<name>Task 3: Handle duplicate tag name error + polish inline form</name>
<files>src/server/routes/admin-tags.ts, src/client/routes/admin/tags/index.tsx</files>
<action>
**Backend** (`src/server/routes/admin-tags.ts`):
Wrap the `createTag` call (line 48) in a try/catch that detects SQLite unique constraint violations and returns a 409:
```typescript
app.post("/", zValidator("json", createTagSchema), async (c) => {
const db = c.get("db");
const data = c.req.valid("json");
try {
const tag = await createTag(db, data);
return c.json(tag, 201);
} catch (err) {
if (err instanceof Error && (err.message.includes("UNIQUE constraint failed") || err.message.includes("unique constraint"))) {
return c.json({ error: `A tag named "${data.name}" already exists` }, 409);
}
throw err;
}
});
```
**Frontend** (`src/client/routes/admin/tags/index.tsx`):
1. In `handleCreate`, improve error handling to show the server's error message:
```typescript
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
setCreateError(null);
try {
await createMutation.mutateAsync({
name: newName.trim(),
parentId: newParentId,
});
setNewName("");
setNewParentId(null);
} catch (err: any) {
const message = err?.response?.error || err?.message || "Failed to create tag";
setCreateError(message);
}
}
```
Note: Check how `apiPost` (or the mutation hook) propagates error response bodies. If the hook uses React Query's `mutateAsync`, the error may be thrown as-is from the API client. Look at how `useCreateAdminTag` is implemented and ensure the `error` field from the 409 JSON response is surfaced. If the api client throws a generic Error, you may need to parse the response body in the hook or catch block.
2. Polish the inline tag creation form. Replace the plain `flex items-center gap-3` row with a more visually consistent card-style form:
```tsx
{/* Quick-add form */}
<div className="mb-6 rounded-xl border border-gray-100 bg-white p-4">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Add Tag</p>
<form onSubmit={handleCreate} className="flex items-end gap-3">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Name</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Bikepacking"
className={inputClass}
/>
</div>
<div className="w-48">
<label className="block text-xs text-gray-500 mb-1">Parent</label>
<select
value={newParentId ?? ""}
onChange={(e) => setNewParentId(e.target.value ? Number(e.target.value) : null)}
className={`${inputClass} appearance-none bg-white`}
>
<option value="">No parent (top-level)</option>
{data?.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<button
type="submit"
disabled={createMutation.isPending || !newName.trim()}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50 shrink-0"
>
{createMutation.isPending ? "Adding..." : "Add Tag"}
</button>
</form>
{createError && (
<p className="text-sm text-red-500 mt-2">{createError}</p>
)}
</div>
```
This wraps the form in a card with labels, making it visually consistent with the rest of the admin UI and easier to navigate.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint</automated>
</verify>
<done>Creating a duplicate tag shows "A tag named X already exists" error. The inline form has labels, is wrapped in a card, and matches admin UI style.</done>
</task>
</tasks>
<verification>
1. `bun run lint` passes with no errors
2. Start dev server (`bun run dev`) and verify:
- Navigate to /admin/items/{id}, use "Fetch from URL" with a valid image URL — preview appears
- Click crop button on the fetched image — crop editor opens
- Navigate to /admin/tags/{id} — edit form renders (not the list)
- On /admin/tags, try creating a tag with a duplicate name — shows friendly error
- Inline form has card styling with labels
</verification>
<success_criteria>
- All 5 UAT issues are resolved
- No lint errors introduced
- Image fetch-from-URL shows preview immediately
- Tag routing renders edit page at /admin/tags/$tagId
- Duplicate tag creation shows 409 error message (not 500)
- Tag form is visually polished with labels and card wrapper
</success_criteria>
<output>
After completion, verify all fixes manually and commit with message:
`fix(admin): resolve UAT issues — image fetch preview, tag routing, duplicate error, form UX`
</output>

View File

@@ -0,0 +1,81 @@
---
phase: quick
plan: 260420-vk0
subsystem: admin
tags: [bugfix, uat, image-upload, routing, tags]
dependency_graph:
requires: []
provides: [image-fetch-preview, tag-duplicate-error, tag-form-polish]
affects: [src/server/routes/images.ts, src/client/routes/admin/items/$itemId.tsx, src/server/routes/admin-tags.ts, src/client/routes/admin/tags/index.tsx]
tech_stack:
added: []
patterns: [presigned-url-passthrough, api-error-message-surfacing, card-form-pattern]
key_files:
created: []
modified:
- src/server/routes/images.ts
- src/client/routes/admin/items/$itemId.tsx
- src/server/routes/admin-tags.ts
- src/client/routes/admin/tags/index.tsx
decisions:
- "ApiError.message carries the server's error field from JSON response body — no custom parsing needed in catch blocks"
- "Task 2 (tag routing) was already resolved by commit 31a9e3c before this quick task ran — no changes needed"
metrics:
duration: "~8 minutes"
completed: "2026-04-20"
tasks_completed: 3
files_modified: 4
---
# Quick Task 260420-vk0: Fix UAT Issues — Image Fetch Preview, Tag Routing, Duplicate Error, Form UX
**One-liner:** Presigned URL passthrough to client fixes fetch-from-URL image preview; 409 unique constraint handler + card-form polish closes tag UAT gaps.
## Tasks Completed
| Task | Status | Commit | Files |
|------|--------|--------|-------|
| 1: Fix image fetch-from-URL preview and cropping | Done | b41b832 | images.ts, $itemId.tsx |
| 2: Fix tag edit page routing | Done (pre-existing) | 31a9e3c | routeTree.gen.ts (prior commit) |
| 3: Handle duplicate tag name error + polish inline form | Done | 113e689 | admin-tags.ts, tags/index.tsx |
## What Was Built
**Task 1 — Image fetch-from-URL preview:**
- `images.ts`: Added `getImageUrl` import from storage service; after `fetchImageFromUrl` completes, calls `getImageUrl(result.filename)` to generate a presigned URL and includes it in the response as `presignedUrl`.
- `$itemId.tsx`: Updated `handleFetchFromUrl` type annotation to include `presignedUrl` and `dominantColor`; after successful fetch, sets `imageUrl: result.presignedUrl` and `dominantColor` in form state. This causes `ImageUpload` to receive the presigned URL via its `imageUrl` prop and render the image preview immediately, making the crop button visible and functional.
**Task 2 — Tag edit page routing:**
Already resolved by commit 31a9e3c (which moved both items and tags to directory-based routing: `items/index.tsx` + `items/$itemId.tsx` / `tags/index.tsx` + `tags/$tagId.tsx`). The route tree correctly registers `AdminTagsTagIdRoute` and `AdminTagsIndexRoute` as siblings under `AdminRoute`. No additional changes required.
**Task 3 — Duplicate tag name error + form polish:**
- `admin-tags.ts`: Wrapped `createTag` in try/catch; detects SQLite UNIQUE constraint violations by checking `err.message` for "UNIQUE constraint failed" or "unique constraint", returning `{ error: "A tag named \"X\" already exists" }` with status 409.
- `tags/index.tsx`: Updated `handleCreate` catch to use `err instanceof Error ? err.message : "Failed to create tag"``ApiError` (thrown by `apiPost`) carries the server's `error` field as its message, so the friendly 409 message surfaces directly.
- `tags/index.tsx`: Replaced bare flex-row form with card-style wrapper (`rounded-xl border border-gray-100 bg-white p-4`), section header, field labels for Name and Parent, and `shrink-0` on the submit button. Error message moved inside the card below the form.
## Deviations from Plan
### Pre-resolved Issues
**Task 2 already fixed before this quick task ran**
- **Found during:** Investigation of tag routing
- **Issue:** Plan described investigating why `/admin/tags/$tagId` showed the list — but commit 31a9e3c (landed same day, prior to this quick task) already moved tag routes to directory structure and regenerated the route tree. Routes are correctly registered as siblings under AdminRoute.
- **Action taken:** Verified route tree, confirmed no changes needed, documented as pre-resolved. Continued to Task 3.
## Known Stubs
None. All implemented functionality is wired to real data.
## Threat Flags
None. No new network endpoints or auth paths introduced.
## Self-Check: PASSED
- [x] `src/server/routes/images.ts` exists and contains `getImageUrl` import and `presignedUrl` in response
- [x] `src/client/routes/admin/items/$itemId.tsx` contains `result.presignedUrl` and `imageUrl: result.presignedUrl` in form state update
- [x] `src/server/routes/admin-tags.ts` contains try/catch with 409 for UNIQUE constraint
- [x] `src/client/routes/admin/tags/index.tsx` contains card-style form with labels
- [x] Commit b41b832 exists (Task 1)
- [x] Commit 113e689 exists (Task 3)
- [x] `bun run lint` passes with no errors

View File

@@ -0,0 +1,15 @@
---
created: 2026-04-20T00:00:00.000Z
title: Make tag selector in global search searchable
area: ui
files:
- src/client/components/CatalogSearchOverlay.tsx
---
## Problem
The tag filter in the global search overlay (`CatalogSearchOverlay.tsx`) displays all tags as chips but provides no search/filter input. When there are many tags, users must scroll through all of them to find the one they want — not ergonomic.
## Solution
Add a search input inside the tag filter dropdown/section so users can type to narrow down visible tags before selecting. Similar pattern to how the admin items list (`src/client/routes/admin/items/index.tsx`) handles tag filtering.

View File

@@ -64,16 +64,12 @@ bun run build # Vite build → dist/client/
## Releasing ## Releasing
Releases are managed by a Gitea Actions workflow (`.gitea/workflows/release.yml`). **Never create tags or releases manually** — always trigger the pipeline. Releases are tag-driven. The Gitea Actions workflow (`.gitea/workflows/release.yml`) triggers on any pushed tag matching `v*` — it runs CI (lint, test, build), generates a changelog from the previous tag, builds and pushes a Docker image, and creates a Gitea release. The version comes from the tag name.
The workflow runs CI (lint, test, build), computes the next version from the latest tag, generates a changelog, creates the tag, builds and pushes a Docker image, and creates a Gitea release. To release, tag the desired commit on `Develop` and push:
Trigger via Gitea API:
```bash ```bash
curl -s -X POST "https://gitea.jeanlucmakiola.de/api/v1/repos/makiolaj/GearBox/actions/workflows/release.yml/dispatches" \ git tag v2.3.0
-H "Authorization: token <GITEA_TOKEN>" \ git push origin v2.3.0
-H "Content-Type: application/json" \
-d '{"ref": "Develop", "inputs": {"bump": "patch"}}' # patch | minor | major
``` ```
## Branching ## Branching

View File

@@ -36,7 +36,8 @@
"noLabelWithoutControl": "off" "noLabelWithoutControl": "off"
}, },
"suspicious": { "suspicious": {
"noExplicitAny": "off" "noExplicitAny": "off",
"noArrayIndexKey": "off"
}, },
"style": { "style": {
"noNonNullAssertion": "off" "noNonNullAssertion": "off"

115
docs/BACKLOG.md Normal file
View File

@@ -0,0 +1,115 @@
# GearBox — Backlog
*Migrated from `.planning/` on 2026-04-23. Forward-looking only — see `STATE.md` for what's shipped.*
Staging area for work not yet brainstormed into a superpowers plan. To start on anything here, run `superpowers:brainstorming``superpowers:writing-plans``superpowers:subagent-driven-development` (or `executing-plans`).
---
## v2.4 tail — finish line
- [ ] **Verify Phase 38 Admin Tag Management** — all 2 plans marked complete in roadmap, STATE.md says 97%. Run the phase verification and close out v2.4.
- [ ] **Manufacturer picker UI** — backend entity is shipped (`manufacturers` table, service, route, FK on `globalItems`, seeding), but the user-facing picker component (analogous to `CategoryPicker`) is not yet built. The manual add form still takes freeform brand text in places. See also the pending todo below.
---
## Pending todos
Details preserved in `docs/backlog/todos/`. Only truly-open items listed here — the older v2.3 todos (wrong modal, missing images, slow loading, auth redirect, cursor pointer) all shipped in Phase 35.
- [ ] **Make tag selector in global search searchable** (2026-04-20, ui) — the tag selector inside `CatalogSearchOverlay.tsx` should support typing-to-filter. Currently scrollable only.
- [ ] **Add manufacturer picker UI** (from 2026-04-10 "add-manufacturer-entity" todo) — backend shipped; picker component + wiring into the manual add form and catalog enrichment is the remaining slice.
**Dropped** (already resolved but stale in `.planning/todos/pending/`):
- `add-cursor-pointer-to-all-clickable-links` → shipped in Phase 35 (FIX-05)
- `fix-item-image-not-showing-on-collection-overview` → shipped in Phase 35 (FIX-02)
- `fix-storage-service-tests` → resolved; `withImageUrl`/`withImageUrls` both exported and test imports match
- `investigate-slow-image-loading` → shipped in Phase 35 (FIX-03, lazy loading + skeletons)
- `auth-prompt-sign-in-button-should-redirect-directly-to-logto` → shipped in Phase 35 (FIX-04)
- `fix-add-candidate-button-shows-wrong-modal-on-thread-page` → shipped in Phase 35 (FIX-01)
---
## Backlog phases (pre-brainstorm)
These are product intents, not plans. Each needs brainstorming before becoming a superpowers plan. Sourced from `.planning/ROADMAP.md` Backlog section. *Previously-listed entries 999.2, 999.3, 999.4, 999.6 were promoted to shipped phases and are omitted here.*
### 999.1 — Rewrite E2E Tests for OIDC Auth
E2E tests expect local username/password login, but auth moved to Logto. Rewrite with a mock OIDC provider or API-key bypass. Postgres seed migration already done.
### 999.5 — Legal Pages: ToS, Privacy Policy, Compliance
Terms of Service, Privacy Policy, and any required compliance pages. Essential before opening to real users.
### 999.7 — User Feedback System
In-app feedback collection — bug reports, feature requests, general feedback. Simple form, widget, or external integration.
### 999.8 — Analytics Integration
Privacy-respecting analytics (PostHog, Umami, or similar). Usage patterns, popular categories, search behavior, feature adoption. Self-hosted preferred to align with independent ethos.
### 999.9 — Mobile App
PWA first (offline, home-screen install), then evaluate native (React Native / Flutter) for richer experience — camera for weight verification, barcode scanning, etc.
### 999.10 — Monetization Strategy
How GearBox sustains itself. Options: sponsored/promoted items, premium features, affiliate links. Critical tension: revenue vs. credibility — GearBox's value is *unbiased* gear data. Needs deep discussion before implementation.
### 999.11 — Marketing Website
Standalone marketing site (`www.gearbox.de`) separate from the app (`app.gearbox.de`). Hero, value prop, feature highlights, how-it-works, social proof, sign-up CTA. The public-facing front door.
### 999.12 — Admin UX Polish (new, 2026-04-20)
Overhaul admin panel UX: TanStack Table (sortable/groupable columns), cmdk for GitLab-style composable filter bar (field→operator→value token input), hide FAB on `/admin/*`, replace tag inline form with popup modal, show tags expanded on item rows (collapse to +N when tight), group items by brand, prominent search bar on both admin list pages.
---
## Near-future scope (v2.5 candidates)
Explicitly planned for the next milestone but not yet a phase:
- **Tag-based spec schemas on global items** — key/value typed specs per category, sub-tag hierarchy. Enables proper structured product data per gear class.
- **Global item engagement stats** — view count, likes/saves, setup appearances. Feeds future sorting/ranking.
- **ComparisonTable currency normalization** — hooks are in place; needs real multi-currency test data to prove out.
---
## Deferred requirements (tracked, not scheduled)
Not in any current or v2.5 phase. Promote to a backlog phase when timing is right.
### Personalization
- **PERS-01** — Logged-in feed tuned to the user's collection categories
- **PERS-02** — Feed algorithm recommends by hobby interests
### Reviews & Content
- **REVW-01/02/03** — Structured reviews on catalog items; surface in discovery feed; curated/linked external reviews
- **REV-01/02/03** — Overall star rating, dimension ratings (durability, value), average ratings on item detail
- **MOD-01/02/03** — Freeform text reviews, report inappropriate content, admin review + action *(gated on moderation infra)*
### SEO
- **SEO-01** — Catalog pages crawlable by bots
- **SEO-02** — Meta tags + structured data
### Catalog Seeding
- **SEED-04** — Initial seeding run populates 100+ items across key categories via agent swarm *(crawl scripts exist — this would be the production run)*
### Aggregation
- **AGG-01** — Crowd-verified specs on item detail (manufacturer vs community-measured weight)
- **AGG-02** — "Which setups include this item"
- **AGG-03** — "Commonly paired with" setup composition insights
### Social
- **SOCL-01** — Fork/copy a public setup as template
- **SOCL-02** — Thread candidates auto-populate from global items
- **SOCL-03** — Follow other users
- **SOCL-04** — Activity feed of followed users' content
---
## Workflow
**To start work on something here:**
1. Pick an item above.
2. Run `superpowers:brainstorming` to turn the intent into a spec.
3. For non-trivial work, create a feature branch off `Develop` (e.g., `feature/manufacturer-picker`, `fix/tag-selector-search`).
4. Run `superpowers:writing-plans` to produce `docs/superpowers/plans/YYYY-MM-DD-<name>.md`.
5. Execute with `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`.
6. Remove the item from this doc when shipped.

95
docs/STATE.md Normal file
View File

@@ -0,0 +1,95 @@
# GearBox — Project State
*Snapshot: 2026-04-23. Migrated from `.planning/` (PROJECT.md, ROADMAP.md, STATE.md, REQUIREMENTS.md).*
## What GearBox Is
A gear management and discovery platform. Users catalog their gear (bikepacking, sim racing, any hobby), track weight, price, and source details, research purchases via planning threads with side-by-side comparison, and compose named setups (loadouts) with weight classification. A global item database with crowd-verified specs, market-aware pricing, and manufacturer attribution helps users make informed purchase decisions.
Multi-user, public-read, authenticated-write. Granular setup sharing (private / link / public), multi-currency (USD/EUR/GBP) with ECB rates, i18n (English + German).
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
## Where We Are
- **Current milestone:** v2.4 Admin Foundation — executing (Phases 3538 all complete, pending final verification)
- **Latest activity:** 2026-04-20 — quick task `260420-vk0` (image fetch-from-URL, crop, tag routing, tag-form UX fixes)
- **Position in GSD tracker:** "stopped at Completed 38-02-PLAN.md — admin tag management client UI"
- **Legacy planning data:** `.planning/` remains the live GSD workspace. This doc and `BACKLOG.md` are the superpowers-friendly summary.
## Shipped (v1.0 → v2.4)
**v1.0 MVP** — Collection CRUD, images, categories with totals, planning threads, thread resolution, named setups, dashboard, onboarding.
**v1.1 Fixes & Polish** — Thread creation modal, planning tab, image display polish, Lucide icon picker (119 icons), emoji→icon migration.
**v1.2 Collection Power-Ups** — Search + category filter, weight units (g/oz/lb/kg), per-setup classification (base/worn/consumable), donut chart, candidate status tracking.
**v1.3 Research & Decision Tools** — Pros/cons annotation, candidate ranking with drag-reorder, side-by-side comparison with deltas, setup impact preview.
**v2.0 Platform Foundation** — Postgres migration with PGlite test infra, Logto OIDC auth, multi-user isolation, MinIO/S3 storage, global item catalog, user profiles + public setup sharing, reference-item COALESCE merge, tag system, FAB + catalog search overlay, item/catalog detail pages, add-from-catalog flow.
**v2.1 Public Discovery** — Public read with rate limiting, catalog attribution + unique (brand, model), bulk import with upsert, MCP catalog tools (`upsert_catalog_item`, `bulk_upsert_catalog`), discovery landing page, top-nav + mobile bottom tab bar, Setups elevated to top-level route.
**v2.2 User Experience Polish** — Profile page with Logto-backed account management (name/bio/avatar/email/password/delete), fit-within image framing with dominant-color background and crop editor, catalog-driven onboarding (hobby picker → category-grouped item browser → batch create), mobile icon-based action buttons on detail pages.
**v2.3 Global & Social Ready** — Setup visibility (private/link/public) with shares table and 128-bit tokens, ShareModal (Google-Docs-style UX), `/s/:token` shared-setup viewer, multi-currency (market_prices + community_prices, ECB rates cached 24h), ownership-validated community price aggregation (median, 3-report minimum), react-i18next foundation with 7 namespaces and German translations, language picker.
**v2.4 Admin Foundation** (in verification) — Phase 35 bug-fix sweep (wrong modal, missing images, slow loading, auth redirect, cursor-pointer audit), isAdmin flag on users + CLI grant/revoke script, gated `/admin` route with requireAdmin middleware, admin global-item management (browse/edit/delete with search + tag filter), admin tag management (CRUD + parent-child hierarchy with cycle detection). Also during v2.4: manufacturer backend entity (table, FK, service, route, seeding; **picker UI still missing**), catalog ingestion agent scripts (`crawl-manufacturer`, `crawl-all`).
## Tech Stack
React 19, Hono, Drizzle ORM, PostgreSQL, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, framer-motion, **react-i18next**, **Sharp** (image processing), **react-easy-crop** — all on Bun. MinIO for S3-compatible image storage. Docker Compose for dev/prod. Logto (self-hosted OIDC) for auth; API keys for programmatic; OAuth 2.1 + PKCE for Claude mobile/web. MCP server exposes 21+ tools over streamable HTTP.
## Active Constraints
- **Runtime:** Bun (package manager + runtime)
- **Design:** Light, airy, minimalist — white/light backgrounds, lots of whitespace
- **Navigation:** Top nav (desktop) + bottom tab bar (mobile); public discovery landing page for unauth users
- **Auth:** External self-hosted (Logto) — no in-house auth maintenance
- **DB:** PostgreSQL + Drizzle; prices stored as cents; timestamps as unix epoch integers
- **UGC:** Structured input only (ratings, predefined fields) — no freeform until moderation exists
- **Scope:** Multi-user platform with public discovery; SQLite single-user path diverged at v2.0
## Load-Bearing Decisions (current)
| Decision | Rationale |
|---|---|
| External OIDC (Logto) | Avoid in-house auth burden |
| Postgres over SQLite | Multi-user concurrency + provider needs it |
| Structured UGC only (no freeform) | Minimize moderation burden |
| Discovery-first, not social-first | Users come to research decisions |
| COALESCE merge for reference items | Global base + personal overlay, no duplication |
| Catalog-first add with manual fallback | Encourages catalog usage, preserves flexibility |
| Detail pages over slide-out panels | Better UX + shareable URLs |
| Setups as top-level route | Promoted out of Collection tabs (Phase 27) |
| `visibility` text column (not boolean) | Future-proofs for additional sharing modes |
| `shares` table separate from `setups` | Enables per-person shares, write permissions, revocation |
| 128-bit base64url share tokens | URL-safe entropy, no external dep |
| Deactivate/reactivate on visibility change | Share links survive round-trips |
| EUR default currency | Matches early single-user data assumption |
| Module-level ECB rate cache (24h) | Single-process; avoids Redis |
| `isAdmin` boolean flag on users | Simplest; no Logto role claims needed |
| Admin grant via CLI script | No public UI for privilege escalation |
| Manufacturers as dedicated entity (FK, not text) | Avoids "Ortlieb" vs "ORTLIEB" drift; enables logos/URLs |
## Explicitly Out of Scope
- Personalized feed algorithm (needs usage data)
- SSR / static prerendering (TanStack Router research needed; defer)
- Freeform reviews / comments (no moderation infra)
- Image scraping automation (legal gray area)
- User-to-user messaging (moderation burden)
- Wiki-style open item editing (quality risk)
- Marketplace / buy-sell (payment + fraud + legal)
- AI gear recommendations (training data + hallucination)
- Gamification (incentivizes quantity over quality)
- Price tracking / deal alerts (scraping fragility + legal)
- Native mobile app (web-first, responsive; PWA considered — see 999.9)
- Custom comparison parameters (complexity trap; weight/price covers 80%)
- Barcode scanning (poor UX vs catalog search)
- Maintaining SQLite single-user path in parallel (diverged at v2.0)
## Next Up
See `BACKLOG.md` for the forward-looking list (pending todos, unfinished v2.4 verification, backlog phases, deferred requirements).

View File

@@ -0,0 +1,18 @@
---
created: 2026-04-10T09:23:46.394Z
title: Add manufacturer entity with brand details
area: database
files:
- src/db/schema.ts
- src/server/services/global-item.service.ts
---
## Problem
The manual item adding form doesn't include detailed manufacturer info. Currently `brand` is just a text field on items and globalItems. There's no structured manufacturer data (logo, website, country, description). Users can't select from existing manufacturers — they type freeform text which leads to inconsistencies ("Ortlieb" vs "ORTLIEB" vs "ortlieb").
## Solution
Create a `manufacturers` table with fields: `id`, `name` (unique), `logoUrl`, `websiteUrl`, `country`, `description`, `createdAt`. Add a `manufacturerId` FK on `globalItems` (and potentially `items`). Build a manufacturer picker component (like CategoryPicker) with search and inline create. Update the manual add form and catalog enrichment to use the manufacturer entity. This is a significant feature — likely needs its own phase.
Note: This would also improve the MCP agent seeding workflow — agents could create manufacturers first, then reference them when creating catalog items.

View File

@@ -0,0 +1,15 @@
---
created: 2026-04-20T00:00:00.000Z
title: Make tag selector in global search searchable
area: ui
files:
- src/client/components/CatalogSearchOverlay.tsx
---
## Problem
The tag filter in the global search overlay (`CatalogSearchOverlay.tsx`) displays all tags as chips but provides no search/filter input. When there are many tags, users must scroll through all of them to find the one they want — not ergonomic.
## Solution
Add a search input inside the tag filter dropdown/section so users can type to narrow down visible tags before selecting. Similar pattern to how the admin items list (`src/client/routes/admin/items/index.tsx`) handles tag filtering.

View File

@@ -0,0 +1,235 @@
# Tag Selector Search Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a typing-to-filter input above the tag chip list in the filter sidebar of `CatalogSearchOverlay` so users with many tags can narrow the visible list.
**Architecture:** Single-file UI change. Local `tagSearch` state, computed `filteredTags` derived via case-insensitive substring match, conditionally rendered input (only when `tags.length > 8`), muted "No tags match" hint when the filter empties the list, reset on overlay close. No backend, no new files, no new dependencies.
**Tech Stack:** React 19 + TanStack Query (via existing `useTags`) + Tailwind CSS v4.
**Spec:** `docs/superpowers/specs/2026-04-23-tag-selector-search-design.md`
---
## File Structure
**Modify only:**
- `src/client/components/CatalogSearchOverlay.tsx` — add state, reset hook extension, and JSX changes inside the Tags section of the filter sidebar.
No new files.
---
## Task 1: Implement tag search in the filter sidebar
**Files:**
- Modify: `src/client/components/CatalogSearchOverlay.tsx`
### Step 1: Add `tagSearch` local state
- [ ] Add the following state declaration immediately after the existing `const [filterOpen, setFilterOpen] = useState(false);` (currently line 25). This keeps filter-panel state grouped together.
```tsx
const [tagSearch, setTagSearch] = useState("");
```
### Step 2: Extend the close-reset effect to reset `tagSearch`
- [ ] In the existing reset `useEffect` (currently lines 8498), add `setTagSearch("");` to the list of resets so the filter clears on overlay close/reopen. The updated effect looks like:
```tsx
// Reset state when overlay closes
useEffect(() => {
if (!catalogSearchOpen) {
setSearchInput("");
setDebouncedQuery("");
setSelectedTags([]);
setFilterOpen(false);
setTagSearch("");
setWeightMin(0);
setWeightMax(5000);
setPriceMin(0);
setPriceMax(100000);
setManualEntryMode(false);
setSavedItemName(null);
setCatalogSubmitted(false);
}
}, [catalogSearchOpen]);
```
### Step 3: Replace the Tags section JSX with search-input + filtered list + empty hint
- [ ] In the filter sidebar, the Tags section currently spans lines 333356. Replace the existing block with the following. This (a) conditionally renders a small filter input only when `tags.length > 8`, (b) computes a case-insensitive substring filter inline, (c) renders either the filtered chip list or a muted "No tags match" hint.
Current block to replace (exact):
```tsx
{/* Tags */}
<div>
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Tags
</h3>
<div className="space-y-1">
{tags.map((tag) => {
const isActive = selectedTags.includes(tag.name);
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.name)}
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${
isActive
? "bg-blue-50 text-blue-700 font-medium"
: "text-gray-600 hover:bg-gray-50"
}`}
>
{tag.name}
</button>
);
})}
</div>
</div>
```
Replacement block:
```tsx
{/* Tags */}
<div>
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Tags
</h3>
{tags.length > 8 && (
<input
type="text"
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
placeholder="Filter tags..."
className="w-full px-2 py-1 mb-2 border border-gray-200 rounded-md text-xs text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-300 focus:border-transparent transition-colors"
/>
)}
<div className="space-y-1">
{(() => {
const q = tagSearch.trim().toLowerCase();
const filteredTags = q
? tags.filter((t) => t.name.toLowerCase().includes(q))
: tags;
if (filteredTags.length === 0) {
return (
<p className="text-xs text-gray-400 px-2.5 py-1.5">
No tags match
</p>
);
}
return filteredTags.map((tag) => {
const isActive = selectedTags.includes(tag.name);
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.name)}
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${
isActive
? "bg-blue-50 text-blue-700 font-medium"
: "text-gray-600 hover:bg-gray-50"
}`}
>
{tag.name}
</button>
);
});
})()}
</div>
</div>
```
**Notes:**
- The `(() => { ... })()` IIFE keeps the filter logic local to this block without adding another variable at component top-level. It runs on every render, which is fine — cost is O(n) per keystroke and `tags` is small.
- `q` is trimmed-and-lowercased once so we don't recompute it per tag.
- When `q` is empty, we use `tags` directly to avoid the filter allocation.
- The filter input is only rendered when `tags.length > 8`; below that threshold, the old behavior (no input, no filtering) is preserved.
### Step 4: Run the linter
- [ ] Run:
```bash
bun run lint
```
Expected: exit 0 with no errors. Biome should be happy (tabs, double quotes, organized imports — existing conventions).
If there are complaints about the IIFE or `q` variable, resolve them before continuing. If Biome flags the `no-nested-ternary` or complexity rules, hoist the filter logic into a `useMemo` just above the return statement instead — functionally equivalent, syntactically flatter.
### Step 5: Start the dev server and verify in a browser
- [ ] Run (in a separate terminal or background):
```bash
bun run dev
```
- [ ] Open `http://localhost:5173`, sign in if required, and open the catalog search overlay (FAB or top-nav search). Toggle the Filter icon to open the sidebar.
- [ ] Walk through acceptance criteria from the spec:
1. With >8 tags, the "Filter tags..." input appears above the chip list. With ≤8 tags, the input is hidden.
2. Typing narrows the list immediately (case-insensitive substring).
3. Selecting a tag, then typing a query that excludes it: the chip disappears from the sidebar but the blue pill in the header row remains.
4. Typing a query that matches nothing shows "No tags match".
5. Closing the overlay (Esc / back arrow / clicking outside header) and reopening: the tag search input is empty.
6. Weight/price range filters, active filter pills, and Clear-all still work as before.
- [ ] If any criterion fails, fix inline and re-verify. Do not commit broken work.
### Step 6: Commit
- [ ] Stage and commit:
```bash
git add src/client/components/CatalogSearchOverlay.tsx
git commit -m "$(cat <<'EOF'
feat(catalog): searchable tag filter in global catalog overlay
Adds a typing-to-filter input above the tag chip list in the filter
sidebar, rendered only when there are more than eight tags. Case-
insensitive substring match; shows "No tags match" when the query
empties the list. Selected tags filtered out of the sidebar remain
active as header pills. Resets with the rest of overlay state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Self-Review
**Spec coverage:**
| Spec requirement | Task step |
|---|---|
| Add `tagSearch` local state | Task 1 Step 1 |
| Reset `tagSearch` on overlay close | Task 1 Step 2 |
| Render input only when `tags.length > 8` | Task 1 Step 3 (conditional) |
| Case-insensitive substring match | Task 1 Step 3 (`q`/`includes`) |
| Selected tag filtered out stays active via header pill | Existing behavior preserved (no code removed from header pill row) — verified in Step 5 criterion 3 |
| "No tags match" hint on zero results | Task 1 Step 3 (early return) |
| Placeholder `Filter tags...` | Task 1 Step 3 |
| Style matches minimalist look, narrower for sidebar | Task 1 Step 3 (smaller padding, ring-1, text-xs) |
| Closing and reopening shows empty input | Task 1 Step 2 + Step 5 criterion 5 |
| No regressions to weight/price/pills | Step 5 criterion 6 |
All spec requirements mapped.
**Placeholders:** none. No "TBD" or "handle edge cases" language; all code is shown in full.
**Type consistency:** `tagSearch` is a `string`, always used as `tagSearch.trim().toLowerCase()`. `filteredTags` is `Tag[]` same shape as `tags`. Tag shape from `useTags` is `{ id: number; name: string }` — both fields used correctly.
---
## Execution Handoff
Scope is one file + six small steps. Subagent-driven would be over-engineered. Recommended: inline execution with `superpowers:executing-plans`.

View File

@@ -0,0 +1,66 @@
# Tag Selector Search in CatalogSearchOverlay
**Date:** 2026-04-23
**Status:** Approved, ready for implementation
## Problem
The tag filter in the global catalog search overlay (`src/client/components/CatalogSearchOverlay.tsx`) renders every tag as a chip in a narrow sidebar. When the tag taxonomy grows, users must scroll through the full list to find the one they want. There is no typing-to-filter affordance.
## Goal
Allow users to narrow the visible tag list by typing a query, without disrupting selection or the surrounding filter UX.
## Scope
Single-file change to `src/client/components/CatalogSearchOverlay.tsx`. No backend changes. No new dependencies. No shared component extraction.
## Behavior
**Search state**
- Add a `tagSearch: string` local state, defaulting to `""`.
**Input rendering**
- Render a compact text input at the top of the "Tags" section inside the filter sidebar (above the existing tag chip list).
- Style to match the existing minimalist look: similar to the main catalog search input but narrower and with reduced padding so it fits the 224px sidebar.
- Render the input only when `tags.length > 8`. Below that threshold scrolling isn't an issue and the extra chrome is noise.
- Placeholder text: `Filter tags...`.
**Filter logic**
- Compute the visible tag list as: tags whose `name` includes `tagSearch` (case-insensitive substring match).
- A selected tag that does not match the query is hidden from the sidebar list. It remains active and visible as a blue pill in the header row (existing behavior — no change required).
- When the filter produces zero visible tags, render a small muted hint: `No tags match`.
**Reset**
- When the overlay closes, reset `tagSearch` to `""` by extending the existing reset `useEffect` at lines 8498.
## Non-goals
- No fuzzy matching or ranking — plain case-insensitive substring is sufficient.
- No keyboard navigation of the tag list (arrow keys / Enter to toggle). Users click chips.
- No persistence of `tagSearch` across overlay open/close cycles.
- No changes to the active-pill row, active-filter counting, or weight/price range filters.
- No unit tests — pure UI state, covered by manual UAT verification during milestone close-out.
## Acceptance criteria
1. Typing in the tag search input immediately narrows the visible tag chips by case-insensitive substring match on tag name.
2. Tags that are selected but no longer match the query disappear from the sidebar list and remain selected (visible as blue pills in the header).
3. With 8 or fewer tags, the search input is not rendered.
4. With more than 8 tags and a query that matches none of them, a `No tags match` hint is shown.
5. Closing the overlay and reopening it shows an empty tag search input.
6. No regressions to other filter behavior (weight range, price range, active filter pills, clear-all).
## Files touched
- `src/client/components/CatalogSearchOverlay.tsx`
## Out of scope for this spec
- Tag selector in other components (admin items list, etc.).
- Any server-side tag search or query.
- Hierarchy-aware filtering (parent match reveals children, etc.) — tags are consumed flat from `useTags()` in this component.

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1776624400405, "when": 1776624400405,
"tag": "0009_spotty_lord_tyger", "tag": "0009_spotty_lord_tyger",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1776630390403,
"tag": "0010_yielding_random",
"breakpoints": true
} }
] ]
} }

View File

@@ -25,7 +25,7 @@ const args = Object.fromEntries(
}), }),
); );
const tier = args["tier"] ? Number(args["tier"]) : 1; const tier = args.tier ? Number(args.tier) : 1;
const dryRun = args["dry-run"] === "true"; const dryRun = args["dry-run"] === "true";
async function listActiveManufacturers(targetTier: number) { async function listActiveManufacturers(targetTier: number) {

View File

@@ -34,7 +34,7 @@ const args = Object.fromEntries(
}), }),
); );
const manufacturerSlug = args["manufacturer"]; const manufacturerSlug = args.manufacturer;
const dryRun = args["dry-run"] === "true"; const dryRun = args["dry-run"] === "true";
if (!manufacturerSlug) { if (!manufacturerSlug) {

View File

@@ -22,7 +22,11 @@ const [user] = await db
.update(users) .update(users)
.set({ isAdmin: !revoke }) .set({ isAdmin: !revoke })
.where(eq(users.logtoSub, sub)) .where(eq(users.logtoSub, sub))
.returning({ id: users.id, logtoSub: users.logtoSub, isAdmin: users.isAdmin }); .returning({
id: users.id,
logtoSub: users.logtoSub,
isAdmin: users.isAdmin,
});
if (!user) { if (!user) {
console.error(`User not found with logto_sub: ${sub}`); console.error(`User not found with logto_sub: ${sub}`);
@@ -30,4 +34,6 @@ if (!user) {
} }
const action = revoke ? "Revoked admin from" : "Granted admin to"; const action = revoke ? "Revoked admin from" : "Granted admin to";
console.log(`${action} user ${user.id} (${user.logtoSub}) — isAdmin: ${user.isAdmin}`); console.log(
`${action} user ${user.id} (${user.logtoSub}) — isAdmin: ${user.isAdmin}`,
);

View File

@@ -23,6 +23,7 @@ export function CatalogSearchOverlay() {
const [debouncedQuery, setDebouncedQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [tagSearch, setTagSearch] = useState("");
const [viewMode, setViewMode] = useState<ViewMode>("grid"); const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [manualEntryMode, setManualEntryMode] = useState(false); const [manualEntryMode, setManualEntryMode] = useState(false);
const [savedItemName, setSavedItemName] = useState<string | null>(null); const [savedItemName, setSavedItemName] = useState<string | null>(null);
@@ -87,6 +88,7 @@ export function CatalogSearchOverlay() {
setDebouncedQuery(""); setDebouncedQuery("");
setSelectedTags([]); setSelectedTags([]);
setFilterOpen(false); setFilterOpen(false);
setTagSearch("");
setWeightMin(0); setWeightMin(0);
setWeightMax(5000); setWeightMax(5000);
setPriceMin(0); setPriceMin(0);
@@ -334,24 +336,48 @@ export function CatalogSearchOverlay() {
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3"> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Tags Tags
</h3> </h3>
{tags.length > 8 && (
<input
type="text"
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
placeholder="Filter tags..."
className="w-full px-2 py-1 mb-2 border border-gray-200 rounded-md text-xs text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-300 focus:border-transparent transition-colors"
/>
)}
<div className="space-y-1"> <div className="space-y-1">
{tags.map((tag) => { {(() => {
const isActive = selectedTags.includes(tag.name); const q = tagSearch.trim().toLowerCase();
return ( const filteredTags = q
<button ? tags.filter((t) =>
key={tag.id} t.name.toLowerCase().includes(q),
type="button" )
onClick={() => toggleTag(tag.name)} : tags;
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${ if (filteredTags.length === 0) {
isActive return (
? "bg-blue-50 text-blue-700 font-medium" <p className="text-xs text-gray-400 px-2.5 py-1.5">
: "text-gray-600 hover:bg-gray-50" No tags match
}`} </p>
> );
{tag.name} }
</button> return filteredTags.map((tag) => {
); const isActive = selectedTags.includes(tag.name);
})} return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.name)}
className={`w-full text-left px-2.5 py-1.5 rounded-lg text-sm transition-colors ${
isActive
? "bg-blue-50 text-blue-700 font-medium"
: "text-gray-600 hover:bg-gray-50"
}`}
>
{tag.name}
</button>
);
});
})()}
</div> </div>
</div> </div>

View File

@@ -244,7 +244,6 @@ export function ComparisonTable({
return ( return (
<ul className="list-disc list-inside space-y-0.5"> <ul className="list-disc list-inside space-y-0.5">
{items.map((item, i) => ( {items.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
<li key={i} className="text-xs text-gray-700"> <li key={i} className="text-xs text-gray-700">
{item} {item}
</li> </li>
@@ -263,7 +262,6 @@ export function ComparisonTable({
return ( return (
<ul className="list-disc list-inside space-y-0.5"> <ul className="list-disc list-inside space-y-0.5">
{items.map((item, i) => ( {items.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
<li key={i} className="text-xs text-gray-700"> <li key={i} className="text-xs text-gray-700">
{item} {item}
</li> </li>

View File

@@ -8,6 +8,7 @@ interface ImageUploadProps {
value: string | null; value: string | null;
imageUrl?: string | null; imageUrl?: string | null;
dominantColor?: string | null; dominantColor?: string | null;
initialCrop?: { zoom: number; x: number; y: number } | null;
onChange: (filename: string | null, dominantColor?: string | null) => void; onChange: (filename: string | null, dominantColor?: string | null) => void;
onCropChange?: (crop: { zoom: number; x: number; y: number }) => void; onCropChange?: (crop: { zoom: number; x: number; y: number }) => void;
} }
@@ -19,6 +20,7 @@ export function ImageUpload({
value: _value, value: _value,
imageUrl, imageUrl,
dominantColor, dominantColor,
initialCrop,
onChange, onChange,
onCropChange, onCropChange,
}: ImageUploadProps) { }: ImageUploadProps) {
@@ -31,7 +33,7 @@ export function ImageUpload({
zoom: number; zoom: number;
x: number; x: number;
y: number; y: number;
} | null>(null); } | null>(initialCrop ?? null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {

View File

@@ -61,9 +61,7 @@ export function ItemCard({
const duplicateItem = useDuplicateItem(); const duplicateItem = useDuplicateItem();
const displayName = const displayName =
brand && name.startsWith(`${brand} `) brand && name.startsWith(`${brand} `) ? name.slice(brand.length + 1) : name;
? name.slice(brand.length + 1)
: name;
const handleClick = const handleClick =
linkTo === null linkTo === null

View File

@@ -67,9 +67,7 @@ export function useAdminGlobalItems(query?: string, tagNames?: string[]) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["admin-global-items", query ?? "", tagNames ?? []], queryKey: ["admin-global-items", query ?? "", tagNames ?? []],
queryFn: ({ pageParam = 0 }) => queryFn: ({ pageParam = 0 }) =>
apiGet<AdminGlobalItemPage>( apiGet<AdminGlobalItemPage>(`/api/admin/items?offset=${pageParam}&${qs}`),
`/api/admin/items?offset=${pageParam}&${qs}`,
),
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextOffset : undefined, lastPage.hasMore ? lastPage.nextOffset : undefined,
initialPageParam: 0, initialPageParam: 0,
@@ -79,8 +77,7 @@ export function useAdminGlobalItems(query?: string, tagNames?: string[]) {
export function useAdminGlobalItem(id: number | null) { export function useAdminGlobalItem(id: number | null) {
return useQuery({ return useQuery({
queryKey: ["admin-global-item", id], queryKey: ["admin-global-item", id],
queryFn: () => queryFn: () => apiGet<AdminGlobalItemDetail>(`/api/admin/items/${id}`),
apiGet<AdminGlobalItemDetail>(`/api/admin/items/${id}`),
enabled: id != null, enabled: id != null,
retry: (count, error) => retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3, error instanceof ApiError && error.status === 404 ? false : count < 3,
@@ -90,13 +87,8 @@ export function useAdminGlobalItem(id: number | null) {
export function useUpdateAdminGlobalItem() { export function useUpdateAdminGlobalItem() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({ id, data }: { id: number; data: UpdateGlobalItemPayload }) =>
id, apiPut<AdminGlobalItemDetail>(`/api/admin/items/${id}`, data),
data,
}: {
id: number;
data: UpdateGlobalItemPayload;
}) => apiPut<AdminGlobalItemDetail>(`/api/admin/items/${id}`, data),
onSuccess: (_result, { id }) => { onSuccess: (_result, { id }) => {
queryClient.invalidateQueries({ queryKey: ["admin-global-items"] }); queryClient.invalidateQueries({ queryKey: ["admin-global-items"] });
queryClient.invalidateQueries({ queryKey: ["admin-global-item", id] }); queryClient.invalidateQueries({ queryKey: ["admin-global-item", id] });

View File

@@ -2,7 +2,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost } from "../lib/api"; import { apiDelete, apiGet, apiPost } from "../lib/api";
interface AuthState { interface AuthState {
user: { id: string; email?: string; createdAt?: string; isAdmin?: boolean } | null; user: {
id: string;
email?: string;
createdAt?: string;
isAdmin?: boolean;
} | null;
authenticated: boolean; authenticated: boolean;
} }

View File

@@ -22,9 +22,11 @@ import { Route as UsersUserIdRouteImport } from './routes/users/$userId'
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId' import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
import { Route as ItemsItemIdRouteImport } from './routes/items/$itemId' import { Route as ItemsItemIdRouteImport } from './routes/items/$itemId'
import { Route as GlobalItemsGlobalItemIdRouteImport } from './routes/global-items/$globalItemId' import { Route as GlobalItemsGlobalItemIdRouteImport } from './routes/global-items/$globalItemId'
import { Route as AdminItemsRouteImport } from './routes/admin/items'
import { Route as ThreadsThreadIdIndexRouteImport } from './routes/threads/$threadId/index' import { Route as ThreadsThreadIdIndexRouteImport } from './routes/threads/$threadId/index'
import { Route as AdminItemsItemIdRouteImport } from './routes/admin/items.$itemId' import { Route as AdminTagsIndexRouteImport } from './routes/admin/tags/index'
import { Route as AdminItemsIndexRouteImport } from './routes/admin/items/index'
import { Route as AdminTagsTagIdRouteImport } from './routes/admin/tags/$tagId'
import { Route as AdminItemsItemIdRouteImport } from './routes/admin/items/$itemId'
import { Route as ThreadsThreadIdCandidatesCandidateIdRouteImport } from './routes/threads/$threadId/candidates/$candidateId' import { Route as ThreadsThreadIdCandidatesCandidateIdRouteImport } from './routes/threads/$threadId/candidates/$candidateId'
const SettingsRoute = SettingsRouteImport.update({ const SettingsRoute = SettingsRouteImport.update({
@@ -92,20 +94,30 @@ const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({
path: '/global-items/$globalItemId', path: '/global-items/$globalItemId',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AdminItemsRoute = AdminItemsRouteImport.update({
id: '/items',
path: '/items',
getParentRoute: () => AdminRoute,
} as any)
const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({ const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({
id: '/threads/$threadId/', id: '/threads/$threadId/',
path: '/threads/$threadId/', path: '/threads/$threadId/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AdminTagsIndexRoute = AdminTagsIndexRouteImport.update({
id: '/tags/',
path: '/tags/',
getParentRoute: () => AdminRoute,
} as any)
const AdminItemsIndexRoute = AdminItemsIndexRouteImport.update({
id: '/items/',
path: '/items/',
getParentRoute: () => AdminRoute,
} as any)
const AdminTagsTagIdRoute = AdminTagsTagIdRouteImport.update({
id: '/tags/$tagId',
path: '/tags/$tagId',
getParentRoute: () => AdminRoute,
} as any)
const AdminItemsItemIdRoute = AdminItemsItemIdRouteImport.update({ const AdminItemsItemIdRoute = AdminItemsItemIdRouteImport.update({
id: '/$itemId', id: '/items/$itemId',
path: '/$itemId', path: '/items/$itemId',
getParentRoute: () => AdminItemsRoute, getParentRoute: () => AdminRoute,
} as any) } as any)
const ThreadsThreadIdCandidatesCandidateIdRoute = const ThreadsThreadIdCandidatesCandidateIdRoute =
ThreadsThreadIdCandidatesCandidateIdRouteImport.update({ ThreadsThreadIdCandidatesCandidateIdRouteImport.update({
@@ -120,7 +132,6 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/admin/items': typeof AdminItemsRouteWithChildren
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute
'/setups/$setupId': typeof SetupsSetupIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute
@@ -130,6 +141,9 @@ export interface FileRoutesByFullPath {
'/global-items/': typeof GlobalItemsIndexRoute '/global-items/': typeof GlobalItemsIndexRoute
'/setups/': typeof SetupsIndexRoute '/setups/': typeof SetupsIndexRoute
'/admin/items/$itemId': typeof AdminItemsItemIdRoute '/admin/items/$itemId': typeof AdminItemsItemIdRoute
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
'/admin/items/': typeof AdminItemsIndexRoute
'/admin/tags/': typeof AdminTagsIndexRoute
'/threads/$threadId/': typeof ThreadsThreadIdIndexRoute '/threads/$threadId/': typeof ThreadsThreadIdIndexRoute
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute '/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
} }
@@ -138,7 +152,6 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/admin/items': typeof AdminItemsRouteWithChildren
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute
'/setups/$setupId': typeof SetupsSetupIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute
@@ -148,6 +161,9 @@ export interface FileRoutesByTo {
'/global-items': typeof GlobalItemsIndexRoute '/global-items': typeof GlobalItemsIndexRoute
'/setups': typeof SetupsIndexRoute '/setups': typeof SetupsIndexRoute
'/admin/items/$itemId': typeof AdminItemsItemIdRoute '/admin/items/$itemId': typeof AdminItemsItemIdRoute
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
'/admin/items': typeof AdminItemsIndexRoute
'/admin/tags': typeof AdminTagsIndexRoute
'/threads/$threadId': typeof ThreadsThreadIdIndexRoute '/threads/$threadId': typeof ThreadsThreadIdIndexRoute
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute '/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
} }
@@ -158,7 +174,6 @@ export interface FileRoutesById {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/profile': typeof ProfileRoute '/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/admin/items': typeof AdminItemsRouteWithChildren
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute
'/setups/$setupId': typeof SetupsSetupIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute
@@ -168,6 +183,9 @@ export interface FileRoutesById {
'/global-items/': typeof GlobalItemsIndexRoute '/global-items/': typeof GlobalItemsIndexRoute
'/setups/': typeof SetupsIndexRoute '/setups/': typeof SetupsIndexRoute
'/admin/items/$itemId': typeof AdminItemsItemIdRoute '/admin/items/$itemId': typeof AdminItemsItemIdRoute
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
'/admin/items/': typeof AdminItemsIndexRoute
'/admin/tags/': typeof AdminTagsIndexRoute
'/threads/$threadId/': typeof ThreadsThreadIdIndexRoute '/threads/$threadId/': typeof ThreadsThreadIdIndexRoute
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute '/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
} }
@@ -179,7 +197,6 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/profile' | '/profile'
| '/settings' | '/settings'
| '/admin/items'
| '/global-items/$globalItemId' | '/global-items/$globalItemId'
| '/items/$itemId' | '/items/$itemId'
| '/setups/$setupId' | '/setups/$setupId'
@@ -189,6 +206,9 @@ export interface FileRouteTypes {
| '/global-items/' | '/global-items/'
| '/setups/' | '/setups/'
| '/admin/items/$itemId' | '/admin/items/$itemId'
| '/admin/tags/$tagId'
| '/admin/items/'
| '/admin/tags/'
| '/threads/$threadId/' | '/threads/$threadId/'
| '/threads/$threadId/candidates/$candidateId' | '/threads/$threadId/candidates/$candidateId'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -197,7 +217,6 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/profile' | '/profile'
| '/settings' | '/settings'
| '/admin/items'
| '/global-items/$globalItemId' | '/global-items/$globalItemId'
| '/items/$itemId' | '/items/$itemId'
| '/setups/$setupId' | '/setups/$setupId'
@@ -207,6 +226,9 @@ export interface FileRouteTypes {
| '/global-items' | '/global-items'
| '/setups' | '/setups'
| '/admin/items/$itemId' | '/admin/items/$itemId'
| '/admin/tags/$tagId'
| '/admin/items'
| '/admin/tags'
| '/threads/$threadId' | '/threads/$threadId'
| '/threads/$threadId/candidates/$candidateId' | '/threads/$threadId/candidates/$candidateId'
id: id:
@@ -216,7 +238,6 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/profile' | '/profile'
| '/settings' | '/settings'
| '/admin/items'
| '/global-items/$globalItemId' | '/global-items/$globalItemId'
| '/items/$itemId' | '/items/$itemId'
| '/setups/$setupId' | '/setups/$setupId'
@@ -226,6 +247,9 @@ export interface FileRouteTypes {
| '/global-items/' | '/global-items/'
| '/setups/' | '/setups/'
| '/admin/items/$itemId' | '/admin/items/$itemId'
| '/admin/tags/$tagId'
| '/admin/items/'
| '/admin/tags/'
| '/threads/$threadId/' | '/threads/$threadId/'
| '/threads/$threadId/candidates/$candidateId' | '/threads/$threadId/candidates/$candidateId'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -340,13 +364,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof GlobalItemsGlobalItemIdRouteImport preLoaderRoute: typeof GlobalItemsGlobalItemIdRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/admin/items': {
id: '/admin/items'
path: '/items'
fullPath: '/admin/items'
preLoaderRoute: typeof AdminItemsRouteImport
parentRoute: typeof AdminRoute
}
'/threads/$threadId/': { '/threads/$threadId/': {
id: '/threads/$threadId/' id: '/threads/$threadId/'
path: '/threads/$threadId' path: '/threads/$threadId'
@@ -354,12 +371,33 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/admin/tags/': {
id: '/admin/tags/'
path: '/tags'
fullPath: '/admin/tags/'
preLoaderRoute: typeof AdminTagsIndexRouteImport
parentRoute: typeof AdminRoute
}
'/admin/items/': {
id: '/admin/items/'
path: '/items'
fullPath: '/admin/items/'
preLoaderRoute: typeof AdminItemsIndexRouteImport
parentRoute: typeof AdminRoute
}
'/admin/tags/$tagId': {
id: '/admin/tags/$tagId'
path: '/tags/$tagId'
fullPath: '/admin/tags/$tagId'
preLoaderRoute: typeof AdminTagsTagIdRouteImport
parentRoute: typeof AdminRoute
}
'/admin/items/$itemId': { '/admin/items/$itemId': {
id: '/admin/items/$itemId' id: '/admin/items/$itemId'
path: '/$itemId' path: '/items/$itemId'
fullPath: '/admin/items/$itemId' fullPath: '/admin/items/$itemId'
preLoaderRoute: typeof AdminItemsItemIdRouteImport preLoaderRoute: typeof AdminItemsItemIdRouteImport
parentRoute: typeof AdminItemsRoute parentRoute: typeof AdminRoute
} }
'/threads/$threadId/candidates/$candidateId': { '/threads/$threadId/candidates/$candidateId': {
id: '/threads/$threadId/candidates/$candidateId' id: '/threads/$threadId/candidates/$candidateId'
@@ -371,26 +409,20 @@ declare module '@tanstack/react-router' {
} }
} }
interface AdminItemsRouteChildren {
AdminItemsItemIdRoute: typeof AdminItemsItemIdRoute
}
const AdminItemsRouteChildren: AdminItemsRouteChildren = {
AdminItemsItemIdRoute: AdminItemsItemIdRoute,
}
const AdminItemsRouteWithChildren = AdminItemsRoute._addFileChildren(
AdminItemsRouteChildren,
)
interface AdminRouteChildren { interface AdminRouteChildren {
AdminItemsRoute: typeof AdminItemsRouteWithChildren
AdminIndexRoute: typeof AdminIndexRoute AdminIndexRoute: typeof AdminIndexRoute
AdminItemsItemIdRoute: typeof AdminItemsItemIdRoute
AdminTagsTagIdRoute: typeof AdminTagsTagIdRoute
AdminItemsIndexRoute: typeof AdminItemsIndexRoute
AdminTagsIndexRoute: typeof AdminTagsIndexRoute
} }
const AdminRouteChildren: AdminRouteChildren = { const AdminRouteChildren: AdminRouteChildren = {
AdminItemsRoute: AdminItemsRouteWithChildren,
AdminIndexRoute: AdminIndexRoute, AdminIndexRoute: AdminIndexRoute,
AdminItemsItemIdRoute: AdminItemsItemIdRoute,
AdminTagsTagIdRoute: AdminTagsTagIdRoute,
AdminItemsIndexRoute: AdminItemsIndexRoute,
AdminTagsIndexRoute: AdminTagsIndexRoute,
} }
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren) const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)

View File

@@ -1,4 +1,9 @@
import { Link, createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; import {
createFileRoute,
Link,
Outlet,
useNavigate,
} from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";

View File

@@ -1,11 +1,12 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ImageUpload } from "../../../components/ImageUpload";
import { import {
useAdminGlobalItem, useAdminGlobalItem,
useDeleteAdminGlobalItem, useDeleteAdminGlobalItem,
useUpdateAdminGlobalItem, useUpdateAdminGlobalItem,
} from "../../hooks/useAdminGlobalItems"; } from "../../../hooks/useAdminGlobalItems";
import { apiGet } from "../../lib/api"; import { apiGet, apiPost } from "../../../lib/api";
export const Route = createFileRoute("/admin/items/$itemId")({ export const Route = createFileRoute("/admin/items/$itemId")({
component: AdminItemEditPage, component: AdminItemEditPage,
@@ -63,7 +64,10 @@ function TagInput({
{tag} {tag}
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); removeTag(tag); }} onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
className="text-gray-400 hover:text-gray-600" className="text-gray-400 hover:text-gray-600"
> >
× ×
@@ -76,7 +80,9 @@ function TagInput({
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => { if (inputValue) addTag(inputValue); }} onBlur={() => {
if (inputValue) addTag(inputValue);
}}
placeholder={value.length === 0 ? "Add tags..." : ""} placeholder={value.length === 0 ? "Add tags..." : ""}
className="outline-none bg-transparent text-sm flex-1 min-w-[100px]" className="outline-none bg-transparent text-sm flex-1 min-w-[100px]"
/> />
@@ -91,7 +97,11 @@ function AdminItemEditPage() {
const id = Number(itemId); const id = Number(itemId);
const navigate = useNavigate(); const navigate = useNavigate();
const { data: item, isLoading, isError } = useAdminGlobalItem(isNaN(id) ? null : id); const {
data: item,
isLoading,
isError,
} = useAdminGlobalItem(Number.isNaN(id) ? null : id);
const updateMutation = useUpdateAdminGlobalItem(); const updateMutation = useUpdateAdminGlobalItem();
const deleteMutation = useDeleteAdminGlobalItem(); const deleteMutation = useDeleteAdminGlobalItem();
@@ -105,13 +115,21 @@ function AdminItemEditPage() {
category: "", category: "",
weightGrams: "", weightGrams: "",
priceCents: "", priceCents: "",
imageFilename: "",
imageUrl: "", imageUrl: "",
dominantColor: "",
cropZoom: null as number | null,
cropX: null as number | null,
cropY: null as number | null,
description: "", description: "",
sourceUrl: "", sourceUrl: "",
imageCredit: "", imageCredit: "",
imageSourceUrl: "", imageSourceUrl: "",
tags: [] as string[], tags: [] as string[],
}); });
const [fetchUrl, setFetchUrl] = useState("");
const [fetchingImage, setFetchingImage] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
// Populate form when item loads // Populate form when item loads
useEffect(() => { useEffect(() => {
@@ -121,8 +139,14 @@ function AdminItemEditPage() {
model: item.model, model: item.model,
category: item.category ?? "", category: item.category ?? "",
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "", weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
priceCents: item.priceCents != null ? String(item.priceCents / 100) : "", priceCents:
imageUrl: item.imageUrl ?? "", item.priceCents != null ? String(item.priceCents / 100) : "",
imageFilename: item.imageFilename ?? "",
imageUrl: item.presignedImageUrl ?? "",
dominantColor: item.dominantColor ?? "",
cropZoom: item.cropZoom ?? null,
cropX: item.cropX ?? null,
cropY: item.cropY ?? null,
description: item.description ?? "", description: item.description ?? "",
sourceUrl: item.sourceUrl ?? "", sourceUrl: item.sourceUrl ?? "",
imageCredit: item.imageCredit ?? "", imageCredit: item.imageCredit ?? "",
@@ -132,9 +156,37 @@ function AdminItemEditPage() {
} }
}, [item]); }, [item]);
async function handleFetchFromUrl() {
if (!fetchUrl.trim()) return;
setFetchingImage(true);
setFetchError(null);
try {
const result = await apiPost<{
filename: string;
sourceUrl: string;
presignedUrl: string;
dominantColor: string | null;
}>("/api/images/from-url", { url: fetchUrl.trim() });
setForm((prev) => ({
...prev,
imageFilename: result.filename,
imageUrl: result.presignedUrl,
dominantColor: result.dominantColor ?? "",
imageSourceUrl: fetchUrl.trim(),
}));
setFetchUrl("");
} catch {
setFetchError("Failed to fetch image from URL");
} finally {
setFetchingImage(false);
}
}
// Fetch manufacturers for dropdown // Fetch manufacturers for dropdown
useEffect(() => { useEffect(() => {
apiGet<Manufacturer[]>("/api/manufacturers").then(setManufacturers).catch(() => {}); apiGet<Manufacturer[]>("/api/manufacturers")
.then(setManufacturers)
.catch(() => {});
}, []); }, []);
function handleChange( function handleChange(
@@ -146,7 +198,8 @@ function AdminItemEditPage() {
async function handleSave(e: React.FormEvent) { async function handleSave(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const weightGrams = form.weightGrams !== "" ? Number(form.weightGrams) : null; const weightGrams =
form.weightGrams !== "" ? Number(form.weightGrams) : null;
const priceCents = const priceCents =
form.priceCents !== "" ? Math.round(Number(form.priceCents) * 100) : null; form.priceCents !== "" ? Math.round(Number(form.priceCents) * 100) : null;
@@ -158,7 +211,11 @@ function AdminItemEditPage() {
category: form.category || null, category: form.category || null,
weightGrams: weightGrams, weightGrams: weightGrams,
priceCents: priceCents, priceCents: priceCents,
imageUrl: form.imageUrl || null, imageFilename: form.imageFilename || null,
dominantColor: form.dominantColor || null,
cropZoom: form.cropZoom,
cropX: form.cropX,
cropY: form.cropY,
description: form.description || null, description: form.description || null,
sourceUrl: form.sourceUrl || null, sourceUrl: form.sourceUrl || null,
imageCredit: form.imageCredit || null, imageCredit: form.imageCredit || null,
@@ -184,7 +241,10 @@ function AdminItemEditPage() {
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" /> <div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
<div className="space-y-4"> <div className="space-y-4">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" /> <div
key={i}
className="h-10 bg-gray-100 rounded-lg animate-pulse"
/>
))} ))}
</div> </div>
</div> </div>
@@ -194,7 +254,9 @@ function AdminItemEditPage() {
if (isError || !item) { if (isError || !item) {
return ( return (
<div className="max-w-2xl mx-auto text-center py-12"> <div className="max-w-2xl mx-auto text-center py-12">
<p className="text-sm text-red-500">Failed to load item. Please try again.</p> <p className="text-sm text-red-500">
Failed to load item. Please try again.
</p>
</div> </div>
); );
} }
@@ -228,21 +290,68 @@ function AdminItemEditPage() {
<form onSubmit={handleSave}> <form onSubmit={handleSave}>
{/* Image section */} {/* Image section */}
<div> <div>
{item.imageUrl && ( <label className={labelClass}>Image</label>
<img <ImageUpload
src={item.imageUrl} value={form.imageFilename}
alt={`${item.brand} ${item.model}`} imageUrl={form.imageUrl || null}
className="w-full h-48 object-contain rounded-lg bg-gray-50 mb-3" dominantColor={form.dominantColor || null}
/> initialCrop={
)} form.cropZoom != null
<label className={labelClass}>Image URL</label> ? {
<input zoom: form.cropZoom,
type="url" x: form.cropX ?? 0,
value={form.imageUrl} y: form.cropY ?? 0,
onChange={(e) => handleChange("imageUrl", e.target.value)} }
className={inputClass} : null
placeholder="https://..." }
onChange={(filename, dominantColor) => {
setForm((prev) => ({
...prev,
imageFilename: filename ?? "",
dominantColor: dominantColor ?? "",
}));
}}
onCropChange={(crop) => {
setForm((prev) => ({
...prev,
cropZoom: crop.zoom,
cropX: crop.x,
cropY: crop.y,
}));
}}
/> />
{/* Fetch from URL alternative */}
<div className="mt-3">
<label className={`${labelClass} text-gray-500`}>
Or fetch from URL
</label>
<div className="flex gap-2">
<input
type="url"
value={fetchUrl}
onChange={(e) => setFetchUrl(e.target.value)}
className={`${inputClass} flex-1`}
placeholder="https://example.com/image.jpg"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleFetchFromUrl();
}
}}
/>
<button
type="button"
onClick={handleFetchFromUrl}
disabled={fetchingImage || !fetchUrl.trim()}
className="px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-50 shrink-0"
>
{fetchingImage ? "Fetching..." : "Fetch"}
</button>
</div>
{fetchError && (
<p className="mt-1 text-xs text-red-500">{fetchError}</p>
)}
</div>
</div> </div>
{/* Brand + Model */} {/* Brand + Model */}
@@ -252,7 +361,9 @@ function AdminItemEditPage() {
<label className={labelClass}>Brand (Manufacturer)</label> <label className={labelClass}>Brand (Manufacturer)</label>
<select <select
value={form.manufacturerId} value={form.manufacturerId}
onChange={(e) => handleChange("manufacturerId", Number(e.target.value))} onChange={(e) =>
handleChange("manufacturerId", Number(e.target.value))
}
className={`${inputClass} appearance-none bg-white`} className={`${inputClass} appearance-none bg-white`}
> >
<option value={0}>Select manufacturer...</option> <option value={0}>Select manufacturer...</option>
@@ -335,7 +446,7 @@ function AdminItemEditPage() {
/> />
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label className={labelClass}>Source URL</label> <label className={labelClass}>Product Page URL</label>
<input <input
type="url" type="url"
value={form.sourceUrl} value={form.sourceUrl}

View File

@@ -1,10 +1,10 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useAdminGlobalItems } from "../../hooks/useAdminGlobalItems"; import { useAdminGlobalItems } from "../../../hooks/useAdminGlobalItems";
import { useFormatters } from "../../hooks/useFormatters"; import { useFormatters } from "../../../hooks/useFormatters";
import { useTags } from "../../hooks/useTags"; import { useTags } from "../../../hooks/useTags";
export const Route = createFileRoute("/admin/items")({ export const Route = createFileRoute("/admin/items/")({
component: AdminItemsPage, component: AdminItemsPage,
}); });
@@ -87,6 +87,7 @@ function AdminItemsPage() {
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-4">
{allTags.map((tag) => ( {allTags.map((tag) => (
<button <button
type="button"
key={tag.id} key={tag.id}
onClick={() => toggleTag(tag.name)} onClick={() => toggleTag(tag.name)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${ className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
@@ -150,25 +151,36 @@ function AdminItemsPage() {
key={item.id} key={item.id}
className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => onClick={() =>
navigate({ to: "/admin/items/$itemId", params: { itemId: String(item.id) } }) navigate({
to: "/admin/items/$itemId",
params: { itemId: String(item.id) },
})
} }
> >
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="font-medium text-gray-900">{item.brand}</span> <span className="font-medium text-gray-900">
{item.brand}
</span>
<span className="text-gray-500 ml-1">{item.model}</span> <span className="text-gray-500 ml-1">{item.model}</span>
</td> </td>
<td className="px-4 py-3 text-gray-700"> <td className="px-4 py-3 text-gray-700">
{item.category ?? <span className="text-gray-300"></span>} {item.category ?? (
<span className="text-gray-300"></span>
)}
</td> </td>
<td className="px-4 py-3 text-gray-700"> <td className="px-4 py-3 text-gray-700">
{item.weightGrams != null {item.weightGrams != null ? (
? formatWeight(item.weightGrams) formatWeight(item.weightGrams)
: <span className="text-gray-300"></span>} ) : (
<span className="text-gray-300"></span>
)}
</td> </td>
<td className="px-4 py-3 text-gray-700"> <td className="px-4 py-3 text-gray-700">
{item.priceCents != null {item.priceCents != null ? (
? formatPrice(item.priceCents) formatPrice(item.priceCents)
: <span className="text-gray-300"></span>} ) : (
<span className="text-gray-300"></span>
)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{item.tags.length === 0 ? ( {item.tags.length === 0 ? (
@@ -209,7 +221,9 @@ function AdminItemsPage() {
{/* Empty state (after load, no items) */} {/* Empty state (after load, no items) */}
{!isLoading && allItems.length === 0 && !isError && ( {!isLoading && allItems.length === 0 && !isError && (
<div className="py-12 text-center"> <div className="py-12 text-center">
<p className="text-sm font-medium text-gray-900">No items found</p> <p className="text-sm font-medium text-gray-900">
No items found
</p>
<p className="text-sm text-gray-400 mt-1"> <p className="text-sm text-gray-400 mt-1">
Try a different search or clear your filters. Try a different search or clear your filters.
</p> </p>
@@ -221,7 +235,9 @@ function AdminItemsPage() {
{/* Loading more */} {/* Loading more */}
{isFetchingNextPage && ( {isFetchingNextPage && (
<div className="py-4 text-center text-sm text-gray-400">Loading...</div> <div className="py-4 text-center text-sm text-gray-400">
Loading...
</div>
)} )}
{/* All loaded message */} {/* All loaded message */}

View File

@@ -1,12 +1,12 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
type AdminTag,
useAdminTag, useAdminTag,
useAdminTags, useAdminTags,
useDeleteAdminTag, useDeleteAdminTag,
useUpdateAdminTag, useUpdateAdminTag,
type AdminTag, } from "../../../hooks/useAdminTags";
} from "../../hooks/useAdminTags";
export const Route = createFileRoute("/admin/tags/$tagId")({ export const Route = createFileRoute("/admin/tags/$tagId")({
component: AdminTagEditPage, component: AdminTagEditPage,
@@ -53,12 +53,19 @@ function AdminTagEditPage() {
const id = Number(tagId); const id = Number(tagId);
const navigate = useNavigate(); const navigate = useNavigate();
const { data: tag, isLoading, isError } = useAdminTag(isNaN(id) ? null : id); const {
data: tag,
isLoading,
isError,
} = useAdminTag(Number.isNaN(id) ? null : id);
const { data: allTags } = useAdminTags(); const { data: allTags } = useAdminTags();
const updateMutation = useUpdateAdminTag(); const updateMutation = useUpdateAdminTag();
const deleteMutation = useDeleteAdminTag(); const deleteMutation = useDeleteAdminTag();
const [form, setForm] = useState({ name: "", parentId: null as number | null }); const [form, setForm] = useState({
name: "",
parentId: null as number | null,
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => { useEffect(() => {
@@ -67,7 +74,10 @@ function AdminTagEditPage() {
} }
}, [tag]); }, [tag]);
function handleChange(field: keyof typeof form, value: string | number | null) { function handleChange(
field: keyof typeof form,
value: string | number | null,
) {
setForm((prev) => ({ ...prev, [field]: value })); setForm((prev) => ({ ...prev, [field]: value }));
} }
@@ -95,7 +105,10 @@ function AdminTagEditPage() {
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" /> <div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
<div className="space-y-4"> <div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" /> <div
key={i}
className="h-10 bg-gray-100 rounded-lg animate-pulse"
/>
))} ))}
</div> </div>
</div> </div>
@@ -105,7 +118,9 @@ function AdminTagEditPage() {
if (isError || !tag) { if (isError || !tag) {
return ( return (
<div className="max-w-2xl mx-auto text-center py-12"> <div className="max-w-2xl mx-auto text-center py-12">
<p className="text-sm text-red-500">Failed to load tag. Please try again.</p> <p className="text-sm text-red-500">
Failed to load tag. Please try again.
</p>
</div> </div>
); );
} }
@@ -149,7 +164,10 @@ function AdminTagEditPage() {
<select <select
value={form.parentId ?? ""} value={form.parentId ?? ""}
onChange={(e) => onChange={(e) =>
handleChange("parentId", e.target.value ? Number(e.target.value) : null) handleChange(
"parentId",
e.target.value ? Number(e.target.value) : null,
)
} }
className={`${inputClass} appearance-none bg-white`} className={`${inputClass} appearance-none bg-white`}
> >

View File

@@ -1,13 +1,13 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
type AdminTag,
useAdminTags, useAdminTags,
useCreateAdminTag, useCreateAdminTag,
type AdminTag, } from "../../../hooks/useAdminTags";
} from "../../hooks/useAdminTags"; import { LucideIcon } from "../../../lib/iconData";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/admin/tags")({ export const Route = createFileRoute("/admin/tags/")({
component: AdminTagsPage, component: AdminTagsPage,
}); });
@@ -124,8 +124,10 @@ function AdminTagsPage() {
}); });
setNewName(""); setNewName("");
setNewParentId(null); setNewParentId(null);
} catch { } catch (err: unknown) {
setCreateError("Failed to create tag. Please try again."); const message =
err instanceof Error ? err.message : "Failed to create tag";
setCreateError(message);
} }
} }
@@ -156,39 +158,50 @@ function AdminTagsPage() {
</div> </div>
{/* Quick-add form */} {/* Quick-add form */}
<form onSubmit={handleCreate} className="flex items-center gap-3 mb-4"> <div className="mb-6 rounded-xl border border-gray-100 bg-white p-4">
<input <p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
type="text" Add Tag
value={newName} </p>
onChange={(e) => setNewName(e.target.value)} <form onSubmit={handleCreate} className="flex items-end gap-3">
placeholder="Tag name..." <div className="flex-1">
className={`flex-1 ${inputClass}`} <label className="block text-xs text-gray-500 mb-1">Name</label>
/> <input
<select type="text"
value={newParentId ?? ""} value={newName}
onChange={(e) => onChange={(e) => setNewName(e.target.value)}
setNewParentId(e.target.value ? Number(e.target.value) : null) placeholder="e.g. Bikepacking"
} className={inputClass}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none bg-white appearance-none w-48" />
> </div>
<option value="">No parent (top-level)</option> <div className="w-48">
{data?.map((t) => ( <label className="block text-xs text-gray-500 mb-1">Parent</label>
<option key={t.id} value={t.id}> <select
{t.name} value={newParentId ?? ""}
</option> onChange={(e) =>
))} setNewParentId(e.target.value ? Number(e.target.value) : null)
</select> }
<button className={`${inputClass} appearance-none bg-white`}
type="submit" >
disabled={createMutation.isPending || !newName.trim()} <option value="">No parent (top-level)</option>
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50" {data?.map((t) => (
> <option key={t.id} value={t.id}>
{createMutation.isPending ? "Adding..." : "Add Tag"} {t.name}
</button> </option>
</form> ))}
{createError && ( </select>
<p className="text-sm text-red-500 mt-1 mb-4">{createError}</p> </div>
)} <button
type="submit"
disabled={createMutation.isPending || !newName.trim()}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50 shrink-0"
>
{createMutation.isPending ? "Adding..." : "Add Tag"}
</button>
</form>
{createError && (
<p className="text-sm text-red-500 mt-2">{createError}</p>
)}
</div>
{/* Error state */} {/* Error state */}
{isError && ( {isError && (
@@ -287,7 +300,9 @@ function AdminTagsPage() {
</p> </p>
) : ( ) : (
<> <>
<p className="text-sm font-medium text-gray-900">No tags yet</p> <p className="text-sm font-medium text-gray-900">
No tags yet
</p>
<p className="text-sm text-gray-400 mt-1"> <p className="text-sm text-gray-400 mt-1">
Add your first tag using the form above. Add your first tag using the form above.
</p> </p>

View File

@@ -346,6 +346,15 @@ function ItemDetail() {
value={form.imageFilename} value={form.imageFilename}
imageUrl={imageUrl} imageUrl={imageUrl}
dominantColor={item.dominantColor} dominantColor={item.dominantColor}
initialCrop={
item.cropZoom != null
? {
zoom: item.cropZoom,
x: item.cropX ?? 0,
y: item.cropY ?? 0,
}
: null
}
onChange={(filename, dominantColor) => { onChange={(filename, dominantColor) => {
setForm((f) => ({ ...f, imageFilename: filename })); setForm((f) => ({ ...f, imageFilename: filename }));
if (dominantColor) { if (dominantColor) {

View File

@@ -202,7 +202,6 @@ function ImportExportSection() {
</p> </p>
)} )}
{importResult.errors.map((err, i) => ( {importResult.errors.map((err, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
<p key={i} className="text-red-600"> <p key={i} className="text-red-600">
{err} {err}
</p> </p>

View File

@@ -204,7 +204,9 @@ export const globalItems = pgTable(
export const tags = pgTable("tags", { export const tags = pgTable("tags", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
name: text("name").notNull().unique(), name: text("name").notNull().unique(),
parentId: integer("parent_id").references(() => tags.id, { onDelete: "set null" }), parentId: integer("parent_id").references(() => tags.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
}); });

View File

@@ -1,5 +1,5 @@
import type { Context, Next } from "hono";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Context, Next } from "hono";
import { users } from "../../db/schema.ts"; import { users } from "../../db/schema.ts";
import { getOrCreateUser, verifyApiKey } from "../services/auth.service"; import { getOrCreateUser, verifyApiKey } from "../services/auth.service";
import { getOrCreateUncategorized } from "../services/category.service"; import { getOrCreateUncategorized } from "../services/category.service";

View File

@@ -8,6 +8,7 @@ import {
listGlobalItemsForAdmin, listGlobalItemsForAdmin,
updateGlobalItemById, updateGlobalItemById,
} from "../services/global-item.service.ts"; } from "../services/global-item.service.ts";
import { getImageUrl } from "../services/storage.service.ts";
type Env = { Variables: { db?: any; userId?: number } }; type Env = { Variables: { db?: any; userId?: number } };
@@ -20,6 +21,11 @@ const updateGlobalItemAdminSchema = z.object({
weightGrams: z.number().positive().nullable().optional(), weightGrams: z.number().positive().nullable().optional(),
priceCents: z.number().int().nonnegative().nullable().optional(), priceCents: z.number().int().nonnegative().nullable().optional(),
imageUrl: z.string().url().nullable().optional(), imageUrl: z.string().url().nullable().optional(),
imageFilename: z.string().nullable().optional(),
dominantColor: z.string().nullable().optional(),
cropZoom: z.number().nullable().optional(),
cropX: z.number().nullable().optional(),
cropY: z.number().nullable().optional(),
description: z.string().nullable().optional(), description: z.string().nullable().optional(),
sourceUrl: z.string().url().nullable().optional(), sourceUrl: z.string().url().nullable().optional(),
imageCredit: z.string().nullable().optional(), imageCredit: z.string().nullable().optional(),
@@ -44,8 +50,8 @@ app.get("/", async (c) => {
const result = await listGlobalItemsForAdmin(db, { const result = await listGlobalItemsForAdmin(db, {
query: q || undefined, query: q || undefined,
tagNames, tagNames,
offset: isNaN(offset) ? 0 : offset, offset: Number.isNaN(offset) ? 0 : offset,
limit: isNaN(limit) || limit > 100 ? 50 : limit, limit: Number.isNaN(limit) || limit > 100 ? 50 : limit,
}); });
return c.json(result); return c.json(result);
@@ -58,23 +64,27 @@ app.get("/:id", async (c) => {
if (!id) return c.json({ error: "Invalid item ID" }, 400); if (!id) return c.json({ error: "Invalid item ID" }, 400);
const item = await getGlobalItemWithOwnerCount(db, id); const item = await getGlobalItemWithOwnerCount(db, id);
if (!item) return c.json({ error: "Global item not found" }, 404); if (!item) return c.json({ error: "Global item not found" }, 404);
return c.json(item); // Resolve presigned URL for image display
const presignedImageUrl = item.imageUrl
? await getImageUrl(item.imageUrl)
: null;
return c.json({
...item,
imageFilename: item.imageUrl,
presignedImageUrl,
});
}); });
// PUT /api/admin/items/:id — update item fields // PUT /api/admin/items/:id — update item fields
app.put( app.put("/:id", zValidator("json", updateGlobalItemAdminSchema), async (c) => {
"/:id", const db = c.get("db");
zValidator("json", updateGlobalItemAdminSchema), const id = parseId(c.req.param("id"));
async (c) => { if (!id) return c.json({ error: "Invalid item ID" }, 400);
const db = c.get("db"); const data = c.req.valid("json");
const id = parseId(c.req.param("id")); const item = await updateGlobalItemById(db, id, data);
if (!id) return c.json({ error: "Invalid item ID" }, 400); if (!item) return c.json({ error: "Global item not found" }, 404);
const data = c.req.valid("json"); return c.json(item);
const item = await updateGlobalItemById(db, id, data); });
if (!item) return c.json({ error: "Global item not found" }, 404);
return c.json(item);
},
);
// DELETE /api/admin/items/:id — delete item with FK cleanup // DELETE /api/admin/items/:id — delete item with FK cleanup
app.delete("/:id", async (c) => { app.delete("/:id", async (c) => {

View File

@@ -45,8 +45,22 @@ app.get("/:id", async (c) => {
app.post("/", zValidator("json", createTagSchema), async (c) => { app.post("/", zValidator("json", createTagSchema), async (c) => {
const db = c.get("db"); const db = c.get("db");
const data = c.req.valid("json"); const data = c.req.valid("json");
const tag = await createTag(db, data); try {
return c.json(tag, 201); const tag = await createTag(db, data);
return c.json(tag, 201);
} catch (err) {
if (
err instanceof Error &&
(err.message.includes("UNIQUE constraint failed") ||
err.message.includes("unique constraint"))
) {
return c.json(
{ error: `A tag named "${data.name}" already exists` },
409,
);
}
throw err;
}
}); });
// PUT /api/admin/tags/:id — rename and/or reparent a tag // PUT /api/admin/tags/:id — rename and/or reparent a tag

View File

@@ -6,7 +6,7 @@ import {
extractDominantColor, extractDominantColor,
fetchImageFromUrl, fetchImageFromUrl,
} from "../services/image.service"; } from "../services/image.service";
import { uploadImage } from "../services/storage.service"; import { getImageUrl, uploadImage } from "../services/storage.service";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB const MAX_SIZE = 5 * 1024 * 1024; // 5MB
@@ -19,7 +19,8 @@ app.post("/from-url", zValidator("json", fromUrlSchema), async (c) => {
const { url } = c.req.valid("json"); const { url } = c.req.valid("json");
try { try {
const result = await fetchImageFromUrl(url); const result = await fetchImageFromUrl(url);
return c.json(result, 201); const presignedUrl = await getImageUrl(result.filename);
return c.json({ ...result, presignedUrl }, 201);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
// Known validation errors from the service // Known validation errors from the service

View File

@@ -178,7 +178,12 @@ export async function listGlobalItemsForAdmin(
}) })
.from(globalItemTags) .from(globalItemTags)
.innerJoin(tags, eq(tags.id, globalItemTags.tagId)) .innerJoin(tags, eq(tags.id, globalItemTags.tagId))
.where(sql`${globalItemTags.globalItemId} IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`); .where(
sql`${globalItemTags.globalItemId} IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const tagsByItemId = new Map<number, string[]>(); const tagsByItemId = new Map<number, string[]>();
for (const row of tagRows) { for (const row of tagRows) {
@@ -194,7 +199,12 @@ export async function listGlobalItemsForAdmin(
ownerCount: count(), ownerCount: count(),
}) })
.from(items) .from(items)
.where(sql`${items.globalItemId} IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`) .where(
sql`${items.globalItemId} IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})`,
)
.groupBy(items.globalItemId); .groupBy(items.globalItemId);
const ownerCountById = new Map<number, number>(); const ownerCountById = new Map<number, number>();
@@ -229,6 +239,11 @@ export async function updateGlobalItemById(
weightGrams?: number | null; weightGrams?: number | null;
priceCents?: number | null; priceCents?: number | null;
imageUrl?: string | null; imageUrl?: string | null;
imageFilename?: string | null;
dominantColor?: string | null;
cropZoom?: number | null;
cropX?: number | null;
cropY?: number | null;
description?: string | null; description?: string | null;
sourceUrl?: string | null; sourceUrl?: string | null;
imageCredit?: string | null; imageCredit?: string | null;
@@ -241,16 +256,29 @@ export async function updateGlobalItemById(
// Build partial update — only set provided fields // Build partial update — only set provided fields
const updateSet: Record<string, unknown> = {}; const updateSet: Record<string, unknown> = {};
if (fields.manufacturerId !== undefined) updateSet.manufacturerId = fields.manufacturerId; if (fields.manufacturerId !== undefined)
updateSet.manufacturerId = fields.manufacturerId;
if (fields.model !== undefined) updateSet.model = fields.model; if (fields.model !== undefined) updateSet.model = fields.model;
if ("category" in fields) updateSet.category = fields.category ?? null; if ("category" in fields) updateSet.category = fields.category ?? null;
if ("weightGrams" in fields) updateSet.weightGrams = fields.weightGrams ?? null; if ("weightGrams" in fields)
if ("priceCents" in fields) updateSet.priceCents = fields.priceCents ?? null; updateSet.weightGrams = fields.weightGrams ?? null;
if ("priceCents" in fields)
updateSet.priceCents = fields.priceCents ?? null;
if ("imageUrl" in fields) updateSet.imageUrl = fields.imageUrl ?? null; if ("imageUrl" in fields) updateSet.imageUrl = fields.imageUrl ?? null;
if ("description" in fields) updateSet.description = fields.description ?? null; if ("imageFilename" in fields)
updateSet.imageUrl = fields.imageFilename ?? null;
if ("dominantColor" in fields)
updateSet.dominantColor = fields.dominantColor ?? null;
if ("cropZoom" in fields) updateSet.cropZoom = fields.cropZoom ?? null;
if ("cropX" in fields) updateSet.cropX = fields.cropX ?? null;
if ("cropY" in fields) updateSet.cropY = fields.cropY ?? null;
if ("description" in fields)
updateSet.description = fields.description ?? null;
if ("sourceUrl" in fields) updateSet.sourceUrl = fields.sourceUrl ?? null; if ("sourceUrl" in fields) updateSet.sourceUrl = fields.sourceUrl ?? null;
if ("imageCredit" in fields) updateSet.imageCredit = fields.imageCredit ?? null; if ("imageCredit" in fields)
if ("imageSourceUrl" in fields) updateSet.imageSourceUrl = fields.imageSourceUrl ?? null; updateSet.imageCredit = fields.imageCredit ?? null;
if ("imageSourceUrl" in fields)
updateSet.imageSourceUrl = fields.imageSourceUrl ?? null;
let item: typeof globalItems.$inferSelect | undefined; let item: typeof globalItems.$inferSelect | undefined;
if (Object.keys(updateSet).length > 0) { if (Object.keys(updateSet).length > 0) {
@@ -295,14 +323,10 @@ export async function deleteGlobalItem(db: Db, id: number) {
.where(eq(items.globalItemId, id)); .where(eq(items.globalItemId, id));
// 3. Remove tag associations (FK: globalItemTags.globalItemId → globalItems.id, no cascade) // 3. Remove tag associations (FK: globalItemTags.globalItemId → globalItems.id, no cascade)
await tx await tx.delete(globalItemTags).where(eq(globalItemTags.globalItemId, id));
.delete(globalItemTags)
.where(eq(globalItemTags.globalItemId, id));
// 4. Delete the global item // 4. Delete the global item
await tx await tx.delete(globalItems).where(eq(globalItems.id, id));
.delete(globalItems)
.where(eq(globalItems.id, id));
return true; return true;
}); });

View File

@@ -64,7 +64,11 @@ describe("Admin Tag Routes", () => {
}); });
expect(res.status).toBe(201); expect(res.status).toBe(201);
const body = await res.json(); const body = await res.json();
expect(body).toMatchObject({ id: expect.any(Number), name: "ultralight", parentId: null }); expect(body).toMatchObject({
id: expect.any(Number),
name: "ultralight",
parentId: null,
});
}); });
it("creates tag with parentId", async () => { it("creates tag with parentId", async () => {

View File

@@ -536,9 +536,15 @@ describe("listGlobalItemsForAdmin", () => {
it("filters by query string (brand/model)", async () => { it("filters by query string (brand/model)", async () => {
const mfr = await insertManufacturer(db, "Salsa", "salsa"); const mfr = await insertManufacturer(db, "Salsa", "salsa");
await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Woodsmoke 700" }); await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Woodsmoke 700",
});
const mfr2 = await insertManufacturer(db, "Apidura", "apidura"); const mfr2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: mfr2.id, model: "Racing Saddle Bag" }); await insertGlobalItem(db, {
manufacturerId: mfr2.id,
model: "Racing Saddle Bag",
});
const result = await listGlobalItemsForAdmin(db, { query: "salsa" }); const result = await listGlobalItemsForAdmin(db, { query: "salsa" });
expect(result.items).toHaveLength(1); expect(result.items).toHaveLength(1);
@@ -547,7 +553,10 @@ describe("listGlobalItemsForAdmin", () => {
it("includes tags and ownerCount per item", async () => { it("includes tags and ownerCount per item", async () => {
const mfr = await insertManufacturer(db); const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Test Item" }); const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Test Item",
});
const tag = await insertTag(db, "bikepacking"); const tag = await insertTag(db, "bikepacking");
await tagGlobalItem(db, globalItem.id, tag.id!); await tagGlobalItem(db, globalItem.id, tag.id!);
@@ -556,7 +565,9 @@ describe("listGlobalItemsForAdmin", () => {
.insert(schema.users) .insert(schema.users)
.values({ logtoSub: "test-sub" }) .values({ logtoSub: "test-sub" })
.returning(); .returning();
await insertItem(db, "My Test Item", user!.id, { globalItemId: globalItem.id }); await insertItem(db, "My Test Item", user!.id, {
globalItemId: globalItem.id,
});
const result = await listGlobalItemsForAdmin(db); const result = await listGlobalItemsForAdmin(db);
expect(result.items).toHaveLength(1); expect(result.items).toHaveLength(1);
@@ -579,7 +590,10 @@ describe("updateGlobalItemById", () => {
it("updates model field by id", async () => { it("updates model field by id", async () => {
const mfr = await insertManufacturer(db); const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Original" }); const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Original",
});
await updateGlobalItemById(db, globalItem.id, { model: "Updated" }); await updateGlobalItemById(db, globalItem.id, { model: "Updated" });
@@ -589,9 +603,14 @@ describe("updateGlobalItemById", () => {
it("syncs tags when tags array provided", async () => { it("syncs tags when tags array provided", async () => {
const mfr = await insertManufacturer(db); const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Item" }); const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Tagged Item",
});
await updateGlobalItemById(db, globalItem.id, { tags: ["cycling", "gravel"] }); await updateGlobalItemById(db, globalItem.id, {
tags: ["cycling", "gravel"],
});
const result = await listGlobalItemsForAdmin(db); const result = await listGlobalItemsForAdmin(db);
const found = result.items.find((i) => i.id === globalItem.id); const found = result.items.find((i) => i.id === globalItem.id);
@@ -614,7 +633,10 @@ describe("deleteGlobalItem", () => {
it("deletes item and returns true", async () => { it("deletes item and returns true", async () => {
const mfr = await insertManufacturer(db); const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "To Delete" }); const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "To Delete",
});
const result = await deleteGlobalItem(db, globalItem.id); const result = await deleteGlobalItem(db, globalItem.id);
expect(result).toBe(true); expect(result).toBe(true);
@@ -625,12 +647,17 @@ describe("deleteGlobalItem", () => {
it("nullifies items.globalItemId before deleting", async () => { it("nullifies items.globalItemId before deleting", async () => {
const mfr = await insertManufacturer(db); const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Owned Item" }); const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Owned Item",
});
const [user] = await db const [user] = await db
.insert(schema.users) .insert(schema.users)
.values({ logtoSub: "delete-test-sub" }) .values({ logtoSub: "delete-test-sub" })
.returning(); .returning();
const userItem = await insertItem(db, "User Item", user!.id, { globalItemId: globalItem.id }); const userItem = await insertItem(db, "User Item", user!.id, {
globalItemId: globalItem.id,
});
await deleteGlobalItem(db, globalItem.id); await deleteGlobalItem(db, globalItem.id);
@@ -643,7 +670,10 @@ describe("deleteGlobalItem", () => {
it("removes globalItemTags before deleting", async () => { it("removes globalItemTags before deleting", async () => {
const mfr = await insertManufacturer(db); const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Delete" }); const globalItem = await insertGlobalItem(db, {
manufacturerId: mfr.id,
model: "Tagged Delete",
});
const tag = await insertTag(db, "delete-tag"); const tag = await insertTag(db, "delete-tag");
await tagGlobalItem(db, globalItem.id, tag.id!); await tagGlobalItem(db, globalItem.id, tag.id!);

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { manufacturers } from "../../src/db/schema.ts";
import { import {
createManufacturer, createManufacturer,
getManufacturerBySlug, getManufacturerBySlug,

View File

@@ -5,16 +5,11 @@ import {
deleteTag, deleteTag,
getAdminTags, getAdminTags,
getAllTags, getAllTags,
getTagWithCounts,
updateTag, updateTag,
} from "../../src/server/services/tag.service.ts"; } from "../../src/server/services/tag.service.ts";
import { createTestDb } from "../helpers/db.ts"; import { createTestDb } from "../helpers/db.ts";
async function insertTag( async function insertTag(db: any, name: string, parentId?: number | null) {
db: any,
name: string,
parentId?: number | null,
) {
const [row] = await db const [row] = await db
.insert(tags) .insert(tags)
.values({ name, parentId: parentId ?? null }) .values({ name, parentId: parentId ?? null })
@@ -108,7 +103,10 @@ describe("createTag", () => {
it("creates a tag with parentId set to an existing tag id", async () => { it("creates a tag with parentId set to an existing tag id", async () => {
const parent = await createTag(db, { name: "gear" }); const parent = await createTag(db, { name: "gear" });
const child = await createTag(db, { name: "clothing", parentId: parent.id }); const child = await createTag(db, {
name: "clothing",
parentId: parent.id,
});
expect(child.parentId).toBe(parent.id); expect(child.parentId).toBe(parent.id);
}); });
}); });
@@ -175,7 +173,7 @@ describe("deleteTag", () => {
const parent = await insertTag(db, "parent"); const parent = await insertTag(db, "parent");
const child = await insertTag(db, "child", parent.id); const child = await insertTag(db, "child", parent.id);
await deleteTag(db, parent.id); await deleteTag(db, parent.id);
const [childRow] = await db const [_childRow] = await db
.select({ parentId: tags.parentId }) .select({ parentId: tags.parentId })
.from(tags) .from(tags)
.where((t: any) => t.id === child.id); .where((t: any) => t.id === child.id);