Compare commits
11 Commits
v1.1
...
628907bb20
| Author | SHA1 | Date | |
|---|---|---|---|
| 628907bb20 | |||
| 891bb248c8 | |||
| 81f89fd14e | |||
| b496462df5 | |||
| 4d0452b7b3 | |||
| 8ec96b9a6c | |||
| 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 --ignore-scripts
|
||||||
|
|
||||||
|
- 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 --ignore-scripts
|
||||||
|
|
||||||
|
- 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 docker-cli
|
||||||
|
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.
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM oven/bun:1 AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
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"]
|
||||||
81
README.md
81
README.md
@@ -1,2 +1,83 @@
|
|||||||
# GearBox
|
# GearBox
|
||||||
|
|
||||||
|
A single-user web app for managing gear collections (bikepacking, sim racing, etc.), tracking weight and price, and planning purchases through research threads.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Organize gear into categories with custom icons
|
||||||
|
- Track weight and price for every item
|
||||||
|
- Create setups (packing lists) from your collection with automatic weight/cost totals
|
||||||
|
- Research threads for comparing candidates before buying
|
||||||
|
- Image uploads for items and candidates
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker Compose (recommended)
|
||||||
|
|
||||||
|
Create a `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
gearbox:
|
||||||
|
image: gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
|
||||||
|
container_name: gearbox
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_PATH=./data/gearbox.db
|
||||||
|
volumes:
|
||||||
|
- gearbox-data:/app/data
|
||||||
|
- gearbox-uploads:/app/uploads
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gearbox-data:
|
||||||
|
gearbox-uploads:
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
GearBox will be available at `http://localhost:3000`.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name gearbox \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
-e DATABASE_PATH=./data/gearbox.db \
|
||||||
|
-v gearbox-data:/app/data \
|
||||||
|
-v gearbox-uploads:/app/uploads \
|
||||||
|
--restart unless-stopped \
|
||||||
|
gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
All data is stored in two Docker volumes:
|
||||||
|
|
||||||
|
- **gearbox-data** -- SQLite database
|
||||||
|
- **gearbox-uploads** -- uploaded images
|
||||||
|
|
||||||
|
Back up these volumes to preserve your data.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Database migrations run automatically on startup.
|
||||||
|
|||||||
20
biome.json
20
biome.json
@@ -6,7 +6,8 @@
|
|||||||
"useIgnoreFile": true
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false
|
"ignoreUnknown": false,
|
||||||
|
"includes": ["**", "!src/client/routeTree.gen.ts"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -15,7 +16,22 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"noSvgWithoutTitle": "off",
|
||||||
|
"noStaticElementInteractions": "off",
|
||||||
|
"useKeyWithClickEvents": "off",
|
||||||
|
"useSemanticElements": "off",
|
||||||
|
"noAutofocus": "off",
|
||||||
|
"useAriaPropsSupportedByRole": "off",
|
||||||
|
"noLabelWithoutControl": "off"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
|
|||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
gearbox:
|
||||||
|
image: gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
|
||||||
|
container_name: gearbox
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_PATH=./data/gearbox.db
|
||||||
|
volumes:
|
||||||
|
- gearbox-data:/app/data
|
||||||
|
- gearbox-uploads:/app/uploads
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gearbox-data:
|
||||||
|
gearbox-uploads:
|
||||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
|||||||
schema: "./src/db/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
dialect: "sqlite",
|
dialect: "sqlite",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: "gearbox.db",
|
url: process.env.DATABASE_PATH || "gearbox.db",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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
|
||||||
|
);
|
||||||
441
drizzle/meta/0000_snapshot.json
Normal file
441
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
{
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,9 +40,7 @@
|
|||||||
"indexes": {
|
"indexes": {
|
||||||
"categories_name_unique": {
|
"categories_name_unique": {
|
||||||
"name": "categories_name_unique",
|
"name": "categories_name_unique",
|
||||||
"columns": [
|
"columns": ["name"],
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -131,12 +129,8 @@
|
|||||||
"name": "items_category_id_categories_id_fk",
|
"name": "items_category_id_categories_id_fk",
|
||||||
"tableFrom": "items",
|
"tableFrom": "items",
|
||||||
"tableTo": "categories",
|
"tableTo": "categories",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["category_id"],
|
||||||
"category_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -200,12 +194,8 @@
|
|||||||
"name": "setup_items_setup_id_setups_id_fk",
|
"name": "setup_items_setup_id_setups_id_fk",
|
||||||
"tableFrom": "setup_items",
|
"tableFrom": "setup_items",
|
||||||
"tableTo": "setups",
|
"tableTo": "setups",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["setup_id"],
|
||||||
"setup_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -213,12 +203,8 @@
|
|||||||
"name": "setup_items_item_id_items_id_fk",
|
"name": "setup_items_item_id_items_id_fk",
|
||||||
"tableFrom": "setup_items",
|
"tableFrom": "setup_items",
|
||||||
"tableTo": "items",
|
"tableTo": "items",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["item_id"],
|
||||||
"item_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -352,12 +338,8 @@
|
|||||||
"name": "thread_candidates_thread_id_threads_id_fk",
|
"name": "thread_candidates_thread_id_threads_id_fk",
|
||||||
"tableFrom": "thread_candidates",
|
"tableFrom": "thread_candidates",
|
||||||
"tableTo": "threads",
|
"tableTo": "threads",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["thread_id"],
|
||||||
"thread_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -365,12 +347,8 @@
|
|||||||
"name": "thread_candidates_category_id_categories_id_fk",
|
"name": "thread_candidates_category_id_categories_id_fk",
|
||||||
"tableFrom": "thread_candidates",
|
"tableFrom": "thread_candidates",
|
||||||
"tableTo": "categories",
|
"tableTo": "categories",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["category_id"],
|
||||||
"category_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -439,12 +417,8 @@
|
|||||||
"name": "threads_category_id_categories_id_fk",
|
"name": "threads_category_id_categories_id_fk",
|
||||||
"tableFrom": "threads",
|
"tableFrom": "threads",
|
||||||
"tableTo": "categories",
|
"tableTo": "categories",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["category_id"],
|
||||||
"category_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|||||||
4
entrypoint.sh
Executable file
4
entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
bun run src/db/migrate.ts
|
||||||
|
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
|
||||||
@@ -42,7 +73,11 @@ export function CandidateCard({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||||
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" />
|
<LucideIcon
|
||||||
|
name={categoryIcon}
|
||||||
|
size={36}
|
||||||
|
className="text-gray-400"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +97,12 @@ export function CandidateCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
<LucideIcon name={categoryIcon} size={14} className="inline-block mr-1 text-gray-500" /> {categoryName}
|
<LucideIcon
|
||||||
|
name={categoryIcon}
|
||||||
|
size={14}
|
||||||
|
className="inline-block mr-1 text-gray-500"
|
||||||
|
/>{" "}
|
||||||
|
{categoryName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { useCreateCandidate, useUpdateCandidate } from "../hooks/useCandidates";
|
||||||
useCreateCandidate,
|
|
||||||
useUpdateCandidate,
|
|
||||||
} from "../hooks/useCandidates";
|
|
||||||
import { useThread } from "../hooks/useThreads";
|
import { useThread } from "../hooks/useThreads";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { CategoryPicker } from "./CategoryPicker";
|
import { CategoryPicker } from "./CategoryPicker";
|
||||||
@@ -78,13 +75,13 @@ export function CandidateForm({
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
form.weightGrams &&
|
form.weightGrams &&
|
||||||
(isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
||||||
) {
|
) {
|
||||||
newErrors.weightGrams = "Must be a positive number";
|
newErrors.weightGrams = "Must be a positive number";
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
form.priceDollars &&
|
form.priceDollars &&
|
||||||
(isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
||||||
) {
|
) {
|
||||||
newErrors.priceDollars = "Must be a positive number";
|
newErrors.priceDollars = "Must be a positive number";
|
||||||
}
|
}
|
||||||
@@ -157,7 +154,6 @@ export function CandidateForm({
|
|||||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, name: 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. Osprey Talon 22"
|
placeholder="e.g. Osprey Talon 22"
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { formatWeight, formatPrice } from "../lib/formatters";
|
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
|
||||||
import { useUpdateCategory, useDeleteCategory } from "../hooks/useCategories";
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { IconPicker } from "./IconPicker";
|
import { IconPicker } from "./IconPicker";
|
||||||
|
|
||||||
@@ -39,7 +39,9 @@ export function CategoryHeader({
|
|||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (
|
if (
|
||||||
confirm(`Delete category "${name}"? Items will be moved to Uncategorized.`)
|
confirm(
|
||||||
|
`Delete category "${name}"? Items will be moved to Uncategorized.`,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
deleteCategory.mutate(categoryId);
|
deleteCategory.mutate(categoryId);
|
||||||
}
|
}
|
||||||
@@ -58,7 +60,6 @@ export function CategoryHeader({
|
|||||||
if (e.key === "Enter") handleSave();
|
if (e.key === "Enter") handleSave();
|
||||||
if (e.key === "Escape") setIsEditing(false);
|
if (e.key === "Escape") setIsEditing(false);
|
||||||
}}
|
}}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { useCategories, useCreateCategory } from "../hooks/useCategories";
|
||||||
useCategories,
|
|
||||||
useCreateCategory,
|
|
||||||
} from "../hooks/useCategories";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { IconPicker } from "./IconPicker";
|
import { IconPicker } from "./IconPicker";
|
||||||
|
|
||||||
@@ -109,10 +106,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
handleConfirmCreate();
|
handleConfirmCreate();
|
||||||
} else if (highlightIndex >= 0 && highlightIndex < filtered.length) {
|
} else if (highlightIndex >= 0 && highlightIndex < filtered.length) {
|
||||||
handleSelect(filtered[highlightIndex].id);
|
handleSelect(filtered[highlightIndex].id);
|
||||||
} else if (
|
} else if (showCreateOption && highlightIndex === filtered.length) {
|
||||||
showCreateOption &&
|
|
||||||
highlightIndex === filtered.length
|
|
||||||
) {
|
|
||||||
handleStartCreate();
|
handleStartCreate();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -162,11 +156,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
value={
|
value={
|
||||||
isOpen
|
isOpen ? inputValue : selectedCategory ? selectedCategory.name : ""
|
||||||
? inputValue
|
|
||||||
: selectedCategory
|
|
||||||
? selectedCategory.name
|
|
||||||
: ""
|
|
||||||
}
|
}
|
||||||
placeholder="Search or create category..."
|
placeholder="Search or create category..."
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -188,14 +178,12 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
<ul
|
<ul
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
id="category-listbox"
|
id="category-listbox"
|
||||||
role="listbox"
|
|
||||||
className="absolute z-20 mt-1 w-full max-h-48 overflow-auto bg-white border border-gray-200 rounded-lg shadow-lg"
|
className="absolute z-20 mt-1 w-full max-h-48 overflow-auto bg-white border border-gray-200 rounded-lg shadow-lg"
|
||||||
>
|
>
|
||||||
{filtered.map((cat, i) => (
|
{filtered.map((cat, i) => (
|
||||||
<li
|
<li
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
id={`category-option-${i}`}
|
id={`category-option-${i}`}
|
||||||
role="option"
|
|
||||||
aria-selected={cat.id === value}
|
aria-selected={cat.id === value}
|
||||||
className={`px-3 py-2 text-sm cursor-pointer flex items-center gap-1.5 ${
|
className={`px-3 py-2 text-sm cursor-pointer flex items-center gap-1.5 ${
|
||||||
i === highlightIndex
|
i === highlightIndex
|
||||||
@@ -216,7 +204,6 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
{showCreateOption && !isCreating && (
|
{showCreateOption && !isCreating && (
|
||||||
<li
|
<li
|
||||||
id={`category-option-${filtered.length}`}
|
id={`category-option-${filtered.length}`}
|
||||||
role="option"
|
|
||||||
aria-selected={false}
|
aria-selected={false}
|
||||||
className={`px-3 py-2 text-sm cursor-pointer border-t border-gray-100 ${
|
className={`px-3 py-2 text-sm cursor-pointer border-t border-gray-100 ${
|
||||||
highlightIndex === filtered.length
|
highlightIndex === filtered.length
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
|
import { useDeleteItem, useItems } from "../hooks/useItems";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { useDeleteItem } from "../hooks/useItems";
|
|
||||||
import { useItems } from "../hooks/useItems";
|
|
||||||
|
|
||||||
export function ConfirmDialog() {
|
export function ConfirmDialog() {
|
||||||
const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId);
|
const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import type { ReactNode } from "react";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
interface DashboardCardProps {
|
interface DashboardCardProps {
|
||||||
to: string;
|
to: string;
|
||||||
search?: Record<string, string>;
|
search?: Record<string, string>;
|
||||||
title: string;
|
title: string;
|
||||||
icon: ReactNode;
|
icon: string;
|
||||||
stats: Array<{ label: string; value: string }>;
|
stats: Array<{ label: string; value: string }>;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ export function DashboardCard({
|
|||||||
className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-6"
|
className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-6"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<span className="text-2xl">{icon}</span>
|
<LucideIcon name={icon} size={24} className="text-gray-500" />
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
63
src/client/components/ExternalLinkDialog.tsx
Normal file
63
src/client/components/ExternalLinkDialog.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,11 +8,7 @@ interface IconPickerProps {
|
|||||||
size?: "sm" | "md";
|
size?: "sm" | "md";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IconPicker({
|
export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
size = "md",
|
|
||||||
}: IconPickerProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [activeGroup, setActiveGroup] = useState(0);
|
const [activeGroup, setActiveGroup] = useState(0);
|
||||||
@@ -99,8 +95,7 @@ export function IconPicker({
|
|||||||
const results = iconGroups.flatMap((group) =>
|
const results = iconGroups.flatMap((group) =>
|
||||||
group.icons.filter(
|
group.icons.filter(
|
||||||
(icon) =>
|
(icon) =>
|
||||||
icon.name.includes(q) ||
|
icon.name.includes(q) || icon.keywords.some((kw) => kw.includes(q)),
|
||||||
icon.keywords.some((kw) => kw.includes(q)),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Deduplicate by name (some icons appear in multiple groups)
|
// Deduplicate by name (some icons appear in multiple groups)
|
||||||
@@ -118,8 +113,7 @@ export function IconPicker({
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonSize =
|
const buttonSize = size === "sm" ? "w-10 h-10" : "w-12 h-12";
|
||||||
size === "sm" ? "w-10 h-10" : "w-12 h-12";
|
|
||||||
const iconSize = size === "sm" ? 20 : 24;
|
const iconSize = size === "sm" ? 20 : 24;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -179,9 +173,7 @@ export function IconPicker({
|
|||||||
name={group.icon}
|
name={group.icon}
|
||||||
size={16}
|
size={16}
|
||||||
className={
|
className={
|
||||||
i === activeGroup
|
i === activeGroup ? "text-blue-700" : "text-gray-400"
|
||||||
? "text-blue-700"
|
|
||||||
: "text-gray-400"
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { apiUpload } from "../lib/api";
|
import { apiUpload } from "../lib/api";
|
||||||
|
|
||||||
interface ImageUploadProps {
|
interface ImageUploadProps {
|
||||||
@@ -32,10 +32,7 @@ export function ImageUpload({ value, onChange }: ImageUploadProps) {
|
|||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const result = await apiUpload<{ filename: string }>(
|
const result = await apiUpload<{ filename: string }>("/api/images", file);
|
||||||
"/api/images",
|
|
||||||
file,
|
|
||||||
);
|
|
||||||
onChange(result.filename);
|
onChange(result.filename);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Upload failed. Please try again.");
|
setError("Upload failed. Please try again.");
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -72,7 +107,11 @@ export function ItemCard({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||||
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" />
|
<LucideIcon
|
||||||
|
name={categoryIcon}
|
||||||
|
size={36}
|
||||||
|
className="text-gray-400"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +131,12 @@ export function ItemCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
<LucideIcon name={categoryIcon} size={14} className="inline-block mr-1 text-gray-500" /> {categoryName}
|
<LucideIcon
|
||||||
|
name={categoryIcon}
|
||||||
|
size={14}
|
||||||
|
className="inline-block mr-1 text-gray-500"
|
||||||
|
/>{" "}
|
||||||
|
{categoryName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useCreateItem, useUpdateItem, useItems } from "../hooks/useItems";
|
import { useCreateItem, useItems, useUpdateItem } from "../hooks/useItems";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { CategoryPicker } from "./CategoryPicker";
|
import { CategoryPicker } from "./CategoryPicker";
|
||||||
import { ImageUpload } from "./ImageUpload";
|
import { ImageUpload } from "./ImageUpload";
|
||||||
@@ -46,8 +46,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
if (item) {
|
if (item) {
|
||||||
setForm({
|
setForm({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
weightGrams:
|
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
||||||
item.weightGrams != null ? String(item.weightGrams) : "",
|
|
||||||
priceDollars:
|
priceDollars:
|
||||||
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
|
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
|
||||||
categoryId: item.categoryId,
|
categoryId: item.categoryId,
|
||||||
@@ -66,10 +65,16 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
if (!form.name.trim()) {
|
if (!form.name.trim()) {
|
||||||
newErrors.name = "Name is required";
|
newErrors.name = "Name is required";
|
||||||
}
|
}
|
||||||
if (form.weightGrams && (isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)) {
|
if (
|
||||||
|
form.weightGrams &&
|
||||||
|
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
||||||
|
) {
|
||||||
newErrors.weightGrams = "Must be a positive number";
|
newErrors.weightGrams = "Must be a positive number";
|
||||||
}
|
}
|
||||||
if (form.priceDollars && (isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)) {
|
if (
|
||||||
|
form.priceDollars &&
|
||||||
|
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
||||||
|
) {
|
||||||
newErrors.priceDollars = "Must be a positive number";
|
newErrors.priceDollars = "Must be a positive number";
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -141,7 +146,6 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, name: 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. Osprey Talon 22"
|
placeholder="e.g. Osprey Talon 22"
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { SlideOutPanel } from "./SlideOutPanel";
|
|
||||||
import { useItems } from "../hooks/useItems";
|
import { useItems } from "../hooks/useItems";
|
||||||
import { useSyncSetupItems } from "../hooks/useSetups";
|
import { useSyncSetupItems } from "../hooks/useSetups";
|
||||||
import { formatWeight, formatPrice } from "../lib/formatters";
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
import { SlideOutPanel } from "./SlideOutPanel";
|
||||||
|
|
||||||
interface ItemPickerProps {
|
interface ItemPickerProps {
|
||||||
setupId: number;
|
setupId: number;
|
||||||
@@ -84,10 +84,18 @@ export function ItemPicker({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Array.from(grouped.entries()).map(
|
Array.from(grouped.entries()).map(
|
||||||
([categoryId, { categoryName, categoryIcon, items: catItems }]) => (
|
([
|
||||||
|
categoryId,
|
||||||
|
{ categoryName, categoryIcon, items: catItems },
|
||||||
|
]) => (
|
||||||
<div key={categoryId} className="mb-4">
|
<div key={categoryId} className="mb-4">
|
||||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||||
<LucideIcon name={categoryIcon} size={16} className="inline-block mr-1 text-gray-500" /> {categoryName}
|
<LucideIcon
|
||||||
|
name={categoryIcon}
|
||||||
|
size={16}
|
||||||
|
className="inline-block mr-1 text-gray-500"
|
||||||
|
/>{" "}
|
||||||
|
{categoryName}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{catItems.map((item) => (
|
{catItems.map((item) => (
|
||||||
@@ -105,9 +113,13 @@ export function ItemPicker({
|
|||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400 shrink-0">
|
<span className="text-xs text-gray-400 shrink-0">
|
||||||
{item.weightGrams != null && formatWeight(item.weightGrams)}
|
{item.weightGrams != null &&
|
||||||
{item.weightGrams != null && item.priceCents != null && " · "}
|
formatWeight(item.weightGrams)}
|
||||||
{item.priceCents != null && formatPrice(item.priceCents)}
|
{item.weightGrams != null &&
|
||||||
|
item.priceCents != null &&
|
||||||
|
" · "}
|
||||||
|
{item.priceCents != null &&
|
||||||
|
formatPrice(item.priceCents)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { useCreateCategory } from "../hooks/useCategories";
|
import { useCreateCategory } from "../hooks/useCategories";
|
||||||
import { useCreateItem } from "../hooks/useItems";
|
import { useCreateItem } from "../hooks/useItems";
|
||||||
import { useUpdateSetting } from "../hooks/useSettings";
|
import { useUpdateSetting } from "../hooks/useSettings";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { IconPicker } from "./IconPicker";
|
import { IconPicker } from "./IconPicker";
|
||||||
|
|
||||||
interface OnboardingWizardProps {
|
interface OnboardingWizardProps {
|
||||||
@@ -15,7 +16,9 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
const [categoryName, setCategoryName] = useState("");
|
const [categoryName, setCategoryName] = useState("");
|
||||||
const [categoryIcon, setCategoryIcon] = useState("");
|
const [categoryIcon, setCategoryIcon] = useState("");
|
||||||
const [categoryError, setCategoryError] = useState("");
|
const [categoryError, setCategoryError] = useState("");
|
||||||
const [createdCategoryId, setCreatedCategoryId] = useState<number | null>(null);
|
const [createdCategoryId, setCreatedCategoryId] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// Step 3 state
|
// Step 3 state
|
||||||
const [itemName, setItemName] = useState("");
|
const [itemName, setItemName] = useState("");
|
||||||
@@ -99,9 +102,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
<div
|
<div
|
||||||
key={s}
|
key={s}
|
||||||
className={`h-1.5 rounded-full transition-all ${
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
s <= Math.min(step, 3)
|
s <= Math.min(step, 3) ? "bg-blue-600 w-8" : "bg-gray-200 w-6"
|
||||||
? "bg-blue-600 w-8"
|
|
||||||
: "bg-gray-200 w-6"
|
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -160,7 +161,6 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
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
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -223,7 +223,6 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
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
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -266,9 +265,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{itemError && (
|
{itemError && <p className="text-xs text-red-500">{itemError}</p>}
|
||||||
<p className="text-xs text-red-500">{itemError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -292,13 +289,19 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
{/* Step 4: Done */}
|
{/* Step 4: Done */}
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-4xl mb-4">🎉</div>
|
<div className="mb-4">
|
||||||
|
<LucideIcon
|
||||||
|
name="party-popper"
|
||||||
|
size={48}
|
||||||
|
className="text-gray-400 mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
You're all set!
|
You're all set!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 mb-8">
|
<p className="text-sm text-gray-500 mb-8">
|
||||||
Your first item has been added. You can now browse your collection,
|
Your first item has been added. You can now browse your
|
||||||
add more gear, and track your setup.
|
collection, add more gear, and track your setup.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { formatWeight, formatPrice } from "../lib/formatters";
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
|
|
||||||
interface SetupCardProps {
|
interface SetupCardProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -23,9 +23,7 @@ export function SetupCard({
|
|||||||
className="block w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4"
|
className="block w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 truncate">
|
<h3 className="text-sm font-semibold text-gray-900 truncate">{name}</h3>
|
||||||
{name}
|
|
||||||
</h3>
|
|
||||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 shrink-0">
|
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 shrink-0">
|
||||||
{itemCount} {itemCount === 1 ? "item" : "items"}
|
{itemCount} {itemCount === 1 ? "item" : "items"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -67,7 +67,12 @@ export function ThreadCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
|
||||||
<LucideIcon name={categoryIcon} size={16} className="inline-block mr-1 text-gray-500" /> {categoryName}
|
<LucideIcon
|
||||||
|
name={categoryIcon}
|
||||||
|
size={16}
|
||||||
|
className="inline-block mr-1 text-gray-500"
|
||||||
|
/>{" "}
|
||||||
|
{categoryName}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
|
||||||
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
|
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useTotals } from "../hooks/useTotals";
|
import { useTotals } from "../hooks/useTotals";
|
||||||
import { formatWeight, formatPrice } from "../lib/formatters";
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
|
|
||||||
interface TotalsBarProps {
|
interface TotalsBarProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -8,11 +8,17 @@ interface TotalsBarProps {
|
|||||||
linkTo?: string;
|
linkTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TotalsBar({ title = "GearBox", stats, linkTo }: TotalsBarProps) {
|
export function TotalsBar({
|
||||||
|
title = "GearBox",
|
||||||
|
stats,
|
||||||
|
linkTo,
|
||||||
|
}: TotalsBarProps) {
|
||||||
const { data } = useTotals();
|
const { data } = useTotals();
|
||||||
|
|
||||||
// When no stats provided, use global totals (backward compatible)
|
// When no stats provided, use global totals (backward compatible)
|
||||||
const displayStats = stats ?? (data?.global
|
const displayStats =
|
||||||
|
stats ??
|
||||||
|
(data?.global
|
||||||
? [
|
? [
|
||||||
{ label: "items", value: String(data.global.itemCount) },
|
{ label: "items", value: String(data.global.itemCount) },
|
||||||
{ label: "total", value: formatWeight(data.global.totalWeight) },
|
{ label: "total", value: formatWeight(data.global.totalWeight) },
|
||||||
@@ -25,7 +31,10 @@ export function TotalsBar({ title = "GearBox", stats, linkTo }: TotalsBarProps)
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const titleElement = linkTo ? (
|
const titleElement = linkTo ? (
|
||||||
<Link to={linkTo} className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
|
<Link
|
||||||
|
to={linkTo}
|
||||||
|
className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiPost, apiPut, apiDelete } from "../lib/api";
|
|
||||||
import type { CreateCandidate, UpdateCandidate } from "../../shared/types";
|
import type { CreateCandidate, UpdateCandidate } from "../../shared/types";
|
||||||
|
import { apiDelete, apiPost, apiPut } from "../lib/api";
|
||||||
|
|
||||||
interface CandidateResponse {
|
interface CandidateResponse {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
|
|
||||||
import type { Category, CreateCategory } from "../../shared/types";
|
import type { Category, CreateCategory } from "../../shared/types";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||||
|
|
||||||
export function useCategories() {
|
export function useCategories() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
|
|
||||||
import type { CreateItem } from "../../shared/types";
|
import type { CreateItem } from "../../shared/types";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||||
|
|
||||||
interface ItemWithCategory {
|
interface ItemWithCategory {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiGet, apiPut } from "../lib/api";
|
import { apiGet, apiPut } from "../lib/api";
|
||||||
|
|
||||||
interface Setting {
|
interface Setting {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
|
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||||
|
|
||||||
interface SetupListItem {
|
interface SetupListItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -34,7 +34,7 @@ interface SetupWithItems {
|
|||||||
items: SetupItemWithCategory[];
|
items: SetupItemWithCategory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { SetupListItem, SetupWithItems, SetupItemWithCategory };
|
export type { SetupItemWithCategory, SetupListItem, SetupWithItems };
|
||||||
|
|
||||||
export function useSetups() {
|
export function useSetups() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
|
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||||
|
|
||||||
interface ThreadListItem {
|
interface ThreadListItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
createRootRoute,
|
createRootRoute,
|
||||||
Outlet,
|
Outlet,
|
||||||
useMatchRoute,
|
useMatchRoute,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
import { TotalsBar } from "../components/TotalsBar";
|
|
||||||
import { SlideOutPanel } from "../components/SlideOutPanel";
|
|
||||||
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 { ItemForm } from "../components/ItemForm";
|
||||||
import { OnboardingWizard } from "../components/OnboardingWizard";
|
import { OnboardingWizard } from "../components/OnboardingWizard";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { SlideOutPanel } from "../components/SlideOutPanel";
|
||||||
import { useOnboardingComplete } from "../hooks/useSettings";
|
import { TotalsBar } from "../components/TotalsBar";
|
||||||
import { useThread, useResolveThread } from "../hooks/useThreads";
|
|
||||||
import { useDeleteCandidate } from "../hooks/useCandidates";
|
import { useDeleteCandidate } from "../hooks/useCandidates";
|
||||||
|
import { useOnboardingComplete } from "../hooks/useSettings";
|
||||||
|
import { useResolveThread, useThread } from "../hooks/useThreads";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
@@ -73,7 +74,7 @@ function RootLayout() {
|
|||||||
const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true });
|
const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true });
|
||||||
|
|
||||||
// Determine TotalsBar props based on current route
|
// Determine TotalsBar props based on current route
|
||||||
const totalsBarProps = isDashboard
|
const _totalsBarProps = isDashboard
|
||||||
? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link
|
? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link
|
||||||
: isSetupDetail
|
: isSetupDetail
|
||||||
? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link
|
? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link
|
||||||
@@ -88,8 +89,13 @@ function RootLayout() {
|
|||||||
: { linkTo: "/" };
|
: { linkTo: "/" };
|
||||||
|
|
||||||
// FAB visibility: only show on /collection route when gear tab is active
|
// FAB visibility: only show on /collection route when gear tab is active
|
||||||
const collectionSearch = matchRoute({ to: "/collection" }) as { tab?: string } | false;
|
const collectionSearch = matchRoute({ to: "/collection" }) as
|
||||||
const showFab = isCollection && (!collectionSearch || (collectionSearch as Record<string, string>).tab !== "planning");
|
| { tab?: string }
|
||||||
|
| false;
|
||||||
|
const showFab =
|
||||||
|
isCollection &&
|
||||||
|
(!collectionSearch ||
|
||||||
|
(collectionSearch as Record<string, string>).tab !== "planning");
|
||||||
|
|
||||||
// Show a minimal loading state while checking onboarding status
|
// Show a minimal loading state while checking onboarding status
|
||||||
if (onboardingLoading) {
|
if (onboardingLoading) {
|
||||||
@@ -142,6 +148,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,9 +1,9 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useTotals } from "../hooks/useTotals";
|
|
||||||
import { useThreads } from "../hooks/useThreads";
|
|
||||||
import { useSetups } from "../hooks/useSetups";
|
|
||||||
import { DashboardCard } from "../components/DashboardCard";
|
import { DashboardCard } from "../components/DashboardCard";
|
||||||
import { formatWeight, formatPrice } from "../lib/formatters";
|
import { useSetups } from "../hooks/useSetups";
|
||||||
|
import { useThreads } from "../hooks/useThreads";
|
||||||
|
import { useTotals } from "../hooks/useTotals";
|
||||||
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
@@ -24,10 +24,13 @@ function DashboardPage() {
|
|||||||
<DashboardCard
|
<DashboardCard
|
||||||
to="/collection"
|
to="/collection"
|
||||||
title="Collection"
|
title="Collection"
|
||||||
icon="🎒"
|
icon="backpack"
|
||||||
stats={[
|
stats={[
|
||||||
{ label: "Items", value: String(global?.itemCount ?? 0) },
|
{ label: "Items", value: String(global?.itemCount ?? 0) },
|
||||||
{ label: "Weight", value: formatWeight(global?.totalWeight ?? null) },
|
{
|
||||||
|
label: "Weight",
|
||||||
|
value: formatWeight(global?.totalWeight ?? null),
|
||||||
|
},
|
||||||
{ label: "Cost", value: formatPrice(global?.totalCost ?? null) },
|
{ label: "Cost", value: formatPrice(global?.totalCost ?? null) },
|
||||||
]}
|
]}
|
||||||
emptyText="Get started"
|
emptyText="Get started"
|
||||||
@@ -36,7 +39,7 @@ function DashboardPage() {
|
|||||||
to="/collection"
|
to="/collection"
|
||||||
search={{ tab: "planning" }}
|
search={{ tab: "planning" }}
|
||||||
title="Planning"
|
title="Planning"
|
||||||
icon="🔍"
|
icon="search"
|
||||||
stats={[
|
stats={[
|
||||||
{ label: "Active threads", value: String(activeThreadCount) },
|
{ label: "Active threads", value: String(activeThreadCount) },
|
||||||
]}
|
]}
|
||||||
@@ -44,10 +47,8 @@ function DashboardPage() {
|
|||||||
<DashboardCard
|
<DashboardCard
|
||||||
to="/setups"
|
to="/setups"
|
||||||
title="Setups"
|
title="Setups"
|
||||||
icon="🏕️"
|
icon="tent"
|
||||||
stats={[
|
stats={[{ label: "Setups", value: String(setupCount) }]}
|
||||||
{ label: "Setups", value: String(setupCount) },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import {
|
import { useState } from "react";
|
||||||
useSetup,
|
|
||||||
useDeleteSetup,
|
|
||||||
useRemoveSetupItem,
|
|
||||||
} from "../../hooks/useSetups";
|
|
||||||
import { CategoryHeader } from "../../components/CategoryHeader";
|
import { CategoryHeader } from "../../components/CategoryHeader";
|
||||||
import { ItemCard } from "../../components/ItemCard";
|
import { ItemCard } from "../../components/ItemCard";
|
||||||
import { ItemPicker } from "../../components/ItemPicker";
|
import { ItemPicker } from "../../components/ItemPicker";
|
||||||
import { formatWeight, formatPrice } from "../../lib/formatters";
|
import {
|
||||||
|
useDeleteSetup,
|
||||||
|
useRemoveSetupItem,
|
||||||
|
useSetup,
|
||||||
|
} from "../../hooks/useSetups";
|
||||||
|
import { formatPrice, formatWeight } from "../../lib/formatters";
|
||||||
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
|
|
||||||
export const Route = createFileRoute("/setups/$setupId")({
|
export const Route = createFileRoute("/setups/$setupId")({
|
||||||
component: SetupDetailPage,
|
component: SetupDetailPage,
|
||||||
@@ -153,7 +154,13 @@ function SetupDetailPage() {
|
|||||||
{itemCount === 0 && (
|
{itemCount === 0 && (
|
||||||
<div className="py-16 text-center">
|
<div className="py-16 text-center">
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
<div className="text-5xl mb-4">📦</div>
|
<div className="mb-4">
|
||||||
|
<LucideIcon
|
||||||
|
name="package"
|
||||||
|
size={48}
|
||||||
|
className="text-gray-400 mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
No items in this setup
|
No items in this setup
|
||||||
</h2>
|
</h2>
|
||||||
@@ -208,6 +215,7 @@ function SetupDetailPage() {
|
|||||||
categoryName={categoryName}
|
categoryName={categoryName}
|
||||||
categoryIcon={categoryIcon}
|
categoryIcon={categoryIcon}
|
||||||
imageFilename={item.imageFilename}
|
imageFilename={item.imageFilename}
|
||||||
|
productUrl={item.productUrl}
|
||||||
onRemove={() => removeItem.mutate(item.id)}
|
onRemove={() => removeItem.mutate(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useSetups, useCreateSetup } from "../../hooks/useSetups";
|
import { useState } from "react";
|
||||||
import { SetupCard } from "../../components/SetupCard";
|
import { SetupCard } from "../../components/SetupCard";
|
||||||
|
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
|
||||||
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
|
|
||||||
export const Route = createFileRoute("/setups/")({
|
export const Route = createFileRoute("/setups/")({
|
||||||
component: SetupsListPage,
|
component: SetupsListPage,
|
||||||
@@ -16,10 +17,7 @@ function SetupsListPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const name = newSetupName.trim();
|
const name = newSetupName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
createSetup.mutate(
|
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
|
||||||
{ name },
|
|
||||||
{ onSuccess: () => setNewSetupName("") },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,7 +44,10 @@ function SetupsListPage() {
|
|||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{[1, 2].map((i) => (
|
{[1, 2].map((i) => (
|
||||||
<div key={i} className="h-24 bg-gray-200 rounded-xl animate-pulse" />
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-24 bg-gray-200 rounded-xl animate-pulse"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -55,7 +56,13 @@ function SetupsListPage() {
|
|||||||
{!isLoading && (!setups || setups.length === 0) && (
|
{!isLoading && (!setups || setups.length === 0) && (
|
||||||
<div className="py-16 text-center">
|
<div className="py-16 text-center">
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
<div className="text-5xl mb-4">🏕️</div>
|
<div className="mb-4">
|
||||||
|
<LucideIcon
|
||||||
|
name="tent"
|
||||||
|
size={48}
|
||||||
|
className="text-gray-400 mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
No setups yet
|
No setups yet
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useThread } from "../../hooks/useThreads";
|
|
||||||
import { CandidateCard } from "../../components/CandidateCard";
|
import { CandidateCard } from "../../components/CandidateCard";
|
||||||
|
import { useThread } from "../../hooks/useThreads";
|
||||||
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
|
|
||||||
export const Route = createFileRoute("/threads/$threadId")({
|
export const Route = createFileRoute("/threads/$threadId")({
|
||||||
@@ -62,9 +63,7 @@ function ThreadDetailPage() {
|
|||||||
← 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}
|
|
||||||
</h1>
|
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
isActive
|
isActive
|
||||||
@@ -116,7 +115,13 @@ function ThreadDetailPage() {
|
|||||||
{/* Candidate grid */}
|
{/* Candidate grid */}
|
||||||
{thread.candidates.length === 0 ? (
|
{thread.candidates.length === 0 ? (
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<div className="text-4xl mb-3">🏷️</div>
|
<div className="mb-3">
|
||||||
|
<LucideIcon
|
||||||
|
name="tag"
|
||||||
|
size={48}
|
||||||
|
className="text-gray-400 mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||||||
No candidates yet
|
No candidates yet
|
||||||
</h3>
|
</h3>
|
||||||
@@ -136,6 +141,7 @@ function ThreadDetailPage() {
|
|||||||
categoryName={candidate.categoryName}
|
categoryName={candidate.categoryName}
|
||||||
categoryIcon={candidate.categoryIcon}
|
categoryIcon={candidate.categoryIcon}
|
||||||
imageFilename={candidate.imageFilename}
|
imageFilename={candidate.imageFilename}
|
||||||
|
productUrl={candidate.productUrl}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
13
src/db/migrate.ts
Normal file
13
src/db/migrate.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
|
|
||||||
|
const sqlite = new Database(process.env.DATABASE_PATH || "gearbox.db");
|
||||||
|
sqlite.run("PRAGMA journal_mode = WAL");
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
const db = drizzle(sqlite);
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
console.log("Migrations applied successfully");
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
export const categories = sqliteTable("categories", {
|
export const categories = sqliteTable("categories", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import { seedDefaults } from "../db/seed.ts";
|
import { seedDefaults } from "../db/seed.ts";
|
||||||
import { itemRoutes } from "./routes/items.ts";
|
|
||||||
import { categoryRoutes } from "./routes/categories.ts";
|
import { categoryRoutes } from "./routes/categories.ts";
|
||||||
import { totalRoutes } from "./routes/totals.ts";
|
|
||||||
import { imageRoutes } from "./routes/images.ts";
|
import { imageRoutes } from "./routes/images.ts";
|
||||||
|
import { itemRoutes } from "./routes/items.ts";
|
||||||
import { settingsRoutes } from "./routes/settings.ts";
|
import { settingsRoutes } from "./routes/settings.ts";
|
||||||
import { threadRoutes } from "./routes/threads.ts";
|
|
||||||
import { setupRoutes } from "./routes/setups.ts";
|
import { setupRoutes } from "./routes/setups.ts";
|
||||||
|
import { threadRoutes } from "./routes/threads.ts";
|
||||||
|
import { totalRoutes } from "./routes/totals.ts";
|
||||||
|
|
||||||
// Seed default data on startup
|
// Seed default data on startup
|
||||||
seedDefaults();
|
seedDefaults();
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
createCategorySchema,
|
createCategorySchema,
|
||||||
updateCategorySchema,
|
updateCategorySchema,
|
||||||
} from "../../shared/schemas.ts";
|
} from "../../shared/schemas.ts";
|
||||||
import {
|
import {
|
||||||
getAllCategories,
|
|
||||||
createCategory,
|
createCategory,
|
||||||
updateCategory,
|
|
||||||
deleteCategory,
|
deleteCategory,
|
||||||
|
getAllCategories,
|
||||||
|
updateCategory,
|
||||||
} from "../services/category.service.ts";
|
} from "../services/category.service.ts";
|
||||||
|
|
||||||
type Env = { Variables: { db?: any } };
|
type Env = { Variables: { db?: any } };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { join } from "node:path";
|
|
||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
@@ -10,7 +10,7 @@ const app = new Hono();
|
|||||||
|
|
||||||
app.post("/", async (c) => {
|
app.post("/", async (c) => {
|
||||||
const body = await c.req.parseBody();
|
const body = await c.req.parseBody();
|
||||||
const file = body["image"];
|
const file = body.image;
|
||||||
|
|
||||||
if (!file || typeof file === "string") {
|
if (!file || typeof file === "string") {
|
||||||
return c.json({ error: "No image file provided" }, 400);
|
return c.json({ error: "No image file provided" }, 400);
|
||||||
@@ -30,7 +30,8 @@ app.post("/", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
const ext =
|
||||||
|
file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
||||||
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
|
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
|
||||||
|
|
||||||
// Ensure uploads directory exists
|
// Ensure uploads directory exists
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { zValidator } from "@hono/zod-validator";
|
|
||||||
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
|
||||||
import {
|
|
||||||
getAllItems,
|
|
||||||
getItemById,
|
|
||||||
createItem,
|
|
||||||
updateItem,
|
|
||||||
deleteItem,
|
|
||||||
} from "../services/item.service.ts";
|
|
||||||
import { unlink } from "node:fs/promises";
|
import { unlink } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
||||||
|
import {
|
||||||
|
createItem,
|
||||||
|
deleteItem,
|
||||||
|
getAllItems,
|
||||||
|
getItemById,
|
||||||
|
updateItem,
|
||||||
|
} from "../services/item.service.ts";
|
||||||
|
|
||||||
type Env = { Variables: { db?: any } };
|
type Env = { Variables: { db?: any } };
|
||||||
|
|
||||||
@@ -36,14 +36,18 @@ app.post("/", zValidator("json", createItemSchema), (c) => {
|
|||||||
return c.json(item, 201);
|
return c.json(item, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put("/:id", zValidator("json", updateItemSchema.omit({ id: true })), (c) => {
|
app.put(
|
||||||
|
"/:id",
|
||||||
|
zValidator("json", updateItemSchema.omit({ id: true })),
|
||||||
|
(c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
const data = c.req.valid("json");
|
const data = c.req.valid("json");
|
||||||
const item = updateItem(db, id, data);
|
const item = updateItem(db, id, data);
|
||||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||||
return c.json(item);
|
return c.json(item);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.delete("/:id", async (c) => {
|
app.delete("/:id", async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { Hono } from "hono";
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
import { settings } from "../../db/schema.ts";
|
import { settings } from "../../db/schema.ts";
|
||||||
|
|
||||||
@@ -10,7 +10,11 @@ const app = new Hono<Env>();
|
|||||||
app.get("/:key", (c) => {
|
app.get("/:key", (c) => {
|
||||||
const database = c.get("db") ?? prodDb;
|
const database = c.get("db") ?? prodDb;
|
||||||
const key = c.req.param("key");
|
const key = c.req.param("key");
|
||||||
const row = database.select().from(settings).where(eq(settings.key, key)).get();
|
const row = database
|
||||||
|
.select()
|
||||||
|
.from(settings)
|
||||||
|
.where(eq(settings.key, key))
|
||||||
|
.get();
|
||||||
if (!row) return c.json({ error: "Setting not found" }, 404);
|
if (!row) return c.json({ error: "Setting not found" }, 404);
|
||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
@@ -30,7 +34,11 @@ app.put("/:key", async (c) => {
|
|||||||
.onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
|
.onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
const row = database.select().from(settings).where(eq(settings.key, key)).get();
|
const row = database
|
||||||
|
.select()
|
||||||
|
.from(settings)
|
||||||
|
.where(eq(settings.key, key))
|
||||||
|
.get();
|
||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
createSetupSchema,
|
createSetupSchema,
|
||||||
updateSetupSchema,
|
|
||||||
syncSetupItemsSchema,
|
syncSetupItemsSchema,
|
||||||
|
updateSetupSchema,
|
||||||
} from "../../shared/schemas.ts";
|
} from "../../shared/schemas.ts";
|
||||||
import {
|
import {
|
||||||
|
createSetup,
|
||||||
|
deleteSetup,
|
||||||
getAllSetups,
|
getAllSetups,
|
||||||
getSetupWithItems,
|
getSetupWithItems,
|
||||||
createSetup,
|
|
||||||
updateSetup,
|
|
||||||
deleteSetup,
|
|
||||||
syncSetupItems,
|
|
||||||
removeSetupItem,
|
removeSetupItem,
|
||||||
|
syncSetupItems,
|
||||||
|
updateSetup,
|
||||||
} from "../services/setup.service.ts";
|
} from "../services/setup.service.ts";
|
||||||
|
|
||||||
type Env = { Variables: { db?: any } };
|
type Env = { Variables: { db?: any } };
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { zValidator } from "@hono/zod-validator";
|
|
||||||
import {
|
|
||||||
createThreadSchema,
|
|
||||||
updateThreadSchema,
|
|
||||||
createCandidateSchema,
|
|
||||||
updateCandidateSchema,
|
|
||||||
resolveThreadSchema,
|
|
||||||
} from "../../shared/schemas.ts";
|
|
||||||
import {
|
|
||||||
getAllThreads,
|
|
||||||
getThreadWithCandidates,
|
|
||||||
createThread,
|
|
||||||
updateThread,
|
|
||||||
deleteThread,
|
|
||||||
createCandidate,
|
|
||||||
updateCandidate,
|
|
||||||
deleteCandidate,
|
|
||||||
resolveThread,
|
|
||||||
} from "../services/thread.service.ts";
|
|
||||||
import { unlink } from "node:fs/promises";
|
import { unlink } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import {
|
||||||
|
createCandidateSchema,
|
||||||
|
createThreadSchema,
|
||||||
|
resolveThreadSchema,
|
||||||
|
updateCandidateSchema,
|
||||||
|
updateThreadSchema,
|
||||||
|
} from "../../shared/schemas.ts";
|
||||||
|
import {
|
||||||
|
createCandidate,
|
||||||
|
createThread,
|
||||||
|
deleteCandidate,
|
||||||
|
deleteThread,
|
||||||
|
getAllThreads,
|
||||||
|
getThreadWithCandidates,
|
||||||
|
resolveThread,
|
||||||
|
updateCandidate,
|
||||||
|
updateThread,
|
||||||
|
} from "../services/thread.service.ts";
|
||||||
|
|
||||||
type Env = { Variables: { db?: any } };
|
type Env = { Variables: { db?: any } };
|
||||||
|
|
||||||
@@ -91,14 +91,18 @@ app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => {
|
|||||||
return c.json(candidate, 201);
|
return c.json(candidate, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put("/:threadId/candidates/:candidateId", zValidator("json", updateCandidateSchema), (c) => {
|
app.put(
|
||||||
|
"/:threadId/candidates/:candidateId",
|
||||||
|
zValidator("json", updateCandidateSchema),
|
||||||
|
(c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const candidateId = Number(c.req.param("candidateId"));
|
const candidateId = Number(c.req.param("candidateId"));
|
||||||
const data = c.req.valid("json");
|
const data = c.req.valid("json");
|
||||||
const candidate = updateCandidate(db, candidateId, data);
|
const candidate = updateCandidate(db, candidateId, data);
|
||||||
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
|
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
|
||||||
return c.json(candidate);
|
return c.json(candidate);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.delete("/:threadId/candidates/:candidateId", async (c) => {
|
app.delete("/:threadId/candidates/:candidateId", async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq, asc } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
import { categories, items } from "../../db/schema.ts";
|
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { categories, items } from "../../db/schema.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
@@ -49,7 +49,10 @@ export function deleteCategory(
|
|||||||
): { success: boolean; error?: string } {
|
): { success: boolean; error?: string } {
|
||||||
// Guard: cannot delete Uncategorized (id=1)
|
// Guard: cannot delete Uncategorized (id=1)
|
||||||
if (id === 1) {
|
if (id === 1) {
|
||||||
return { success: false, error: "Cannot delete the Uncategorized category" };
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Cannot delete the Uncategorized category",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if category exists
|
// Check if category exists
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { items, categories } from "../../db/schema.ts";
|
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { categories, items } from "../../db/schema.ts";
|
||||||
import type { CreateItem } from "../../shared/types.ts";
|
import type { CreateItem } from "../../shared/types.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
@@ -49,7 +49,11 @@ export function getItemById(db: Db = prodDb, id: number) {
|
|||||||
|
|
||||||
export function createItem(
|
export function createItem(
|
||||||
db: Db = prodDb,
|
db: Db = prodDb,
|
||||||
data: Partial<CreateItem> & { name: string; categoryId: number; imageFilename?: string },
|
data: Partial<CreateItem> & {
|
||||||
|
name: string;
|
||||||
|
categoryId: number;
|
||||||
|
imageFilename?: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return db
|
return db
|
||||||
.insert(items)
|
.insert(items)
|
||||||
@@ -98,11 +102,7 @@ export function updateItem(
|
|||||||
|
|
||||||
export function deleteItem(db: Db = prodDb, id: number) {
|
export function deleteItem(db: Db = prodDb, id: number) {
|
||||||
// Get item first (for image cleanup info)
|
// Get item first (for image cleanup info)
|
||||||
const item = db
|
const item = db.select().from(items).where(eq(items.id, id)).get();
|
||||||
.select()
|
|
||||||
.from(items)
|
|
||||||
.where(eq(items.id, id))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { setups, setupItems, items, categories } from "../../db/schema.ts";
|
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { categories, items, setupItems, setups } from "../../db/schema.ts";
|
||||||
import type { CreateSetup, UpdateSetup } from "../../shared/types.ts";
|
import type { CreateSetup, UpdateSetup } from "../../shared/types.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
export function createSetup(db: Db = prodDb, data: CreateSetup) {
|
export function createSetup(db: Db = prodDb, data: CreateSetup) {
|
||||||
return db
|
return db.insert(setups).values({ name: data.name }).returning().get();
|
||||||
.insert(setups)
|
|
||||||
.values({ name: data.name })
|
|
||||||
.returning()
|
|
||||||
.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllSetups(db: Db = prodDb) {
|
export function getAllSetups(db: Db = prodDb) {
|
||||||
@@ -40,8 +36,7 @@ export function getAllSetups(db: Db = prodDb) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSetupWithItems(db: Db = prodDb, setupId: number) {
|
export function getSetupWithItems(db: Db = prodDb, setupId: number) {
|
||||||
const setup = db.select().from(setups)
|
const setup = db.select().from(setups).where(eq(setups.id, setupId)).get();
|
||||||
.where(eq(setups.id, setupId)).get();
|
|
||||||
if (!setup) return null;
|
if (!setup) return null;
|
||||||
|
|
||||||
const itemList = db
|
const itemList = db
|
||||||
@@ -68,9 +63,16 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) {
|
|||||||
return { ...setup, items: itemList };
|
return { ...setup, items: itemList };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSetup(db: Db = prodDb, setupId: number, data: UpdateSetup) {
|
export function updateSetup(
|
||||||
const existing = db.select({ id: setups.id }).from(setups)
|
db: Db = prodDb,
|
||||||
.where(eq(setups.id, setupId)).get();
|
setupId: number,
|
||||||
|
data: UpdateSetup,
|
||||||
|
) {
|
||||||
|
const existing = db
|
||||||
|
.select({ id: setups.id })
|
||||||
|
.from(setups)
|
||||||
|
.where(eq(setups.id, setupId))
|
||||||
|
.get();
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
return db
|
return db
|
||||||
@@ -82,15 +84,22 @@ export function updateSetup(db: Db = prodDb, setupId: number, data: UpdateSetup)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deleteSetup(db: Db = prodDb, setupId: number) {
|
export function deleteSetup(db: Db = prodDb, setupId: number) {
|
||||||
const existing = db.select({ id: setups.id }).from(setups)
|
const existing = db
|
||||||
.where(eq(setups.id, setupId)).get();
|
.select({ id: setups.id })
|
||||||
|
.from(setups)
|
||||||
|
.where(eq(setups.id, setupId))
|
||||||
|
.get();
|
||||||
if (!existing) return false;
|
if (!existing) return false;
|
||||||
|
|
||||||
db.delete(setups).where(eq(setups.id, setupId)).run();
|
db.delete(setups).where(eq(setups.id, setupId)).run();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncSetupItems(db: Db = prodDb, setupId: number, itemIds: number[]) {
|
export function syncSetupItems(
|
||||||
|
db: Db = prodDb,
|
||||||
|
setupId: number,
|
||||||
|
itemIds: number[],
|
||||||
|
) {
|
||||||
return db.transaction((tx) => {
|
return db.transaction((tx) => {
|
||||||
// Delete all existing items for this setup
|
// Delete all existing items for this setup
|
||||||
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
|
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
|
||||||
@@ -102,10 +111,14 @@ export function syncSetupItems(db: Db = prodDb, setupId: number, itemIds: number
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeSetupItem(db: Db = prodDb, setupId: number, itemId: number) {
|
export function removeSetupItem(
|
||||||
|
db: Db = prodDb,
|
||||||
|
setupId: number,
|
||||||
|
itemId: number,
|
||||||
|
) {
|
||||||
db.delete(setupItems)
|
db.delete(setupItems)
|
||||||
.where(
|
.where(
|
||||||
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`
|
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { eq, desc, sql } from "drizzle-orm";
|
import { desc, eq, sql } from "drizzle-orm";
|
||||||
import { threads, threadCandidates, items, categories } from "../../db/schema.ts";
|
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
import type { CreateThread, UpdateThread, CreateCandidate } from "../../shared/types.ts";
|
import {
|
||||||
|
categories,
|
||||||
|
items,
|
||||||
|
threadCandidates,
|
||||||
|
threads,
|
||||||
|
} from "../../db/schema.ts";
|
||||||
|
import type { CreateCandidate, CreateThread } from "../../shared/types.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
@@ -49,8 +54,11 @@ export function getAllThreads(db: Db = prodDb, includeResolved = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
|
export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
|
||||||
const thread = db.select().from(threads)
|
const thread = db
|
||||||
.where(eq(threads.id, threadId)).get();
|
.select()
|
||||||
|
.from(threads)
|
||||||
|
.where(eq(threads.id, threadId))
|
||||||
|
.get();
|
||||||
if (!thread) return null;
|
if (!thread) return null;
|
||||||
|
|
||||||
const candidateList = db
|
const candidateList = db
|
||||||
@@ -77,9 +85,16 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
|
|||||||
return { ...thread, candidates: candidateList };
|
return { ...thread, candidates: candidateList };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateThread(db: Db = prodDb, threadId: number, data: Partial<{ name: string; categoryId: number }>) {
|
export function updateThread(
|
||||||
const existing = db.select({ id: threads.id }).from(threads)
|
db: Db = prodDb,
|
||||||
.where(eq(threads.id, threadId)).get();
|
threadId: number,
|
||||||
|
data: Partial<{ name: string; categoryId: number }>,
|
||||||
|
) {
|
||||||
|
const existing = db
|
||||||
|
.select({ id: threads.id })
|
||||||
|
.from(threads)
|
||||||
|
.where(eq(threads.id, threadId))
|
||||||
|
.get();
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
return db
|
return db
|
||||||
@@ -91,8 +106,11 @@ export function updateThread(db: Db = prodDb, threadId: number, data: Partial<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deleteThread(db: Db = prodDb, threadId: number) {
|
export function deleteThread(db: Db = prodDb, threadId: number) {
|
||||||
const thread = db.select().from(threads)
|
const thread = db
|
||||||
.where(eq(threads.id, threadId)).get();
|
.select()
|
||||||
|
.from(threads)
|
||||||
|
.where(eq(threads.id, threadId))
|
||||||
|
.get();
|
||||||
if (!thread) return null;
|
if (!thread) return null;
|
||||||
|
|
||||||
// Collect candidate image filenames for cleanup
|
// Collect candidate image filenames for cleanup
|
||||||
@@ -105,13 +123,20 @@ export function deleteThread(db: Db = prodDb, threadId: number) {
|
|||||||
|
|
||||||
db.delete(threads).where(eq(threads.id, threadId)).run();
|
db.delete(threads).where(eq(threads.id, threadId)).run();
|
||||||
|
|
||||||
return { ...thread, candidateImages: candidatesWithImages.map((c) => c.imageFilename!) };
|
return {
|
||||||
|
...thread,
|
||||||
|
candidateImages: candidatesWithImages.map((c) => c.imageFilename!),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCandidate(
|
export function createCandidate(
|
||||||
db: Db = prodDb,
|
db: Db = prodDb,
|
||||||
threadId: number,
|
threadId: number,
|
||||||
data: Partial<CreateCandidate> & { name: string; categoryId: number; imageFilename?: string },
|
data: Partial<CreateCandidate> & {
|
||||||
|
name: string;
|
||||||
|
categoryId: number;
|
||||||
|
imageFilename?: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return db
|
return db
|
||||||
.insert(threadCandidates)
|
.insert(threadCandidates)
|
||||||
@@ -142,8 +167,11 @@ export function updateCandidate(
|
|||||||
imageFilename: string;
|
imageFilename: string;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
const existing = db.select({ id: threadCandidates.id }).from(threadCandidates)
|
const existing = db
|
||||||
.where(eq(threadCandidates.id, candidateId)).get();
|
.select({ id: threadCandidates.id })
|
||||||
|
.from(threadCandidates)
|
||||||
|
.where(eq(threadCandidates.id, candidateId))
|
||||||
|
.get();
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
return db
|
return db
|
||||||
@@ -155,8 +183,11 @@ export function updateCandidate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deleteCandidate(db: Db = prodDb, candidateId: number) {
|
export function deleteCandidate(db: Db = prodDb, candidateId: number) {
|
||||||
const candidate = db.select().from(threadCandidates)
|
const candidate = db
|
||||||
.where(eq(threadCandidates.id, candidateId)).get();
|
.select()
|
||||||
|
.from(threadCandidates)
|
||||||
|
.where(eq(threadCandidates.id, candidateId))
|
||||||
|
.get();
|
||||||
if (!candidate) return null;
|
if (!candidate) return null;
|
||||||
|
|
||||||
db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run();
|
db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run();
|
||||||
@@ -170,15 +201,21 @@ export function resolveThread(
|
|||||||
): { success: boolean; item?: any; error?: string } {
|
): { success: boolean; item?: any; error?: string } {
|
||||||
return db.transaction((tx) => {
|
return db.transaction((tx) => {
|
||||||
// 1. Check thread is active
|
// 1. Check thread is active
|
||||||
const thread = tx.select().from(threads)
|
const thread = tx
|
||||||
.where(eq(threads.id, threadId)).get();
|
.select()
|
||||||
|
.from(threads)
|
||||||
|
.where(eq(threads.id, threadId))
|
||||||
|
.get();
|
||||||
if (!thread || thread.status !== "active") {
|
if (!thread || thread.status !== "active") {
|
||||||
return { success: false, error: "Thread not active" };
|
return { success: false, error: "Thread not active" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get the candidate data
|
// 2. Get the candidate data
|
||||||
const candidate = tx.select().from(threadCandidates)
|
const candidate = tx
|
||||||
.where(eq(threadCandidates.id, candidateId)).get();
|
.select()
|
||||||
|
.from(threadCandidates)
|
||||||
|
.where(eq(threadCandidates.id, candidateId))
|
||||||
|
.get();
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
return { success: false, error: "Candidate not found" };
|
return { success: false, error: "Candidate not found" };
|
||||||
}
|
}
|
||||||
@@ -187,8 +224,11 @@ export function resolveThread(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Verify categoryId still exists, fallback to Uncategorized (id=1)
|
// 3. Verify categoryId still exists, fallback to Uncategorized (id=1)
|
||||||
const category = tx.select({ id: categories.id }).from(categories)
|
const category = tx
|
||||||
.where(eq(categories.id, candidate.categoryId)).get();
|
.select({ id: categories.id })
|
||||||
|
.from(categories)
|
||||||
|
.where(eq(categories.id, candidate.categoryId))
|
||||||
|
.get();
|
||||||
const safeCategoryId = category ? candidate.categoryId : 1;
|
const safeCategoryId = category ? candidate.categoryId : 1;
|
||||||
|
|
||||||
// 4. Create collection item from candidate data
|
// 4. Create collection item from candidate data
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { items, categories } from "../../db/schema.ts";
|
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { categories, items } from "../../db/schema.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import type {
|
import type {
|
||||||
createItemSchema,
|
categories,
|
||||||
updateItemSchema,
|
items,
|
||||||
createCategorySchema,
|
setupItems,
|
||||||
updateCategorySchema,
|
setups,
|
||||||
createThreadSchema,
|
threadCandidates,
|
||||||
updateThreadSchema,
|
threads,
|
||||||
|
} from "../db/schema.ts";
|
||||||
|
import type {
|
||||||
createCandidateSchema,
|
createCandidateSchema,
|
||||||
updateCandidateSchema,
|
createCategorySchema,
|
||||||
resolveThreadSchema,
|
createItemSchema,
|
||||||
createSetupSchema,
|
createSetupSchema,
|
||||||
updateSetupSchema,
|
createThreadSchema,
|
||||||
|
resolveThreadSchema,
|
||||||
syncSetupItemsSchema,
|
syncSetupItemsSchema,
|
||||||
|
updateCandidateSchema,
|
||||||
|
updateCategorySchema,
|
||||||
|
updateItemSchema,
|
||||||
|
updateSetupSchema,
|
||||||
|
updateThreadSchema,
|
||||||
} from "./schemas.ts";
|
} from "./schemas.ts";
|
||||||
import type { items, categories, threads, threadCandidates, setups, setupItems } from "../db/schema.ts";
|
|
||||||
|
|
||||||
// Types inferred from Zod schemas
|
// Types inferred from Zod schemas
|
||||||
export type CreateItem = z.infer<typeof createItemSchema>;
|
export type CreateItem = z.infer<typeof createItemSchema>;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
|
||||||
import { categoryRoutes } from "../../src/server/routes/categories.ts";
|
import { categoryRoutes } from "../../src/server/routes/categories.ts";
|
||||||
import { itemRoutes } from "../../src/server/routes/items.ts";
|
import { itemRoutes } from "../../src/server/routes/items.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
function createTestApp() {
|
function createTestApp() {
|
||||||
const db = createTestDb();
|
const db = createTestDb();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
|
||||||
import { itemRoutes } from "../../src/server/routes/items.ts";
|
|
||||||
import { categoryRoutes } from "../../src/server/routes/categories.ts";
|
import { categoryRoutes } from "../../src/server/routes/categories.ts";
|
||||||
|
import { itemRoutes } from "../../src/server/routes/items.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
function createTestApp() {
|
function createTestApp() {
|
||||||
const db = createTestDb();
|
const db = createTestDb();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
|
||||||
import { setupRoutes } from "../../src/server/routes/setups.ts";
|
|
||||||
import { itemRoutes } from "../../src/server/routes/items.ts";
|
import { itemRoutes } from "../../src/server/routes/items.ts";
|
||||||
|
import { setupRoutes } from "../../src/server/routes/setups.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
function createTestApp() {
|
function createTestApp() {
|
||||||
const db = createTestDb();
|
const db = createTestDb();
|
||||||
@@ -179,8 +179,14 @@ describe("Setup Routes", () => {
|
|||||||
describe("PUT /api/setups/:id/items", () => {
|
describe("PUT /api/setups/:id/items", () => {
|
||||||
it("syncs items to setup", async () => {
|
it("syncs items to setup", async () => {
|
||||||
const setup = await createSetupViaAPI(app, "Kit");
|
const setup = await createSetupViaAPI(app, "Kit");
|
||||||
const item1 = await createItemViaAPI(app, { name: "Item 1", categoryId: 1 });
|
const item1 = await createItemViaAPI(app, {
|
||||||
const item2 = await createItemViaAPI(app, { name: "Item 2", categoryId: 1 });
|
name: "Item 1",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
const item2 = await createItemViaAPI(app, {
|
||||||
|
name: "Item 2",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const res = await app.request(`/api/setups/${setup.id}/items`, {
|
const res = await app.request(`/api/setups/${setup.id}/items`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -202,8 +208,14 @@ describe("Setup Routes", () => {
|
|||||||
describe("DELETE /api/setups/:id/items/:itemId", () => {
|
describe("DELETE /api/setups/:id/items/:itemId", () => {
|
||||||
it("removes single item from setup", async () => {
|
it("removes single item from setup", async () => {
|
||||||
const setup = await createSetupViaAPI(app, "Kit");
|
const setup = await createSetupViaAPI(app, "Kit");
|
||||||
const item1 = await createItemViaAPI(app, { name: "Item 1", categoryId: 1 });
|
const item1 = await createItemViaAPI(app, {
|
||||||
const item2 = await createItemViaAPI(app, { name: "Item 2", categoryId: 1 });
|
name: "Item 1",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
const item2 = await createItemViaAPI(app, {
|
||||||
|
name: "Item 2",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
// Sync both items
|
// Sync both items
|
||||||
await app.request(`/api/setups/${setup.id}/items`, {
|
await app.request(`/api/setups/${setup.id}/items`, {
|
||||||
@@ -213,9 +225,12 @@ describe("Setup Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Remove one
|
// Remove one
|
||||||
const res = await app.request(`/api/setups/${setup.id}/items/${item1.id}`, {
|
const res = await app.request(
|
||||||
|
`/api/setups/${setup.id}/items/${item1.id}`,
|
||||||
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
|
||||||
import { threadRoutes } from "../../src/server/routes/threads.ts";
|
import { threadRoutes } from "../../src/server/routes/threads.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
function createTestApp() {
|
function createTestApp() {
|
||||||
const db = createTestDb();
|
const db = createTestDb();
|
||||||
@@ -87,7 +87,7 @@ describe("Thread Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("?includeResolved=true includes archived threads", async () => {
|
it("?includeResolved=true includes archived threads", async () => {
|
||||||
const t1 = await createThreadViaAPI(app, "Active");
|
const _t1 = await createThreadViaAPI(app, "Active");
|
||||||
const t2 = await createThreadViaAPI(app, "To Resolve");
|
const t2 = await createThreadViaAPI(app, "To Resolve");
|
||||||
const candidate = await createCandidateViaAPI(app, t2.id, {
|
const candidate = await createCandidateViaAPI(app, t2.id, {
|
||||||
name: "Winner",
|
name: "Winner",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { items } from "../../src/db/schema.ts";
|
||||||
import {
|
import {
|
||||||
getAllCategories,
|
|
||||||
createCategory,
|
createCategory,
|
||||||
updateCategory,
|
|
||||||
deleteCategory,
|
deleteCategory,
|
||||||
|
getAllCategories,
|
||||||
|
updateCategory,
|
||||||
} from "../../src/server/services/category.service.ts";
|
} from "../../src/server/services/category.service.ts";
|
||||||
import { createItem } from "../../src/server/services/item.service.ts";
|
import { createItem } from "../../src/server/services/item.service.ts";
|
||||||
import { items } from "../../src/db/schema.ts";
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
describe("Category Service", () => {
|
describe("Category Service", () => {
|
||||||
let db: ReturnType<typeof createTestDb>;
|
let db: ReturnType<typeof createTestDb>;
|
||||||
@@ -22,16 +22,16 @@ describe("Category Service", () => {
|
|||||||
const cat = createCategory(db, { name: "Shelter", icon: "tent" });
|
const cat = createCategory(db, { name: "Shelter", icon: "tent" });
|
||||||
|
|
||||||
expect(cat).toBeDefined();
|
expect(cat).toBeDefined();
|
||||||
expect(cat!.id).toBeGreaterThan(0);
|
expect(cat?.id).toBeGreaterThan(0);
|
||||||
expect(cat!.name).toBe("Shelter");
|
expect(cat?.name).toBe("Shelter");
|
||||||
expect(cat!.icon).toBe("tent");
|
expect(cat?.icon).toBe("tent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses default icon if not provided", () => {
|
it("uses default icon if not provided", () => {
|
||||||
const cat = createCategory(db, { name: "Cooking" });
|
const cat = createCategory(db, { name: "Cooking" });
|
||||||
|
|
||||||
expect(cat).toBeDefined();
|
expect(cat).toBeDefined();
|
||||||
expect(cat!.icon).toBe("package");
|
expect(cat?.icon).toBe("package");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,19 +49,19 @@ describe("Category Service", () => {
|
|||||||
describe("updateCategory", () => {
|
describe("updateCategory", () => {
|
||||||
it("renames category", () => {
|
it("renames category", () => {
|
||||||
const cat = createCategory(db, { name: "Shelter", icon: "tent" });
|
const cat = createCategory(db, { name: "Shelter", icon: "tent" });
|
||||||
const updated = updateCategory(db, cat!.id, { name: "Sleep System" });
|
const updated = updateCategory(db, cat?.id, { name: "Sleep System" });
|
||||||
|
|
||||||
expect(updated).toBeDefined();
|
expect(updated).toBeDefined();
|
||||||
expect(updated!.name).toBe("Sleep System");
|
expect(updated?.name).toBe("Sleep System");
|
||||||
expect(updated!.icon).toBe("tent");
|
expect(updated?.icon).toBe("tent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("changes icon", () => {
|
it("changes icon", () => {
|
||||||
const cat = createCategory(db, { name: "Shelter", icon: "tent" });
|
const cat = createCategory(db, { name: "Shelter", icon: "tent" });
|
||||||
const updated = updateCategory(db, cat!.id, { icon: "home" });
|
const updated = updateCategory(db, cat?.id, { icon: "home" });
|
||||||
|
|
||||||
expect(updated).toBeDefined();
|
expect(updated).toBeDefined();
|
||||||
expect(updated!.icon).toBe("home");
|
expect(updated?.icon).toBe("home");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for non-existent id", () => {
|
it("returns null for non-existent id", () => {
|
||||||
@@ -73,10 +73,10 @@ describe("Category Service", () => {
|
|||||||
describe("deleteCategory", () => {
|
describe("deleteCategory", () => {
|
||||||
it("reassigns items to Uncategorized (id=1) then deletes", () => {
|
it("reassigns items to Uncategorized (id=1) then deletes", () => {
|
||||||
const shelter = createCategory(db, { name: "Shelter", icon: "tent" });
|
const shelter = createCategory(db, { name: "Shelter", icon: "tent" });
|
||||||
createItem(db, { name: "Tent", categoryId: shelter!.id });
|
createItem(db, { name: "Tent", categoryId: shelter?.id });
|
||||||
createItem(db, { name: "Tarp", categoryId: shelter!.id });
|
createItem(db, { name: "Tarp", categoryId: shelter?.id });
|
||||||
|
|
||||||
const result = deleteCategory(db, shelter!.id);
|
const result = deleteCategory(db, shelter?.id);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
// Items should now be in Uncategorized (id=1)
|
// Items should now be in Uncategorized (id=1)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
|
||||||
import {
|
import {
|
||||||
|
createItem,
|
||||||
|
deleteItem,
|
||||||
getAllItems,
|
getAllItems,
|
||||||
getItemById,
|
getItemById,
|
||||||
createItem,
|
|
||||||
updateItem,
|
updateItem,
|
||||||
deleteItem,
|
|
||||||
} from "../../src/server/services/item.service.ts";
|
} from "../../src/server/services/item.service.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
describe("Item Service", () => {
|
describe("Item Service", () => {
|
||||||
let db: ReturnType<typeof createTestDb>;
|
let db: ReturnType<typeof createTestDb>;
|
||||||
@@ -17,39 +17,36 @@ describe("Item Service", () => {
|
|||||||
|
|
||||||
describe("createItem", () => {
|
describe("createItem", () => {
|
||||||
it("creates item with all fields, returns item with id and timestamps", () => {
|
it("creates item with all fields, returns item with id and timestamps", () => {
|
||||||
const item = createItem(
|
const item = createItem(db, {
|
||||||
db,
|
|
||||||
{
|
|
||||||
name: "Tent",
|
name: "Tent",
|
||||||
weightGrams: 1200,
|
weightGrams: 1200,
|
||||||
priceCents: 35000,
|
priceCents: 35000,
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
notes: "Ultralight 2-person",
|
notes: "Ultralight 2-person",
|
||||||
productUrl: "https://example.com/tent",
|
productUrl: "https://example.com/tent",
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(item).toBeDefined();
|
expect(item).toBeDefined();
|
||||||
expect(item!.id).toBeGreaterThan(0);
|
expect(item?.id).toBeGreaterThan(0);
|
||||||
expect(item!.name).toBe("Tent");
|
expect(item?.name).toBe("Tent");
|
||||||
expect(item!.weightGrams).toBe(1200);
|
expect(item?.weightGrams).toBe(1200);
|
||||||
expect(item!.priceCents).toBe(35000);
|
expect(item?.priceCents).toBe(35000);
|
||||||
expect(item!.categoryId).toBe(1);
|
expect(item?.categoryId).toBe(1);
|
||||||
expect(item!.notes).toBe("Ultralight 2-person");
|
expect(item?.notes).toBe("Ultralight 2-person");
|
||||||
expect(item!.productUrl).toBe("https://example.com/tent");
|
expect(item?.productUrl).toBe("https://example.com/tent");
|
||||||
expect(item!.createdAt).toBeDefined();
|
expect(item?.createdAt).toBeDefined();
|
||||||
expect(item!.updatedAt).toBeDefined();
|
expect(item?.updatedAt).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only name and categoryId are required, other fields optional", () => {
|
it("only name and categoryId are required, other fields optional", () => {
|
||||||
const item = createItem(db, { name: "Spork", categoryId: 1 });
|
const item = createItem(db, { name: "Spork", categoryId: 1 });
|
||||||
|
|
||||||
expect(item).toBeDefined();
|
expect(item).toBeDefined();
|
||||||
expect(item!.name).toBe("Spork");
|
expect(item?.name).toBe("Spork");
|
||||||
expect(item!.weightGrams).toBeNull();
|
expect(item?.weightGrams).toBeNull();
|
||||||
expect(item!.priceCents).toBeNull();
|
expect(item?.priceCents).toBeNull();
|
||||||
expect(item!.notes).toBeNull();
|
expect(item?.notes).toBeNull();
|
||||||
expect(item!.productUrl).toBeNull();
|
expect(item?.productUrl).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,9 +65,9 @@ describe("Item Service", () => {
|
|||||||
describe("getItemById", () => {
|
describe("getItemById", () => {
|
||||||
it("returns single item or null", () => {
|
it("returns single item or null", () => {
|
||||||
const created = createItem(db, { name: "Tent", categoryId: 1 });
|
const created = createItem(db, { name: "Tent", categoryId: 1 });
|
||||||
const found = getItemById(db, created!.id);
|
const found = getItemById(db, created?.id);
|
||||||
expect(found).toBeDefined();
|
expect(found).toBeDefined();
|
||||||
expect(found!.name).toBe("Tent");
|
expect(found?.name).toBe("Tent");
|
||||||
|
|
||||||
const notFound = getItemById(db, 9999);
|
const notFound = getItemById(db, 9999);
|
||||||
expect(notFound).toBeNull();
|
expect(notFound).toBeNull();
|
||||||
@@ -85,14 +82,14 @@ describe("Item Service", () => {
|
|||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = updateItem(db, created!.id, {
|
const updated = updateItem(db, created?.id, {
|
||||||
name: "Big Agnes Tent",
|
name: "Big Agnes Tent",
|
||||||
weightGrams: 1100,
|
weightGrams: 1100,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(updated).toBeDefined();
|
expect(updated).toBeDefined();
|
||||||
expect(updated!.name).toBe("Big Agnes Tent");
|
expect(updated?.name).toBe("Big Agnes Tent");
|
||||||
expect(updated!.weightGrams).toBe(1100);
|
expect(updated?.weightGrams).toBe(1100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for non-existent id", () => {
|
it("returns null for non-existent id", () => {
|
||||||
@@ -109,13 +106,13 @@ describe("Item Service", () => {
|
|||||||
imageFilename: "tent.jpg",
|
imageFilename: "tent.jpg",
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleted = deleteItem(db, created!.id);
|
const deleted = deleteItem(db, created?.id);
|
||||||
expect(deleted).toBeDefined();
|
expect(deleted).toBeDefined();
|
||||||
expect(deleted!.name).toBe("Tent");
|
expect(deleted?.name).toBe("Tent");
|
||||||
expect(deleted!.imageFilename).toBe("tent.jpg");
|
expect(deleted?.imageFilename).toBe("tent.jpg");
|
||||||
|
|
||||||
// Verify it's gone
|
// Verify it's gone
|
||||||
const found = getItemById(db, created!.id);
|
const found = getItemById(db, created?.id);
|
||||||
expect(found).toBeNull();
|
expect(found).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
import { createItem } from "../../src/server/services/item.service.ts";
|
||||||
import {
|
import {
|
||||||
|
createSetup,
|
||||||
|
deleteSetup,
|
||||||
getAllSetups,
|
getAllSetups,
|
||||||
getSetupWithItems,
|
getSetupWithItems,
|
||||||
createSetup,
|
|
||||||
updateSetup,
|
|
||||||
deleteSetup,
|
|
||||||
syncSetupItems,
|
|
||||||
removeSetupItem,
|
removeSetupItem,
|
||||||
|
syncSetupItems,
|
||||||
|
updateSetup,
|
||||||
} from "../../src/server/services/setup.service.ts";
|
} from "../../src/server/services/setup.service.ts";
|
||||||
import { createItem } from "../../src/server/services/item.service.ts";
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
describe("Setup Service", () => {
|
describe("Setup Service", () => {
|
||||||
let db: ReturnType<typeof createTestDb>;
|
let db: ReturnType<typeof createTestDb>;
|
||||||
@@ -79,11 +79,11 @@ describe("Setup Service", () => {
|
|||||||
|
|
||||||
const result = getSetupWithItems(db, setup.id);
|
const result = getSetupWithItems(db, setup.id);
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result!.name).toBe("Day Hike");
|
expect(result?.name).toBe("Day Hike");
|
||||||
expect(result!.items).toHaveLength(1);
|
expect(result?.items).toHaveLength(1);
|
||||||
expect(result!.items[0].name).toBe("Water Bottle");
|
expect(result?.items[0].name).toBe("Water Bottle");
|
||||||
expect(result!.items[0].categoryName).toBe("Uncategorized");
|
expect(result?.items[0].categoryName).toBe("Uncategorized");
|
||||||
expect(result!.items[0].categoryIcon).toBeDefined();
|
expect(result?.items[0].categoryIcon).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for non-existent setup", () => {
|
it("returns null for non-existent setup", () => {
|
||||||
@@ -98,7 +98,7 @@ describe("Setup Service", () => {
|
|||||||
const updated = updateSetup(db, setup.id, { name: "Renamed" });
|
const updated = updateSetup(db, setup.id, { name: "Renamed" });
|
||||||
|
|
||||||
expect(updated).toBeDefined();
|
expect(updated).toBeDefined();
|
||||||
expect(updated!.name).toBe("Renamed");
|
expect(updated?.name).toBe("Renamed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for non-existent setup", () => {
|
it("returns null for non-existent setup", () => {
|
||||||
@@ -137,13 +137,13 @@ describe("Setup Service", () => {
|
|||||||
// Initial sync
|
// Initial sync
|
||||||
syncSetupItems(db, setup.id, [item1.id, item2.id]);
|
syncSetupItems(db, setup.id, [item1.id, item2.id]);
|
||||||
let result = getSetupWithItems(db, setup.id);
|
let result = getSetupWithItems(db, setup.id);
|
||||||
expect(result!.items).toHaveLength(2);
|
expect(result?.items).toHaveLength(2);
|
||||||
|
|
||||||
// Re-sync with different items
|
// Re-sync with different items
|
||||||
syncSetupItems(db, setup.id, [item2.id, item3.id]);
|
syncSetupItems(db, setup.id, [item2.id, item3.id]);
|
||||||
result = getSetupWithItems(db, setup.id);
|
result = getSetupWithItems(db, setup.id);
|
||||||
expect(result!.items).toHaveLength(2);
|
expect(result?.items).toHaveLength(2);
|
||||||
const names = result!.items.map((i: any) => i.name).sort();
|
const names = result?.items.map((i: any) => i.name).sort();
|
||||||
expect(names).toEqual(["Item 2", "Item 3"]);
|
expect(names).toEqual(["Item 2", "Item 3"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ describe("Setup Service", () => {
|
|||||||
|
|
||||||
syncSetupItems(db, setup.id, []);
|
syncSetupItems(db, setup.id, []);
|
||||||
const result = getSetupWithItems(db, setup.id);
|
const result = getSetupWithItems(db, setup.id);
|
||||||
expect(result!.items).toHaveLength(0);
|
expect(result?.items).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,8 +167,8 @@ describe("Setup Service", () => {
|
|||||||
|
|
||||||
removeSetupItem(db, setup.id, item1.id);
|
removeSetupItem(db, setup.id, item1.id);
|
||||||
const result = getSetupWithItems(db, setup.id);
|
const result = getSetupWithItems(db, setup.id);
|
||||||
expect(result!.items).toHaveLength(1);
|
expect(result?.items).toHaveLength(1);
|
||||||
expect(result!.items[0].name).toBe("Item 2");
|
expect(result?.items[0].name).toBe("Item 2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,8 +185,8 @@ describe("Setup Service", () => {
|
|||||||
db.delete(itemsTable).where(eq(itemsTable.id, item1.id)).run();
|
db.delete(itemsTable).where(eq(itemsTable.id, item1.id)).run();
|
||||||
|
|
||||||
const result = getSetupWithItems(db, setup.id);
|
const result = getSetupWithItems(db, setup.id);
|
||||||
expect(result!.items).toHaveLength(1);
|
expect(result?.items).toHaveLength(1);
|
||||||
expect(result!.items[0].name).toBe("Item 2");
|
expect(result?.items[0].name).toBe("Item 2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
|
||||||
import {
|
import {
|
||||||
|
createCandidate,
|
||||||
createThread,
|
createThread,
|
||||||
|
deleteCandidate,
|
||||||
|
deleteThread,
|
||||||
getAllThreads,
|
getAllThreads,
|
||||||
getThreadWithCandidates,
|
getThreadWithCandidates,
|
||||||
createCandidate,
|
|
||||||
updateCandidate,
|
|
||||||
deleteCandidate,
|
|
||||||
updateThread,
|
|
||||||
deleteThread,
|
|
||||||
resolveThread,
|
resolveThread,
|
||||||
|
updateCandidate,
|
||||||
|
updateThread,
|
||||||
} from "../../src/server/services/thread.service.ts";
|
} from "../../src/server/services/thread.service.ts";
|
||||||
import { createItem } from "../../src/server/services/item.service.ts";
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
describe("Thread Service", () => {
|
describe("Thread Service", () => {
|
||||||
let db: ReturnType<typeof createTestDb>;
|
let db: ReturnType<typeof createTestDb>;
|
||||||
@@ -36,7 +35,10 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
describe("getAllThreads", () => {
|
describe("getAllThreads", () => {
|
||||||
it("returns active threads with candidateCount and price range", () => {
|
it("returns active threads with candidateCount and price range", () => {
|
||||||
const thread = createThread(db, { name: "Backpack Options", categoryId: 1 });
|
const thread = createThread(db, {
|
||||||
|
name: "Backpack Options",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
createCandidate(db, thread.id, {
|
createCandidate(db, thread.id, {
|
||||||
name: "Pack A",
|
name: "Pack A",
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
@@ -57,7 +59,7 @@ describe("Thread Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("excludes resolved threads by default", () => {
|
it("excludes resolved threads by default", () => {
|
||||||
const t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
|
const _t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
|
||||||
const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
|
const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
|
||||||
const candidate = createCandidate(db, t2.id, {
|
const candidate = createCandidate(db, t2.id, {
|
||||||
name: "Winner",
|
name: "Winner",
|
||||||
@@ -71,7 +73,7 @@ describe("Thread Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes resolved threads when includeResolved=true", () => {
|
it("includes resolved threads when includeResolved=true", () => {
|
||||||
const t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
|
const _t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
|
||||||
const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
|
const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
|
||||||
const candidate = createCandidate(db, t2.id, {
|
const candidate = createCandidate(db, t2.id, {
|
||||||
name: "Winner",
|
name: "Winner",
|
||||||
@@ -96,11 +98,11 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
const result = getThreadWithCandidates(db, thread.id);
|
const result = getThreadWithCandidates(db, thread.id);
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result!.name).toBe("Tent Options");
|
expect(result?.name).toBe("Tent Options");
|
||||||
expect(result!.candidates).toHaveLength(1);
|
expect(result?.candidates).toHaveLength(1);
|
||||||
expect(result!.candidates[0].name).toBe("Tent A");
|
expect(result?.candidates[0].name).toBe("Tent A");
|
||||||
expect(result!.candidates[0].categoryName).toBe("Uncategorized");
|
expect(result?.candidates[0].categoryName).toBe("Uncategorized");
|
||||||
expect(result!.candidates[0].categoryIcon).toBeDefined();
|
expect(result?.candidates[0].categoryIcon).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for non-existent thread", () => {
|
it("returns null for non-existent thread", () => {
|
||||||
@@ -147,8 +149,8 @@ describe("Thread Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(updated).toBeDefined();
|
expect(updated).toBeDefined();
|
||||||
expect(updated!.name).toBe("Updated Name");
|
expect(updated?.name).toBe("Updated Name");
|
||||||
expect(updated!.priceCents).toBe(15000);
|
expect(updated?.priceCents).toBe(15000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for non-existent candidate", () => {
|
it("returns null for non-existent candidate", () => {
|
||||||
@@ -167,11 +169,11 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
const deleted = deleteCandidate(db, candidate.id);
|
const deleted = deleteCandidate(db, candidate.id);
|
||||||
expect(deleted).toBeDefined();
|
expect(deleted).toBeDefined();
|
||||||
expect(deleted!.name).toBe("To Delete");
|
expect(deleted?.name).toBe("To Delete");
|
||||||
|
|
||||||
// Verify it's gone
|
// Verify it's gone
|
||||||
const result = getThreadWithCandidates(db, thread.id);
|
const result = getThreadWithCandidates(db, thread.id);
|
||||||
expect(result!.candidates).toHaveLength(0);
|
expect(result?.candidates).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for non-existent candidate", () => {
|
it("returns null for non-existent candidate", () => {
|
||||||
@@ -186,7 +188,7 @@ describe("Thread Service", () => {
|
|||||||
const updated = updateThread(db, thread.id, { name: "Renamed" });
|
const updated = updateThread(db, thread.id, { name: "Renamed" });
|
||||||
|
|
||||||
expect(updated).toBeDefined();
|
expect(updated).toBeDefined();
|
||||||
expect(updated!.name).toBe("Renamed");
|
expect(updated?.name).toBe("Renamed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for non-existent thread", () => {
|
it("returns null for non-existent thread", () => {
|
||||||
@@ -202,7 +204,7 @@ describe("Thread Service", () => {
|
|||||||
|
|
||||||
const deleted = deleteThread(db, thread.id);
|
const deleted = deleteThread(db, thread.id);
|
||||||
expect(deleted).toBeDefined();
|
expect(deleted).toBeDefined();
|
||||||
expect(deleted!.name).toBe("To Delete");
|
expect(deleted?.name).toBe("To Delete");
|
||||||
|
|
||||||
// Thread and candidates gone
|
// Thread and candidates gone
|
||||||
const result = getThreadWithCandidates(db, thread.id);
|
const result = getThreadWithCandidates(db, thread.id);
|
||||||
@@ -230,21 +232,24 @@ describe("Thread Service", () => {
|
|||||||
const result = resolveThread(db, thread.id, candidate.id);
|
const result = resolveThread(db, thread.id, candidate.id);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.item).toBeDefined();
|
expect(result.item).toBeDefined();
|
||||||
expect(result.item!.name).toBe("Winner Tent");
|
expect(result.item?.name).toBe("Winner Tent");
|
||||||
expect(result.item!.weightGrams).toBe(1200);
|
expect(result.item?.weightGrams).toBe(1200);
|
||||||
expect(result.item!.priceCents).toBe(30000);
|
expect(result.item?.priceCents).toBe(30000);
|
||||||
expect(result.item!.categoryId).toBe(1);
|
expect(result.item?.categoryId).toBe(1);
|
||||||
expect(result.item!.notes).toBe("Best choice");
|
expect(result.item?.notes).toBe("Best choice");
|
||||||
expect(result.item!.productUrl).toBe("https://example.com/tent");
|
expect(result.item?.productUrl).toBe("https://example.com/tent");
|
||||||
|
|
||||||
// Thread should be resolved
|
// Thread should be resolved
|
||||||
const resolved = getThreadWithCandidates(db, thread.id);
|
const resolved = getThreadWithCandidates(db, thread.id);
|
||||||
expect(resolved!.status).toBe("resolved");
|
expect(resolved?.status).toBe("resolved");
|
||||||
expect(resolved!.resolvedCandidateId).toBe(candidate.id);
|
expect(resolved?.resolvedCandidateId).toBe(candidate.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails if thread is not active", () => {
|
it("fails if thread is not active", () => {
|
||||||
const thread = createThread(db, { name: "Already Resolved", categoryId: 1 });
|
const thread = createThread(db, {
|
||||||
|
name: "Already Resolved",
|
||||||
|
categoryId: 1,
|
||||||
|
});
|
||||||
const candidate = createCandidate(db, thread.id, {
|
const candidate = createCandidate(db, thread.id, {
|
||||||
name: "Winner",
|
name: "Winner",
|
||||||
categoryId: 1,
|
categoryId: 1,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
|
||||||
import { createItem } from "../../src/server/services/item.service.ts";
|
|
||||||
import { createCategory } from "../../src/server/services/category.service.ts";
|
import { createCategory } from "../../src/server/services/category.service.ts";
|
||||||
|
import { createItem } from "../../src/server/services/item.service.ts";
|
||||||
import {
|
import {
|
||||||
getCategoryTotals,
|
getCategoryTotals,
|
||||||
getGlobalTotals,
|
getGlobalTotals,
|
||||||
} from "../../src/server/services/totals.service.ts";
|
} from "../../src/server/services/totals.service.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
describe("Totals Service", () => {
|
describe("Totals Service", () => {
|
||||||
let db: ReturnType<typeof createTestDb>;
|
let db: ReturnType<typeof createTestDb>;
|
||||||
@@ -21,13 +21,13 @@ describe("Totals Service", () => {
|
|||||||
name: "Tent",
|
name: "Tent",
|
||||||
weightGrams: 1200,
|
weightGrams: 1200,
|
||||||
priceCents: 35000,
|
priceCents: 35000,
|
||||||
categoryId: shelter!.id,
|
categoryId: shelter?.id,
|
||||||
});
|
});
|
||||||
createItem(db, {
|
createItem(db, {
|
||||||
name: "Tarp",
|
name: "Tarp",
|
||||||
weightGrams: 300,
|
weightGrams: 300,
|
||||||
priceCents: 8000,
|
priceCents: 8000,
|
||||||
categoryId: shelter!.id,
|
categoryId: shelter?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totals = getCategoryTotals(db);
|
const totals = getCategoryTotals(db);
|
||||||
@@ -63,17 +63,17 @@ describe("Totals Service", () => {
|
|||||||
|
|
||||||
const totals = getGlobalTotals(db);
|
const totals = getGlobalTotals(db);
|
||||||
expect(totals).toBeDefined();
|
expect(totals).toBeDefined();
|
||||||
expect(totals!.totalWeight).toBe(1220);
|
expect(totals?.totalWeight).toBe(1220);
|
||||||
expect(totals!.totalCost).toBe(35500);
|
expect(totals?.totalCost).toBe(35500);
|
||||||
expect(totals!.itemCount).toBe(2);
|
expect(totals?.itemCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns zeros when no items exist", () => {
|
it("returns zeros when no items exist", () => {
|
||||||
const totals = getGlobalTotals(db);
|
const totals = getGlobalTotals(db);
|
||||||
expect(totals).toBeDefined();
|
expect(totals).toBeDefined();
|
||||||
expect(totals!.totalWeight).toBe(0);
|
expect(totals?.totalWeight).toBe(0);
|
||||||
expect(totals!.totalCost).toBe(0);
|
expect(totals?.totalCost).toBe(0);
|
||||||
expect(totals!.itemCount).toBe(0);
|
expect(totals?.itemCount).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
Reference in New Issue
Block a user