Compare commits
16 Commits
22f5004e53
...
Develop
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ec330aca | |||
| 7890de141e | |||
| b41aa9301e | |||
| 076616cd1b | |||
| 0202d0bb5c | |||
| 1f2e8e18c4 | |||
| ddf9b9554f | |||
| 113e689932 | |||
| b41b8329bc | |||
| e4c0298a08 | |||
| 2f39a7241a | |||
| f1825fc722 | |||
| 8b60428b3b | |||
| 31a9e3c1ff | |||
| 88c5339b98 | |||
| e044547121 |
@@ -1,17 +1,9 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: "Version bump type"
|
||||
required: true
|
||||
default: "patch"
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
@@ -45,25 +37,17 @@ jobs:
|
||||
cd repo
|
||||
git checkout ${{ gitea.ref_name }}
|
||||
|
||||
- name: Compute version
|
||||
- name: Resolve version from tag
|
||||
working-directory: repo
|
||||
run: |
|
||||
LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -n1)
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
LATEST_TAG="v0.0.0"
|
||||
VERSION="${{ gitea.ref_name }}"
|
||||
PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -vxF "$VERSION" | head -n1)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
PREV_TAG="v0.0.0"
|
||||
fi
|
||||
MAJOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f1)
|
||||
MINOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f2)
|
||||
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f3)
|
||||
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"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "PREV_TAG=$PREV_TAG" >> "$GITHUB_ENV"
|
||||
echo "Releasing $VERSION (previous: $PREV_TAG)"
|
||||
|
||||
- name: Generate changelog
|
||||
working-directory: repo
|
||||
@@ -77,14 +61,6 @@ jobs:
|
||||
echo "$CHANGELOG" >> "$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
|
||||
working-directory: repo
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -233,6 +233,9 @@ e2e/pgdata
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# JetBrains IDEs (full directory)
|
||||
.idea/
|
||||
|
||||
# Obsidian
|
||||
.obsidian/
|
||||
|
||||
|
||||
@@ -401,3 +401,11 @@ Plans:
|
||||
|
||||
Plans:
|
||||
- [ ] 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)
|
||||
|
||||
@@ -5,7 +5,7 @@ milestone_name: Admin Foundation
|
||||
status: executing
|
||||
stopped_at: Completed 38-02-PLAN.md — admin tag management client UI
|
||||
last_updated: "2026-04-19T20:32:22Z"
|
||||
last_activity: 2026-04-19
|
||||
last_activity: 2026-04-20
|
||||
progress:
|
||||
total_phases: 20
|
||||
completed_phases: 10
|
||||
@@ -80,6 +80,7 @@ Phase 35 decisions (35-02):
|
||||
### Pending Todos
|
||||
|
||||
- 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:
|
||||
|
||||
@@ -95,6 +96,12 @@ Resolved in 35-02:
|
||||
|
||||
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
|
||||
|
||||
Items carried forward from v2.3:
|
||||
|
||||
74
.planning/phases/35-bug-fixes/35-UAT.md
Normal file
74
.planning/phases/35-bug-fixes/35-UAT.md
Normal 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: []
|
||||
42
.planning/phases/36-admin-role-panel-foundation/36-UAT.md
Normal file
42
.planning/phases/36-admin-role-panel-foundation/36-UAT.md
Normal 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]
|
||||
81
.planning/phases/37-admin-global-item-management/37-UAT.md
Normal file
81
.planning/phases/37-admin-global-item-management/37-UAT.md
Normal 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: []
|
||||
70
.planning/phases/38-admin-tag-management/38-UAT.md
Normal file
70
.planning/phases/38-admin-tag-management/38-UAT.md
Normal 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: []
|
||||
0
.planning/phases/999.12-admin-ux-polish/.gitkeep
Normal file
0
.planning/phases/999.12-admin-ux-polish/.gitkeep
Normal 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>
|
||||
@@ -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
|
||||
@@ -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.
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -64,16 +64,12 @@ bun run build # Vite build → dist/client/
|
||||
|
||||
## 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.
|
||||
|
||||
Trigger via Gitea API:
|
||||
To release, tag the desired commit on `Develop` and push:
|
||||
```bash
|
||||
curl -s -X POST "https://gitea.jeanlucmakiola.de/api/v1/repos/makiolaj/GearBox/actions/workflows/release.yml/dispatches" \
|
||||
-H "Authorization: token <GITEA_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ref": "Develop", "inputs": {"bump": "patch"}}' # patch | minor | major
|
||||
git tag v2.3.0
|
||||
git push origin v2.3.0
|
||||
```
|
||||
|
||||
## Branching
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"noLabelWithoutControl": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
|
||||
115
docs/BACKLOG.md
Normal file
115
docs/BACKLOG.md
Normal 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
95
docs/STATE.md
Normal 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 35–38 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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
235
docs/superpowers/plans/2026-04-23-tag-selector-search.md
Normal file
235
docs/superpowers/plans/2026-04-23-tag-selector-search.md
Normal 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 84–98), 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 333–356. 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`.
|
||||
@@ -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 84–98.
|
||||
|
||||
## 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.
|
||||
1724
drizzle-pg/meta/0010_snapshot.json
Normal file
1724
drizzle-pg/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
||||
"when": 1776624400405,
|
||||
"tag": "0009_spotty_lord_tyger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1776630390403,
|
||||
"tag": "0010_yielding_random",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
async function listActiveManufacturers(targetTier: number) {
|
||||
|
||||
@@ -34,7 +34,7 @@ const args = Object.fromEntries(
|
||||
}),
|
||||
);
|
||||
|
||||
const manufacturerSlug = args["manufacturer"];
|
||||
const manufacturerSlug = args.manufacturer;
|
||||
const dryRun = args["dry-run"] === "true";
|
||||
|
||||
if (!manufacturerSlug) {
|
||||
|
||||
@@ -22,7 +22,11 @@ const [user] = await db
|
||||
.update(users)
|
||||
.set({ isAdmin: !revoke })
|
||||
.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) {
|
||||
console.error(`User not found with logto_sub: ${sub}`);
|
||||
@@ -30,4 +34,6 @@ if (!user) {
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ export function CatalogSearchOverlay() {
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [tagSearch, setTagSearch] = useState("");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||
const [manualEntryMode, setManualEntryMode] = useState(false);
|
||||
const [savedItemName, setSavedItemName] = useState<string | null>(null);
|
||||
@@ -87,6 +88,7 @@ export function CatalogSearchOverlay() {
|
||||
setDebouncedQuery("");
|
||||
setSelectedTags([]);
|
||||
setFilterOpen(false);
|
||||
setTagSearch("");
|
||||
setWeightMin(0);
|
||||
setWeightMax(5000);
|
||||
setPriceMin(0);
|
||||
@@ -334,24 +336,48 @@ export function CatalogSearchOverlay() {
|
||||
<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">
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
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>
|
||||
|
||||
|
||||
@@ -244,7 +244,6 @@ export function ComparisonTable({
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
|
||||
<li key={i} className="text-xs text-gray-700">
|
||||
{item}
|
||||
</li>
|
||||
@@ -263,7 +262,6 @@ export function ComparisonTable({
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
|
||||
<li key={i} className="text-xs text-gray-700">
|
||||
{item}
|
||||
</li>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
}
|
||||
@@ -19,6 +20,7 @@ export function ImageUpload({
|
||||
value: _value,
|
||||
imageUrl,
|
||||
dominantColor,
|
||||
initialCrop,
|
||||
onChange,
|
||||
onCropChange,
|
||||
}: ImageUploadProps) {
|
||||
@@ -31,7 +33,7 @@ export function ImageUpload({
|
||||
zoom: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
} | null>(initialCrop ?? null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
|
||||
@@ -61,9 +61,7 @@ export function ItemCard({
|
||||
const duplicateItem = useDuplicateItem();
|
||||
|
||||
const displayName =
|
||||
brand && name.startsWith(`${brand} `)
|
||||
? name.slice(brand.length + 1)
|
||||
: name;
|
||||
brand && name.startsWith(`${brand} `) ? name.slice(brand.length + 1) : name;
|
||||
|
||||
const handleClick =
|
||||
linkTo === null
|
||||
|
||||
@@ -67,9 +67,7 @@ export function useAdminGlobalItems(query?: string, tagNames?: string[]) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["admin-global-items", query ?? "", tagNames ?? []],
|
||||
queryFn: ({ pageParam = 0 }) =>
|
||||
apiGet<AdminGlobalItemPage>(
|
||||
`/api/admin/items?offset=${pageParam}&${qs}`,
|
||||
),
|
||||
apiGet<AdminGlobalItemPage>(`/api/admin/items?offset=${pageParam}&${qs}`),
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||
initialPageParam: 0,
|
||||
@@ -79,8 +77,7 @@ export function useAdminGlobalItems(query?: string, tagNames?: string[]) {
|
||||
export function useAdminGlobalItem(id: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-global-item", id],
|
||||
queryFn: () =>
|
||||
apiGet<AdminGlobalItemDetail>(`/api/admin/items/${id}`),
|
||||
queryFn: () => apiGet<AdminGlobalItemDetail>(`/api/admin/items/${id}`),
|
||||
enabled: id != null,
|
||||
retry: (count, error) =>
|
||||
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||
@@ -90,13 +87,8 @@ export function useAdminGlobalItem(id: number | null) {
|
||||
export function useUpdateAdminGlobalItem() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: number;
|
||||
data: UpdateGlobalItemPayload;
|
||||
}) => apiPut<AdminGlobalItemDetail>(`/api/admin/items/${id}`, data),
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateGlobalItemPayload }) =>
|
||||
apiPut<AdminGlobalItemDetail>(`/api/admin/items/${id}`, data),
|
||||
onSuccess: (_result, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-global-items"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-global-item", id] });
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiDelete, apiGet, apiPost } from "../lib/api";
|
||||
|
||||
interface AuthState {
|
||||
user: { id: string; email?: string; createdAt?: string; isAdmin?: boolean } | null;
|
||||
user: {
|
||||
id: string;
|
||||
email?: string;
|
||||
createdAt?: string;
|
||||
isAdmin?: boolean;
|
||||
} | null;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ import { Route as UsersUserIdRouteImport } from './routes/users/$userId'
|
||||
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
|
||||
import { Route as ItemsItemIdRouteImport } from './routes/items/$itemId'
|
||||
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 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'
|
||||
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
@@ -92,20 +94,30 @@ const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({
|
||||
path: '/global-items/$globalItemId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminItemsRoute = AdminItemsRouteImport.update({
|
||||
id: '/items',
|
||||
path: '/items',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({
|
||||
id: '/threads/$threadId/',
|
||||
path: '/threads/$threadId/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} 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({
|
||||
id: '/$itemId',
|
||||
path: '/$itemId',
|
||||
getParentRoute: () => AdminItemsRoute,
|
||||
id: '/items/$itemId',
|
||||
path: '/items/$itemId',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const ThreadsThreadIdCandidatesCandidateIdRoute =
|
||||
ThreadsThreadIdCandidatesCandidateIdRouteImport.update({
|
||||
@@ -120,7 +132,6 @@ export interface FileRoutesByFullPath {
|
||||
'/login': typeof LoginRoute
|
||||
'/profile': typeof ProfileRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/items': typeof AdminItemsRouteWithChildren
|
||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||
'/items/$itemId': typeof ItemsItemIdRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
@@ -130,6 +141,9 @@ export interface FileRoutesByFullPath {
|
||||
'/global-items/': typeof GlobalItemsIndexRoute
|
||||
'/setups/': typeof SetupsIndexRoute
|
||||
'/admin/items/$itemId': typeof AdminItemsItemIdRoute
|
||||
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
|
||||
'/admin/items/': typeof AdminItemsIndexRoute
|
||||
'/admin/tags/': typeof AdminTagsIndexRoute
|
||||
'/threads/$threadId/': typeof ThreadsThreadIdIndexRoute
|
||||
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||
}
|
||||
@@ -138,7 +152,6 @@ export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/profile': typeof ProfileRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/items': typeof AdminItemsRouteWithChildren
|
||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||
'/items/$itemId': typeof ItemsItemIdRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
@@ -148,6 +161,9 @@ export interface FileRoutesByTo {
|
||||
'/global-items': typeof GlobalItemsIndexRoute
|
||||
'/setups': typeof SetupsIndexRoute
|
||||
'/admin/items/$itemId': typeof AdminItemsItemIdRoute
|
||||
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
|
||||
'/admin/items': typeof AdminItemsIndexRoute
|
||||
'/admin/tags': typeof AdminTagsIndexRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdIndexRoute
|
||||
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||
}
|
||||
@@ -158,7 +174,6 @@ export interface FileRoutesById {
|
||||
'/login': typeof LoginRoute
|
||||
'/profile': typeof ProfileRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/items': typeof AdminItemsRouteWithChildren
|
||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||
'/items/$itemId': typeof ItemsItemIdRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
@@ -168,6 +183,9 @@ export interface FileRoutesById {
|
||||
'/global-items/': typeof GlobalItemsIndexRoute
|
||||
'/setups/': typeof SetupsIndexRoute
|
||||
'/admin/items/$itemId': typeof AdminItemsItemIdRoute
|
||||
'/admin/tags/$tagId': typeof AdminTagsTagIdRoute
|
||||
'/admin/items/': typeof AdminItemsIndexRoute
|
||||
'/admin/tags/': typeof AdminTagsIndexRoute
|
||||
'/threads/$threadId/': typeof ThreadsThreadIdIndexRoute
|
||||
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||
}
|
||||
@@ -179,7 +197,6 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/profile'
|
||||
| '/settings'
|
||||
| '/admin/items'
|
||||
| '/global-items/$globalItemId'
|
||||
| '/items/$itemId'
|
||||
| '/setups/$setupId'
|
||||
@@ -189,6 +206,9 @@ export interface FileRouteTypes {
|
||||
| '/global-items/'
|
||||
| '/setups/'
|
||||
| '/admin/items/$itemId'
|
||||
| '/admin/tags/$tagId'
|
||||
| '/admin/items/'
|
||||
| '/admin/tags/'
|
||||
| '/threads/$threadId/'
|
||||
| '/threads/$threadId/candidates/$candidateId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -197,7 +217,6 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/profile'
|
||||
| '/settings'
|
||||
| '/admin/items'
|
||||
| '/global-items/$globalItemId'
|
||||
| '/items/$itemId'
|
||||
| '/setups/$setupId'
|
||||
@@ -207,6 +226,9 @@ export interface FileRouteTypes {
|
||||
| '/global-items'
|
||||
| '/setups'
|
||||
| '/admin/items/$itemId'
|
||||
| '/admin/tags/$tagId'
|
||||
| '/admin/items'
|
||||
| '/admin/tags'
|
||||
| '/threads/$threadId'
|
||||
| '/threads/$threadId/candidates/$candidateId'
|
||||
id:
|
||||
@@ -216,7 +238,6 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/profile'
|
||||
| '/settings'
|
||||
| '/admin/items'
|
||||
| '/global-items/$globalItemId'
|
||||
| '/items/$itemId'
|
||||
| '/setups/$setupId'
|
||||
@@ -226,6 +247,9 @@ export interface FileRouteTypes {
|
||||
| '/global-items/'
|
||||
| '/setups/'
|
||||
| '/admin/items/$itemId'
|
||||
| '/admin/tags/$tagId'
|
||||
| '/admin/items/'
|
||||
| '/admin/tags/'
|
||||
| '/threads/$threadId/'
|
||||
| '/threads/$threadId/candidates/$candidateId'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -340,13 +364,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof GlobalItemsGlobalItemIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin/items': {
|
||||
id: '/admin/items'
|
||||
path: '/items'
|
||||
fullPath: '/admin/items'
|
||||
preLoaderRoute: typeof AdminItemsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/threads/$threadId/': {
|
||||
id: '/threads/$threadId/'
|
||||
path: '/threads/$threadId'
|
||||
@@ -354,12 +371,33 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport
|
||||
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': {
|
||||
id: '/admin/items/$itemId'
|
||||
path: '/$itemId'
|
||||
path: '/items/$itemId'
|
||||
fullPath: '/admin/items/$itemId'
|
||||
preLoaderRoute: typeof AdminItemsItemIdRouteImport
|
||||
parentRoute: typeof AdminItemsRoute
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/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 {
|
||||
AdminItemsRoute: typeof AdminItemsRouteWithChildren
|
||||
AdminIndexRoute: typeof AdminIndexRoute
|
||||
AdminItemsItemIdRoute: typeof AdminItemsItemIdRoute
|
||||
AdminTagsTagIdRoute: typeof AdminTagsTagIdRoute
|
||||
AdminItemsIndexRoute: typeof AdminItemsIndexRoute
|
||||
AdminTagsIndexRoute: typeof AdminTagsIndexRoute
|
||||
}
|
||||
|
||||
const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminItemsRoute: AdminItemsRouteWithChildren,
|
||||
AdminIndexRoute: AdminIndexRoute,
|
||||
AdminItemsItemIdRoute: AdminItemsItemIdRoute,
|
||||
AdminTagsTagIdRoute: AdminTagsTagIdRoute,
|
||||
AdminItemsIndexRoute: AdminItemsIndexRoute,
|
||||
AdminTagsIndexRoute: AdminTagsIndexRoute,
|
||||
}
|
||||
|
||||
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
|
||||
|
||||
@@ -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 { useAuth } from "../hooks/useAuth";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ImageUpload } from "../../../components/ImageUpload";
|
||||
import {
|
||||
useAdminGlobalItem,
|
||||
useDeleteAdminGlobalItem,
|
||||
useUpdateAdminGlobalItem,
|
||||
} from "../../hooks/useAdminGlobalItems";
|
||||
import { apiGet } from "../../lib/api";
|
||||
} from "../../../hooks/useAdminGlobalItems";
|
||||
import { apiGet, apiPost } from "../../../lib/api";
|
||||
|
||||
export const Route = createFileRoute("/admin/items/$itemId")({
|
||||
component: AdminItemEditPage,
|
||||
@@ -63,7 +64,10 @@ function TagInput({
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(tag);
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
×
|
||||
@@ -76,7 +80,9 @@ function TagInput({
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => { if (inputValue) addTag(inputValue); }}
|
||||
onBlur={() => {
|
||||
if (inputValue) addTag(inputValue);
|
||||
}}
|
||||
placeholder={value.length === 0 ? "Add tags..." : ""}
|
||||
className="outline-none bg-transparent text-sm flex-1 min-w-[100px]"
|
||||
/>
|
||||
@@ -91,7 +97,11 @@ function AdminItemEditPage() {
|
||||
const id = Number(itemId);
|
||||
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 deleteMutation = useDeleteAdminGlobalItem();
|
||||
|
||||
@@ -105,13 +115,21 @@ function AdminItemEditPage() {
|
||||
category: "",
|
||||
weightGrams: "",
|
||||
priceCents: "",
|
||||
imageFilename: "",
|
||||
imageUrl: "",
|
||||
dominantColor: "",
|
||||
cropZoom: null as number | null,
|
||||
cropX: null as number | null,
|
||||
cropY: null as number | null,
|
||||
description: "",
|
||||
sourceUrl: "",
|
||||
imageCredit: "",
|
||||
imageSourceUrl: "",
|
||||
tags: [] as string[],
|
||||
});
|
||||
const [fetchUrl, setFetchUrl] = useState("");
|
||||
const [fetchingImage, setFetchingImage] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
|
||||
// Populate form when item loads
|
||||
useEffect(() => {
|
||||
@@ -121,8 +139,14 @@ function AdminItemEditPage() {
|
||||
model: item.model,
|
||||
category: item.category ?? "",
|
||||
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
||||
priceCents: item.priceCents != null ? String(item.priceCents / 100) : "",
|
||||
imageUrl: item.imageUrl ?? "",
|
||||
priceCents:
|
||||
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 ?? "",
|
||||
sourceUrl: item.sourceUrl ?? "",
|
||||
imageCredit: item.imageCredit ?? "",
|
||||
@@ -132,9 +156,37 @@ function AdminItemEditPage() {
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
apiGet<Manufacturer[]>("/api/manufacturers").then(setManufacturers).catch(() => {});
|
||||
apiGet<Manufacturer[]>("/api/manufacturers")
|
||||
.then(setManufacturers)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function handleChange(
|
||||
@@ -146,7 +198,8 @@ function AdminItemEditPage() {
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const weightGrams = form.weightGrams !== "" ? Number(form.weightGrams) : null;
|
||||
const weightGrams =
|
||||
form.weightGrams !== "" ? Number(form.weightGrams) : null;
|
||||
const priceCents =
|
||||
form.priceCents !== "" ? Math.round(Number(form.priceCents) * 100) : null;
|
||||
|
||||
@@ -158,7 +211,11 @@ function AdminItemEditPage() {
|
||||
category: form.category || null,
|
||||
weightGrams: weightGrams,
|
||||
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,
|
||||
sourceUrl: form.sourceUrl || 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="space-y-4">
|
||||
{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>
|
||||
@@ -194,7 +254,9 @@ function AdminItemEditPage() {
|
||||
if (isError || !item) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -228,21 +290,68 @@ function AdminItemEditPage() {
|
||||
<form onSubmit={handleSave}>
|
||||
{/* Image section */}
|
||||
<div>
|
||||
{item.imageUrl && (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={`${item.brand} ${item.model}`}
|
||||
className="w-full h-48 object-contain rounded-lg bg-gray-50 mb-3"
|
||||
/>
|
||||
)}
|
||||
<label className={labelClass}>Image URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.imageUrl}
|
||||
onChange={(e) => handleChange("imageUrl", e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="https://..."
|
||||
<label className={labelClass}>Image</label>
|
||||
<ImageUpload
|
||||
value={form.imageFilename}
|
||||
imageUrl={form.imageUrl || null}
|
||||
dominantColor={form.dominantColor || null}
|
||||
initialCrop={
|
||||
form.cropZoom != null
|
||||
? {
|
||||
zoom: form.cropZoom,
|
||||
x: form.cropX ?? 0,
|
||||
y: form.cropY ?? 0,
|
||||
}
|
||||
: null
|
||||
}
|
||||
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>
|
||||
|
||||
{/* Brand + Model */}
|
||||
@@ -252,7 +361,9 @@ function AdminItemEditPage() {
|
||||
<label className={labelClass}>Brand (Manufacturer)</label>
|
||||
<select
|
||||
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`}
|
||||
>
|
||||
<option value={0}>Select manufacturer...</option>
|
||||
@@ -335,7 +446,7 @@ function AdminItemEditPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className={labelClass}>Source URL</label>
|
||||
<label className={labelClass}>Product Page URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.sourceUrl}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAdminGlobalItems } from "../../hooks/useAdminGlobalItems";
|
||||
import { useFormatters } from "../../hooks/useFormatters";
|
||||
import { useTags } from "../../hooks/useTags";
|
||||
import { useAdminGlobalItems } from "../../../hooks/useAdminGlobalItems";
|
||||
import { useFormatters } from "../../../hooks/useFormatters";
|
||||
import { useTags } from "../../../hooks/useTags";
|
||||
|
||||
export const Route = createFileRoute("/admin/items")({
|
||||
export const Route = createFileRoute("/admin/items/")({
|
||||
component: AdminItemsPage,
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ function AdminItemsPage() {
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
@@ -150,25 +151,36 @@ function AdminItemsPage() {
|
||||
key={item.id}
|
||||
className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
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">
|
||||
<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>
|
||||
</td>
|
||||
<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 className="px-4 py-3 text-gray-700">
|
||||
{item.weightGrams != null
|
||||
? formatWeight(item.weightGrams)
|
||||
: <span className="text-gray-300">—</span>}
|
||||
{item.weightGrams != null ? (
|
||||
formatWeight(item.weightGrams)
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700">
|
||||
{item.priceCents != null
|
||||
? formatPrice(item.priceCents)
|
||||
: <span className="text-gray-300">—</span>}
|
||||
{item.priceCents != null ? (
|
||||
formatPrice(item.priceCents)
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.tags.length === 0 ? (
|
||||
@@ -209,7 +221,9 @@ function AdminItemsPage() {
|
||||
{/* Empty state (after load, no items) */}
|
||||
{!isLoading && allItems.length === 0 && !isError && (
|
||||
<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">
|
||||
Try a different search or clear your filters.
|
||||
</p>
|
||||
@@ -221,7 +235,9 @@ function AdminItemsPage() {
|
||||
|
||||
{/* Loading more */}
|
||||
{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 */}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type AdminTag,
|
||||
useAdminTag,
|
||||
useAdminTags,
|
||||
useDeleteAdminTag,
|
||||
useUpdateAdminTag,
|
||||
type AdminTag,
|
||||
} from "../../hooks/useAdminTags";
|
||||
} from "../../../hooks/useAdminTags";
|
||||
|
||||
export const Route = createFileRoute("/admin/tags/$tagId")({
|
||||
component: AdminTagEditPage,
|
||||
@@ -53,12 +53,19 @@ function AdminTagEditPage() {
|
||||
const id = Number(tagId);
|
||||
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 updateMutation = useUpdateAdminTag();
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,7 +74,10 @@ function AdminTagEditPage() {
|
||||
}
|
||||
}, [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 }));
|
||||
}
|
||||
|
||||
@@ -95,7 +105,10 @@ function AdminTagEditPage() {
|
||||
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
|
||||
<div className="space-y-4">
|
||||
{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>
|
||||
@@ -105,7 +118,9 @@ function AdminTagEditPage() {
|
||||
if (isError || !tag) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -149,7 +164,10 @@ function AdminTagEditPage() {
|
||||
<select
|
||||
value={form.parentId ?? ""}
|
||||
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`}
|
||||
>
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type AdminTag,
|
||||
useAdminTags,
|
||||
useCreateAdminTag,
|
||||
type AdminTag,
|
||||
} from "../../hooks/useAdminTags";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
} from "../../../hooks/useAdminTags";
|
||||
import { LucideIcon } from "../../../lib/iconData";
|
||||
|
||||
export const Route = createFileRoute("/admin/tags")({
|
||||
export const Route = createFileRoute("/admin/tags/")({
|
||||
component: AdminTagsPage,
|
||||
});
|
||||
|
||||
@@ -124,8 +124,10 @@ function AdminTagsPage() {
|
||||
});
|
||||
setNewName("");
|
||||
setNewParentId(null);
|
||||
} catch {
|
||||
setCreateError("Failed to create tag. Please try again.");
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to create tag";
|
||||
setCreateError(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,39 +158,50 @@ function AdminTagsPage() {
|
||||
</div>
|
||||
|
||||
{/* Quick-add form */}
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Tag name..."
|
||||
className={`flex-1 ${inputClass}`}
|
||||
/>
|
||||
<select
|
||||
value={newParentId ?? ""}
|
||||
onChange={(e) =>
|
||||
setNewParentId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none bg-white appearance-none w-48"
|
||||
>
|
||||
<option value="">No parent (top-level)</option>
|
||||
{data?.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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"
|
||||
>
|
||||
{createMutation.isPending ? "Adding..." : "Add Tag"}
|
||||
</button>
|
||||
</form>
|
||||
{createError && (
|
||||
<p className="text-sm text-red-500 mt-1 mb-4">{createError}</p>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* Error state */}
|
||||
{isError && (
|
||||
@@ -287,7 +300,9 @@ function AdminTagsPage() {
|
||||
</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">
|
||||
Add your first tag using the form above.
|
||||
</p>
|
||||
@@ -346,6 +346,15 @@ function ItemDetail() {
|
||||
value={form.imageFilename}
|
||||
imageUrl={imageUrl}
|
||||
dominantColor={item.dominantColor}
|
||||
initialCrop={
|
||||
item.cropZoom != null
|
||||
? {
|
||||
zoom: item.cropZoom,
|
||||
x: item.cropX ?? 0,
|
||||
y: item.cropY ?? 0,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={(filename, dominantColor) => {
|
||||
setForm((f) => ({ ...f, imageFilename: filename }));
|
||||
if (dominantColor) {
|
||||
|
||||
@@ -202,7 +202,6 @@ function ImportExportSection() {
|
||||
</p>
|
||||
)}
|
||||
{importResult.errors.map((err, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
|
||||
<p key={i} className="text-red-600">
|
||||
{err}
|
||||
</p>
|
||||
|
||||
@@ -204,7 +204,9 @@ export const globalItems = pgTable(
|
||||
export const tags = pgTable("tags", {
|
||||
id: serial("id").primaryKey(),
|
||||
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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Context, Next } from "hono";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Context, Next } from "hono";
|
||||
import { users } from "../../db/schema.ts";
|
||||
import { getOrCreateUser, verifyApiKey } from "../services/auth.service";
|
||||
import { getOrCreateUncategorized } from "../services/category.service";
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
listGlobalItemsForAdmin,
|
||||
updateGlobalItemById,
|
||||
} from "../services/global-item.service.ts";
|
||||
import { getImageUrl } from "../services/storage.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
|
||||
@@ -20,6 +21,11 @@ const updateGlobalItemAdminSchema = z.object({
|
||||
weightGrams: z.number().positive().nullable().optional(),
|
||||
priceCents: z.number().int().nonnegative().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(),
|
||||
sourceUrl: z.string().url().nullable().optional(),
|
||||
imageCredit: z.string().nullable().optional(),
|
||||
@@ -44,8 +50,8 @@ app.get("/", async (c) => {
|
||||
const result = await listGlobalItemsForAdmin(db, {
|
||||
query: q || undefined,
|
||||
tagNames,
|
||||
offset: isNaN(offset) ? 0 : offset,
|
||||
limit: isNaN(limit) || limit > 100 ? 50 : limit,
|
||||
offset: Number.isNaN(offset) ? 0 : offset,
|
||||
limit: Number.isNaN(limit) || limit > 100 ? 50 : limit,
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
@@ -58,23 +64,27 @@ app.get("/:id", async (c) => {
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const item = await getGlobalItemWithOwnerCount(db, id);
|
||||
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
|
||||
app.put(
|
||||
"/:id",
|
||||
zValidator("json", updateGlobalItemAdminSchema),
|
||||
async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
const item = await updateGlobalItemById(db, id, data);
|
||||
if (!item) return c.json({ error: "Global item not found" }, 404);
|
||||
return c.json(item);
|
||||
},
|
||||
);
|
||||
app.put("/:id", zValidator("json", updateGlobalItemAdminSchema), async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
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
|
||||
app.delete("/:id", async (c) => {
|
||||
|
||||
@@ -45,8 +45,22 @@ app.get("/:id", async (c) => {
|
||||
app.post("/", zValidator("json", createTagSchema), async (c) => {
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const tag = await createTag(db, data);
|
||||
return c.json(tag, 201);
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/tags/:id — rename and/or reparent a tag
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
extractDominantColor,
|
||||
fetchImageFromUrl,
|
||||
} 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 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");
|
||||
try {
|
||||
const result = await fetchImageFromUrl(url);
|
||||
return c.json(result, 201);
|
||||
const presignedUrl = await getImageUrl(result.filename);
|
||||
return c.json({ ...result, presignedUrl }, 201);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
// Known validation errors from the service
|
||||
|
||||
@@ -178,7 +178,12 @@ export async function listGlobalItemsForAdmin(
|
||||
})
|
||||
.from(globalItemTags)
|
||||
.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[]>();
|
||||
for (const row of tagRows) {
|
||||
@@ -194,7 +199,12 @@ export async function listGlobalItemsForAdmin(
|
||||
ownerCount: count(),
|
||||
})
|
||||
.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);
|
||||
|
||||
const ownerCountById = new Map<number, number>();
|
||||
@@ -229,6 +239,11 @@ export async function updateGlobalItemById(
|
||||
weightGrams?: number | null;
|
||||
priceCents?: number | null;
|
||||
imageUrl?: string | null;
|
||||
imageFilename?: string | null;
|
||||
dominantColor?: string | null;
|
||||
cropZoom?: number | null;
|
||||
cropX?: number | null;
|
||||
cropY?: number | null;
|
||||
description?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
imageCredit?: string | null;
|
||||
@@ -241,16 +256,29 @@ export async function updateGlobalItemById(
|
||||
|
||||
// Build partial update — only set provided fields
|
||||
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 ("category" in fields) updateSet.category = fields.category ?? null;
|
||||
if ("weightGrams" in fields) updateSet.weightGrams = fields.weightGrams ?? null;
|
||||
if ("priceCents" in fields) updateSet.priceCents = fields.priceCents ?? null;
|
||||
if ("weightGrams" in fields)
|
||||
updateSet.weightGrams = fields.weightGrams ?? null;
|
||||
if ("priceCents" in fields)
|
||||
updateSet.priceCents = fields.priceCents ?? 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 ("imageCredit" in fields) updateSet.imageCredit = fields.imageCredit ?? null;
|
||||
if ("imageSourceUrl" in fields) updateSet.imageSourceUrl = fields.imageSourceUrl ?? null;
|
||||
if ("imageCredit" in fields)
|
||||
updateSet.imageCredit = fields.imageCredit ?? null;
|
||||
if ("imageSourceUrl" in fields)
|
||||
updateSet.imageSourceUrl = fields.imageSourceUrl ?? null;
|
||||
|
||||
let item: typeof globalItems.$inferSelect | undefined;
|
||||
if (Object.keys(updateSet).length > 0) {
|
||||
@@ -295,14 +323,10 @@ export async function deleteGlobalItem(db: Db, id: number) {
|
||||
.where(eq(items.globalItemId, id));
|
||||
|
||||
// 3. Remove tag associations (FK: globalItemTags.globalItemId → globalItems.id, no cascade)
|
||||
await tx
|
||||
.delete(globalItemTags)
|
||||
.where(eq(globalItemTags.globalItemId, id));
|
||||
await tx.delete(globalItemTags).where(eq(globalItemTags.globalItemId, id));
|
||||
|
||||
// 4. Delete the global item
|
||||
await tx
|
||||
.delete(globalItems)
|
||||
.where(eq(globalItems.id, id));
|
||||
await tx.delete(globalItems).where(eq(globalItems.id, id));
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -64,7 +64,11 @@ describe("Admin Tag Routes", () => {
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
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 () => {
|
||||
|
||||
@@ -536,9 +536,15 @@ describe("listGlobalItemsForAdmin", () => {
|
||||
|
||||
it("filters by query string (brand/model)", async () => {
|
||||
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");
|
||||
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" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
@@ -547,7 +553,10 @@ describe("listGlobalItemsForAdmin", () => {
|
||||
|
||||
it("includes tags and ownerCount per item", async () => {
|
||||
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");
|
||||
await tagGlobalItem(db, globalItem.id, tag.id!);
|
||||
|
||||
@@ -556,7 +565,9 @@ describe("listGlobalItemsForAdmin", () => {
|
||||
.insert(schema.users)
|
||||
.values({ logtoSub: "test-sub" })
|
||||
.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);
|
||||
expect(result.items).toHaveLength(1);
|
||||
@@ -579,7 +590,10 @@ describe("updateGlobalItemById", () => {
|
||||
|
||||
it("updates model field by id", async () => {
|
||||
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" });
|
||||
|
||||
@@ -589,9 +603,14 @@ describe("updateGlobalItemById", () => {
|
||||
|
||||
it("syncs tags when tags array provided", async () => {
|
||||
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 found = result.items.find((i) => i.id === globalItem.id);
|
||||
@@ -614,7 +633,10 @@ describe("deleteGlobalItem", () => {
|
||||
|
||||
it("deletes item and returns true", async () => {
|
||||
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);
|
||||
expect(result).toBe(true);
|
||||
@@ -625,12 +647,17 @@ describe("deleteGlobalItem", () => {
|
||||
|
||||
it("nullifies items.globalItemId before deleting", async () => {
|
||||
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
|
||||
.insert(schema.users)
|
||||
.values({ logtoSub: "delete-test-sub" })
|
||||
.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);
|
||||
|
||||
@@ -643,7 +670,10 @@ describe("deleteGlobalItem", () => {
|
||||
|
||||
it("removes globalItemTags before deleting", async () => {
|
||||
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");
|
||||
await tagGlobalItem(db, globalItem.id, tag.id!);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { manufacturers } from "../../src/db/schema.ts";
|
||||
import {
|
||||
createManufacturer,
|
||||
getManufacturerBySlug,
|
||||
|
||||
@@ -5,16 +5,11 @@ import {
|
||||
deleteTag,
|
||||
getAdminTags,
|
||||
getAllTags,
|
||||
getTagWithCounts,
|
||||
updateTag,
|
||||
} from "../../src/server/services/tag.service.ts";
|
||||
import { createTestDb } from "../helpers/db.ts";
|
||||
|
||||
async function insertTag(
|
||||
db: any,
|
||||
name: string,
|
||||
parentId?: number | null,
|
||||
) {
|
||||
async function insertTag(db: any, name: string, parentId?: number | null) {
|
||||
const [row] = await db
|
||||
.insert(tags)
|
||||
.values({ name, parentId: parentId ?? null })
|
||||
@@ -108,7 +103,10 @@ describe("createTag", () => {
|
||||
|
||||
it("creates a tag with parentId set to an existing tag id", async () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -175,7 +173,7 @@ describe("deleteTag", () => {
|
||||
const parent = await insertTag(db, "parent");
|
||||
const child = await insertTag(db, "child", parent.id);
|
||||
await deleteTag(db, parent.id);
|
||||
const [childRow] = await db
|
||||
const [_childRow] = await db
|
||||
.select({ parentId: tags.parentId })
|
||||
.from(tags)
|
||||
.where((t: any) => t.id === child.id);
|
||||
|
||||
Reference in New Issue
Block a user