5 Commits

Author SHA1 Message Date
48985b5eb2 feat: add Docker deployment and Gitea Actions CI/CD
Add multi-stage Dockerfile, docker-compose with persistent volumes,
and Gitea Actions workflows for CI (lint/test/build) and releases
(tag, Docker image push, changelog). Support DATABASE_PATH env var
for configurable SQLite location to enable proper volume mounting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:36:23 +01:00
37c4272c08 chore: add CLAUDE.md, initial Drizzle migration, and update gitignore
Add project instructions for Claude Code, the initial database migration,
and ignore the .claude/ local config directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:57:40 +01:00
ad941ae281 chore: disable research workflow step in planning config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:48:32 +01:00
87fe94037e feat: add external link confirmation dialog for product URLs
Show an external link icon on ItemCard and CandidateCard that opens a
confirmation dialog before navigating to product URLs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:48:27 +01:00
7c3740fc72 refactor: replace remaining emojis with Lucide icons
Replace all raw emoji characters in dashboard cards, empty states,
and onboarding wizard with LucideIcon components for visual consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:47:50 +01:00
25 changed files with 1807 additions and 827 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
gearbox.db*
uploads/*
!uploads/.gitkeep
.git
.idea
.claude
.gitea
.planning

28
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,28 @@
name: CI
on:
push:
branches: [develop]
pull_request:
branches: [develop]
jobs:
ci:
runs-on: docker
container:
image: oven/bun:1
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Test
run: bun test
- name: Build
run: bun run build

View File

@@ -0,0 +1,108 @@
name: Release
on:
workflow_dispatch:
inputs:
bump:
description: "Version bump type"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
jobs:
ci:
runs-on: docker
container:
image: oven/bun:1
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Test
run: bun test
- name: Build
run: bun run build
release:
needs: ci
runs-on: dind
steps:
- name: Clone repository
run: |
apk add --no-cache git curl jq
git clone https://${{ secrets.GITEA_TOKEN }}@gitea.jeanlucmakiola.de/${{ gitea.repository }}.git repo
cd repo
git checkout ${{ gitea.ref_name }}
- name: Compute version
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"
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"
- name: Generate changelog
working-directory: repo
run: |
if [ "$PREV_TAG" = "v0.0.0" ]; then
CHANGELOG=$(git log --pretty=format:"- %s" HEAD)
else
CHANGELOG=$(git log --pretty=format:"- %s" "${PREV_TAG}..HEAD")
fi
echo "CHANGELOG<<CHANGELOG_EOF" >> "$GITHUB_ENV"
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: |
REGISTRY="gitea.jeanlucmakiola.de"
IMAGE="${REGISTRY}/${{ gitea.repository_owner }}/gearbox"
docker build -t "${IMAGE}:${VERSION}" -t "${IMAGE}:latest" .
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ gitea.repository_owner }}" --password-stdin
docker push "${IMAGE}:${VERSION}"
docker push "${IMAGE}:latest"
- name: Create Gitea release
run: |
API_URL="${GITHUB_SERVER_URL}/api/v1/repos/${{ gitea.repository }}/releases"
curl -s -X POST "$API_URL" \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "$VERSION" \
--arg name "$VERSION" \
--arg body "$CHANGELOG" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')"

3
.gitignore vendored
View File

@@ -223,3 +223,6 @@ dist/
uploads/* uploads/*
!uploads/.gitkeep !uploads/.gitkeep
# Claude Code
.claude/

View File

@@ -5,7 +5,7 @@
"commit_docs": true, "commit_docs": true,
"model_profile": "quality", "model_profile": "quality",
"workflow": { "workflow": {
"research": true, "research": false,
"plan_check": true, "plan_check": true,
"verifier": true, "verifier": true,
"nyquist_validation": true, "nyquist_validation": true,

70
CLAUDE.md Normal file
View File

@@ -0,0 +1,70 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
GearBox is a single-user web app for managing gear collections (bikepacking, sim racing, etc.), tracking weight/price, and planning purchases through research threads. Full-stack TypeScript monolith running on Bun.
## Commands
```bash
# Development (run both in separate terminals)
bun run dev:client # Vite dev server on :5173 (proxies /api to :3000)
bun run dev:server # Hono server on :3000 with hot reload
# Database
bun run db:generate # Generate Drizzle migration from schema changes
bun run db:push # Apply migrations to gearbox.db
# Testing
bun test # Run all tests
bun test tests/services/item.service.test.ts # Run single test file
# Lint & Format
bun run lint # Biome check (tabs, double quotes, organized imports)
# Build
bun run build # Vite build → dist/client/
```
## Architecture
**Stack**: React 19 + Hono + Drizzle ORM + SQLite, all running on Bun.
### Client (`src/client/`)
- **Routing**: TanStack Router with file-based routes in `src/client/routes/`. Route tree auto-generated to `routeTree.gen.ts` — never edit manually.
- **Data fetching**: TanStack React Query via custom hooks in `src/client/hooks/` (e.g., `useItems`, `useThreads`, `useSetups`). Mutations invalidate related query keys.
- **UI state**: Zustand store (`stores/uiStore.ts`) for panel/dialog state only — server data lives in React Query.
- **API calls**: Thin fetch wrapper in `lib/api.ts` (`apiGet`, `apiPost`, `apiPut`, `apiDelete`, `apiUpload`).
- **Styling**: Tailwind CSS v4.
### Server (`src/server/`)
- **Routes** (`routes/`): Hono handlers with Zod validation via `@hono/zod-validator`. Delegate to services.
- **Services** (`services/`): Pure business logic functions that take a db instance. No HTTP awareness — testable without mocking.
- Route registration in `src/server/index.ts` via `app.route("/api/...", routes)`.
### Shared (`src/shared/`)
- **`schemas.ts`**: Zod schemas for API request validation (source of truth for types).
- **`types.ts`**: Types inferred from Zod schemas + Drizzle table definitions. No manual type duplication.
### Database (`src/db/`)
- **Schema**: `schema.ts` — Drizzle table definitions for SQLite.
- **Prices stored as cents** (`priceCents: integer`) to avoid float rounding.
- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`.
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`.
### Testing (`tests/`)
- Bun test runner. Tests at service level and route level.
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite with full schema and seeds an "Uncategorized" category. When adding schema columns, update both `src/db/schema.ts` and the test helper's CREATE TABLE statements.
## Path Alias
`@/*` maps to `./src/*` (configured in tsconfig.json).
## Key Patterns
- **Thread resolution**: Resolving a thread copies the winning candidate's data into a new item in the collection, sets `resolvedCandidateId`, and changes status to "resolved".
- **Setup item sync**: `PUT /api/setups/:id/items` replaces all setup_items atomically (delete all, re-insert).
- **Image uploads**: `POST /api/images` saves to `./uploads/` with UUID filename, returned as `imageFilename` on item/candidate records.
- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records.

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
FROM deps AS build
COPY . .
RUN bun run build
FROM oven/bun:1-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist/client ./dist/client
COPY src/server ./src/server
COPY src/db ./src/db
COPY src/shared ./src/shared
COPY drizzle.config.ts package.json ./
COPY drizzle ./drizzle
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh && mkdir -p data uploads
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"
ENTRYPOINT ["./entrypoint.sh"]

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
gearbox:
build: .
container_name: gearbox
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_PATH=./data/gearbox.db
volumes:
- gearbox-data:/app/data
- gearbox-uploads:/app/uploads
restart: unless-stopped
volumes:
gearbox-data:
gearbox-uploads:

View File

@@ -1,10 +1,10 @@
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({
out: "./drizzle", out: "./drizzle",
schema: "./src/db/schema.ts", schema: "./src/db/schema.ts",
dialect: "sqlite", dialect: "sqlite",
dbCredentials: { dbCredentials: {
url: "gearbox.db", url: process.env.DATABASE_PATH || "gearbox.db",
}, },
}); });

View File

@@ -0,0 +1,68 @@
CREATE TABLE `categories` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`emoji` text DEFAULT '📦' NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `categories_name_unique` ON `categories` (`name`);--> statement-breakpoint
CREATE TABLE `items` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`weight_grams` real,
`price_cents` integer,
`category_id` integer NOT NULL,
`notes` text,
`product_url` text,
`image_filename` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `setup_items` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`setup_id` integer NOT NULL,
`item_id` integer NOT NULL,
FOREIGN KEY (`setup_id`) REFERENCES `setups`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`item_id`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `setups` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `thread_candidates` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`thread_id` integer NOT NULL,
`name` text NOT NULL,
`weight_grams` real,
`price_cents` integer,
`category_id` integer NOT NULL,
`notes` text,
`product_url` text,
`image_filename` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`thread_id`) REFERENCES `threads`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `threads` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`resolved_candidate_id` integer,
`category_id` integer NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action
);

View File

@@ -0,0 +1,467 @@
{
"version": "6",
"dialect": "sqlite",
"id": "78e5f5c8-f8f0-43f4-93f8-5ef68154ed17",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"categories": {
"name": "categories",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'📦'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"categories_name_unique": {
"name": "categories_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"items": {
"name": "items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"weight_grams": {
"name": "weight_grams",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"product_url": {
"name": "product_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_filename": {
"name": "image_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"items_category_id_categories_id_fk": {
"name": "items_category_id_categories_id_fk",
"tableFrom": "items",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_items": {
"name": "setup_items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"setup_id": {
"name": "setup_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"item_id": {
"name": "item_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"setup_items_setup_id_setups_id_fk": {
"name": "setup_items_setup_id_setups_id_fk",
"tableFrom": "setup_items",
"tableTo": "setups",
"columnsFrom": [
"setup_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"setup_items_item_id_items_id_fk": {
"name": "setup_items_item_id_items_id_fk",
"tableFrom": "setup_items",
"tableTo": "items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setups": {
"name": "setups",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"thread_candidates": {
"name": "thread_candidates",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"thread_id": {
"name": "thread_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"weight_grams": {
"name": "weight_grams",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"product_url": {
"name": "product_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_filename": {
"name": "image_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"thread_candidates_thread_id_threads_id_fk": {
"name": "thread_candidates_thread_id_threads_id_fk",
"tableFrom": "thread_candidates",
"tableTo": "threads",
"columnsFrom": [
"thread_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"thread_candidates_category_id_categories_id_fk": {
"name": "thread_candidates_category_id_categories_id_fk",
"tableFrom": "thread_candidates",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"threads": {
"name": "threads",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"resolved_candidate_id": {
"name": "resolved_candidate_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"threads_category_id_categories_id_fk": {
"name": "threads_category_id_categories_id_fk",
"tableFrom": "threads",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

4
entrypoint.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -e
bun run db:push
exec bun run src/server/index.ts

View File

@@ -10,6 +10,7 @@ interface CandidateCardProps {
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
imageFilename: string | null; imageFilename: string | null;
productUrl?: string | null;
threadId: number; threadId: number;
isActive: boolean; isActive: boolean;
} }
@@ -22,6 +23,7 @@ export function CandidateCard({
categoryName, categoryName,
categoryIcon, categoryIcon,
imageFilename, imageFilename,
productUrl,
threadId, threadId,
isActive, isActive,
}: CandidateCardProps) { }: CandidateCardProps) {
@@ -30,9 +32,38 @@ export function CandidateCard({
(s) => s.openConfirmDeleteCandidate, (s) => s.openConfirmDeleteCandidate,
); );
const openResolveDialog = useUIStore((s) => s.openResolveDialog); const openResolveDialog = useUIStore((s) => s.openResolveDialog);
const openExternalLink = useUIStore((s) => s.openExternalLink);
return ( return (
<div className="bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden"> <div className="relative bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group">
{productUrl && (
<span
role="button"
tabIndex={0}
onClick={() => openExternalLink(productUrl)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
openExternalLink(productUrl);
}
}}
className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
title="Open product link"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"
/>
</svg>
</span>
)}
<div className="aspect-[4/3] bg-gray-50"> <div className="aspect-[4/3] bg-gray-50">
{imageFilename ? ( {imageFilename ? (
<img <img

View File

@@ -1,50 +1,50 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react"; import { LucideIcon } from "../lib/iconData";
interface DashboardCardProps { interface DashboardCardProps {
to: string; to: string;
search?: Record<string, string>; search?: Record<string, string>;
title: string; title: string;
icon: ReactNode; icon: string;
stats: Array<{ label: string; value: string }>; stats: Array<{ label: string; value: string }>;
emptyText?: string; emptyText?: string;
} }
export function DashboardCard({ export function DashboardCard({
to, to,
search, search,
title, title,
icon, icon,
stats, stats,
emptyText, emptyText,
}: DashboardCardProps) { }: DashboardCardProps) {
const allZero = stats.every( const allZero = stats.every(
(s) => s.value === "0" || s.value === "$0.00" || s.value === "0g", (s) => s.value === "0" || s.value === "$0.00" || s.value === "0g",
); );
return ( return (
<Link <Link
to={to} to={to}
search={search} search={search}
className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-6" className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-6"
> >
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="text-2xl">{icon}</span> <LucideIcon name={icon} size={24} className="text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">{title}</h2> <h2 className="text-lg font-semibold text-gray-900">{title}</h2>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
{stats.map((stat) => ( {stats.map((stat) => (
<div key={stat.label} className="flex items-center justify-between"> <div key={stat.label} className="flex items-center justify-between">
<span className="text-sm text-gray-500">{stat.label}</span> <span className="text-sm text-gray-500">{stat.label}</span>
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-gray-700">
{stat.value} {stat.value}
</span> </span>
</div> </div>
))} ))}
</div> </div>
{allZero && emptyText && ( {allZero && emptyText && (
<p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p> <p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p>
)} )}
</Link> </Link>
); );
} }

View File

@@ -0,0 +1,65 @@
import { useEffect } from "react";
import { useUIStore } from "../stores/uiStore";
export function ExternalLinkDialog() {
const externalLinkUrl = useUIStore((s) => s.externalLinkUrl);
const closeExternalLink = useUIStore((s) => s.closeExternalLink);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") closeExternalLink();
}
if (externalLinkUrl) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [externalLinkUrl, closeExternalLink]);
if (!externalLinkUrl) return null;
function handleContinue() {
if (externalLinkUrl) {
window.open(externalLinkUrl, "_blank", "noopener,noreferrer");
}
closeExternalLink();
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={closeExternalLink}
onKeyDown={(e) => {
if (e.key === "Escape") closeExternalLink();
}}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
You are about to leave GearBox
</h3>
<p className="text-sm text-gray-600 mb-1">
You will be redirected to:
</p>
<p className="text-sm text-blue-600 break-all mb-6">
{externalLinkUrl}
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={closeExternalLink}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleContinue}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
Continue
</button>
</div>
</div>
</div>
);
}

View File

@@ -10,6 +10,7 @@ interface ItemCardProps {
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
imageFilename: string | null; imageFilename: string | null;
productUrl?: string | null;
onRemove?: () => void; onRemove?: () => void;
} }
@@ -21,9 +22,11 @@ export function ItemCard({
categoryName, categoryName,
categoryIcon, categoryIcon,
imageFilename, imageFilename,
productUrl,
onRemove, onRemove,
}: ItemCardProps) { }: ItemCardProps) {
const openEditPanel = useUIStore((s) => s.openEditPanel); const openEditPanel = useUIStore((s) => s.openEditPanel);
const openExternalLink = useUIStore((s) => s.openExternalLink);
return ( return (
<button <button
@@ -31,6 +34,38 @@ export function ItemCard({
onClick={() => openEditPanel(id)} onClick={() => openEditPanel(id)}
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group" className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
> >
{productUrl && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
openExternalLink(productUrl);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
openExternalLink(productUrl);
}
}}
className={`absolute top-2 ${onRemove ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
title="Open product link"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"
/>
</svg>
</span>
)}
{onRemove && ( {onRemove && (
<span <span
role="button" role="button"

View File

@@ -2,315 +2,320 @@ import { useState } from "react";
import { useCreateCategory } from "../hooks/useCategories"; import { useCreateCategory } from "../hooks/useCategories";
import { useCreateItem } from "../hooks/useItems"; import { useCreateItem } from "../hooks/useItems";
import { useUpdateSetting } from "../hooks/useSettings"; import { useUpdateSetting } from "../hooks/useSettings";
import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker"; import { IconPicker } from "./IconPicker";
interface OnboardingWizardProps { interface OnboardingWizardProps {
onComplete: () => void; onComplete: () => void;
} }
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
// Step 2 state // Step 2 state
const [categoryName, setCategoryName] = useState(""); const [categoryName, setCategoryName] = useState("");
const [categoryIcon, setCategoryIcon] = useState(""); const [categoryIcon, setCategoryIcon] = useState("");
const [categoryError, setCategoryError] = useState(""); const [categoryError, setCategoryError] = useState("");
const [createdCategoryId, setCreatedCategoryId] = useState<number | null>(null); const [createdCategoryId, setCreatedCategoryId] = useState<number | null>(
null,
);
// Step 3 state // Step 3 state
const [itemName, setItemName] = useState(""); const [itemName, setItemName] = useState("");
const [itemWeight, setItemWeight] = useState(""); const [itemWeight, setItemWeight] = useState("");
const [itemPrice, setItemPrice] = useState(""); const [itemPrice, setItemPrice] = useState("");
const [itemError, setItemError] = useState(""); const [itemError, setItemError] = useState("");
const createCategory = useCreateCategory(); const createCategory = useCreateCategory();
const createItem = useCreateItem(); const createItem = useCreateItem();
const updateSetting = useUpdateSetting(); const updateSetting = useUpdateSetting();
function handleSkip() { function handleSkip() {
updateSetting.mutate( updateSetting.mutate(
{ key: "onboardingComplete", value: "true" }, { key: "onboardingComplete", value: "true" },
{ onSuccess: onComplete }, { onSuccess: onComplete },
); );
} }
function handleCreateCategory() { function handleCreateCategory() {
const name = categoryName.trim(); const name = categoryName.trim();
if (!name) { if (!name) {
setCategoryError("Please enter a category name"); setCategoryError("Please enter a category name");
return; return;
} }
setCategoryError(""); setCategoryError("");
createCategory.mutate( createCategory.mutate(
{ name, icon: categoryIcon.trim() || undefined }, { name, icon: categoryIcon.trim() || undefined },
{ {
onSuccess: (created) => { onSuccess: (created) => {
setCreatedCategoryId(created.id); setCreatedCategoryId(created.id);
setStep(3); setStep(3);
}, },
onError: (err) => { onError: (err) => {
setCategoryError(err.message || "Failed to create category"); setCategoryError(err.message || "Failed to create category");
}, },
}, },
); );
} }
function handleCreateItem() { function handleCreateItem() {
const name = itemName.trim(); const name = itemName.trim();
if (!name) { if (!name) {
setItemError("Please enter an item name"); setItemError("Please enter an item name");
return; return;
} }
if (!createdCategoryId) return; if (!createdCategoryId) return;
setItemError(""); setItemError("");
const payload: any = { const payload: any = {
name, name,
categoryId: createdCategoryId, categoryId: createdCategoryId,
}; };
if (itemWeight) payload.weightGrams = Number(itemWeight); if (itemWeight) payload.weightGrams = Number(itemWeight);
if (itemPrice) payload.priceCents = Math.round(Number(itemPrice) * 100); if (itemPrice) payload.priceCents = Math.round(Number(itemPrice) * 100);
createItem.mutate(payload, { createItem.mutate(payload, {
onSuccess: () => setStep(4), onSuccess: () => setStep(4),
onError: (err) => { onError: (err) => {
setItemError(err.message || "Failed to add item"); setItemError(err.message || "Failed to add item");
}, },
}); });
} }
function handleDone() { function handleDone() {
updateSetting.mutate( updateSetting.mutate(
{ key: "onboardingComplete", value: "true" }, { key: "onboardingComplete", value: "true" },
{ onSuccess: onComplete }, { onSuccess: onComplete },
); );
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */} {/* Backdrop */}
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" /> <div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
{/* Card */} {/* Card */}
<div className="relative z-10 w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8"> <div className="relative z-10 w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8">
{/* Step indicator */} {/* Step indicator */}
<div className="flex items-center justify-center gap-2 mb-6"> <div className="flex items-center justify-center gap-2 mb-6">
{[1, 2, 3].map((s) => ( {[1, 2, 3].map((s) => (
<div <div
key={s} key={s}
className={`h-1.5 rounded-full transition-all ${ className={`h-1.5 rounded-full transition-all ${
s <= Math.min(step, 3) s <= Math.min(step, 3) ? "bg-blue-600 w-8" : "bg-gray-200 w-6"
? "bg-blue-600 w-8" }`}
: "bg-gray-200 w-6" />
}`} ))}
/> </div>
))}
</div>
{/* Step 1: Welcome */} {/* Step 1: Welcome */}
{step === 1 && ( {step === 1 && (
<div className="text-center"> <div className="text-center">
<h2 className="text-2xl font-semibold text-gray-900 mb-2"> <h2 className="text-2xl font-semibold text-gray-900 mb-2">
Welcome to GearBox! Welcome to GearBox!
</h2> </h2>
<p className="text-gray-500 mb-8 leading-relaxed"> <p className="text-gray-500 mb-8 leading-relaxed">
Track your gear, compare weights, and plan smarter purchases. Track your gear, compare weights, and plan smarter purchases.
Let&apos;s set up your first category and item. Let&apos;s set up your first category and item.
</p> </p>
<button <button
type="button" type="button"
onClick={() => setStep(2)} onClick={() => setStep(2)}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors" className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
> >
Get Started Get Started
</button> </button>
<button <button
type="button" type="button"
onClick={handleSkip} onClick={handleSkip}
className="mt-3 text-sm text-gray-400 hover:text-gray-600 transition-colors" className="mt-3 text-sm text-gray-400 hover:text-gray-600 transition-colors"
> >
Skip setup Skip setup
</button> </button>
</div> </div>
)} )}
{/* Step 2: Create category */} {/* Step 2: Create category */}
{step === 2 && ( {step === 2 && (
<div> <div>
<h2 className="text-xl font-semibold text-gray-900 mb-1"> <h2 className="text-xl font-semibold text-gray-900 mb-1">
Create a category Create a category
</h2> </h2>
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">
Categories help you organize your gear (e.g. Shelter, Cooking, Categories help you organize your gear (e.g. Shelter, Cooking,
Clothing). Clothing).
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label <label
htmlFor="onboard-cat-name" htmlFor="onboard-cat-name"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Category name * Category name *
</label> </label>
<input <input
id="onboard-cat-name" id="onboard-cat-name"
type="text" type="text"
value={categoryName} value={categoryName}
onChange={(e) => setCategoryName(e.target.value)} onChange={(e) => setCategoryName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. Shelter" placeholder="e.g. Shelter"
autoFocus autoFocus
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Icon (optional) Icon (optional)
</label> </label>
<IconPicker <IconPicker
value={categoryIcon} value={categoryIcon}
onChange={setCategoryIcon} onChange={setCategoryIcon}
size="md" size="md"
/> />
</div> </div>
{categoryError && ( {categoryError && (
<p className="text-xs text-red-500">{categoryError}</p> <p className="text-xs text-red-500">{categoryError}</p>
)} )}
</div> </div>
<button <button
type="button" type="button"
onClick={handleCreateCategory} onClick={handleCreateCategory}
disabled={createCategory.isPending} disabled={createCategory.isPending}
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors" className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
> >
{createCategory.isPending ? "Creating..." : "Create Category"} {createCategory.isPending ? "Creating..." : "Create Category"}
</button> </button>
<button <button
type="button" type="button"
onClick={handleSkip} onClick={handleSkip}
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors" className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors"
> >
Skip setup Skip setup
</button> </button>
</div> </div>
)} )}
{/* Step 3: Add item */} {/* Step 3: Add item */}
{step === 3 && ( {step === 3 && (
<div> <div>
<h2 className="text-xl font-semibold text-gray-900 mb-1"> <h2 className="text-xl font-semibold text-gray-900 mb-1">
Add your first item Add your first item
</h2> </h2>
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">
Add a piece of gear to your collection. Add a piece of gear to your collection.
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label <label
htmlFor="onboard-item-name" htmlFor="onboard-item-name"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Item name * Item name *
</label> </label>
<input <input
id="onboard-item-name" id="onboard-item-name"
type="text" type="text"
value={itemName} value={itemName}
onChange={(e) => setItemName(e.target.value)} onChange={(e) => setItemName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. Big Agnes Copper Spur" placeholder="e.g. Big Agnes Copper Spur"
autoFocus autoFocus
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label <label
htmlFor="onboard-item-weight" htmlFor="onboard-item-weight"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Weight (g) Weight (g)
</label> </label>
<input <input
id="onboard-item-weight" id="onboard-item-weight"
type="number" type="number"
min="0" min="0"
step="any" step="any"
value={itemWeight} value={itemWeight}
onChange={(e) => setItemWeight(e.target.value)} onChange={(e) => setItemWeight(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. 1200" placeholder="e.g. 1200"
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="onboard-item-price" htmlFor="onboard-item-price"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Price ($) Price ($)
</label> </label>
<input <input
id="onboard-item-price" id="onboard-item-price"
type="number" type="number"
min="0" min="0"
step="0.01" step="0.01"
value={itemPrice} value={itemPrice}
onChange={(e) => setItemPrice(e.target.value)} onChange={(e) => setItemPrice(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. 349.99" placeholder="e.g. 349.99"
/> />
</div> </div>
</div> </div>
{itemError && ( {itemError && <p className="text-xs text-red-500">{itemError}</p>}
<p className="text-xs text-red-500">{itemError}</p> </div>
)}
</div>
<button <button
type="button" type="button"
onClick={handleCreateItem} onClick={handleCreateItem}
disabled={createItem.isPending} disabled={createItem.isPending}
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors" className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
> >
{createItem.isPending ? "Adding..." : "Add Item"} {createItem.isPending ? "Adding..." : "Add Item"}
</button> </button>
<button <button
type="button" type="button"
onClick={handleSkip} onClick={handleSkip}
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors" className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors"
> >
Skip setup Skip setup
</button> </button>
</div> </div>
)} )}
{/* Step 4: Done */} {/* Step 4: Done */}
{step === 4 && ( {step === 4 && (
<div className="text-center"> <div className="text-center">
<div className="text-4xl mb-4">&#127881;</div> <div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <LucideIcon
You&apos;re all set! name="party-popper"
</h2> size={48}
<p className="text-sm text-gray-500 mb-8"> className="text-gray-400 mx-auto"
Your first item has been added. You can now browse your collection, />
add more gear, and track your setup. </div>
</p> <h2 className="text-xl font-semibold text-gray-900 mb-2">
<button You&apos;re all set!
type="button" </h2>
onClick={handleDone} <p className="text-sm text-gray-500 mb-8">
disabled={updateSetting.isPending} Your first item has been added. You can now browse your
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors" collection, add more gear, and track your setup.
> </p>
{updateSetting.isPending ? "Finishing..." : "Done"} <button
</button> type="button"
</div> onClick={handleDone}
)} disabled={updateSetting.isPending}
</div> className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
</div> >
); {updateSetting.isPending ? "Finishing..." : "Done"}
</button>
</div>
)}
</div>
</div>
);
} }

View File

@@ -11,6 +11,7 @@ import { SlideOutPanel } from "../components/SlideOutPanel";
import { ItemForm } from "../components/ItemForm"; import { ItemForm } from "../components/ItemForm";
import { CandidateForm } from "../components/CandidateForm"; import { CandidateForm } from "../components/CandidateForm";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
import { OnboardingWizard } from "../components/OnboardingWizard"; import { OnboardingWizard } from "../components/OnboardingWizard";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
import { useOnboardingComplete } from "../hooks/useSettings"; import { useOnboardingComplete } from "../hooks/useSettings";
@@ -142,6 +143,9 @@ function RootLayout() {
{/* Item Confirm Delete Dialog */} {/* Item Confirm Delete Dialog */}
<ConfirmDialog /> <ConfirmDialog />
{/* External Link Confirmation Dialog */}
<ExternalLinkDialog />
{/* Candidate Delete Confirm Dialog */} {/* Candidate Delete Confirm Dialog */}
{confirmDeleteCandidateId != null && currentThreadId != null && ( {confirmDeleteCandidateId != null && currentThreadId != null && (
<CandidateDeleteDialog <CandidateDeleteDialog

View File

@@ -10,6 +10,7 @@ import { useCategories } from "../../hooks/useCategories";
import { useItems } from "../../hooks/useItems"; import { useItems } from "../../hooks/useItems";
import { useThreads } from "../../hooks/useThreads"; import { useThreads } from "../../hooks/useThreads";
import { useTotals } from "../../hooks/useTotals"; import { useTotals } from "../../hooks/useTotals";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore"; import { useUIStore } from "../../stores/uiStore";
const searchSchema = z.object({ const searchSchema = z.object({
@@ -61,7 +62,13 @@ function CollectionView() {
return ( return (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="text-5xl mb-4">🎒</div> <div className="mb-4">
<LucideIcon
name="backpack"
size={48}
className="text-gray-400 mx-auto"
/>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Your collection is empty Your collection is empty
</h2> </h2>
@@ -158,6 +165,7 @@ function CollectionView() {
categoryName={categoryName} categoryName={categoryName}
categoryIcon={categoryIcon} categoryIcon={categoryIcon}
imageFilename={item.imageFilename} imageFilename={item.imageFilename}
productUrl={item.productUrl}
/> />
))} ))}
</div> </div>

View File

@@ -1,55 +1,56 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useTotals } from "../hooks/useTotals";
import { useThreads } from "../hooks/useThreads";
import { useSetups } from "../hooks/useSetups";
import { DashboardCard } from "../components/DashboardCard"; import { DashboardCard } from "../components/DashboardCard";
import { formatWeight, formatPrice } from "../lib/formatters"; import { useSetups } from "../hooks/useSetups";
import { useThreads } from "../hooks/useThreads";
import { useTotals } from "../hooks/useTotals";
import { formatPrice, formatWeight } from "../lib/formatters";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: DashboardPage, component: DashboardPage,
}); });
function DashboardPage() { function DashboardPage() {
const { data: totals } = useTotals(); const { data: totals } = useTotals();
const { data: threads } = useThreads(false); const { data: threads } = useThreads(false);
const { data: setups } = useSetups(); const { data: setups } = useSetups();
const global = totals?.global; const global = totals?.global;
const activeThreadCount = threads?.length ?? 0; const activeThreadCount = threads?.length ?? 0;
const setupCount = setups?.length ?? 0; const setupCount = setups?.length ?? 0;
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<DashboardCard <DashboardCard
to="/collection" to="/collection"
title="Collection" title="Collection"
icon="🎒" icon="backpack"
stats={[ stats={[
{ label: "Items", value: String(global?.itemCount ?? 0) }, { label: "Items", value: String(global?.itemCount ?? 0) },
{ label: "Weight", value: formatWeight(global?.totalWeight ?? null) }, {
{ label: "Cost", value: formatPrice(global?.totalCost ?? null) }, label: "Weight",
]} value: formatWeight(global?.totalWeight ?? null),
emptyText="Get started" },
/> { label: "Cost", value: formatPrice(global?.totalCost ?? null) },
<DashboardCard ]}
to="/collection" emptyText="Get started"
search={{ tab: "planning" }} />
title="Planning" <DashboardCard
icon="🔍" to="/collection"
stats={[ search={{ tab: "planning" }}
{ label: "Active threads", value: String(activeThreadCount) }, title="Planning"
]} icon="search"
/> stats={[
<DashboardCard { label: "Active threads", value: String(activeThreadCount) },
to="/setups" ]}
title="Setups" />
icon="🏕️" <DashboardCard
stats={[ to="/setups"
{ label: "Setups", value: String(setupCount) }, title="Setups"
]} icon="tent"
/> stats={[{ label: "Setups", value: String(setupCount) }]}
</div> />
</div> </div>
); </div>
);
} }

View File

@@ -1,268 +1,276 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { import { useState } from "react";
useSetup,
useDeleteSetup,
useRemoveSetupItem,
} from "../../hooks/useSetups";
import { CategoryHeader } from "../../components/CategoryHeader"; import { CategoryHeader } from "../../components/CategoryHeader";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
import { ItemPicker } from "../../components/ItemPicker"; import { ItemPicker } from "../../components/ItemPicker";
import { formatWeight, formatPrice } from "../../lib/formatters"; import {
useDeleteSetup,
useRemoveSetupItem,
useSetup,
} from "../../hooks/useSetups";
import { formatPrice, formatWeight } from "../../lib/formatters";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/setups/$setupId")({ export const Route = createFileRoute("/setups/$setupId")({
component: SetupDetailPage, component: SetupDetailPage,
}); });
function SetupDetailPage() { function SetupDetailPage() {
const { setupId } = Route.useParams(); const { setupId } = Route.useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const numericId = Number(setupId); const numericId = Number(setupId);
const { data: setup, isLoading } = useSetup(numericId); const { data: setup, isLoading } = useSetup(numericId);
const deleteSetup = useDeleteSetup(); const deleteSetup = useDeleteSetup();
const removeItem = useRemoveSetupItem(numericId); const removeItem = useRemoveSetupItem(numericId);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="animate-pulse space-y-6"> <div className="animate-pulse space-y-6">
<div className="h-8 bg-gray-200 rounded w-48" /> <div className="h-8 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" /> <div key={i} className="h-40 bg-gray-200 rounded-xl" />
))} ))}
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (!setup) { if (!setup) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<p className="text-gray-500">Setup not found.</p> <p className="text-gray-500">Setup not found.</p>
</div> </div>
); );
} }
// Compute totals from items // Compute totals from items
const totalWeight = setup.items.reduce( const totalWeight = setup.items.reduce(
(sum, item) => sum + (item.weightGrams ?? 0), (sum, item) => sum + (item.weightGrams ?? 0),
0, 0,
); );
const totalCost = setup.items.reduce( const totalCost = setup.items.reduce(
(sum, item) => sum + (item.priceCents ?? 0), (sum, item) => sum + (item.priceCents ?? 0),
0, 0,
); );
const itemCount = setup.items.length; const itemCount = setup.items.length;
const currentItemIds = setup.items.map((item) => item.id); const currentItemIds = setup.items.map((item) => item.id);
// Group items by category // Group items by category
const groupedItems = new Map< const groupedItems = new Map<
number, number,
{ {
items: typeof setup.items; items: typeof setup.items;
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
} }
>(); >();
for (const item of setup.items) { for (const item of setup.items) {
const group = groupedItems.get(item.categoryId); const group = groupedItems.get(item.categoryId);
if (group) { if (group) {
group.items.push(item); group.items.push(item);
} else { } else {
groupedItems.set(item.categoryId, { groupedItems.set(item.categoryId, {
items: [item], items: [item],
categoryName: item.categoryName, categoryName: item.categoryName,
categoryIcon: item.categoryIcon, categoryIcon: item.categoryIcon,
}); });
} }
} }
function handleDelete() { function handleDelete() {
deleteSetup.mutate(numericId, { deleteSetup.mutate(numericId, {
onSuccess: () => navigate({ to: "/setups" }), onSuccess: () => navigate({ to: "/setups" }),
}); });
} }
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Setup-specific sticky bar */} {/* Setup-specific sticky bar */}
<div className="sticky top-14 z-[9] bg-gray-50 border-b border-gray-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8"> <div className="sticky top-14 z-[9] bg-gray-50 border-b border-gray-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-12"> <div className="flex items-center justify-between h-12">
<h2 className="text-base font-semibold text-gray-900 truncate"> <h2 className="text-base font-semibold text-gray-900 truncate">
{setup.name} {setup.name}
</h2> </h2>
<div className="flex items-center gap-4 text-sm text-gray-500"> <div className="flex items-center gap-4 text-sm text-gray-500">
<span> <span>
<span className="font-medium text-gray-700">{itemCount}</span>{" "} <span className="font-medium text-gray-700">{itemCount}</span>{" "}
{itemCount === 1 ? "item" : "items"} {itemCount === 1 ? "item" : "items"}
</span> </span>
<span> <span>
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">
{formatWeight(totalWeight)} {formatWeight(totalWeight)}
</span>{" "} </span>{" "}
total total
</span> </span>
<span> <span>
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">
{formatPrice(totalCost)} {formatPrice(totalCost)}
</span>{" "} </span>{" "}
cost cost
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-3 py-4"> <div className="flex items-center gap-3 py-4">
<button <button
type="button" type="button"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Add Items Add Items
</button> </button>
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
> >
Delete Setup Delete Setup
</button> </button>
</div> </div>
{/* Empty state */} {/* Empty state */}
{itemCount === 0 && ( {itemCount === 0 && (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="text-5xl mb-4">📦</div> <div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <LucideIcon
No items in this setup name="package"
</h2> size={48}
<p className="text-sm text-gray-500 mb-6"> className="text-gray-400 mx-auto"
Add items from your collection to build this loadout. />
</p> </div>
<button <h2 className="text-xl font-semibold text-gray-900 mb-2">
type="button" No items in this setup
onClick={() => setPickerOpen(true)} </h2>
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" <p className="text-sm text-gray-500 mb-6">
> Add items from your collection to build this loadout.
Add Items </p>
</button> <button
</div> type="button"
</div> onClick={() => setPickerOpen(true)}
)} className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
>
Add Items
</button>
</div>
</div>
)}
{/* Items grouped by category */} {/* Items grouped by category */}
{itemCount > 0 && ( {itemCount > 0 && (
<div className="pb-6"> <div className="pb-6">
{Array.from(groupedItems.entries()).map( {Array.from(groupedItems.entries()).map(
([ ([
categoryId, categoryId,
{ items: categoryItems, categoryName, categoryIcon }, { items: categoryItems, categoryName, categoryIcon },
]) => { ]) => {
const catWeight = categoryItems.reduce( const catWeight = categoryItems.reduce(
(sum, item) => sum + (item.weightGrams ?? 0), (sum, item) => sum + (item.weightGrams ?? 0),
0, 0,
); );
const catCost = categoryItems.reduce( const catCost = categoryItems.reduce(
(sum, item) => sum + (item.priceCents ?? 0), (sum, item) => sum + (item.priceCents ?? 0),
0, 0,
); );
return ( return (
<div key={categoryId} className="mb-8"> <div key={categoryId} className="mb-8">
<CategoryHeader <CategoryHeader
categoryId={categoryId} categoryId={categoryId}
name={categoryName} name={categoryName}
icon={categoryIcon} icon={categoryIcon}
totalWeight={catWeight} totalWeight={catWeight}
totalCost={catCost} totalCost={catCost}
itemCount={categoryItems.length} itemCount={categoryItems.length}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryItems.map((item) => ( {categoryItems.map((item) => (
<ItemCard <ItemCard
key={item.id} key={item.id}
id={item.id} id={item.id}
name={item.name} name={item.name}
weightGrams={item.weightGrams} weightGrams={item.weightGrams}
priceCents={item.priceCents} priceCents={item.priceCents}
categoryName={categoryName} categoryName={categoryName}
categoryIcon={categoryIcon} categoryIcon={categoryIcon}
imageFilename={item.imageFilename} imageFilename={item.imageFilename}
onRemove={() => removeItem.mutate(item.id)} productUrl={item.productUrl}
/> onRemove={() => removeItem.mutate(item.id)}
))} />
</div> ))}
</div> </div>
); </div>
}, );
)} },
</div> )}
)} </div>
)}
{/* Item Picker */} {/* Item Picker */}
<ItemPicker <ItemPicker
setupId={numericId} setupId={numericId}
currentItemIds={currentItemIds} currentItemIds={currentItemIds}
isOpen={pickerOpen} isOpen={pickerOpen}
onClose={() => setPickerOpen(false)} onClose={() => setPickerOpen(false)}
/> />
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
{confirmDelete && ( {confirmDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div <div
className="absolute inset-0 bg-black/30" className="absolute inset-0 bg-black/30"
onClick={() => setConfirmDelete(false)} onClick={() => setConfirmDelete(false)}
/> />
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full"> <div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2"> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Setup Delete Setup
</h3> </h3>
<p className="text-sm text-gray-600 mb-6"> <p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-medium">{setup.name}</span>? This will not <span className="font-medium">{setup.name}</span>? This will not
remove items from your collection. remove items from your collection.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(false)} onClick={() => setConfirmDelete(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
> >
Cancel Cancel
</button> </button>
<button <button
type="button" type="button"
onClick={handleDelete} onClick={handleDelete}
disabled={deleteSetup.isPending} disabled={deleteSetup.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
> >
{deleteSetup.isPending ? "Deleting..." : "Delete"} {deleteSetup.isPending ? "Deleting..." : "Delete"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
); );
} }

View File

@@ -1,86 +1,93 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useSetups, useCreateSetup } from "../../hooks/useSetups"; import { useState } from "react";
import { SetupCard } from "../../components/SetupCard"; import { SetupCard } from "../../components/SetupCard";
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/setups/")({ export const Route = createFileRoute("/setups/")({
component: SetupsListPage, component: SetupsListPage,
}); });
function SetupsListPage() { function SetupsListPage() {
const [newSetupName, setNewSetupName] = useState(""); const [newSetupName, setNewSetupName] = useState("");
const { data: setups, isLoading } = useSetups(); const { data: setups, isLoading } = useSetups();
const createSetup = useCreateSetup(); const createSetup = useCreateSetup();
function handleCreateSetup(e: React.FormEvent) { function handleCreateSetup(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const name = newSetupName.trim(); const name = newSetupName.trim();
if (!name) return; if (!name) return;
createSetup.mutate( createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
{ name }, }
{ onSuccess: () => setNewSetupName("") },
);
}
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Create setup form */} {/* Create setup form */}
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6"> <form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
<input <input
type="text" type="text"
value={newSetupName} value={newSetupName}
onChange={(e) => setNewSetupName(e.target.value)} onChange={(e) => setNewSetupName(e.target.value)}
placeholder="New setup name..." placeholder="New setup name..."
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> />
<button <button
type="submit" type="submit"
disabled={!newSetupName.trim() || createSetup.isPending} disabled={!newSetupName.trim() || createSetup.isPending}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
> >
{createSetup.isPending ? "Creating..." : "Create"} {createSetup.isPending ? "Creating..." : "Create"}
</button> </button>
</form> </form>
{/* Loading skeleton */} {/* Loading skeleton */}
{isLoading && ( {isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2].map((i) => ( {[1, 2].map((i) => (
<div key={i} className="h-24 bg-gray-200 rounded-xl animate-pulse" /> <div
))} key={i}
</div> className="h-24 bg-gray-200 rounded-xl animate-pulse"
)} />
))}
</div>
)}
{/* Empty state */} {/* Empty state */}
{!isLoading && (!setups || setups.length === 0) && ( {!isLoading && (!setups || setups.length === 0) && (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="text-5xl mb-4">🏕</div> <div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <LucideIcon
No setups yet name="tent"
</h2> size={48}
<p className="text-sm text-gray-500"> className="text-gray-400 mx-auto"
Create one to plan your loadout. />
</p> </div>
</div> <h2 className="text-xl font-semibold text-gray-900 mb-2">
</div> No setups yet
)} </h2>
<p className="text-sm text-gray-500">
Create one to plan your loadout.
</p>
</div>
</div>
)}
{/* Setup grid */} {/* Setup grid */}
{!isLoading && setups && setups.length > 0 && ( {!isLoading && setups && setups.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{setups.map((setup) => ( {setups.map((setup) => (
<SetupCard <SetupCard
key={setup.id} key={setup.id}
id={setup.id} id={setup.id}
name={setup.name} name={setup.name}
itemCount={setup.itemCount} itemCount={setup.itemCount}
totalWeight={setup.totalWeight} totalWeight={setup.totalWeight}
totalCost={setup.totalCost} totalCost={setup.totalCost}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
); );
} }

View File

@@ -1,147 +1,153 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useThread } from "../../hooks/useThreads";
import { CandidateCard } from "../../components/CandidateCard"; import { CandidateCard } from "../../components/CandidateCard";
import { useThread } from "../../hooks/useThreads";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore"; import { useUIStore } from "../../stores/uiStore";
export const Route = createFileRoute("/threads/$threadId")({ export const Route = createFileRoute("/threads/$threadId")({
component: ThreadDetailPage, component: ThreadDetailPage,
}); });
function ThreadDetailPage() { function ThreadDetailPage() {
const { threadId: threadIdParam } = Route.useParams(); const { threadId: threadIdParam } = Route.useParams();
const threadId = Number(threadIdParam); const threadId = Number(threadIdParam);
const { data: thread, isLoading, isError } = useThread(threadId); const { data: thread, isLoading, isError } = useThread(threadId);
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel); const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="animate-pulse space-y-6"> <div className="animate-pulse space-y-6">
<div className="h-6 bg-gray-200 rounded w-48" /> <div className="h-6 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" /> <div key={i} className="h-40 bg-gray-200 rounded-xl" />
))} ))}
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (isError || !thread) { if (isError || !thread) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Thread not found Thread not found
</h2> </h2>
<Link <Link
to="/" to="/"
search={{ tab: "planning" }} search={{ tab: "planning" }}
className="text-sm text-blue-600 hover:text-blue-700" className="text-sm text-blue-600 hover:text-blue-700"
> >
Back to planning Back to planning
</Link> </Link>
</div> </div>
); );
} }
const isActive = thread.status === "active"; const isActive = thread.status === "active";
const winningCandidate = thread.resolvedCandidateId const winningCandidate = thread.resolvedCandidateId
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId) ? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
: null; : null;
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<Link <Link
to="/" to="/"
search={{ tab: "planning" }} search={{ tab: "planning" }}
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block" className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
> >
&larr; Back to planning &larr; Back to planning
</Link> </Link>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-gray-900"> <h1 className="text-xl font-semibold text-gray-900">{thread.name}</h1>
{thread.name} <span
</h1> className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
<span isActive
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ ? "bg-blue-50 text-blue-700"
isActive : "bg-gray-100 text-gray-500"
? "bg-blue-50 text-blue-700" }`}
: "bg-gray-100 text-gray-500" >
}`} {isActive ? "Active" : "Resolved"}
> </span>
{isActive ? "Active" : "Resolved"} </div>
</span> </div>
</div>
</div>
{/* Resolution banner */} {/* Resolution banner */}
{!isActive && winningCandidate && ( {!isActive && winningCandidate && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl"> <div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-sm text-amber-800"> <p className="text-sm text-amber-800">
<span className="font-medium">{winningCandidate.name}</span> was <span className="font-medium">{winningCandidate.name}</span> was
picked as the winner and added to your collection. picked as the winner and added to your collection.
</p> </p>
</div> </div>
)} )}
{/* Add candidate button */} {/* Add candidate button */}
{isActive && ( {isActive && (
<div className="mb-6"> <div className="mb-6">
<button <button
type="button" type="button"
onClick={openCandidateAddPanel} onClick={openCandidateAddPanel}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Add Candidate Add Candidate
</button> </button>
</div> </div>
)} )}
{/* Candidate grid */} {/* Candidate grid */}
{thread.candidates.length === 0 ? ( {thread.candidates.length === 0 ? (
<div className="py-12 text-center"> <div className="py-12 text-center">
<div className="text-4xl mb-3">🏷</div> <div className="mb-3">
<h3 className="text-lg font-semibold text-gray-900 mb-1"> <LucideIcon
No candidates yet name="tag"
</h3> size={48}
<p className="text-sm text-gray-500"> className="text-gray-400 mx-auto"
Add your first candidate to start comparing. />
</p> </div>
</div> <h3 className="text-lg font-semibold text-gray-900 mb-1">
) : ( No candidates yet
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> </h3>
{thread.candidates.map((candidate) => ( <p className="text-sm text-gray-500">
<CandidateCard Add your first candidate to start comparing.
key={candidate.id} </p>
id={candidate.id} </div>
name={candidate.name} ) : (
weightGrams={candidate.weightGrams} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
priceCents={candidate.priceCents} {thread.candidates.map((candidate) => (
categoryName={candidate.categoryName} <CandidateCard
categoryIcon={candidate.categoryIcon} key={candidate.id}
imageFilename={candidate.imageFilename} id={candidate.id}
threadId={threadId} name={candidate.name}
isActive={isActive} weightGrams={candidate.weightGrams}
/> priceCents={candidate.priceCents}
))} categoryName={candidate.categoryName}
</div> categoryIcon={candidate.categoryIcon}
)} imageFilename={candidate.imageFilename}
</div> productUrl={candidate.productUrl}
); threadId={threadId}
isActive={isActive}
/>
))}
</div>
)}
</div>
);
} }

