6 Commits

Author SHA1 Message Date
94ebd84cc7 refactor: move setups list into collection page as third tab
All checks were successful
CI / ci (push) Successful in 13s
Setups now lives alongside My Gear and Planning under /collection?tab=setups
instead of its own /setups route. Dashboard card updated to link to the new
tab. Setup detail pages (/setups/:id) remain unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 00:07:48 +01:00
5938a686c7 feat: add package icon as favicon and in top bar title
All checks were successful
CI / ci (push) Successful in 12s
Add Lucide package icon as SVG favicon (white stroke) and display it
next to the GearBox title in the TotalsBar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:57:43 +01:00
9bcdcc7168 style: replace blue accent with gray and mute card badge colors
Switch all interactive UI elements (buttons, focus rings, active tabs,
FAB, links, spinners) from blue to gray to match icon colors for a
more cohesive look. Mute card badge text colors to pastels (blue-400,
green-500, purple-500) to keep the focus on card content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:42:38 +01:00
628907bb20 docs: add user-facing README and update compose for production
All checks were successful
CI / ci (push) Successful in 20s
Add README with Docker setup instructions for self-hosting. Update
docker-compose.yml to use the pre-built registry image instead of
local build, and add a healthcheck against /api/health.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:53:29 +01:00
891bb248c8 fix: use bun-sqlite migrator instead of drizzle-kit push in Docker
All checks were successful
CI / ci (push) Successful in 21s
drizzle-kit push depends on better-sqlite3 which isn't supported in
Bun, causing migrations to fail and the server to crash-loop in prod.
Replace with drizzle-orm/bun-sqlite/migrator that applies the existing
SQL migration files using the native bun:sqlite driver.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:30:39 +01:00
81f89fd14e fix: install docker-cli on dind runner for image build
All checks were successful
CI / ci (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:53:41 +01:00
30 changed files with 294 additions and 209 deletions

View File

@@ -40,7 +40,7 @@ jobs:
steps:
- name: Clone repository
run: |
apk add --no-cache git curl jq
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 }}

View File

@@ -1,2 +1,83 @@
# 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.

View File

@@ -1,6 +1,6 @@
services:
gearbox:
build: .
image: gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
container_name: gearbox
ports:
- "3000:3000"
@@ -10,6 +10,12 @@ services:
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:

View File

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

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>GearBox</title>
</head>
<body>

