Compare commits
3 Commits
2829b95f7c
...
d519a83cc4
| Author | SHA1 | Date | |
|---|---|---|---|
| d519a83cc4 | |||
| 41e58d0153 | |||
| bd023acdd2 |
33
.env.example
33
.env.example
@@ -1,23 +1,22 @@
|
|||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
POSTGRES_PASSWORD=changeme
|
DATABASE_URL=postgresql://gearbox:changeme@localhost:5432/gearbox
|
||||||
|
|
||||||
# Logto OIDC (get from Logto Admin Console at http://localhost:3002)
|
# S3-compatible Object Storage (Garage, R2, AWS S3)
|
||||||
|
S3_ENDPOINT=http://localhost:3900
|
||||||
|
S3_ACCESS_KEY=your-access-key
|
||||||
|
S3_SECRET_KEY=your-secret-key
|
||||||
|
S3_BUCKET=gearbox-images
|
||||||
|
S3_REGION=garage
|
||||||
|
# S3_PRESIGN_EXPIRY=3600 # Presigned URL expiry in seconds (default: 1 hour)
|
||||||
|
|
||||||
|
# Logto OIDC
|
||||||
LOGTO_ENDPOINT=http://localhost:3001
|
LOGTO_ENDPOINT=http://localhost:3001
|
||||||
LOGTO_ADMIN_ENDPOINT=http://localhost:3002
|
OIDC_ISSUER=http://localhost:3001/oidc
|
||||||
LOGTO_CLIENT_ID=your-app-client-id
|
OIDC_CLIENT_ID=your-app-client-id
|
||||||
LOGTO_CLIENT_SECRET=your-app-client-secret
|
OIDC_CLIENT_SECRET=your-app-client-secret
|
||||||
OIDC_AUTH_SECRET=generate-a-random-32-char-string-here
|
OIDC_AUTH_SECRET=generate-a-random-32-char-string
|
||||||
|
OIDC_SCOPES=openid profile email
|
||||||
# Derived (set in docker-compose.yml, not needed here):
|
OIDC_REDIRECT_URI=http://localhost:5173/callback
|
||||||
# OIDC_ISSUER=${LOGTO_ENDPOINT}/oidc
|
|
||||||
|
|
||||||
# GearBox
|
# GearBox
|
||||||
GEARBOX_URL=http://localhost:3000
|
GEARBOX_URL=http://localhost:3000
|
||||||
|
|
||||||
# S3-compatible Object Storage (MinIO)
|
|
||||||
S3_ENDPOINT=http://localhost:9000
|
|
||||||
S3_ACCESS_KEY=minioadmin
|
|
||||||
S3_SECRET_KEY=minioadmin
|
|
||||||
S3_BUCKET=gearbox-images
|
|
||||||
S3_REGION=us-east-1
|
|
||||||
# S3_PRESIGN_EXPIRY=3600 # Presigned URL expiry in seconds (default: 1 hour)
|
|
||||||
|
|||||||
@@ -29,6 +29,31 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: ci
|
||||||
|
if: gitea.ref == 'refs/heads/Develop' && gitea.event_name == 'push'
|
||||||
|
runs-on: dind
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git curl docker-cli
|
||||||
|
git clone https://${{ secrets.GITEA_TOKEN }}@gitea.jeanlucmakiola.de/${{ gitea.repository }}.git repo
|
||||||
|
cd repo
|
||||||
|
git checkout Develop
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
working-directory: repo
|
||||||
|
run: |
|
||||||
|
REGISTRY="gitea.jeanlucmakiola.de"
|
||||||
|
IMAGE="${REGISTRY}/${{ gitea.repository_owner }}/gearbox"
|
||||||
|
docker build -t "${IMAGE}:develop" .
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ gitea.repository_owner }}" --password-stdin
|
||||||
|
docker push "${IMAGE}:develop"
|
||||||
|
|
||||||
|
- name: Trigger Coolify deploy
|
||||||
|
run: |
|
||||||
|
curl -s -X GET "${{ secrets.COOLIFY_WEBHOOK }}"
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
if: false # E2E tests need rewrite: auth moved from local login to OIDC (Logto). Tests still expect username/password flow.
|
if: false # E2E tests need rewrite: auth moved from local login to OIDC (Logto). Tests still expect username/password flow.
|
||||||
needs: ci
|
needs: ci
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -154,6 +154,7 @@ web_modules/
|
|||||||
|
|
||||||
# dotenv environment variable files
|
# dotenv environment variable files
|
||||||
.env
|
.env
|
||||||
|
.env.coolify-*
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ Requirements for this milestone. Each maps to roadmap phases.
|
|||||||
- [x] **CATFLOW-04**: Collection items referencing global items display merged data (global base + personal overlay)
|
- [x] **CATFLOW-04**: Collection items referencing global items display merged data (global base + personal overlay)
|
||||||
- [x] **CATFLOW-05**: Thread candidates can be added from catalog with global item link
|
- [x] **CATFLOW-05**: Thread candidates can be added from catalog with global item link
|
||||||
- [x] **CATFLOW-06**: Thread resolution with catalog-linked candidate creates reference item with auto-link
|
- [x] **CATFLOW-06**: Thread resolution with catalog-linked candidate creates reference item with auto-link
|
||||||
- [ ] **CATFLOW-07**: Manual entry fallback when item not in catalog
|
- [x] **CATFLOW-07**: Manual entry fallback when item not in catalog
|
||||||
- [ ] **CATFLOW-08**: Non-functional "Submit to catalog?" prompt shown after manual save
|
- [x] **CATFLOW-08**: Non-functional "Submit to catalog?" prompt shown after manual save
|
||||||
|
|
||||||
### Item & Catalog Detail Pages
|
### Item & Catalog Detail Pages
|
||||||
|
|
||||||
@@ -177,8 +177,8 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
| CATFLOW-04 | Phase 19 | Complete |
|
| CATFLOW-04 | Phase 19 | Complete |
|
||||||
| CATFLOW-05 | Phase 19, 22 | Complete |
|
| CATFLOW-05 | Phase 19, 22 | Complete |
|
||||||
| CATFLOW-06 | Phase 19, 22 | Complete |
|
| CATFLOW-06 | Phase 19, 22 | Complete |
|
||||||
| CATFLOW-07 | Phase 23 | Pending |
|
| CATFLOW-07 | Phase 23 | Complete |
|
||||||
| CATFLOW-08 | Phase 23 | Pending |
|
| CATFLOW-08 | Phase 23 | Complete |
|
||||||
| TAG-01 | Phase 19 | Complete |
|
| TAG-01 | Phase 19 | Complete |
|
||||||
| TAG-02 | Phase 19 | Complete |
|
| TAG-02 | Phase 19 | Complete |
|
||||||
| DETAIL-01 | Phase 21 | Pending |
|
| DETAIL-01 | Phase 21 | Pending |
|
||||||
|
|||||||
@@ -274,3 +274,13 @@ Plans:
|
|||||||
| 21. Item & Catalog Detail Pages | v2.0 | 1/1 | Complete | 2026-04-06 |
|
| 21. Item & Catalog Detail Pages | v2.0 | 1/1 | Complete | 2026-04-06 |
|
||||||
| 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 |
|
| 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 |
|
||||||
| 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 |
|
| 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 |
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
### Phase 999.1: Rewrite E2E Tests for OIDC Auth (BACKLOG)
|
||||||
|
**Goal**: E2E tests currently expect local username/password login but auth moved to external OIDC (Logto). Rewrite with mock OIDC provider or API-key-based auth bypass. Seed migration to Postgres is already done.
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"granularity": "coarse",
|
"granularity": "coarse",
|
||||||
"parallelization": true,
|
"parallelization": true,
|
||||||
"commit_docs": true,
|
"commit_docs": true,
|
||||||
"model_profile": "quality",
|
"model_profile": "balanced",
|
||||||
"workflow": {
|
"workflow": {
|
||||||
"research": true,
|
"research": true,
|
||||||
"plan_check": true,
|
"plan_check": true,
|
||||||
|
|||||||
16
CLAUDE.md
16
CLAUDE.md
@@ -86,12 +86,24 @@ curl -s -X POST "https://gitea.jeanlucmakiola.de/api/v1/repos/makiolaj/GearBox/a
|
|||||||
|
|
||||||
`@/*` maps to `./src/*` (configured in tsconfig.json).
|
`@/*` maps to `./src/*` (configured in tsconfig.json).
|
||||||
|
|
||||||
|
## Reusable Components
|
||||||
|
|
||||||
|
Always use existing components instead of rebuilding with plain HTML. Check `src/client/components/` before creating new form elements or UI patterns.
|
||||||
|
|
||||||
|
| Need | Use | Not |
|
||||||
|
|------|-----|-----|
|
||||||
|
| Category selection | `CategoryPicker` (icons, search, inline create) | Plain `<select>` |
|
||||||
|
| Icon selection | `IconPicker` (119 curated Lucide icons, grouped) | Manual icon input |
|
||||||
|
| Image upload | `ImageUpload` (preview, click-to-upload) | Raw file input |
|
||||||
|
| Lucide icon rendering | `LucideIcon` from `lib/iconData` | Direct SVG or lucide-react imports |
|
||||||
|
| Weight/price formatting | `useFormatters()` hook | Manual formatting |
|
||||||
|
|
||||||
## Key Patterns
|
## 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".
|
- **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).
|
- **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.
|
- **Image uploads**: `POST /api/images` saves to S3-compatible storage (Garage/R2), returned as `imageFilename` on item/candidate records.
|
||||||
- **Image URL fetching**: `POST /api/images/from-url` fetches an image from a URL, saves locally, returns `{ filename, sourceUrl }`.
|
- **Image URL fetching**: `POST /api/images/from-url` fetches an image from a URL, saves to S3, returns `{ filename, sourceUrl }`.
|
||||||
- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records.
|
- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records.
|
||||||
- **Authentication**: Public-read, authenticated-write. Cookie sessions for web UI, API keys (`X-API-Key` header) for programmatic access. `POST /api/auth/setup` for first-time account creation. Auth middleware protects all POST/PUT/DELETE on `/api/*` except `/api/auth/*`.
|
- **Authentication**: Public-read, authenticated-write. Cookie sessions for web UI, API keys (`X-API-Key` header) for programmatic access. `POST /api/auth/setup` for first-time account creation. Auth middleware protects all POST/PUT/DELETE on `/api/*` except `/api/auth/*`.
|
||||||
|
|
||||||
|
|||||||
108
README.md
108
README.md
@@ -1,6 +1,6 @@
|
|||||||
# 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.
|
A web app for managing gear collections (bikepacking, sim racing, etc.), tracking weight and price, and planning purchases through research threads.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -10,113 +10,65 @@ A single-user web app for managing gear collections (bikepacking, sim racing, et
|
|||||||
- Research threads for comparing candidates before buying
|
- Research threads for comparing candidates before buying
|
||||||
- Image uploads for items and candidates
|
- Image uploads for items and candidates
|
||||||
|
|
||||||
## Quick Start (Docker)
|
## Deployment
|
||||||
|
|
||||||
### Docker Compose (recommended)
|
GearBox is deployed via [Coolify](https://coolify.io/) as a Docker image with separate services for dependencies.
|
||||||
|
|
||||||
Create a `docker-compose.yml`:
|
**Required services:**
|
||||||
|
- **PostgreSQL 16** — primary database
|
||||||
|
- **Garage** (or any S3-compatible storage) — image uploads
|
||||||
|
- **Logto** — OIDC authentication
|
||||||
|
|
||||||
```yaml
|
**GearBox image:** `gitea.jeanlucmakiola.de/makiolaj/gearbox:latest`
|
||||||
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:
|
See `.env.example` for required environment variables.
|
||||||
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
|
## Updating
|
||||||
|
|
||||||
```bash
|
CI pushes a new Docker image on every release. Coolify auto-deploys when the image tag updates.
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Database migrations run automatically on startup.
|
Database migrations run automatically on startup via `entrypoint.sh`.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Runtime & Package Manager:** [Bun](https://bun.sh)
|
- **Runtime & Package Manager:** [Bun](https://bun.sh)
|
||||||
- **Frontend:** React 19, Vite, TanStack Router, TanStack Query, Tailwind CSS v4, Zustand
|
- **Frontend:** React 19, Vite, TanStack Router, TanStack Query, Tailwind CSS v4, Zustand
|
||||||
- **Backend:** Hono, Drizzle ORM, SQLite (`bun:sqlite`)
|
- **Backend:** Hono, Drizzle ORM, PostgreSQL
|
||||||
|
- **Storage:** S3-compatible (Garage, Cloudflare R2, AWS S3)
|
||||||
|
- **Auth:** OIDC via Logto
|
||||||
|
|
||||||
## Local Development Setup
|
## Local Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
You must have [Bun](https://bun.sh/) installed on your machine. Docker is not required for local development.
|
- [Bun](https://bun.sh/) installed
|
||||||
|
- PostgreSQL, Logto, and Garage running (via Coolify test instance or locally)
|
||||||
|
|
||||||
### Installation
|
### Setup
|
||||||
|
|
||||||
1. Install all dependencies:
|
1. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Initialize the local SQLite database (`gearbox.db`):
|
2. Copy and configure environment:
|
||||||
```bash
|
```bash
|
||||||
bun run db:push
|
cp .env.example .env
|
||||||
|
# Edit .env with your service URLs and credentials
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start the development servers:
|
3. Start the development servers:
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
This single command will start both the Vite frontend server (port `5173`) and the Hono backend server (port `3000`) concurrently.
|
Starts both the Vite frontend (port `5173`) and Hono backend (port `3000`).
|
||||||
|
|
||||||
Open [http://localhost:5173](http://localhost:5173) in your browser to view the app.
|
Open [http://localhost:5173](http://localhost:5173) in your browser.
|
||||||
|
|
||||||
## Additional Commands
|
## Commands
|
||||||
|
|
||||||
- `bun run build` — Build the production assets into `dist/client/`
|
- `bun run dev` — Start dev servers (frontend + backend)
|
||||||
- `bun test` — Run the test suite
|
- `bun run build` — Build production assets
|
||||||
- `bun run lint` — Check formatting and lint rules using Biome
|
- `bun test` — Run tests
|
||||||
- `bun run db:generate` — Generate Drizzle migrations after making schema changes
|
- `bun run lint` — Lint with Biome
|
||||||
|
- `bun run db:generate` — Generate Drizzle migrations after schema changes
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: gearbox
|
|
||||||
POSTGRES_PASSWORD: gearbox
|
|
||||||
POSTGRES_DB: gearbox
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- pgdata-dev:/var/lib/postgresql/data
|
|
||||||
- ./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U gearbox"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
logto:
|
|
||||||
image: svhd/logto:latest
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
- "3002:3002"
|
|
||||||
environment:
|
|
||||||
TRUST_PROXY_HEADER: "1"
|
|
||||||
DB_URL: postgres://gearbox:gearbox@postgres:5432/logto
|
|
||||||
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
|
|
||||||
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
|
|
||||||
|
|
||||||
# MinIO S3-compatible object storage for image uploads.
|
|
||||||
# Note: MinIO GitHub repo archived Feb 2026. The S3 API abstraction in
|
|
||||||
# storage.service.ts makes the provider swappable (SeaweedFS, Garage, AWS S3).
|
|
||||||
minio:
|
|
||||||
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
|
|
||||||
command: server /data --console-address ":9001"
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: minioadmin
|
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
|
||||||
ports:
|
|
||||||
- "9000:9000"
|
|
||||||
- "9001:9001"
|
|
||||||
volumes:
|
|
||||||
- minio-data-dev:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mc", "ready", "local"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
minio-init:
|
|
||||||
image: quay.io/minio/mc:latest
|
|
||||||
depends_on:
|
|
||||||
minio:
|
|
||||||
condition: service_healthy
|
|
||||||
entrypoint: >
|
|
||||||
/bin/sh -c "
|
|
||||||
mc alias set myminio http://minio:9000 minioadmin minioadmin;
|
|
||||||
mc mb --ignore-existing myminio/gearbox-images;
|
|
||||||
exit 0;
|
|
||||||
"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata-dev:
|
|
||||||
minio-data-dev:
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: gearbox
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
POSTGRES_DB: gearbox
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
- ./docker/init-logto-db.sql:/docker-entrypoint-initdb.d/init-logto-db.sql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U gearbox"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
logto:
|
|
||||||
image: svhd/logto:latest
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
- "3002:3002"
|
|
||||||
environment:
|
|
||||||
TRUST_PROXY_HEADER: "1"
|
|
||||||
DB_URL: postgres://gearbox:${POSTGRES_PASSWORD}@postgres:5432/logto
|
|
||||||
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
|
|
||||||
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
|
|
||||||
|
|
||||||
# MinIO S3-compatible object storage for image uploads.
|
|
||||||
# Note: MinIO GitHub repo archived Feb 2026. The S3 API abstraction in
|
|
||||||
# storage.service.ts makes the provider swappable (SeaweedFS, Garage, AWS S3).
|
|
||||||
minio:
|
|
||||||
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
|
|
||||||
command: server /data --console-address ":9001"
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY}
|
|
||||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY}
|
|
||||||
ports:
|
|
||||||
- "9000:9000"
|
|
||||||
volumes:
|
|
||||||
- minio-data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mc", "ready", "local"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
minio-init:
|
|
||||||
image: quay.io/minio/mc:latest
|
|
||||||
depends_on:
|
|
||||||
minio:
|
|
||||||
condition: service_healthy
|
|
||||||
entrypoint: >
|
|
||||||
/bin/sh -c "
|
|
||||||
mc alias set myminio http://minio:9000 ${S3_ACCESS_KEY:-minioadmin} ${S3_SECRET_KEY:-minioadmin};
|
|
||||||
mc mb --ignore-existing myminio/gearbox-images;
|
|
||||||
exit 0;
|
|
||||||
"
|
|
||||||
|
|
||||||
app:
|
|
||||||
image: gearbox:latest
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://gearbox:${POSTGRES_PASSWORD}@postgres:5432/gearbox
|
|
||||||
GEARBOX_URL: ${GEARBOX_URL}
|
|
||||||
OIDC_ISSUER: ${LOGTO_ENDPOINT:-http://localhost:3001}/oidc
|
|
||||||
OIDC_CLIENT_ID: ${LOGTO_CLIENT_ID}
|
|
||||||
OIDC_CLIENT_SECRET: ${LOGTO_CLIENT_SECRET}
|
|
||||||
OIDC_AUTH_SECRET: ${OIDC_AUTH_SECRET}
|
|
||||||
S3_ENDPOINT: http://minio:9000
|
|
||||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
|
||||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
|
||||||
S3_BUCKET: gearbox-images
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
logto:
|
|
||||||
condition: service_started
|
|
||||||
minio:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
minio-data:
|
|
||||||
20
docker/garage.toml
Normal file
20
docker/garage.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
metadata_dir = "/var/lib/garage/meta"
|
||||||
|
data_dir = "/var/lib/garage/data"
|
||||||
|
db_engine = "sqlite"
|
||||||
|
|
||||||
|
replication_factor = 1
|
||||||
|
|
||||||
|
[s3_api]
|
||||||
|
s3_region = "garage"
|
||||||
|
api_bind_addr = "[::]:3900"
|
||||||
|
root_domain = ".s3.garage.localhost"
|
||||||
|
|
||||||
|
[s3_web]
|
||||||
|
bind_addr = "[::]:3902"
|
||||||
|
root_domain = ".web.garage.localhost"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
api_bind_addr = "[::]:3903"
|
||||||
|
|
||||||
|
[rpc]
|
||||||
|
bind_addr = "[::]:3901"
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Creates a separate database for Logto on the shared Postgres instance
|
|
||||||
CREATE DATABASE logto;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* One-time migration script: uploads/ -> MinIO (S3-compatible object storage)
|
* One-time migration script: uploads/ -> S3-compatible object storage
|
||||||
*
|
*
|
||||||
* Reads all image files from the local uploads/ directory and uploads each
|
* Reads all image files from the local uploads/ directory and uploads each
|
||||||
* to the S3 bucket via the storage service. Preserves original filenames
|
* to the S3 bucket via the storage service. Preserves original filenames
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
*
|
*
|
||||||
* Prerequisites:
|
* Prerequisites:
|
||||||
* - S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY env vars must be set
|
* - S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY env vars must be set
|
||||||
* - The S3 bucket must exist (created by docker-compose or manually)
|
* - The S3 bucket must exist
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* bun run scripts/migrate-images-to-minio.ts
|
* bun run scripts/migrate-images-to-s3.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readdir } from "node:fs/promises";
|
import { readdir } from "node:fs/promises";
|
||||||
@@ -3,6 +3,7 @@ import { toast } from "sonner";
|
|||||||
import { useCategories } from "../hooks/useCategories";
|
import { useCategories } from "../hooks/useCategories";
|
||||||
import { useCreateItem } from "../hooks/useItems";
|
import { useCreateItem } from "../hooks/useItems";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { CategoryPicker } from "./CategoryPicker";
|
||||||
|
|
||||||
export function AddToCollectionModal() {
|
export function AddToCollectionModal() {
|
||||||
const { open, globalItemId, globalItemName } = useUIStore(
|
const { open, globalItemId, globalItemName } = useUIStore(
|
||||||
@@ -95,24 +96,13 @@ export function AddToCollectionModal() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
htmlFor="collection-category"
|
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Category
|
Category
|
||||||
</label>
|
</label>
|
||||||
<select
|
<CategoryPicker
|
||||||
id="collection-category"
|
value={categoryId ?? 0}
|
||||||
value={categoryId ?? ""}
|
onChange={(id) => setCategoryId(id)}
|
||||||
onChange={(e) => setCategoryId(Number(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-gray-400 focus:border-transparent bg-white"
|
|
||||||
>
|
|
||||||
{categories?.map((cat) => (
|
|
||||||
<option key={cat.id} value={cat.id}>
|
|
||||||
{cat.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export function CollectionView() {
|
|||||||
imageFilename={item.imageFilename}
|
imageFilename={item.imageFilename}
|
||||||
imageUrl={item.imageUrl}
|
imageUrl={item.imageUrl}
|
||||||
productUrl={item.productUrl}
|
productUrl={item.productUrl}
|
||||||
|
brand={item.brand}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,6 +265,7 @@ export function CollectionView() {
|
|||||||
categoryIcon={categoryIcon}
|
categoryIcon={categoryIcon}
|
||||||
imageFilename={item.imageFilename}
|
imageFilename={item.imageFilename}
|
||||||
productUrl={item.productUrl}
|
productUrl={item.productUrl}
|
||||||
|
brand={item.brand}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ interface ItemWithCategory {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
productUrl: string | null;
|
productUrl: string | null;
|
||||||
imageFilename: string | null;
|
imageFilename: string | null;
|
||||||
|
globalItemId: number | null;
|
||||||
|
brand: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const iconGroups: IconGroup[] = [
|
|||||||
// --- LucideIcon render component ---
|
// --- LucideIcon render component ---
|
||||||
|
|
||||||
function toPascalCase(str: string): string {
|
function toPascalCase(str: string): string {
|
||||||
|
if (!str) return "Package";
|
||||||
return str
|
return str
|
||||||
.split("-")
|
.split("-")
|
||||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export async function getAllItems(db: Db, userId: number) {
|
|||||||
)`.as("image_filename"),
|
)`.as("image_filename"),
|
||||||
imageSourceUrl: items.imageSourceUrl,
|
imageSourceUrl: items.imageSourceUrl,
|
||||||
globalItemId: items.globalItemId,
|
globalItemId: items.globalItemId,
|
||||||
|
brand: sql<
|
||||||
|
string | null
|
||||||
|
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
|
||||||
createdAt: items.createdAt,
|
createdAt: items.createdAt,
|
||||||
updatedAt: items.updatedAt,
|
updatedAt: items.updatedAt,
|
||||||
categoryName: categories.name,
|
categoryName: categories.name,
|
||||||
@@ -66,6 +69,7 @@ export async function getItemById(db: Db, userId: number, id: number) {
|
|||||||
${items.priceCents}
|
${items.priceCents}
|
||||||
)`.as("price_cents"),
|
)`.as("price_cents"),
|
||||||
purchasePriceCents: items.purchasePriceCents,
|
purchasePriceCents: items.purchasePriceCents,
|
||||||
|
quantity: items.quantity,
|
||||||
categoryId: items.categoryId,
|
categoryId: items.categoryId,
|
||||||
notes: items.notes,
|
notes: items.notes,
|
||||||
productUrl: items.productUrl,
|
productUrl: items.productUrl,
|
||||||
@@ -75,10 +79,16 @@ export async function getItemById(db: Db, userId: number, id: number) {
|
|||||||
)`.as("image_filename"),
|
)`.as("image_filename"),
|
||||||
imageSourceUrl: items.imageSourceUrl,
|
imageSourceUrl: items.imageSourceUrl,
|
||||||
globalItemId: items.globalItemId,
|
globalItemId: items.globalItemId,
|
||||||
|
brand: sql<
|
||||||
|
string | null
|
||||||
|
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
|
||||||
createdAt: items.createdAt,
|
createdAt: items.createdAt,
|
||||||
updatedAt: items.updatedAt,
|
updatedAt: items.updatedAt,
|
||||||
|
categoryName: categories.name,
|
||||||
|
categoryIcon: categories.icon,
|
||||||
})
|
})
|
||||||
.from(items)
|
.from(items)
|
||||||
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||||
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
||||||
|
|
||||||
@@ -143,6 +153,7 @@ export async function updateItem(
|
|||||||
imageSourceUrl: string;
|
imageSourceUrl: string;
|
||||||
globalItemId: number;
|
globalItemId: number;
|
||||||
purchasePriceCents: number;
|
purchasePriceCents: number;
|
||||||
|
brand: string;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
// Check if item exists and belongs to user
|
// Check if item exists and belongs to user
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import {
|
|||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
|
||||||
// MinIO GitHub repository was archived Feb 2026. The S3 API abstraction
|
// S3 API abstraction — provider-agnostic (Garage, Cloudflare R2, AWS S3).
|
||||||
// makes the underlying provider swappable (SeaweedFS, Garage, AWS S3, etc.)
|
|
||||||
// without code changes.
|
|
||||||
|
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
@@ -17,7 +15,7 @@ const s3 = new S3Client({
|
|||||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||||
secretAccessKey: process.env.S3_SECRET_KEY!,
|
secretAccessKey: process.env.S3_SECRET_KEY!,
|
||||||
},
|
},
|
||||||
forcePathStyle: true, // REQUIRED for MinIO and most S3-compatible services
|
forcePathStyle: true, // REQUIRED for Garage and most S3-compatible services
|
||||||
});
|
});
|
||||||
|
|
||||||
const bucket = process.env.S3_BUCKET ?? "gearbox-images";
|
const bucket = process.env.S3_BUCKET ?? "gearbox-images";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const createItemSchema = z.object({
|
|||||||
quantity: z.number().int().positive().optional(),
|
quantity: z.number().int().positive().optional(),
|
||||||
globalItemId: z.number().int().positive().optional(),
|
globalItemId: z.number().int().positive().optional(),
|
||||||
purchasePriceCents: z.number().int().nonnegative().optional(),
|
purchasePriceCents: z.number().int().nonnegative().optional(),
|
||||||
|
brand: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateItemSchema = createItemSchema.partial().extend({
|
export const updateItemSchema = createItemSchema.partial().extend({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|||||||
// Mock the S3 client send method
|
// Mock the S3 client send method
|
||||||
const mockSend = mock(() => Promise.resolve({}));
|
const mockSend = mock(() => Promise.resolve({}));
|
||||||
const mockGetSignedUrl = mock(() =>
|
const mockGetSignedUrl = mock(() =>
|
||||||
Promise.resolve("https://minio:9000/gearbox-images/test.jpg?signed=1"),
|
Promise.resolve("https://s3.example.com/gearbox-images/test.jpg?signed=1"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mock modules before importing the service
|
// Mock modules before importing the service
|
||||||
@@ -36,11 +36,11 @@ mock.module("@aws-sdk/s3-request-presigner", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Set env vars before importing the service
|
// Set env vars before importing the service
|
||||||
process.env.S3_ENDPOINT = "http://localhost:9000";
|
process.env.S3_ENDPOINT = "http://localhost:3900";
|
||||||
process.env.S3_ACCESS_KEY = "minioadmin";
|
process.env.S3_ACCESS_KEY = "test-access-key";
|
||||||
process.env.S3_SECRET_KEY = "minioadmin";
|
process.env.S3_SECRET_KEY = "test-secret-key";
|
||||||
process.env.S3_BUCKET = "gearbox-images";
|
process.env.S3_BUCKET = "gearbox-images";
|
||||||
process.env.S3_REGION = "us-east-1";
|
process.env.S3_REGION = "garage";
|
||||||
|
|
||||||
// Import after mocking
|
// Import after mocking
|
||||||
const { uploadImage, deleteImage, getImageUrl, withImageUrl, withImageUrls } =
|
const { uploadImage, deleteImage, getImageUrl, withImageUrl, withImageUrls } =
|
||||||
@@ -51,7 +51,7 @@ describe("storage.service", () => {
|
|||||||
mockSend.mockClear();
|
mockSend.mockClear();
|
||||||
mockGetSignedUrl.mockClear();
|
mockGetSignedUrl.mockClear();
|
||||||
mockGetSignedUrl.mockResolvedValue(
|
mockGetSignedUrl.mockResolvedValue(
|
||||||
"https://minio:9000/gearbox-images/test.jpg?signed=1",
|
"https://s3.example.com/gearbox-images/test.jpg?signed=1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ describe("storage.service", () => {
|
|||||||
const url = await getImageUrl("test-image.jpg");
|
const url = await getImageUrl("test-image.jpg");
|
||||||
|
|
||||||
expect(mockGetSignedUrl).toHaveBeenCalledTimes(1);
|
expect(mockGetSignedUrl).toHaveBeenCalledTimes(1);
|
||||||
expect(url).toBe("https://minio:9000/gearbox-images/test.jpg?signed=1");
|
expect(url).toBe("https://s3.example.com/gearbox-images/test.jpg?signed=1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ describe("storage.service", () => {
|
|||||||
const result = await withImageUrl(record);
|
const result = await withImageUrl(record);
|
||||||
|
|
||||||
expect(result.imageUrl).toBe(
|
expect(result.imageUrl).toBe(
|
||||||
"https://minio:9000/gearbox-images/test.jpg?signed=1",
|
"https://s3.example.com/gearbox-images/test.jpg?signed=1",
|
||||||
);
|
);
|
||||||
expect(result.id).toBe(1);
|
expect(result.id).toBe(1);
|
||||||
expect(mockGetSignedUrl).toHaveBeenCalledTimes(1);
|
expect(mockGetSignedUrl).toHaveBeenCalledTimes(1);
|
||||||
@@ -142,11 +142,11 @@ describe("storage.service", () => {
|
|||||||
|
|
||||||
expect(results).toHaveLength(3);
|
expect(results).toHaveLength(3);
|
||||||
expect(results[0].imageUrl).toBe(
|
expect(results[0].imageUrl).toBe(
|
||||||
"https://minio:9000/gearbox-images/test.jpg?signed=1",
|
"https://s3.example.com/gearbox-images/test.jpg?signed=1",
|
||||||
);
|
);
|
||||||
expect(results[1].imageUrl).toBeNull();
|
expect(results[1].imageUrl).toBeNull();
|
||||||
expect(results[2].imageUrl).toBe(
|
expect(results[2].imageUrl).toBe(
|
||||||
"https://minio:9000/gearbox-images/test.jpg?signed=1",
|
"https://s3.example.com/gearbox-images/test.jpg?signed=1",
|
||||||
);
|
);
|
||||||
// Called twice: for records[0] and records[2]
|
// Called twice: for records[0] and records[2]
|
||||||
expect(mockGetSignedUrl).toHaveBeenCalledTimes(2);
|
expect(mockGetSignedUrl).toHaveBeenCalledTimes(2);
|
||||||
|
|||||||
Reference in New Issue
Block a user