Compare commits
5 Commits
v1.1
...
48985b5eb2
| Author | SHA1 | Date | |
|---|---|---|---|
| 48985b5eb2 | |||
| 37c4272c08 | |||
| ad941ae281 | |||
| 87fe94037e | |||
| 7c3740fc72 |
10
.dockerignore
Normal file
10
.dockerignore
Normal 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
28
.gitea/workflows/ci.yml
Normal 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
|
||||||
108
.gitea/workflows/release.yml
Normal file
108
.gitea/workflows/release.yml
Normal 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
3
.gitignore
vendored
@@ -223,3 +223,6 @@ dist/
|
|||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
|||||||
@@ -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
70
CLAUDE.md
Normal 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
25
Dockerfile
Normal 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
17
docker-compose.yml
Normal 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:
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
68
drizzle/0000_bitter_luckman.sql
Normal file
68
drizzle/0000_bitter_luckman.sql
Normal 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
|
||||||
|
);
|
||||||
467
drizzle/meta/0000_snapshot.json
Normal file
467
drizzle/meta/0000_snapshot.json
Normal 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
4
entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
bun run db:push
|
||||||
|
exec bun run src/server/index.ts
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/client/components/ExternalLinkDialog.tsx
Normal file
65
src/client/components/ExternalLinkDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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's set up your first category and item.
|
Let'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">🎉</div>
|
<div className="mb-4">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
<LucideIcon
|
||||||
You'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'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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
← Back to planning
|
← 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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user