1
public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -46,7 +46,7 @@ export function CandidateCard({
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"
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-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
title="Open product link"
>
<svg
@@ -87,12 +87,12 @@ export function CandidateCard({
</h3>
<div className="flex flex-wrap gap-1.5 mb-3">
{weightGrams != null && (
<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-400">
{formatWeight(weightGrams)}
</span>
)}
{priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(priceCents)}
</span>
)}
@@ -109,7 +109,7 @@ export function CandidateCard({
<button
type="button"
onClick={() => openCandidateEditPanel(id)}
className="text-xs text-gray-500 hover:text-blue-600 transition-colors"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Edit
</button>

View File

@@ -152,7 +152,7 @@ export function CandidateForm({
type="text"
value={form.name}
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-gray-400 focus:border-transparent"
placeholder="e.g. Osprey Talon 22"
/>
{errors.name && (
@@ -177,7 +177,7 @@ export function CandidateForm({
onChange={(e) =>
setForm((f) => ({ ...f, weightGrams: 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-gray-400 focus:border-transparent"
placeholder="e.g. 680"
/>
{errors.weightGrams && (
@@ -202,7 +202,7 @@ export function CandidateForm({
onChange={(e) =>
setForm((f) => ({ ...f, priceDollars: 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-gray-400 focus:border-transparent"
placeholder="e.g. 129.99"
/>
{errors.priceDollars && (
@@ -234,7 +234,7 @@ export function CandidateForm({
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
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 resize-none"
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 resize-none"
placeholder="Any additional notes..."
/>
</div>
@@ -254,7 +254,7 @@ export function CandidateForm({
onChange={(e) =>
setForm((f) => ({ ...f, productUrl: 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-gray-400 focus:border-transparent"
placeholder="https://..."
/>
{errors.productUrl && (
@@ -267,7 +267,7 @@ export function CandidateForm({
<button
type="submit"
disabled={isPending}
className="flex-1 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{isPending
? "Saving..."

View File

@@ -64,7 +64,7 @@ export function CategoryHeader({
<button
type="button"
onClick={handleSave}
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
className="text-sm text-gray-600 hover:text-gray-800 font-medium"
>
Save
</button>

View File

@@ -169,7 +169,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
setInputValue("");
}}
onKeyDown={handleKeyDown}
className={`w-full 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 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent ${
!isOpen && selectedCategory ? "pl-8 pr-3" : "px-3"
}`}
/>
@@ -187,7 +187,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
aria-selected={cat.id === value}
className={`px-3 py-2 text-sm cursor-pointer flex items-center gap-1.5 ${
i === highlightIndex
? "bg-blue-50 text-blue-900"
? "bg-gray-100 text-gray-900"
: "hover:bg-gray-50"
} ${cat.id === value ? "font-medium" : ""}`}
onClick={() => handleSelect(cat.id)}
@@ -207,7 +207,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
aria-selected={false}
className={`px-3 py-2 text-sm cursor-pointer border-t border-gray-100 ${
highlightIndex === filtered.length
? "bg-blue-50 text-blue-900"
? "bg-gray-100 text-gray-900"
: "hover:bg-gray-50 text-gray-600"
}`}
onClick={handleStartCreate}
@@ -231,7 +231,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
type="button"
onClick={handleConfirmCreate}
disabled={createCategory.isPending}
className="text-xs font-medium text-blue-600 hover:text-blue-800 disabled:opacity-50"
className="text-xs font-medium text-gray-600 hover:text-gray-800 disabled:opacity-50"
>
{createCategory.isPending ? "..." : "Create"}
</button>

View File

@@ -93,7 +93,7 @@ export function CreateThreadModal() {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Lightweight sleeping bag"
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-gray-400 focus:border-transparent"
/>
</div>
@@ -108,7 +108,7 @@ export function CreateThreadModal() {
id="thread-category"
value={categoryId ?? ""}
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-blue-500 focus:border-transparent bg-white"
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}>
@@ -131,7 +131,7 @@ export function CreateThreadModal() {
<button
type="submit"
disabled={createThread.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg transition-colors"
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{createThread.isPending ? "Creating..." : "Create Thread"}
</button>

View File

@@ -43,7 +43,7 @@ export function DashboardCard({
))}
</div>
{allZero && emptyText && (
<p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p>
<p className="mt-4 text-sm text-gray-500 font-medium">{emptyText}</p>
)}
</Link>
);

View File

@@ -38,7 +38,7 @@ export function ExternalLinkDialog() {
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">
<p className="text-sm text-gray-600 break-all mb-6">
{externalLinkUrl}
</p>
<div className="flex justify-end gap-3">
@@ -52,7 +52,7 @@ export function ExternalLinkDialog() {
<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"
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
>
Continue
</button>

View File

@@ -150,7 +150,7 @@ export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
setActiveGroup(0);
}}
placeholder="Search icons..."
className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
/>
</div>
@@ -164,7 +164,7 @@ export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
onClick={() => setActiveGroup(i)}
className={`flex-1 flex items-center justify-center py-1 rounded transition-colors ${
i === activeGroup
? "bg-blue-50 text-blue-700"
? "bg-gray-200 text-gray-700"
: "hover:bg-gray-50 text-gray-500"
}`}
title={group.name}
@@ -173,7 +173,7 @@ export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
name={group.icon}
size={16}
className={
i === activeGroup ? "text-blue-700" : "text-gray-400"
i === activeGroup ? "text-gray-700" : "text-gray-400"
}
/>
</button>

View File

@@ -48,7 +48,7 @@ export function ItemCard({
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`}
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-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
title="Open product link"
>
<svg
@@ -121,12 +121,12 @@ export function ItemCard({
</h3>
<div className="flex flex-wrap gap-1.5">
{weightGrams != null && (
<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-400">
{formatWeight(weightGrams)}
</span>
)}
{priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(priceCents)}
</span>
)}

View File

@@ -144,7 +144,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
type="text"
value={form.name}
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-gray-400 focus:border-transparent"
placeholder="e.g. Osprey Talon 22"
/>
{errors.name && (
@@ -169,7 +169,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
onChange={(e) =>
setForm((f) => ({ ...f, weightGrams: 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-gray-400 focus:border-transparent"
placeholder="e.g. 680"
/>
{errors.weightGrams && (
@@ -194,7 +194,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
onChange={(e) =>
setForm((f) => ({ ...f, priceDollars: 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-gray-400 focus:border-transparent"
placeholder="e.g. 129.99"
/>
{errors.priceDollars && (
@@ -226,7 +226,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
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 resize-none"
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 resize-none"
placeholder="Any additional notes..."
/>
</div>
@@ -246,7 +246,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
onChange={(e) =>
setForm((f) => ({ ...f, productUrl: 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-gray-400 focus:border-transparent"
placeholder="https://..."
/>
{errors.productUrl && (
@@ -259,7 +259,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
<button
type="submit"
disabled={isPending}
className="flex-1 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{isPending
? "Saving..."

View File

@@ -107,7 +107,7 @@ export function ItemPicker({
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() => handleToggle(item.id)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
className="rounded border-gray-300 text-gray-600 focus:ring-gray-400"
/>
<span className="flex-1 text-sm text-gray-900 truncate">
{item.name}
@@ -143,7 +143,7 @@ export function ItemPicker({
type="button"
onClick={handleDone}
disabled={syncItems.isPending}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg transition-colors"
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{syncItems.isPending ? "Saving..." : "Done"}
</button>

View File

@@ -102,7 +102,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
<div
key={s}
className={`h-1.5 rounded-full transition-all ${
s <= Math.min(step, 3) ? "bg-blue-600 w-8" : "bg-gray-200 w-6"
s <= Math.min(step, 3) ? "bg-gray-700 w-8" : "bg-gray-200 w-6"
}`}
/>
))}
@@ -121,7 +121,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
<button
type="button"
onClick={() => setStep(2)}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
>
Get Started
</button>
@@ -159,7 +159,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="text"
value={categoryName}
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-gray-400 focus:border-transparent"
placeholder="e.g. Shelter"
/>
</div>
@@ -184,7 +184,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="button"
onClick={handleCreateCategory}
disabled={createCategory.isPending}
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
{createCategory.isPending ? "Creating..." : "Create Category"}
</button>
@@ -221,7 +221,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="text"
value={itemName}
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-gray-400 focus:border-transparent"
placeholder="e.g. Big Agnes Copper Spur"
/>
</div>
@@ -241,7 +241,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
step="any"
value={itemWeight}
onChange={(e) => setItemWeight(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. 1200"
/>
</div>
@@ -259,7 +259,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
step="0.01"
value={itemPrice}
onChange={(e) => setItemPrice(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. 349.99"
/>
</div>
@@ -272,7 +272,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="button"
onClick={handleCreateItem}
disabled={createItem.isPending}
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
{createItem.isPending ? "Adding..." : "Add Item"}
</button>
@@ -307,7 +307,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
type="button"
onClick={handleDone}
disabled={updateSetting.isPending}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
{updateSetting.isPending ? "Finishing..." : "Done"}
</button>

View File

@@ -24,15 +24,15 @@ export function SetupCard({
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 truncate">{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-400 shrink-0">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>
</div>
<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-400">
{formatWeight(totalWeight)}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(totalCost)}
</span>
</div>

View File

@@ -66,7 +66,7 @@ export function ThreadCard({
)}
</div>
<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-gray-50 text-gray-600">
<LucideIcon
name={categoryIcon}
size={16}
@@ -74,14 +74,14 @@ export function ThreadCard({
/>{" "}
{categoryName}
</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-500">
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
{formatDate(createdAt)}
</span>
{priceRange && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{priceRange}
</span>
)}

View File

@@ -1,14 +1,17 @@
interface ThreadTabsProps {
active: "gear" | "planning";
onChange: (tab: "gear" | "planning") => void;
type TabKey = "gear" | "planning" | "setups";
interface CollectionTabsProps {
active: TabKey;
onChange: (tab: TabKey) => void;
}
const tabs = [
{ key: "gear" as const, label: "My Gear" },
{ key: "planning" as const, label: "Planning" },
{ key: "setups" as const, label: "Setups" },
];
export function ThreadTabs({ active, onChange }: ThreadTabsProps) {
export function CollectionTabs({ active, onChange }: CollectionTabsProps) {
return (
<div className="flex border-b border-gray-200">
{tabs.map((tab) => (
@@ -18,13 +21,13 @@ export function ThreadTabs({ active, onChange }: ThreadTabsProps) {
onClick={() => onChange(tab.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
active === tab.key
? "text-blue-600"
? "text-gray-700"
: "text-gray-500 hover:text-gray-700"
}`}
>
{tab.label}
{active === tab.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 rounded-t" />
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-700 rounded-t" />
)}
</button>
))}

View File

@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router";
import { useTotals } from "../hooks/useTotals";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
interface TotalsBarProps {
title?: string;
@@ -30,15 +31,22 @@ export function TotalsBar({
{ label: "spent", value: formatPrice(null) },
]);
const titleContent = (
<span className="flex items-center gap-2">
<LucideIcon name="package" size={20} className="text-gray-500" />
{title}
</span>
);
const titleElement = linkTo ? (
<Link
to={linkTo}
className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors"
className="text-lg font-semibold text-gray-900 hover:text-gray-600 transition-colors"
>
{title}
{titleContent}
</Link>
) : (
<h1 className="text-lg font-semibold text-gray-900">{title}</h1>
<h1 className="text-lg font-semibold text-gray-900">{titleContent}</h1>
);
// If stats prop is explicitly an empty array, show title only (dashboard mode)

View File

@@ -10,7 +10,6 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SetupsIndexRouteImport } from './routes/setups/index'
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
@@ -20,11 +19,6 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SetupsIndexRoute = SetupsIndexRouteImport.update({
id: '/setups/',
path: '/setups/',
getParentRoute: () => rootRouteImport,
} as any)
const CollectionIndexRoute = CollectionIndexRouteImport.update({
id: '/collection/',
path: '/collection/',
@@ -46,14 +40,12 @@ export interface FileRoutesByFullPath {
'/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection/': typeof CollectionIndexRoute
'/setups/': typeof SetupsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection': typeof CollectionIndexRoute
'/setups': typeof SetupsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -61,30 +53,18 @@ export interface FileRoutesById {
'/setups/$setupId': typeof SetupsSetupIdRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection/': typeof CollectionIndexRoute
'/setups/': typeof SetupsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/setups/$setupId'
| '/threads/$threadId'
| '/collection/'
| '/setups/'
fullPaths: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/setups/$setupId'
| '/threads/$threadId'
| '/collection'
| '/setups'
to: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection'
id:
| '__root__'
| '/'
| '/setups/$setupId'
| '/threads/$threadId'
| '/collection/'
| '/setups/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -92,7 +72,6 @@ export interface RootRouteChildren {
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
CollectionIndexRoute: typeof CollectionIndexRoute
SetupsIndexRoute: typeof SetupsIndexRoute
}
declare module '@tanstack/react-router' {
@@ -104,13 +83,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/setups/': {
id: '/setups/'
path: '/setups'
fullPath: '/setups/'
preLoaderRoute: typeof SetupsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/collection/': {
id: '/collection/'
path: '/collection'
@@ -140,7 +112,6 @@ const rootRouteChildren: RootRouteChildren = {
SetupsSetupIdRoute: SetupsSetupIdRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
CollectionIndexRoute: CollectionIndexRoute,
SetupsIndexRoute: SetupsIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -95,13 +95,14 @@ function RootLayout() {
const showFab =
isCollection &&
(!collectionSearch ||
(collectionSearch as Record<string, string>).tab !== "planning");
!(collectionSearch as Record<string, string>).tab ||
(collectionSearch as Record<string, string>).tab === "gear");
// Show a minimal loading state while checking onboarding status
if (onboardingLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
@@ -178,7 +179,7 @@ function RootLayout() {
<button
type="button"
onClick={openAddPanel}
className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-gray-700 hover:bg-gray-800 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
title="Add new item"
>
<svg

View File

@@ -4,17 +4,19 @@ import { z } from "zod";
import { CategoryHeader } from "../../components/CategoryHeader";
import { CreateThreadModal } from "../../components/CreateThreadModal";
import { ItemCard } from "../../components/ItemCard";
import { SetupCard } from "../../components/SetupCard";
import { ThreadCard } from "../../components/ThreadCard";
import { ThreadTabs } from "../../components/ThreadTabs";
import { CollectionTabs } from "../../components/ThreadTabs";
import { useCategories } from "../../hooks/useCategories";
import { useItems } from "../../hooks/useItems";
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
import { useThreads } from "../../hooks/useThreads";
import { useTotals } from "../../hooks/useTotals";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore";
const searchSchema = z.object({
tab: z.enum(["gear", "planning"]).catch("gear"),
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
});
export const Route = createFileRoute("/collection/")({
@@ -26,15 +28,21 @@ function CollectionPage() {
const { tab } = Route.useSearch();
const navigate = useNavigate();
function handleTabChange(newTab: "gear" | "planning") {
function handleTabChange(newTab: "gear" | "planning" | "setups") {
navigate({ to: "/collection", search: { tab: newTab } });
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<ThreadTabs active={tab} onChange={handleTabChange} />
<CollectionTabs active={tab} onChange={handleTabChange} />
<div className="mt-6">
{tab === "gear" ? <CollectionView /> : <PlanningView />}
{tab === "gear" ? (
<CollectionView />
) : tab === "planning" ? (
<PlanningView />
) : (
<SetupsView />
)}
</div>
</div>
);
@@ -79,7 +87,7 @@ function CollectionView() {
<button
type="button"
onClick={openAddPanel}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
aria-hidden="true"
@@ -217,7 +225,7 @@ function PlanningView() {
<button
type="button"
onClick={openCreateThreadModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
aria-hidden="true"
@@ -246,7 +254,7 @@ function PlanningView() {
onClick={() => setActiveTab("active")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "active"
? "bg-blue-600 text-white"
? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
@@ -257,7 +265,7 @@ function PlanningView() {
onClick={() => setActiveTab("resolved")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "resolved"
? "bg-blue-600 text-white"
? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
@@ -271,7 +279,7 @@ function PlanningView() {
onChange={(e) =>
setCategoryFilter(e.target.value ? Number(e.target.value) : null)
}
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
>
<option value="">All categories</option>
{categories?.map((cat) => (
@@ -291,7 +299,7 @@ function PlanningView() {
</h2>
<div className="space-y-6 text-left mb-10">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
1
</div>
<div>
@@ -302,7 +310,7 @@ function PlanningView() {
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
2
</div>
<div>
@@ -313,7 +321,7 @@ function PlanningView() {
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
3
</div>
<div>
@@ -327,7 +335,7 @@ function PlanningView() {
<button
type="button"
onClick={openCreateThreadModal}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
aria-hidden="true"
@@ -374,3 +382,87 @@ function PlanningView() {
</div>
);
}
function SetupsView() {
const [newSetupName, setNewSetupName] = useState("");
const { data: setups, isLoading } = useSetups();
const createSetup = useCreateSetup();
function handleCreateSetup(e: React.FormEvent) {
e.preventDefault();
const name = newSetupName.trim();
if (!name) return;
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
}
return (
<div>
{/* Create setup form */}
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
<input
type="text"
value={newSetupName}
onChange={(e) => setNewSetupName(e.target.value)}
placeholder="New setup name..."
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
/>
<button
type="submit"
disabled={!newSetupName.trim() || createSetup.isPending}
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{createSetup.isPending ? "Creating..." : "Create"}
</button>
</form>
{/* Loading skeleton */}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2].map((i) => (
<div
key={i}
className="h-24 bg-gray-200 rounded-xl animate-pulse"
/>
))}
</div>
)}
{/* Empty state */}
{!isLoading && (!setups || setups.length === 0) && (
<div className="py-16 text-center">
<div className="max-w-md mx-auto">
<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">
No setups yet
</h2>
<p className="text-sm text-gray-500">
Create one to plan your loadout.
</p>
</div>
</div>
)}
{/* Setup grid */}
{!isLoading && setups && setups.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{setups.map((setup) => (
<SetupCard
key={setup.id}
id={setup.id}
name={setup.name}
itemCount={setup.itemCount}
totalWeight={setup.totalWeight}
totalCost={setup.totalCost}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -45,7 +45,8 @@ function DashboardPage() {
]}
/>
<DashboardCard
to="/setups"
to="/collection"
search={{ tab: "setups" }}
title="Setups"
icon="tent"
stats={[{ label: "Setups", value: String(setupCount) }]}

View File

@@ -124,7 +124,7 @@ function SetupDetailPage() {
<button
type="button"
onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
className="w-4 h-4"
@@ -170,7 +170,7 @@ function SetupDetailPage() {
<button
type="button"
onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
Add Items
</button>

View File

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

View File

@@ -38,7 +38,7 @@ function ThreadDetailPage() {
<Link
to="/"
search={{ tab: "planning" }}
className="text-sm text-blue-600 hover:text-blue-700"
className="text-sm text-gray-600 hover:text-gray-700"
>
Back to planning
</Link>
@@ -67,7 +67,7 @@ function ThreadDetailPage() {
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
isActive
? "bg-blue-50 text-blue-700"
? "bg-gray-100 text-gray-600"
: "bg-gray-100 text-gray-500"
}`}
>
@@ -92,7 +92,7 @@ function ThreadDetailPage() {
<button
type="button"
onClick={openCandidateAddPanel}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
className="w-4 h-4"

13
src/db/migrate.ts Normal file
View 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");