View File

@@ -43,6 +43,11 @@ interface UIState {
createThreadModalOpen: boolean; createThreadModalOpen: boolean;
openCreateThreadModal: () => void; openCreateThreadModal: () => void;
closeCreateThreadModal: () => void; closeCreateThreadModal: () => void;
// External link dialog
externalLinkUrl: string | null;
openExternalLink: (url: string) => void;
closeExternalLink: () => void;
} }
export const useUIStore = create<UIState>((set) => ({ export const useUIStore = create<UIState>((set) => ({
@@ -93,4 +98,9 @@ export const useUIStore = create<UIState>((set) => ({
createThreadModalOpen: false, createThreadModalOpen: false,
openCreateThreadModal: () => set({ createThreadModalOpen: true }), openCreateThreadModal: () => set({ createThreadModalOpen: true }),
closeCreateThreadModal: () => set({ createThreadModalOpen: false }), closeCreateThreadModal: () => set({ createThreadModalOpen: false }),
// External link dialog
externalLinkUrl: null,
openExternalLink: (url) => set({ externalLinkUrl: url }),
closeExternalLink: () => set({ externalLinkUrl: null }),
})); }));

View File

@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema.ts"; import * as schema from "./schema.ts";
const sqlite = new Database("gearbox.db"); const sqlite = new Database(process.env.DATABASE_PATH || "gearbox.db");
sqlite.run("PRAGMA journal_mode = WAL"); sqlite.run("PRAGMA journal_mode = WAL");
sqlite.run("PRAGMA foreign_keys = ON"); sqlite.run("PRAGMA foreign_keys = ON");