chore: auto-fix Biome formatting and configure lint rules
All checks were successful
CI / ci (push) Successful in 15s

Run biome check --write --unsafe to fix tabs, import ordering, and
non-null assertions across entire codebase. Disable a11y rules not
applicable to this single-user app. Exclude auto-generated routeTree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:51:34 +01:00
parent 4d0452b7b3
commit b496462df5
63 changed files with 4752 additions and 4672 deletions

View File

@@ -73,7 +73,11 @@ export function CandidateCard({
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" />
<LucideIcon
name={categoryIcon}
size={36}
className="text-gray-400"
/>
</div>
)}
</div>
@@ -93,7 +97,12 @@ export function CandidateCard({
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon name={categoryIcon} size={14} className="inline-block mr-1 text-gray-500" /> {categoryName}
<LucideIcon
name={categoryIcon}
size={14}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</span>
</div>
<div className="flex gap-2">

View File

@@ -1,285 +1,281 @@
import { useState, useEffect } from "react";
import {
useCreateCandidate,
useUpdateCandidate,
} from "../hooks/useCandidates";
import { useEffect, useState } from "react";
import { useCreateCandidate, useUpdateCandidate } from "../hooks/useCandidates";
import { useThread } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore";
import { CategoryPicker } from "./CategoryPicker";
import { ImageUpload } from "./ImageUpload";
interface CandidateFormProps {
mode: "add" | "edit";
threadId: number;
candidateId?: number | null;
mode: "add" | "edit";
threadId: number;
candidateId?: number | null;
}
interface FormData {
name: string;
weightGrams: string;
priceDollars: string;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string | null;
name: string;
weightGrams: string;
priceDollars: string;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string | null;
}
const INITIAL_FORM: FormData = {
name: "",
weightGrams: "",
priceDollars: "",
categoryId: 1,
notes: "",
productUrl: "",
imageFilename: null,
name: "",
weightGrams: "",
priceDollars: "",
categoryId: 1,
notes: "",
productUrl: "",
imageFilename: null,
};
export function CandidateForm({
mode,
threadId,
candidateId,
mode,
threadId,
candidateId,
}: CandidateFormProps) {
const { data: thread } = useThread(threadId);
const createCandidate = useCreateCandidate(threadId);
const updateCandidate = useUpdateCandidate(threadId);
const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
const { data: thread } = useThread(threadId);
const createCandidate = useCreateCandidate(threadId);
const updateCandidate = useUpdateCandidate(threadId);
const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
const [form, setForm] = useState<FormData>(INITIAL_FORM);
const [errors, setErrors] = useState<Record<string, string>>({});
const [form, setForm] = useState<FormData>(INITIAL_FORM);
const [errors, setErrors] = useState<Record<string, string>>({});
// Pre-fill form when editing
useEffect(() => {
if (mode === "edit" && candidateId != null && thread?.candidates) {
const candidate = thread.candidates.find((c) => c.id === candidateId);
if (candidate) {
setForm({
name: candidate.name,
weightGrams:
candidate.weightGrams != null ? String(candidate.weightGrams) : "",
priceDollars:
candidate.priceCents != null
? (candidate.priceCents / 100).toFixed(2)
: "",
categoryId: candidate.categoryId,
notes: candidate.notes ?? "",
productUrl: candidate.productUrl ?? "",
imageFilename: candidate.imageFilename,
});
}
} else if (mode === "add") {
setForm(INITIAL_FORM);
}
}, [mode, candidateId, thread?.candidates]);
// Pre-fill form when editing
useEffect(() => {
if (mode === "edit" && candidateId != null && thread?.candidates) {
const candidate = thread.candidates.find((c) => c.id === candidateId);
if (candidate) {
setForm({
name: candidate.name,
weightGrams:
candidate.weightGrams != null ? String(candidate.weightGrams) : "",
priceDollars:
candidate.priceCents != null
? (candidate.priceCents / 100).toFixed(2)
: "",
categoryId: candidate.categoryId,
notes: candidate.notes ?? "",
productUrl: candidate.productUrl ?? "",
imageFilename: candidate.imageFilename,
});
}
} else if (mode === "add") {
setForm(INITIAL_FORM);
}
}, [mode, candidateId, thread?.candidates]);
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) {
newErrors.name = "Name is required";
}
if (
form.weightGrams &&
(isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
) {
newErrors.weightGrams = "Must be a positive number";
}
if (
form.priceDollars &&
(isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
) {
newErrors.priceDollars = "Must be a positive number";
}
if (
form.productUrl &&
form.productUrl.trim() !== "" &&
!form.productUrl.match(/^https?:\/\//)
) {
newErrors.productUrl = "Must be a valid URL (https://...)";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) {
newErrors.name = "Name is required";
}
if (
form.weightGrams &&
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
) {
newErrors.weightGrams = "Must be a positive number";
}
if (
form.priceDollars &&
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
) {
newErrors.priceDollars = "Must be a positive number";
}
if (
form.productUrl &&
form.productUrl.trim() !== "" &&
!form.productUrl.match(/^https?:\/\//)
) {
newErrors.productUrl = "Must be a valid URL (https://...)";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const payload = {
name: form.name.trim(),
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
priceCents: form.priceDollars
? Math.round(Number(form.priceDollars) * 100)
: undefined,
categoryId: form.categoryId,
notes: form.notes.trim() || undefined,
productUrl: form.productUrl.trim() || undefined,
imageFilename: form.imageFilename ?? undefined,
};
const payload = {
name: form.name.trim(),
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
priceCents: form.priceDollars
? Math.round(Number(form.priceDollars) * 100)
: undefined,
categoryId: form.categoryId,
notes: form.notes.trim() || undefined,
productUrl: form.productUrl.trim() || undefined,
imageFilename: form.imageFilename ?? undefined,
};
if (mode === "add") {
createCandidate.mutate(payload, {
onSuccess: () => {
setForm(INITIAL_FORM);
closeCandidatePanel();
},
});
} else if (candidateId != null) {
updateCandidate.mutate(
{ candidateId, ...payload },
{ onSuccess: () => closeCandidatePanel() },
);
}
}
if (mode === "add") {
createCandidate.mutate(payload, {
onSuccess: () => {
setForm(INITIAL_FORM);
closeCandidatePanel();
},
});
} else if (candidateId != null) {
updateCandidate.mutate(
{ candidateId, ...payload },
{ onSuccess: () => closeCandidatePanel() },
);
}
}
const isPending = createCandidate.isPending || updateCandidate.isPending;
const isPending = createCandidate.isPending || updateCandidate.isPending;
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Image */}
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Image */}
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
{/* Name */}
<div>
<label
htmlFor="candidate-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name *
</label>
<input
id="candidate-name"
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"
placeholder="e.g. Osprey Talon 22"
autoFocus
/>
{errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
)}
</div>
{/* Name */}
<div>
<label
htmlFor="candidate-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name *
</label>
<input
id="candidate-name"
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"
placeholder="e.g. Osprey Talon 22"
/>
{errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
)}
</div>
{/* Weight */}
<div>
<label
htmlFor="candidate-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
</label>
<input
id="candidate-weight"
type="number"
min="0"
step="any"
value={form.weightGrams}
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"
placeholder="e.g. 680"
/>
{errors.weightGrams && (
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
)}
</div>
{/* Weight */}
<div>
<label
htmlFor="candidate-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
</label>
<input
id="candidate-weight"
type="number"
min="0"
step="any"
value={form.weightGrams}
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"
placeholder="e.g. 680"
/>
{errors.weightGrams && (
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
)}
</div>
{/* Price */}
<div>
<label
htmlFor="candidate-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
Price ($)
</label>
<input
id="candidate-price"
type="number"
min="0"
step="0.01"
value={form.priceDollars}
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"
placeholder="e.g. 129.99"
/>
{errors.priceDollars && (
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
)}
</div>
{/* Price */}
<div>
<label
htmlFor="candidate-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
Price ($)
</label>
<input
id="candidate-price"
type="number"
min="0"
step="0.01"
value={form.priceDollars}
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"
placeholder="e.g. 129.99"
/>
{errors.priceDollars && (
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
)}
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<CategoryPicker
value={form.categoryId}
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<CategoryPicker
value={form.categoryId}
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
/>
</div>
{/* Notes */}
<div>
<label
htmlFor="candidate-notes"
className="block text-sm font-medium text-gray-700 mb-1"
>
Notes
</label>
<textarea
id="candidate-notes"
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"
placeholder="Any additional notes..."
/>
</div>
{/* Notes */}
<div>
<label
htmlFor="candidate-notes"
className="block text-sm font-medium text-gray-700 mb-1"
>
Notes
</label>
<textarea
id="candidate-notes"
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"
placeholder="Any additional notes..."
/>
</div>
{/* Product Link */}
<div>
<label
htmlFor="candidate-url"
className="block text-sm font-medium text-gray-700 mb-1"
>
Product Link
</label>
<input
id="candidate-url"
type="url"
value={form.productUrl}
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"
placeholder="https://..."
/>
{errors.productUrl && (
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
)}
</div>
{/* Product Link */}
<div>
<label
htmlFor="candidate-url"
className="block text-sm font-medium text-gray-700 mb-1"
>
Product Link
</label>
<input
id="candidate-url"
type="url"
value={form.productUrl}
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"
placeholder="https://..."
/>
{errors.productUrl && (
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
)}
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<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"
>
{isPending
? "Saving..."
: mode === "add"
? "Add Candidate"
: "Save Changes"}
</button>
</div>
</form>
);
{/* Actions */}
<div className="flex gap-3 pt-2">
<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"
>
{isPending
? "Saving..."
: mode === "add"
? "Add Candidate"
: "Save Changes"}
</button>
</div>
</form>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { formatWeight, formatPrice } from "../lib/formatters";
import { useUpdateCategory, useDeleteCategory } from "../hooks/useCategories";
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker";
@@ -39,7 +39,9 @@ export function CategoryHeader({
function handleDelete() {
if (
confirm(`Delete category "${name}"? Items will be moved to Uncategorized.`)
confirm(
`Delete category "${name}"? Items will be moved to Uncategorized.`,
)
) {
deleteCategory.mutate(categoryId);
}
@@ -58,7 +60,6 @@ export function CategoryHeader({
if (e.key === "Enter") handleSave();
if (e.key === "Escape") setIsEditing(false);
}}
autoFocus
/>
<button
type="button"

View File

@@ -1,8 +1,5 @@
import { useEffect, useRef, useState } from "react";
import {
useCategories,
useCreateCategory,
} from "../hooks/useCategories";
import { useCategories, useCreateCategory } from "../hooks/useCategories";
import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker";
@@ -109,10 +106,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
handleConfirmCreate();
} else if (highlightIndex >= 0 && highlightIndex < filtered.length) {
handleSelect(filtered[highlightIndex].id);
} else if (
showCreateOption &&
highlightIndex === filtered.length
) {
} else if (showCreateOption && highlightIndex === filtered.length) {
handleStartCreate();
}
break;
@@ -162,11 +156,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
: undefined
}
value={
isOpen
? inputValue
: selectedCategory
? selectedCategory.name
: ""
isOpen ? inputValue : selectedCategory ? selectedCategory.name : ""
}
placeholder="Search or create category..."
onChange={(e) => {
@@ -188,14 +178,12 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
<ul
ref={listRef}
id="category-listbox"
role="listbox"
className="absolute z-20 mt-1 w-full max-h-48 overflow-auto bg-white border border-gray-200 rounded-lg shadow-lg"
>
{filtered.map((cat, i) => (
<li
key={cat.id}
id={`category-option-${i}`}
role="option"
aria-selected={cat.id === value}
className={`px-3 py-2 text-sm cursor-pointer flex items-center gap-1.5 ${
i === highlightIndex
@@ -216,7 +204,6 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
{showCreateOption && !isCreating && (
<li
id={`category-option-${filtered.length}`}
role="option"
aria-selected={false}
className={`px-3 py-2 text-sm cursor-pointer border-t border-gray-100 ${
highlightIndex === filtered.length

View File

@@ -1,61 +1,60 @@
import { useDeleteItem, useItems } from "../hooks/useItems";
import { useUIStore } from "../stores/uiStore";
import { useDeleteItem } from "../hooks/useItems";
import { useItems } from "../hooks/useItems";
export function ConfirmDialog() {
const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId);
const closeConfirmDelete = useUIStore((s) => s.closeConfirmDelete);
const deleteItem = useDeleteItem();
const { data: items } = useItems();
const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId);
const closeConfirmDelete = useUIStore((s) => s.closeConfirmDelete);
const deleteItem = useDeleteItem();
const { data: items } = useItems();
if (confirmDeleteItemId == null) return null;
if (confirmDeleteItemId == null) return null;
const item = items?.find((i) => i.id === confirmDeleteItemId);
const itemName = item?.name ?? "this item";
const item = items?.find((i) => i.id === confirmDeleteItemId);
const itemName = item?.name ?? "this item";
function handleDelete() {
if (confirmDeleteItemId == null) return;
deleteItem.mutate(confirmDeleteItemId, {
onSuccess: () => closeConfirmDelete(),
});
}
function handleDelete() {
if (confirmDeleteItemId == null) return;
deleteItem.mutate(confirmDeleteItemId, {
onSuccess: () => closeConfirmDelete(),
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={closeConfirmDelete}
onKeyDown={(e) => {
if (e.key === "Escape") closeConfirmDelete();
}}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Item
</h3>
<p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "}
<span className="font-medium">{itemName}</span>? This action cannot be
undone.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={closeConfirmDelete}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleteItem.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
>
{deleteItem.isPending ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={closeConfirmDelete}
onKeyDown={(e) => {
if (e.key === "Escape") closeConfirmDelete();
}}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Item
</h3>
<p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "}
<span className="font-medium">{itemName}</span>? This action cannot be
undone.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={closeConfirmDelete}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleteItem.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
>
{deleteItem.isPending ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -37,9 +37,7 @@ export function ExternalLinkDialog() {
<h3 className="text-lg font-semibold text-gray-900 mb-2">
You are about to leave GearBox
</h3>
<p className="text-sm text-gray-600 mb-1">
You will be redirected to:
</p>
<p className="text-sm text-gray-600 mb-1">You will be redirected to:</p>
<p className="text-sm text-blue-600 break-all mb-6">
{externalLinkUrl}
</p>

View File

@@ -8,11 +8,7 @@ interface IconPickerProps {
size?: "sm" | "md";
}
export function IconPicker({
value,
onChange,
size = "md",
}: IconPickerProps) {
export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeGroup, setActiveGroup] = useState(0);
@@ -99,8 +95,7 @@ export function IconPicker({
const results = iconGroups.flatMap((group) =>
group.icons.filter(
(icon) =>
icon.name.includes(q) ||
icon.keywords.some((kw) => kw.includes(q)),
icon.name.includes(q) || icon.keywords.some((kw) => kw.includes(q)),
),
);
// Deduplicate by name (some icons appear in multiple groups)
@@ -118,8 +113,7 @@ export function IconPicker({
setSearch("");
}
const buttonSize =
size === "sm" ? "w-10 h-10" : "w-12 h-12";
const buttonSize = size === "sm" ? "w-10 h-10" : "w-12 h-12";
const iconSize = size === "sm" ? 20 : 24;
return (
@@ -179,9 +173,7 @@ export function IconPicker({
name={group.icon}
size={16}
className={
i === activeGroup
? "text-blue-700"
: "text-gray-400"
i === activeGroup ? "text-blue-700" : "text-gray-400"
}
/>
</button>

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from "react";
import { useRef, useState } from "react";
import { apiUpload } from "../lib/api";
interface ImageUploadProps {
@@ -32,10 +32,7 @@ export function ImageUpload({ value, onChange }: ImageUploadProps) {
setUploading(true);
try {
const result = await apiUpload<{ filename: string }>(
"/api/images",
file,
);
const result = await apiUpload<{ filename: string }>("/api/images", file);
onChange(result.filename);
} catch {
setError("Upload failed. Please try again.");

View File

@@ -107,7 +107,11 @@ export function ItemCard({
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" />
<LucideIcon
name={categoryIcon}
size={36}
className="text-gray-400"
/>
</div>
)}
</div>
@@ -127,7 +131,12 @@ export function ItemCard({
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon name={categoryIcon} size={14} className="inline-block mr-1 text-gray-500" /> {categoryName}
<LucideIcon
name={categoryIcon}
size={14}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</span>
</div>
</div>

View File

@@ -1,278 +1,282 @@
import { useState, useEffect } from "react";
import { useCreateItem, useUpdateItem, useItems } from "../hooks/useItems";
import { useEffect, useState } from "react";
import { useCreateItem, useItems, useUpdateItem } from "../hooks/useItems";
import { useUIStore } from "../stores/uiStore";
import { CategoryPicker } from "./CategoryPicker";
import { ImageUpload } from "./ImageUpload";
interface ItemFormProps {
mode: "add" | "edit";
itemId?: number | null;
mode: "add" | "edit";
itemId?: number | null;
}
interface FormData {
name: string;
weightGrams: string;
priceDollars: string;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string | null;
name: string;
weightGrams: string;
priceDollars: string;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string | null;
}
const INITIAL_FORM: FormData = {
name: "",
weightGrams: "",
priceDollars: "",
categoryId: 1,
notes: "",
productUrl: "",
imageFilename: null,
name: "",
weightGrams: "",
priceDollars: "",
categoryId: 1,
notes: "",
productUrl: "",
imageFilename: null,
};
export function ItemForm({ mode, itemId }: ItemFormProps) {
const { data: items } = useItems();
const createItem = useCreateItem();
const updateItem = useUpdateItem();
const closePanel = useUIStore((s) => s.closePanel);
const openConfirmDelete = useUIStore((s) => s.openConfirmDelete);
const { data: items } = useItems();
const createItem = useCreateItem();
const updateItem = useUpdateItem();
const closePanel = useUIStore((s) => s.closePanel);
const openConfirmDelete = useUIStore((s) => s.openConfirmDelete);
const [form, setForm] = useState<FormData>(INITIAL_FORM);
const [errors, setErrors] = useState<Record<string, string>>({});
const [form, setForm] = useState<FormData>(INITIAL_FORM);
const [errors, setErrors] = useState<Record<string, string>>({});
// Pre-fill form when editing
useEffect(() => {
if (mode === "edit" && itemId != null && items) {
const item = items.find((i) => i.id === itemId);
if (item) {
setForm({
name: item.name,
weightGrams:
item.weightGrams != null ? String(item.weightGrams) : "",
priceDollars:
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
categoryId: item.categoryId,
notes: item.notes ?? "",
productUrl: item.productUrl ?? "",
imageFilename: item.imageFilename,
});
}
} else if (mode === "add") {
setForm(INITIAL_FORM);
}
}, [mode, itemId, items]);
// Pre-fill form when editing
useEffect(() => {
if (mode === "edit" && itemId != null && items) {
const item = items.find((i) => i.id === itemId);
if (item) {
setForm({
name: item.name,
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
priceDollars:
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
categoryId: item.categoryId,
notes: item.notes ?? "",
productUrl: item.productUrl ?? "",
imageFilename: item.imageFilename,
});
}
} else if (mode === "add") {
setForm(INITIAL_FORM);
}
}, [mode, itemId, items]);
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) {
newErrors.name = "Name is required";
}
if (form.weightGrams && (isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)) {
newErrors.weightGrams = "Must be a positive number";
}
if (form.priceDollars && (isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)) {
newErrors.priceDollars = "Must be a positive number";
}
if (
form.productUrl &&
form.productUrl.trim() !== "" &&
!form.productUrl.match(/^https?:\/\//)
) {
newErrors.productUrl = "Must be a valid URL (https://...)";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) {
newErrors.name = "Name is required";
}
if (
form.weightGrams &&
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
) {
newErrors.weightGrams = "Must be a positive number";
}
if (
form.priceDollars &&
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
) {
newErrors.priceDollars = "Must be a positive number";
}
if (
form.productUrl &&
form.productUrl.trim() !== "" &&
!form.productUrl.match(/^https?:\/\//)
) {
newErrors.productUrl = "Must be a valid URL (https://...)";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const payload = {
name: form.name.trim(),
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
priceCents: form.priceDollars
? Math.round(Number(form.priceDollars) * 100)
: undefined,
categoryId: form.categoryId,
notes: form.notes.trim() || undefined,
productUrl: form.productUrl.trim() || undefined,
imageFilename: form.imageFilename ?? undefined,
};
const payload = {
name: form.name.trim(),
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
priceCents: form.priceDollars
? Math.round(Number(form.priceDollars) * 100)
: undefined,
categoryId: form.categoryId,
notes: form.notes.trim() || undefined,
productUrl: form.productUrl.trim() || undefined,
imageFilename: form.imageFilename ?? undefined,
};
if (mode === "add") {
createItem.mutate(payload, {
onSuccess: () => {
setForm(INITIAL_FORM);
closePanel();
},
});
} else if (itemId != null) {
updateItem.mutate(
{ id: itemId, ...payload },
{ onSuccess: () => closePanel() },
);
}
}
if (mode === "add") {
createItem.mutate(payload, {
onSuccess: () => {
setForm(INITIAL_FORM);
closePanel();
},
});
} else if (itemId != null) {
updateItem.mutate(
{ id: itemId, ...payload },
{ onSuccess: () => closePanel() },
);
}
}
const isPending = createItem.isPending || updateItem.isPending;
const isPending = createItem.isPending || updateItem.isPending;
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Image */}
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Image */}
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
{/* Name */}
<div>
<label
htmlFor="item-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name *
</label>
<input
id="item-name"
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"
placeholder="e.g. Osprey Talon 22"
autoFocus
/>
{errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
)}
</div>
{/* Name */}
<div>
<label
htmlFor="item-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name *
</label>
<input
id="item-name"
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"
placeholder="e.g. Osprey Talon 22"
/>
{errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
)}
</div>
{/* Weight */}
<div>
<label
htmlFor="item-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
</label>
<input
id="item-weight"
type="number"
min="0"
step="any"
value={form.weightGrams}
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"
placeholder="e.g. 680"
/>
{errors.weightGrams && (
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
)}
</div>
{/* Weight */}
<div>
<label
htmlFor="item-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
</label>
<input
id="item-weight"
type="number"
min="0"
step="any"
value={form.weightGrams}
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"
placeholder="e.g. 680"
/>
{errors.weightGrams && (
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
)}
</div>
{/* Price */}
<div>
<label
htmlFor="item-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
Price ($)
</label>
<input
id="item-price"
type="number"
min="0"
step="0.01"
value={form.priceDollars}
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"
placeholder="e.g. 129.99"
/>
{errors.priceDollars && (
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
)}
</div>
{/* Price */}
<div>
<label
htmlFor="item-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
Price ($)
</label>
<input
id="item-price"
type="number"
min="0"
step="0.01"
value={form.priceDollars}
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"
placeholder="e.g. 129.99"
/>
{errors.priceDollars && (
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
)}
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<CategoryPicker
value={form.categoryId}
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<CategoryPicker
value={form.categoryId}
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
/>
</div>
{/* Notes */}
<div>
<label
htmlFor="item-notes"
className="block text-sm font-medium text-gray-700 mb-1"
>
Notes
</label>
<textarea
id="item-notes"
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"
placeholder="Any additional notes..."
/>
</div>
{/* Notes */}
<div>
<label
htmlFor="item-notes"
className="block text-sm font-medium text-gray-700 mb-1"
>
Notes
</label>
<textarea
id="item-notes"
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"
placeholder="Any additional notes..."
/>
</div>
{/* Product Link */}
<div>
<label
htmlFor="item-url"
className="block text-sm font-medium text-gray-700 mb-1"
>
Product Link
</label>
<input
id="item-url"
type="url"
value={form.productUrl}
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"
placeholder="https://..."
/>
{errors.productUrl && (
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
)}
</div>
{/* Product Link */}
<div>
<label
htmlFor="item-url"
className="block text-sm font-medium text-gray-700 mb-1"
>
Product Link
</label>
<input
id="item-url"
type="url"
value={form.productUrl}
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"
placeholder="https://..."
/>
{errors.productUrl && (
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
)}
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<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"
>
{isPending
? "Saving..."
: mode === "add"
? "Add Item"
: "Save Changes"}
</button>
{mode === "edit" && itemId != null && (
<button
type="button"
onClick={() => openConfirmDelete(itemId)}
className="py-2.5 px-4 text-red-600 hover:bg-red-50 text-sm font-medium rounded-lg transition-colors"
>
Delete
</button>
)}
</div>
</form>
);
{/* Actions */}
<div className="flex gap-3 pt-2">
<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"
>
{isPending
? "Saving..."
: mode === "add"
? "Add Item"
: "Save Changes"}
</button>
{mode === "edit" && itemId != null && (
<button
type="button"
onClick={() => openConfirmDelete(itemId)}
className="py-2.5 px-4 text-red-600 hover:bg-red-50 text-sm font-medium rounded-lg transition-colors"
>
Delete
</button>
)}
</div>
</form>
);
}

View File

@@ -1,142 +1,154 @@
import { useState, useEffect } from "react";
import { SlideOutPanel } from "./SlideOutPanel";
import { useEffect, useState } from "react";
import { useItems } from "../hooks/useItems";
import { useSyncSetupItems } from "../hooks/useSetups";
import { formatWeight, formatPrice } from "../lib/formatters";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
import { SlideOutPanel } from "./SlideOutPanel";
interface ItemPickerProps {
setupId: number;
currentItemIds: number[];
isOpen: boolean;
onClose: () => void;
setupId: number;
currentItemIds: number[];
isOpen: boolean;
onClose: () => void;
}
export function ItemPicker({
setupId,
currentItemIds,
isOpen,
onClose,
setupId,
currentItemIds,
isOpen,
onClose,
}: ItemPickerProps) {
const { data: items } = useItems();
const syncItems = useSyncSetupItems(setupId);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const { data: items } = useItems();
const syncItems = useSyncSetupItems(setupId);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
// Reset selected IDs when panel opens
useEffect(() => {
if (isOpen) {
setSelectedIds(new Set(currentItemIds));
}
}, [isOpen, currentItemIds]);
// Reset selected IDs when panel opens
useEffect(() => {
if (isOpen) {
setSelectedIds(new Set(currentItemIds));
}
}, [isOpen, currentItemIds]);
function handleToggle(itemId: number) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(itemId)) {
next.delete(itemId);
} else {
next.add(itemId);
}
return next;
});
}
function handleToggle(itemId: number) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(itemId)) {
next.delete(itemId);
} else {
next.add(itemId);
}
return next;
});
}
function handleDone() {
syncItems.mutate(Array.from(selectedIds), {
onSuccess: () => onClose(),
});
}
function handleDone() {
syncItems.mutate(Array.from(selectedIds), {
onSuccess: () => onClose(),
});
}
// Group items by category
const grouped = new Map<
number,
{
categoryName: string;
categoryIcon: string;
items: NonNullable<typeof items>;
}
>();
// Group items by category
const grouped = new Map<
number,
{
categoryName: string;
categoryIcon: string;
items: NonNullable<typeof items>;
}
>();
if (items) {
for (const item of items) {
const group = grouped.get(item.categoryId);
if (group) {
group.items.push(item);
} else {
grouped.set(item.categoryId, {
categoryName: item.categoryName,
categoryIcon: item.categoryIcon,
items: [item],
});
}
}
}
if (items) {
for (const item of items) {
const group = grouped.get(item.categoryId);
if (group) {
group.items.push(item);
} else {
grouped.set(item.categoryId, {
categoryName: item.categoryName,
categoryIcon: item.categoryIcon,
items: [item],
});
}
}
}
return (
<SlideOutPanel isOpen={isOpen} onClose={onClose} title="Select Items">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto -mx-6 px-6">
{!items || items.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-gray-500">
No items in your collection yet.
</p>
</div>
) : (
Array.from(grouped.entries()).map(
([categoryId, { categoryName, categoryIcon, items: catItems }]) => (
<div key={categoryId} className="mb-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
<LucideIcon name={categoryIcon} size={16} className="inline-block mr-1 text-gray-500" /> {categoryName}
</h3>
<div className="space-y-1">
{catItems.map((item) => (
<label
key={item.id}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() => handleToggle(item.id)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="flex-1 text-sm text-gray-900 truncate">
{item.name}
</span>
<span className="text-xs text-gray-400 shrink-0">
{item.weightGrams != null && formatWeight(item.weightGrams)}
{item.weightGrams != null && item.priceCents != null && " · "}
{item.priceCents != null && formatPrice(item.priceCents)}
</span>
</label>
))}
</div>
</div>
),
)
)}
</div>
return (
<SlideOutPanel isOpen={isOpen} onClose={onClose} title="Select Items">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto -mx-6 px-6">
{!items || items.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-gray-500">
No items in your collection yet.
</p>
</div>
) : (
Array.from(grouped.entries()).map(
([
categoryId,
{ categoryName, categoryIcon, items: catItems },
]) => (
<div key={categoryId} className="mb-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
<LucideIcon
name={categoryIcon}
size={16}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</h3>
<div className="space-y-1">
{catItems.map((item) => (
<label
key={item.id}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() => handleToggle(item.id)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="flex-1 text-sm text-gray-900 truncate">
{item.name}
</span>
<span className="text-xs text-gray-400 shrink-0">
{item.weightGrams != null &&
formatWeight(item.weightGrams)}
{item.weightGrams != null &&
item.priceCents != null &&
" · "}
{item.priceCents != null &&
formatPrice(item.priceCents)}
</span>
</label>
))}
</div>
</div>
),
)
)}
</div>
{/* Action buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-100 -mx-6 px-6 pb-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={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"
>
{syncItems.isPending ? "Saving..." : "Done"}
</button>
</div>
</div>
</SlideOutPanel>
);
{/* Action buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-100 -mx-6 px-6 pb-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={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"
>
{syncItems.isPending ? "Saving..." : "Done"}
</button>
</div>
</div>
</SlideOutPanel>
);
}

View File

@@ -161,7 +161,6 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
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"
placeholder="e.g. Shelter"
autoFocus
/>
</div>
@@ -224,7 +223,6 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
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"
placeholder="e.g. Big Agnes Copper Spur"
autoFocus
/>
</div>

View File

@@ -1,43 +1,41 @@
import { Link } from "@tanstack/react-router";
import { formatWeight, formatPrice } from "../lib/formatters";
import { formatPrice, formatWeight } from "../lib/formatters";
interface SetupCardProps {
id: number;
name: string;
itemCount: number;
totalWeight: number;
totalCost: number;
id: number;
name: string;
itemCount: number;
totalWeight: number;
totalCost: number;
}
export function SetupCard({
id,
name,
itemCount,
totalWeight,
totalCost,
id,
name,
itemCount,
totalWeight,
totalCost,
}: SetupCardProps) {
return (
<Link
to="/setups/$setupId"
params={{ setupId: String(id) }}
className="block w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4"
>
<div className="flex items-start justify-between mb-3">
<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">
{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">
{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">
{formatPrice(totalCost)}
</span>
</div>
</Link>
);
return (
<Link
to="/setups/$setupId"
params={{ setupId: String(id) }}
className="block w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4"
>
<div className="flex items-start justify-between mb-3">
<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">
{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">
{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">
{formatPrice(totalCost)}
</span>
</div>
</Link>
);
}

View File

@@ -1,76 +1,76 @@
import { useEffect } from "react";
interface SlideOutPanelProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function SlideOutPanel({
isOpen,
onClose,
title,
children,
isOpen,
onClose,
title,
children,
}: SlideOutPanelProps) {
// Close on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [isOpen, onClose]);
// Close on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [isOpen, onClose]);
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-30 bg-black/20 transition-opacity ${
isOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-30 bg-black/20 transition-opacity ${
isOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* Panel */}
<div
className={`fixed top-0 right-0 z-40 h-full w-full sm:w-[400px] bg-white shadow-xl transition-transform duration-300 ease-in-out ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Panel */}
<div
className={`fixed top-0 right-0 z-40 h-full w-full sm:w-[400px] bg-white shadow-xl transition-transform duration-300 ease-in-out ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Content */}
<div className="overflow-y-auto h-[calc(100%-65px)] px-6 py-4">
{children}
</div>
</div>
</>
);
{/* Content */}
<div className="overflow-y-auto h-[calc(100%-65px)] px-6 py-4">
{children}
</div>
</div>
</>
);
}

View File

@@ -67,7 +67,12 @@ 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">
<LucideIcon name={categoryIcon} size={16} className="inline-block mr-1 text-gray-500" /> {categoryName}
<LucideIcon
name={categoryIcon}
size={16}
className="inline-block mr-1 text-gray-500"
/>{" "}
{categoryName}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}

View File

@@ -1,33 +1,33 @@
interface ThreadTabsProps {
active: "gear" | "planning";
onChange: (tab: "gear" | "planning") => void;
active: "gear" | "planning";
onChange: (tab: "gear" | "planning") => void;
}
const tabs = [
{ key: "gear" as const, label: "My Gear" },
{ key: "planning" as const, label: "Planning" },
{ key: "gear" as const, label: "My Gear" },
{ key: "planning" as const, label: "Planning" },
];
export function ThreadTabs({ active, onChange }: ThreadTabsProps) {
return (
<div className="flex border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
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-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" />
)}
</button>
))}
</div>
);
return (
<div className="flex border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
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-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" />
)}
</button>
))}
</div>
);
}

