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

@@ -5,6 +5,6 @@ export default defineConfig({
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,11 +1,11 @@
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;
} }
@@ -29,7 +29,7 @@ export function DashboardCard({
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">

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,6 +2,7 @@ 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 {
@@ -15,7 +16,9 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
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("");
@@ -99,9 +102,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
<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"
}`} }`}
/> />
))} ))}
@@ -266,9 +267,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
</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
@@ -292,13 +291,19 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
{/* 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">
<LucideIcon
name="party-popper"
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">
You&apos;re all set! You&apos;re all set!
</h2> </h2>
<p className="text-sm text-gray-500 mb-8"> <p className="text-sm text-gray-500 mb-8">
Your first item has been added. You can now browse your collection, Your first item has been added. You can now browse your
add more gear, and track your setup. collection, add more gear, and track your setup.
</p> </p>
<button <button
type="button" type="button"

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,9 +1,9 @@
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,
@@ -24,10 +24,13 @@ function DashboardPage() {
<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: "Weight",
value: formatWeight(global?.totalWeight ?? null),
},
{ label: "Cost", value: formatPrice(global?.totalCost ?? null) }, { label: "Cost", value: formatPrice(global?.totalCost ?? null) },
]} ]}
emptyText="Get started" emptyText="Get started"
@@ -36,7 +39,7 @@ function DashboardPage() {
to="/collection" to="/collection"
search={{ tab: "planning" }} search={{ tab: "planning" }}
title="Planning" title="Planning"
icon="🔍" icon="search"
stats={[ stats={[
{ label: "Active threads", value: String(activeThreadCount) }, { label: "Active threads", value: String(activeThreadCount) },
]} ]}
@@ -44,10 +47,8 @@ function DashboardPage() {
<DashboardCard <DashboardCard
to="/setups" to="/setups"
title="Setups" title="Setups"
icon="🏕️" icon="tent"
stats={[ stats={[{ label: "Setups", value: String(setupCount) }]}
{ label: "Setups", value: String(setupCount) },
]}
/> />
</div> </div>
</div> </div>

View File

@@ -1,14 +1,15 @@
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,
@@ -153,7 +154,13 @@ function SetupDetailPage() {
{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">
<LucideIcon
name="package"
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">
No items in this setup No items in this setup
</h2> </h2>
@@ -208,6 +215,7 @@ function SetupDetailPage() {
categoryName={categoryName} categoryName={categoryName}
categoryIcon={categoryIcon} categoryIcon={categoryIcon}
imageFilename={item.imageFilename} imageFilename={item.imageFilename}
productUrl={item.productUrl}
onRemove={() => removeItem.mutate(item.id)} onRemove={() => removeItem.mutate(item.id)}
/> />
))} ))}

View File

@@ -1,7 +1,8 @@
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,
@@ -16,10 +17,7 @@ function SetupsListPage() {
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 (
@@ -46,7 +44,10 @@ function SetupsListPage() {
{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}
className="h-24 bg-gray-200 rounded-xl animate-pulse"
/>
))} ))}
</div> </div>
)} )}
@@ -55,7 +56,13 @@ function SetupsListPage() {
{!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">
<LucideIcon
name="tent"
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">
No setups yet No setups yet
</h2> </h2>

View File

@@ -1,6 +1,7 @@
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")({
@@ -62,9 +63,7 @@ function ThreadDetailPage() {
&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}
</h1>
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
isActive isActive
@@ -116,7 +115,13 @@ function ThreadDetailPage() {
{/* 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">
<LucideIcon
name="tag"
size={48}
className="text-gray-400 mx-auto"
/>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-1"> <h3 className="text-lg font-semibold text-gray-900 mb-1">
No candidates yet No candidates yet
</h3> </h3>
@@ -136,6 +141,7 @@ function ThreadDetailPage() {
categoryName={candidate.categoryName} categoryName={candidate.categoryName}
categoryIcon={candidate.categoryIcon} categoryIcon={candidate.categoryIcon}
imageFilename={candidate.imageFilename} imageFilename={candidate.imageFilename}
productUrl={candidate.productUrl}
threadId={threadId} threadId={threadId}
isActive={isActive} isActive={isActive}
/> />

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");