Compare commits
5 Commits
v1.1.2
...
94ebd84cc7
| Author | SHA1 | Date | |
|---|---|---|---|
| 94ebd84cc7 | |||
| 5938a686c7 | |||
| 9bcdcc7168 | |||
| 628907bb20 | |||
| 891bb248c8 |
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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
gearbox:
|
gearbox:
|
||||||
build: .
|
image: gitea.jeanlucmakiola.de/makiolaj/gearbox:latest
|
||||||
container_name: gearbox
|
container_name: gearbox
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -10,6 +10,12 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- gearbox-data:/app/data
|
- gearbox-data:/app/data
|
||||||
- gearbox-uploads:/app/uploads
|
- 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
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
bun run db:push
|
bun run src/db/migrate.ts
|
||||||
exec bun run src/server/index.ts
|
exec bun run src/server/index.ts
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>GearBox</title>
|
<title>GearBox</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal 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 |
@@ -46,7 +46,7 @@ export function CandidateCard({
|
|||||||
openExternalLink(productUrl);
|
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"
|
title="Open product link"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -87,12 +87,12 @@ export function CandidateCard({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
{weightGrams != null && (
|
{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)}
|
{formatWeight(weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{priceCents != null && (
|
{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)}
|
{formatPrice(priceCents)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -109,7 +109,7 @@ export function CandidateCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openCandidateEditPanel(id)}
|
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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export function CandidateForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
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-gray-400 focus:border-transparent"
|
||||||
placeholder="e.g. Osprey Talon 22"
|
placeholder="e.g. Osprey Talon 22"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
@@ -177,7 +177,7 @@ export function CandidateForm({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((f) => ({ ...f, weightGrams: e.target.value }))
|
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"
|
placeholder="e.g. 680"
|
||||||
/>
|
/>
|
||||||
{errors.weightGrams && (
|
{errors.weightGrams && (
|
||||||
@@ -202,7 +202,7 @@ export function CandidateForm({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((f) => ({ ...f, priceDollars: e.target.value }))
|
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"
|
placeholder="e.g. 129.99"
|
||||||
/>
|
/>
|
||||||
{errors.priceDollars && (
|
{errors.priceDollars && (
|
||||||
@@ -234,7 +234,7 @@ export function CandidateForm({
|
|||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
rows={3}
|
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..."
|
placeholder="Any additional notes..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,7 +254,7 @@ export function CandidateForm({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((f) => ({ ...f, productUrl: e.target.value }))
|
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://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
{errors.productUrl && (
|
{errors.productUrl && (
|
||||||
@@ -267,7 +267,7 @@ export function CandidateForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
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
|
{isPending
|
||||||
? "Saving..."
|
? "Saving..."
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function CategoryHeader({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
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
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
setInputValue("");
|
setInputValue("");
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
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"
|
!isOpen && selectedCategory ? "pl-8 pr-3" : "px-3"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -187,7 +187,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
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
|
||||||
? "bg-blue-50 text-blue-900"
|
? "bg-gray-100 text-gray-900"
|
||||||
: "hover:bg-gray-50"
|
: "hover:bg-gray-50"
|
||||||
} ${cat.id === value ? "font-medium" : ""}`}
|
} ${cat.id === value ? "font-medium" : ""}`}
|
||||||
onClick={() => handleSelect(cat.id)}
|
onClick={() => handleSelect(cat.id)}
|
||||||
@@ -207,7 +207,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
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
|
||||||
? "bg-blue-50 text-blue-900"
|
? "bg-gray-100 text-gray-900"
|
||||||
: "hover:bg-gray-50 text-gray-600"
|
: "hover:bg-gray-50 text-gray-600"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleStartCreate}
|
onClick={handleStartCreate}
|
||||||
@@ -231,7 +231,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleConfirmCreate}
|
onClick={handleConfirmCreate}
|
||||||
disabled={createCategory.isPending}
|
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"}
|
{createCategory.isPending ? "..." : "Create"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function CreateThreadModal() {
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g. Lightweight sleeping bag"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export function CreateThreadModal() {
|
|||||||
id="thread-category"
|
id="thread-category"
|
||||||
value={categoryId ?? ""}
|
value={categoryId ?? ""}
|
||||||
onChange={(e) => setCategoryId(Number(e.target.value))}
|
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) => (
|
{categories?.map((cat) => (
|
||||||
<option key={cat.id} value={cat.id}>
|
<option key={cat.id} value={cat.id}>
|
||||||
@@ -131,7 +131,7 @@ export function CreateThreadModal() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createThread.isPending}
|
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"}
|
{createThread.isPending ? "Creating..." : "Create Thread"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function DashboardCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{allZero && emptyText && (
|
{allZero && emptyText && (
|
||||||
<p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p>
|
<p className="mt-4 text-sm text-gray-500 font-medium">{emptyText}</p>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function ExternalLinkDialog() {
|
|||||||
You are about to leave GearBox
|
You are about to leave GearBox
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 mb-1">You will be redirected to:</p>
|
<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}
|
{externalLinkUrl}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
@@ -52,7 +52,7 @@ export function ExternalLinkDialog() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleContinue}
|
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
|
Continue
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
|
|||||||
setActiveGroup(0);
|
setActiveGroup(0);
|
||||||
}}
|
}}
|
||||||
placeholder="Search icons..."
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
|
|||||||
onClick={() => setActiveGroup(i)}
|
onClick={() => setActiveGroup(i)}
|
||||||
className={`flex-1 flex items-center justify-center py-1 rounded transition-colors ${
|
className={`flex-1 flex items-center justify-center py-1 rounded transition-colors ${
|
||||||
i === activeGroup
|
i === activeGroup
|
||||||
? "bg-blue-50 text-blue-700"
|
? "bg-gray-200 text-gray-700"
|
||||||
: "hover:bg-gray-50 text-gray-500"
|
: "hover:bg-gray-50 text-gray-500"
|
||||||
}`}
|
}`}
|
||||||
title={group.name}
|
title={group.name}
|
||||||
@@ -173,7 +173,7 @@ export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
|
|||||||
name={group.icon}
|
name={group.icon}
|
||||||
size={16}
|
size={16}
|
||||||
className={
|
className={
|
||||||
i === activeGroup ? "text-blue-700" : "text-gray-400"
|
i === activeGroup ? "text-gray-700" : "text-gray-400"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function ItemCard({
|
|||||||
openExternalLink(productUrl);
|
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"
|
title="Open product link"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -121,12 +121,12 @@ export function ItemCard({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{weightGrams != null && (
|
{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)}
|
{formatWeight(weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{priceCents != null && (
|
{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)}
|
{formatPrice(priceCents)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
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-gray-400 focus:border-transparent"
|
||||||
placeholder="e.g. Osprey Talon 22"
|
placeholder="e.g. Osprey Talon 22"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
@@ -169,7 +169,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((f) => ({ ...f, weightGrams: e.target.value }))
|
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"
|
placeholder="e.g. 680"
|
||||||
/>
|
/>
|
||||||
{errors.weightGrams && (
|
{errors.weightGrams && (
|
||||||
@@ -194,7 +194,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((f) => ({ ...f, priceDollars: e.target.value }))
|
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"
|
placeholder="e.g. 129.99"
|
||||||
/>
|
/>
|
||||||
{errors.priceDollars && (
|
{errors.priceDollars && (
|
||||||
@@ -226,7 +226,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
rows={3}
|
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..."
|
placeholder="Any additional notes..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +246,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((f) => ({ ...f, productUrl: e.target.value }))
|
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://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
{errors.productUrl && (
|
{errors.productUrl && (
|
||||||
@@ -259,7 +259,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
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
|
{isPending
|
||||||
? "Saving..."
|
? "Saving..."
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function ItemPicker({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedIds.has(item.id)}
|
checked={selectedIds.has(item.id)}
|
||||||
onChange={() => handleToggle(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">
|
<span className="flex-1 text-sm text-gray-900 truncate">
|
||||||
{item.name}
|
{item.name}
|
||||||
@@ -143,7 +143,7 @@ export function ItemPicker({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleDone}
|
onClick={handleDone}
|
||||||
disabled={syncItems.isPending}
|
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"}
|
{syncItems.isPending ? "Saving..." : "Done"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -102,7 +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) ? "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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStep(2)}
|
onClick={() => setStep(2)}
|
||||||
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
</button>
|
</button>
|
||||||
@@ -159,7 +159,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={categoryName}
|
value={categoryName}
|
||||||
onChange={(e) => setCategoryName(e.target.value)}
|
onChange={(e) => setCategoryName(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
placeholder="e.g. Shelter"
|
placeholder="e.g. Shelter"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +184,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreateCategory}
|
onClick={handleCreateCategory}
|
||||||
disabled={createCategory.isPending}
|
disabled={createCategory.isPending}
|
||||||
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{createCategory.isPending ? "Creating..." : "Create Category"}
|
{createCategory.isPending ? "Creating..." : "Create Category"}
|
||||||
</button>
|
</button>
|
||||||
@@ -221,7 +221,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={itemName}
|
value={itemName}
|
||||||
onChange={(e) => setItemName(e.target.value)}
|
onChange={(e) => setItemName(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
placeholder="e.g. Big Agnes Copper Spur"
|
placeholder="e.g. Big Agnes Copper Spur"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,7 +241,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
step="any"
|
step="any"
|
||||||
value={itemWeight}
|
value={itemWeight}
|
||||||
onChange={(e) => setItemWeight(e.target.value)}
|
onChange={(e) => setItemWeight(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
placeholder="e.g. 1200"
|
placeholder="e.g. 1200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,7 +259,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
value={itemPrice}
|
value={itemPrice}
|
||||||
onChange={(e) => setItemPrice(e.target.value)}
|
onChange={(e) => setItemPrice(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
placeholder="e.g. 349.99"
|
placeholder="e.g. 349.99"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,7 +272,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreateItem}
|
onClick={handleCreateItem}
|
||||||
disabled={createItem.isPending}
|
disabled={createItem.isPending}
|
||||||
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{createItem.isPending ? "Adding..." : "Add Item"}
|
{createItem.isPending ? "Adding..." : "Add Item"}
|
||||||
</button>
|
</button>
|
||||||
@@ -307,7 +307,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleDone}
|
onClick={handleDone}
|
||||||
disabled={updateSetting.isPending}
|
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"}
|
{updateSetting.isPending ? "Finishing..." : "Done"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ export function SetupCard({
|
|||||||
>
|
>
|
||||||
<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">{name}</h3>
|
<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"}
|
{itemCount} {itemCount === 1 ? "item" : "items"}
|
||||||
</span>
|
</span>
|
||||||
</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-400">
|
||||||
{formatWeight(totalWeight)}
|
{formatWeight(totalWeight)}
|
||||||
</span>
|
</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)}
|
{formatPrice(totalCost)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ 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-gray-50 text-gray-600">
|
||||||
<LucideIcon
|
<LucideIcon
|
||||||
name={categoryIcon}
|
name={categoryIcon}
|
||||||
size={16}
|
size={16}
|
||||||
@@ -74,14 +74,14 @@ export function ThreadCard({
|
|||||||
/>{" "}
|
/>{" "}
|
||||||
{categoryName}
|
{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-500">
|
||||||
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
|
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
|
||||||
</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">
|
||||||
{formatDate(createdAt)}
|
{formatDate(createdAt)}
|
||||||
</span>
|
</span>
|
||||||
{priceRange && (
|
{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}
|
{priceRange}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
interface ThreadTabsProps {
|
type TabKey = "gear" | "planning" | "setups";
|
||||||
active: "gear" | "planning";
|
|
||||||
onChange: (tab: "gear" | "planning") => void;
|
interface CollectionTabsProps {
|
||||||
|
active: TabKey;
|
||||||
|
onChange: (tab: TabKey) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: "gear" as const, label: "My Gear" },
|
{ key: "gear" as const, label: "My Gear" },
|
||||||
{ key: "planning" as const, label: "Planning" },
|
{ 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 (
|
return (
|
||||||
<div className="flex border-b border-gray-200">
|
<div className="flex border-b border-gray-200">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
@@ -18,13 +21,13 @@ export function ThreadTabs({ active, onChange }: ThreadTabsProps) {
|
|||||||
onClick={() => onChange(tab.key)}
|
onClick={() => onChange(tab.key)}
|
||||||
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||||
active === tab.key
|
active === tab.key
|
||||||
? "text-blue-600"
|
? "text-gray-700"
|
||||||
: "text-gray-500 hover:text-gray-700"
|
: "text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{active === tab.key && (
|
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useTotals } from "../hooks/useTotals";
|
import { useTotals } from "../hooks/useTotals";
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
interface TotalsBarProps {
|
interface TotalsBarProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -30,15 +31,22 @@ export function TotalsBar({
|
|||||||
{ label: "spent", value: formatPrice(null) },
|
{ 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 ? (
|
const titleElement = linkTo ? (
|
||||||
<Link
|
<Link
|
||||||
to={linkTo}
|
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>
|
</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)
|
// If stats prop is explicitly an empty array, show title only (dashboard mode)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
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 CollectionIndexRouteImport } from './routes/collection/index'
|
||||||
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
||||||
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
|
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
|
||||||
@@ -20,11 +19,6 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const SetupsIndexRoute = SetupsIndexRouteImport.update({
|
|
||||||
id: '/setups/',
|
|
||||||
path: '/setups/',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const CollectionIndexRoute = CollectionIndexRouteImport.update({
|
const CollectionIndexRoute = CollectionIndexRouteImport.update({
|
||||||
id: '/collection/',
|
id: '/collection/',
|
||||||
path: '/collection/',
|
path: '/collection/',
|
||||||
@@ -46,14 +40,12 @@ export interface FileRoutesByFullPath {
|
|||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
'/collection/': typeof CollectionIndexRoute
|
'/collection/': typeof CollectionIndexRoute
|
||||||
'/setups/': typeof SetupsIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
'/collection': typeof CollectionIndexRoute
|
'/collection': typeof CollectionIndexRoute
|
||||||
'/setups': typeof SetupsIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -61,30 +53,18 @@ export interface FileRoutesById {
|
|||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
'/collection/': typeof CollectionIndexRoute
|
'/collection/': typeof CollectionIndexRoute
|
||||||
'/setups/': typeof SetupsIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection/'
|
||||||
| '/'
|
|
||||||
| '/setups/$setupId'
|
|
||||||
| '/threads/$threadId'
|
|
||||||
| '/collection/'
|
|
||||||
| '/setups/'
|
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to: '/' | '/setups/$setupId' | '/threads/$threadId' | '/collection'
|
||||||
| '/'
|
|
||||||
| '/setups/$setupId'
|
|
||||||
| '/threads/$threadId'
|
|
||||||
| '/collection'
|
|
||||||
| '/setups'
|
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/setups/$setupId'
|
| '/setups/$setupId'
|
||||||
| '/threads/$threadId'
|
| '/threads/$threadId'
|
||||||
| '/collection/'
|
| '/collection/'
|
||||||
| '/setups/'
|
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -92,7 +72,6 @@ export interface RootRouteChildren {
|
|||||||
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
||||||
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
||||||
CollectionIndexRoute: typeof CollectionIndexRoute
|
CollectionIndexRoute: typeof CollectionIndexRoute
|
||||||
SetupsIndexRoute: typeof SetupsIndexRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -104,13 +83,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/setups/': {
|
|
||||||
id: '/setups/'
|
|
||||||
path: '/setups'
|
|
||||||
fullPath: '/setups/'
|
|
||||||
preLoaderRoute: typeof SetupsIndexRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/collection/': {
|
'/collection/': {
|
||||||
id: '/collection/'
|
id: '/collection/'
|
||||||
path: '/collection'
|
path: '/collection'
|
||||||
@@ -140,7 +112,6 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
||||||
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
||||||
CollectionIndexRoute: CollectionIndexRoute,
|
CollectionIndexRoute: CollectionIndexRoute,
|
||||||
SetupsIndexRoute: SetupsIndexRoute,
|
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -95,13 +95,14 @@ function RootLayout() {
|
|||||||
const showFab =
|
const showFab =
|
||||||
isCollection &&
|
isCollection &&
|
||||||
(!collectionSearch ||
|
(!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
|
// Show a minimal loading state while checking onboarding status
|
||||||
if (onboardingLoading) {
|
if (onboardingLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -178,7 +179,7 @@ function RootLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openAddPanel}
|
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"
|
title="Add new item"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ import { z } from "zod";
|
|||||||
import { CategoryHeader } from "../../components/CategoryHeader";
|
import { CategoryHeader } from "../../components/CategoryHeader";
|
||||||
import { CreateThreadModal } from "../../components/CreateThreadModal";
|
import { CreateThreadModal } from "../../components/CreateThreadModal";
|
||||||
import { ItemCard } from "../../components/ItemCard";
|
import { ItemCard } from "../../components/ItemCard";
|
||||||
|
import { SetupCard } from "../../components/SetupCard";
|
||||||
import { ThreadCard } from "../../components/ThreadCard";
|
import { ThreadCard } from "../../components/ThreadCard";
|
||||||
import { ThreadTabs } from "../../components/ThreadTabs";
|
import { CollectionTabs } from "../../components/ThreadTabs";
|
||||||
import { useCategories } from "../../hooks/useCategories";
|
import { useCategories } from "../../hooks/useCategories";
|
||||||
import { useItems } from "../../hooks/useItems";
|
import { useItems } from "../../hooks/useItems";
|
||||||
|
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
|
||||||
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 { LucideIcon } from "../../lib/iconData";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
tab: z.enum(["gear", "planning"]).catch("gear"),
|
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Route = createFileRoute("/collection/")({
|
export const Route = createFileRoute("/collection/")({
|
||||||
@@ -26,15 +28,21 @@ function CollectionPage() {
|
|||||||
const { tab } = Route.useSearch();
|
const { tab } = Route.useSearch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
function handleTabChange(newTab: "gear" | "planning") {
|
function handleTabChange(newTab: "gear" | "planning" | "setups") {
|
||||||
navigate({ to: "/collection", search: { tab: newTab } });
|
navigate({ to: "/collection", search: { tab: newTab } });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<ThreadTabs active={tab} onChange={handleTabChange} />
|
<CollectionTabs active={tab} onChange={handleTabChange} />
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{tab === "gear" ? <CollectionView /> : <PlanningView />}
|
{tab === "gear" ? (
|
||||||
|
<CollectionView />
|
||||||
|
) : tab === "planning" ? (
|
||||||
|
<PlanningView />
|
||||||
|
) : (
|
||||||
|
<SetupsView />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -79,7 +87,7 @@ function CollectionView() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openAddPanel}
|
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
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -217,7 +225,7 @@ function PlanningView() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCreateThreadModal}
|
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
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -246,7 +254,7 @@ function PlanningView() {
|
|||||||
onClick={() => setActiveTab("active")}
|
onClick={() => setActiveTab("active")}
|
||||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||||
activeTab === "active"
|
activeTab === "active"
|
||||||
? "bg-blue-600 text-white"
|
? "bg-gray-700 text-white"
|
||||||
: "text-gray-600 hover:bg-gray-200"
|
: "text-gray-600 hover:bg-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -257,7 +265,7 @@ function PlanningView() {
|
|||||||
onClick={() => setActiveTab("resolved")}
|
onClick={() => setActiveTab("resolved")}
|
||||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||||
activeTab === "resolved"
|
activeTab === "resolved"
|
||||||
? "bg-blue-600 text-white"
|
? "bg-gray-700 text-white"
|
||||||
: "text-gray-600 hover:bg-gray-200"
|
: "text-gray-600 hover:bg-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -271,7 +279,7 @@ function PlanningView() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setCategoryFilter(e.target.value ? Number(e.target.value) : null)
|
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>
|
<option value="">All categories</option>
|
||||||
{categories?.map((cat) => (
|
{categories?.map((cat) => (
|
||||||
@@ -291,7 +299,7 @@ function PlanningView() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-6 text-left mb-10">
|
<div className="space-y-6 text-left mb-10">
|
||||||
<div className="flex items-start gap-4">
|
<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
|
1
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -302,7 +310,7 @@ function PlanningView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-4">
|
<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
|
2
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -313,7 +321,7 @@ function PlanningView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-4">
|
<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
|
3
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -327,7 +335,7 @@ function PlanningView() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCreateThreadModal}
|
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
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -374,3 +382,87 @@ function PlanningView() {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ function DashboardPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DashboardCard
|
<DashboardCard
|
||||||
to="/setups"
|
to="/collection"
|
||||||
|
search={{ tab: "setups" }}
|
||||||
title="Setups"
|
title="Setups"
|
||||||
icon="tent"
|
icon="tent"
|
||||||
stats={[{ label: "Setups", value: String(setupCount) }]}
|
stats={[{ label: "Setups", value: String(setupCount) }]}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ function SetupDetailPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPickerOpen(true)}
|
onClick={() => setPickerOpen(true)}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
@@ -170,7 +170,7 @@ function SetupDetailPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPickerOpen(true)}
|
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
|
Add Items
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -38,7 +38,7 @@ function ThreadDetailPage() {
|
|||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
search={{ tab: "planning" }}
|
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
|
Back to planning
|
||||||
</Link>
|
</Link>
|
||||||
@@ -67,7 +67,7 @@ function ThreadDetailPage() {
|
|||||||
<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
|
||||||
? "bg-blue-50 text-blue-700"
|
? "bg-gray-100 text-gray-600"
|
||||||
: "bg-gray-100 text-gray-500"
|
: "bg-gray-100 text-gray-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -92,7 +92,7 @@ function ThreadDetailPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCandidateAddPanel}
|
onClick={openCandidateAddPanel}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
|
|||||||
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");
|
||||||
Reference in New Issue
Block a user