View File

@@ -1,59 +1,68 @@
import { Link } from "@tanstack/react-router";
import { useTotals } from "../hooks/useTotals";
import { formatWeight, formatPrice } from "../lib/formatters";
import { formatPrice, formatWeight } from "../lib/formatters";
interface TotalsBarProps {
title?: string;
stats?: Array<{ label: string; value: string }>;
linkTo?: string;
title?: string;
stats?: Array<{ label: string; value: string }>;
linkTo?: string;
}
export function TotalsBar({ title = "GearBox", stats, linkTo }: TotalsBarProps) {
const { data } = useTotals();
export function TotalsBar({
title = "GearBox",
stats,
linkTo,
}: TotalsBarProps) {
const { data } = useTotals();
// When no stats provided, use global totals (backward compatible)
const displayStats = stats ?? (data?.global
? [
{ label: "items", value: String(data.global.itemCount) },
{ label: "total", value: formatWeight(data.global.totalWeight) },
{ label: "spent", value: formatPrice(data.global.totalCost) },
]
: [
{ label: "items", value: "0" },
{ label: "total", value: formatWeight(null) },
{ label: "spent", value: formatPrice(null) },
]);
// When no stats provided, use global totals (backward compatible)
const displayStats =
stats ??
(data?.global
? [
{ label: "items", value: String(data.global.itemCount) },
{ label: "total", value: formatWeight(data.global.totalWeight) },
{ label: "spent", value: formatPrice(data.global.totalCost) },
]
: [
{ label: "items", value: "0" },
{ label: "total", value: formatWeight(null) },
{ label: "spent", value: formatPrice(null) },
]);
const titleElement = linkTo ? (
<Link to={linkTo} className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
{title}
</Link>
) : (
<h1 className="text-lg font-semibold text-gray-900">{title}</h1>
);
const titleElement = linkTo ? (
<Link
to={linkTo}
className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors"
>
{title}
</Link>
) : (
<h1 className="text-lg font-semibold text-gray-900">{title}</h1>
);
// If stats prop is explicitly an empty array, show title only (dashboard mode)
const showStats = stats === undefined || stats.length > 0;
// If stats prop is explicitly an empty array, show title only (dashboard mode)
const showStats = stats === undefined || stats.length > 0;
return (
<div className="sticky top-0 z-10 bg-white border-b border-gray-100">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
{titleElement}
{showStats && (
<div className="flex items-center gap-6 text-sm text-gray-500">
{displayStats.map((stat) => (
<span key={stat.label}>
<span className="font-medium text-gray-700">
{stat.value}
</span>{" "}
{stat.label}
</span>
))}
</div>
)}
</div>
</div>
</div>
);
return (
<div className="sticky top-0 z-10 bg-white border-b border-gray-100">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
{titleElement}
{showStats && (
<div className="flex items-center gap-6 text-sm text-gray-500">
{displayStats.map((stat) => (
<span key={stat.label}>
<span className="font-medium text-gray-700">
{stat.value}
</span>{" "}
{stat.label}
</span>
))}
</div>
)}
</div>
</div>
</div>
);
}