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>
);
}

View File

@@ -1,61 +1,61 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost, apiPut, apiDelete } from "../lib/api";
import type { CreateCandidate, UpdateCandidate } from "../../shared/types";
import { apiDelete, apiPost, apiPut } from "../lib/api";
interface CandidateResponse {
id: number;
threadId: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
createdAt: string;
updatedAt: string;
id: number;
threadId: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
createdAt: string;
updatedAt: string;
}
export function useCreateCandidate(threadId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCandidate & { imageFilename?: string }) =>
apiPost<CandidateResponse>(`/api/threads/${threadId}/candidates`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCandidate & { imageFilename?: string }) =>
apiPost<CandidateResponse>(`/api/threads/${threadId}/candidates`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
}
export function useUpdateCandidate(threadId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
candidateId,
...data
}: UpdateCandidate & { candidateId: number; imageFilename?: string }) =>
apiPut<CandidateResponse>(
`/api/threads/${threadId}/candidates/${candidateId}`,
data,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
candidateId,
...data
}: UpdateCandidate & { candidateId: number; imageFilename?: string }) =>
apiPut<CandidateResponse>(
`/api/threads/${threadId}/candidates/${candidateId}`,
data,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
}
export function useDeleteCandidate(threadId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (candidateId: number) =>
apiDelete<{ success: boolean }>(
`/api/threads/${threadId}/candidates/${candidateId}`,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (candidateId: number) =>
apiDelete<{ success: boolean }>(
`/api/threads/${threadId}/candidates/${candidateId}`,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
}

View File

@@ -1,53 +1,53 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Category, CreateCategory } from "../../shared/types";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
export function useCategories() {
return useQuery({
queryKey: ["categories"],
queryFn: () => apiGet<Category[]>("/api/categories"),
});
return useQuery({
queryKey: ["categories"],
queryFn: () => apiGet<Category[]>("/api/categories"),
});
}
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCategory) =>
apiPost<Category>("/api/categories", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCategory) =>
apiPost<Category>("/api/categories", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
},
});
}
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
...data
}: {
id: number;
name?: string;
icon?: string;
}) => apiPut<Category>(`/api/categories/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
...data
}: {
id: number;
name?: string;
icon?: string;
}) => apiPut<Category>(`/api/categories/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}
export function useDeleteCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/categories/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/categories/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}

View File

@@ -1,71 +1,71 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CreateItem } from "../../shared/types";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface ItemWithCategory {
id: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
createdAt: string;
updatedAt: string;
categoryName: string;
categoryIcon: string;
id: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
createdAt: string;
updatedAt: string;
categoryName: string;
categoryIcon: string;
}
export function useItems() {
return useQuery({
queryKey: ["items"],
queryFn: () => apiGet<ItemWithCategory[]>("/api/items"),
});
return useQuery({
queryKey: ["items"],
queryFn: () => apiGet<ItemWithCategory[]>("/api/items"),
});
}
export function useItem(id: number | null) {
return useQuery({
queryKey: ["items", id],
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
enabled: id != null,
});
return useQuery({
queryKey: ["items", id],
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
enabled: id != null,
});
}
export function useCreateItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateItem) =>
apiPost<ItemWithCategory>("/api/items", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateItem) =>
apiPost<ItemWithCategory>("/api/items", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}
export function useUpdateItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number } & Partial<CreateItem>) =>
apiPut<ItemWithCategory>(`/api/items/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number } & Partial<CreateItem>) =>
apiPut<ItemWithCategory>(`/api/items/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}
export function useDeleteItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/items/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/items/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}

View File

@@ -1,37 +1,37 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPut } from "../lib/api";
interface Setting {
key: string;
value: string;
key: string;
value: string;
}
export function useSetting(key: string) {
return useQuery({
queryKey: ["settings", key],
queryFn: async () => {
try {
const result = await apiGet<Setting>(`/api/settings/${key}`);
return result.value;
} catch (err: any) {
if (err?.status === 404) return null;
throw err;
}
},
});
return useQuery({
queryKey: ["settings", key],
queryFn: async () => {
try {
const result = await apiGet<Setting>(`/api/settings/${key}`);
return result.value;
} catch (err: any) {
if (err?.status === 404) return null;
throw err;
}
},
});
}
export function useUpdateSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
apiPut<Setting>(`/api/settings/${key}`, { value }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["settings", variables.key] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
apiPut<Setting>(`/api/settings/${key}`, { value }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["settings", variables.key] });
},
});
}
export function useOnboardingComplete() {
return useSetting("onboardingComplete");
return useSetting("onboardingComplete");
}

View File

@@ -1,107 +1,107 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface SetupListItem {
id: number;
name: string;
createdAt: string;
updatedAt: string;
itemCount: number;
totalWeight: number;
totalCost: number;
id: number;
name: string;
createdAt: string;
updatedAt: string;
itemCount: number;
totalWeight: number;
totalCost: number;
}
interface SetupItemWithCategory {
id: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
createdAt: string;
updatedAt: string;
categoryName: string;
categoryIcon: string;
id: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
createdAt: string;
updatedAt: string;
categoryName: string;
categoryIcon: string;
}
interface SetupWithItems {
id: number;
name: string;
createdAt: string;
updatedAt: string;
items: SetupItemWithCategory[];
id: number;
name: string;
createdAt: string;
updatedAt: string;
items: SetupItemWithCategory[];
}
export type { SetupListItem, SetupWithItems, SetupItemWithCategory };
export type { SetupItemWithCategory, SetupListItem, SetupWithItems };
export function useSetups() {
return useQuery({
queryKey: ["setups"],
queryFn: () => apiGet<SetupListItem[]>("/api/setups"),
});
return useQuery({
queryKey: ["setups"],
queryFn: () => apiGet<SetupListItem[]>("/api/setups"),
});
}
export function useSetup(setupId: number | null) {
return useQuery({
queryKey: ["setups", setupId],
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
enabled: setupId != null,
});
return useQuery({
queryKey: ["setups", setupId],
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
enabled: setupId != null,
});
}
export function useCreateSetup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string }) =>
apiPost<SetupListItem>("/api/setups", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string }) =>
apiPost<SetupListItem>("/api/setups", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}
export function useUpdateSetup(setupId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name?: string }) =>
apiPut<SetupListItem>(`/api/setups/${setupId}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name?: string }) =>
apiPut<SetupListItem>(`/api/setups/${setupId}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}
export function useDeleteSetup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/setups/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/setups/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}
export function useSyncSetupItems(setupId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemIds: number[]) =>
apiPut<{ success: boolean }>(`/api/setups/${setupId}/items`, { itemIds }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemIds: number[]) =>
apiPut<{ success: boolean }>(`/api/setups/${setupId}/items`, { itemIds }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}
export function useRemoveSetupItem(setupId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemId: number) =>
apiDelete<{ success: boolean }>(`/api/setups/${setupId}/items/${itemId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemId: number) =>
apiDelete<{ success: boolean }>(`/api/setups/${setupId}/items/${itemId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}

View File

@@ -1,116 +1,116 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface ThreadListItem {
id: number;
name: string;
status: "active" | "resolved";
resolvedCandidateId: number | null;
categoryId: number;
categoryName: string;
categoryIcon: string;
createdAt: string;
updatedAt: string;
candidateCount: number;
minPriceCents: number | null;
maxPriceCents: number | null;
id: number;
name: string;
status: "active" | "resolved";
resolvedCandidateId: number | null;
categoryId: number;
categoryName: string;
categoryIcon: string;
createdAt: string;
updatedAt: string;
candidateCount: number;
minPriceCents: number | null;
maxPriceCents: number | null;
}
interface CandidateWithCategory {
id: number;
threadId: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
createdAt: string;
updatedAt: string;
categoryName: string;
categoryIcon: string;
id: number;
threadId: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
createdAt: string;
updatedAt: string;
categoryName: string;
categoryIcon: string;
}
interface ThreadWithCandidates {
id: number;
name: string;
status: "active" | "resolved";
resolvedCandidateId: number | null;
createdAt: string;
updatedAt: string;
candidates: CandidateWithCategory[];
id: number;
name: string;
status: "active" | "resolved";
resolvedCandidateId: number | null;
createdAt: string;
updatedAt: string;
candidates: CandidateWithCategory[];
}
export function useThreads(includeResolved = false) {
return useQuery({
queryKey: ["threads", { includeResolved }],
queryFn: () =>
apiGet<ThreadListItem[]>(
`/api/threads${includeResolved ? "?includeResolved=true" : ""}`,
),
});
return useQuery({
queryKey: ["threads", { includeResolved }],
queryFn: () =>
apiGet<ThreadListItem[]>(
`/api/threads${includeResolved ? "?includeResolved=true" : ""}`,
),
});
}
export function useThread(threadId: number | null) {
return useQuery({
queryKey: ["threads", threadId],
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
enabled: threadId != null,
});
return useQuery({
queryKey: ["threads", threadId],
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
enabled: threadId != null,
});
}
export function useCreateThread() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; categoryId: number }) =>
apiPost<ThreadListItem>("/api/threads", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; categoryId: number }) =>
apiPost<ThreadListItem>("/api/threads", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
}
export function useUpdateThread() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; name?: string }) =>
apiPut<ThreadListItem>(`/api/threads/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; name?: string }) =>
apiPut<ThreadListItem>(`/api/threads/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
}
export function useDeleteThread() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/threads/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/threads/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
}
export function useResolveThread() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
threadId,
candidateId,
}: {
threadId: number;
candidateId: number;
}) =>
apiPost<{ success: boolean; item: unknown }>(
`/api/threads/${threadId}/resolve`,
{ candidateId },
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
threadId,
candidateId,
}: {
threadId: number;
candidateId: number;
}) =>
apiPost<{ success: boolean; item: unknown }>(
`/api/threads/${threadId}/resolve`,
{ candidateId },
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}

View File

@@ -2,30 +2,30 @@ import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../lib/api";
interface CategoryTotals {
categoryId: number;
categoryName: string;
categoryIcon: string;
totalWeight: number;
totalCost: number;
itemCount: number;
categoryId: number;
categoryName: string;
categoryIcon: string;
totalWeight: number;
totalCost: number;
itemCount: number;
}
interface GlobalTotals {
totalWeight: number;
totalCost: number;
itemCount: number;
totalWeight: number;
totalCost: number;
itemCount: number;
}
interface TotalsResponse {
categories: CategoryTotals[];
global: GlobalTotals;
categories: CategoryTotals[];
global: GlobalTotals;
}
export type { CategoryTotals, GlobalTotals, TotalsResponse };
export function useTotals() {
return useQuery({
queryKey: ["totals"],
queryFn: () => apiGet<TotalsResponse>("/api/totals"),
});
return useQuery({
queryKey: ["totals"],
queryFn: () => apiGet<TotalsResponse>("/api/totals"),
});
}

View File

@@ -1,61 +1,61 @@
class ApiError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
this.name = "ApiError";
}
constructor(
message: string,
public status: number,
) {
super(message);
this.name = "ApiError";
}
}
async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
let message = `Request failed with status ${res.status}`;
try {
const body = await res.json();
if (body.error) message = body.error;
} catch {
// Use default message
}
throw new ApiError(message, res.status);
}
return res.json() as Promise<T>;
if (!res.ok) {
let message = `Request failed with status ${res.status}`;
try {
const body = await res.json();
if (body.error) message = body.error;
} catch {
// Use default message
}
throw new ApiError(message, res.status);
}
return res.json() as Promise<T>;
}
export async function apiGet<T>(url: string): Promise<T> {
const res = await fetch(url);
return handleResponse<T>(res);
const res = await fetch(url);
return handleResponse<T>(res);
}
export async function apiPost<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res);
}
export async function apiPut<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res);
const res = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res);
}
export async function apiDelete<T>(url: string): Promise<T> {
const res = await fetch(url, { method: "DELETE" });
return handleResponse<T>(res);
const res = await fetch(url, { method: "DELETE" });
return handleResponse<T>(res);
}
export async function apiUpload<T>(url: string, file: File): Promise<T> {
const formData = new FormData();
formData.append("image", file);
const res = await fetch(url, {
method: "POST",
body: formData,
});
return handleResponse<T>(res);
const formData = new FormData();
formData.append("image", file);
const res = await fetch(url, {
method: "POST",
body: formData,
});
return handleResponse<T>(res);
}

View File

@@ -1,9 +1,9 @@
export function formatWeight(grams: number | null | undefined): string {
if (grams == null) return "--";
return `${Math.round(grams)}g`;
if (grams == null) return "--";
return `${Math.round(grams)}g`;
}
export function formatPrice(cents: number | null | undefined): string {
if (cents == null) return "--";
return `$${(cents / 100).toFixed(2)}`;
if (cents == null) return "--";
return `$${(cents / 100).toFixed(2)}`;
}

View File

@@ -1,29 +1,29 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen";
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
context: {},
routeTree,
context: {},
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -1,323 +1,328 @@
import { useState } from "react";
import {
createRootRoute,
Outlet,
useMatchRoute,
useNavigate,
createRootRoute,
Outlet,
useMatchRoute,
useNavigate,
} from "@tanstack/react-router";
import { useState } from "react";
import "../app.css";
import { TotalsBar } from "../components/TotalsBar";
import { SlideOutPanel } from "../components/SlideOutPanel";
import { ItemForm } from "../components/ItemForm";
import { CandidateForm } from "../components/CandidateForm";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
import { ItemForm } from "../components/ItemForm";
import { OnboardingWizard } from "../components/OnboardingWizard";
import { useUIStore } from "../stores/uiStore";
import { useOnboardingComplete } from "../hooks/useSettings";
import { useThread, useResolveThread } from "../hooks/useThreads";
import { SlideOutPanel } from "../components/SlideOutPanel";
import { TotalsBar } from "../components/TotalsBar";
import { useDeleteCandidate } from "../hooks/useCandidates";
import { useOnboardingComplete } from "../hooks/useSettings";
import { useResolveThread, useThread } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore";
export const Route = createRootRoute({
component: RootLayout,
component: RootLayout,
});
function RootLayout() {
const navigate = useNavigate();
const navigate = useNavigate();
// Item panel state
const panelMode = useUIStore((s) => s.panelMode);
const editingItemId = useUIStore((s) => s.editingItemId);
const openAddPanel = useUIStore((s) => s.openAddPanel);
const closePanel = useUIStore((s) => s.closePanel);
// Item panel state
const panelMode = useUIStore((s) => s.panelMode);
const editingItemId = useUIStore((s) => s.editingItemId);
const openAddPanel = useUIStore((s) => s.openAddPanel);
const closePanel = useUIStore((s) => s.closePanel);
// Candidate panel state
const candidatePanelMode = useUIStore((s) => s.candidatePanelMode);
const editingCandidateId = useUIStore((s) => s.editingCandidateId);
const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
// Candidate panel state
const candidatePanelMode = useUIStore((s) => s.candidatePanelMode);
const editingCandidateId = useUIStore((s) => s.editingCandidateId);
const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
// Candidate delete state
const confirmDeleteCandidateId = useUIStore(
(s) => s.confirmDeleteCandidateId,
);
const closeConfirmDeleteCandidate = useUIStore(
(s) => s.closeConfirmDeleteCandidate,
);
// Candidate delete state
const confirmDeleteCandidateId = useUIStore(
(s) => s.confirmDeleteCandidateId,
);
const closeConfirmDeleteCandidate = useUIStore(
(s) => s.closeConfirmDeleteCandidate,
);
// Resolution dialog state
const resolveThreadId = useUIStore((s) => s.resolveThreadId);
const resolveCandidateId = useUIStore((s) => s.resolveCandidateId);
const closeResolveDialog = useUIStore((s) => s.closeResolveDialog);
// Resolution dialog state
const resolveThreadId = useUIStore((s) => s.resolveThreadId);
const resolveCandidateId = useUIStore((s) => s.resolveCandidateId);
const closeResolveDialog = useUIStore((s) => s.closeResolveDialog);
// Onboarding
const { data: onboardingComplete, isLoading: onboardingLoading } =
useOnboardingComplete();
const [wizardDismissed, setWizardDismissed] = useState(false);
// Onboarding
const { data: onboardingComplete, isLoading: onboardingLoading } =
useOnboardingComplete();
const [wizardDismissed, setWizardDismissed] = useState(false);
const showWizard =
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
const showWizard =
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
const isItemPanelOpen = panelMode !== "closed";
const isCandidatePanelOpen = candidatePanelMode !== "closed";
const isItemPanelOpen = panelMode !== "closed";
const isCandidatePanelOpen = candidatePanelMode !== "closed";
// Route matching for contextual behavior
const matchRoute = useMatchRoute();
// Route matching for contextual behavior
const matchRoute = useMatchRoute();
const threadMatch = matchRoute({
to: "/threads/$threadId",
fuzzy: true,
}) as { threadId?: string } | false;
const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null;
const threadMatch = matchRoute({
to: "/threads/$threadId",
fuzzy: true,
}) as { threadId?: string } | false;
const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null;
const isDashboard = !!matchRoute({ to: "/" });
const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true });
const isDashboard = !!matchRoute({ to: "/" });
const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true });
// Determine TotalsBar props based on current route
const totalsBarProps = isDashboard
? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link
: isSetupDetail
? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link
: { linkTo: "/" }; // All other pages: default stats + link to dashboard
// Determine TotalsBar props based on current route
const _totalsBarProps = isDashboard
? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link
: isSetupDetail
? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link
: { linkTo: "/" }; // All other pages: default stats + link to dashboard
// On dashboard, don't show the default global stats - pass empty stats
// On collection, let TotalsBar fetch its own global stats (default behavior)
const finalTotalsProps = isDashboard
? { stats: [] as Array<{ label: string; value: string }> }
: isCollection
? { linkTo: "/" }
: { linkTo: "/" };
// On dashboard, don't show the default global stats - pass empty stats
// On collection, let TotalsBar fetch its own global stats (default behavior)
const finalTotalsProps = isDashboard
? { stats: [] as Array<{ label: string; value: string }> }
: isCollection
? { linkTo: "/" }
: { linkTo: "/" };
// FAB visibility: only show on /collection route when gear tab is active
const collectionSearch = matchRoute({ to: "/collection" }) as { tab?: string } | false;
const showFab = isCollection && (!collectionSearch || (collectionSearch as Record<string, string>).tab !== "planning");
// FAB visibility: only show on /collection route when gear tab is active
const collectionSearch = matchRoute({ to: "/collection" }) as
| { tab?: string }
| false;
const showFab =
isCollection &&
(!collectionSearch ||
(collectionSearch as Record<string, string>).tab !== "planning");
// Show a minimal loading state while checking onboarding status
if (onboardingLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
// Show a minimal loading state while checking onboarding status
if (onboardingLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<TotalsBar {...finalTotalsProps} />
<Outlet />
return (
<div className="min-h-screen bg-gray-50">
<TotalsBar {...finalTotalsProps} />
<Outlet />
{/* Item Slide-out Panel */}
<SlideOutPanel
isOpen={isItemPanelOpen}
onClose={closePanel}
title={panelMode === "add" ? "Add Item" : "Edit Item"}
>
{panelMode === "add" && <ItemForm mode="add" />}
{panelMode === "edit" && (
<ItemForm mode="edit" itemId={editingItemId} />
)}
</SlideOutPanel>
{/* Item Slide-out Panel */}
<SlideOutPanel
isOpen={isItemPanelOpen}
onClose={closePanel}
title={panelMode === "add" ? "Add Item" : "Edit Item"}
>
{panelMode === "add" && <ItemForm mode="add" />}
{panelMode === "edit" && (
<ItemForm mode="edit" itemId={editingItemId} />
)}
</SlideOutPanel>
{/* Candidate Slide-out Panel */}
{currentThreadId != null && (
<SlideOutPanel
isOpen={isCandidatePanelOpen}
onClose={closeCandidatePanel}
title={
candidatePanelMode === "add" ? "Add Candidate" : "Edit Candidate"
}
>
{candidatePanelMode === "add" && (
<CandidateForm mode="add" threadId={currentThreadId} />
)}
{candidatePanelMode === "edit" && (
<CandidateForm
mode="edit"
threadId={currentThreadId}
candidateId={editingCandidateId}
/>
)}
</SlideOutPanel>
)}
{/* Candidate Slide-out Panel */}
{currentThreadId != null && (
<SlideOutPanel
isOpen={isCandidatePanelOpen}
onClose={closeCandidatePanel}
title={
candidatePanelMode === "add" ? "Add Candidate" : "Edit Candidate"
}
>
{candidatePanelMode === "add" && (
<CandidateForm mode="add" threadId={currentThreadId} />
)}
{candidatePanelMode === "edit" && (
<CandidateForm
mode="edit"
threadId={currentThreadId}
candidateId={editingCandidateId}
/>
)}
</SlideOutPanel>
)}
{/* Item Confirm Delete Dialog */}
<ConfirmDialog />
{/* Item Confirm Delete Dialog */}
<ConfirmDialog />
{/* External Link Confirmation Dialog */}
<ExternalLinkDialog />
{/* External Link Confirmation Dialog */}
<ExternalLinkDialog />
{/* Candidate Delete Confirm Dialog */}
{confirmDeleteCandidateId != null && currentThreadId != null && (
<CandidateDeleteDialog
candidateId={confirmDeleteCandidateId}
threadId={currentThreadId}
onClose={closeConfirmDeleteCandidate}
/>
)}
{/* Candidate Delete Confirm Dialog */}
{confirmDeleteCandidateId != null && currentThreadId != null && (
<CandidateDeleteDialog
candidateId={confirmDeleteCandidateId}
threadId={currentThreadId}
onClose={closeConfirmDeleteCandidate}
/>
)}
{/* Resolution Confirm Dialog */}
{resolveThreadId != null && resolveCandidateId != null && (
<ResolveDialog
threadId={resolveThreadId}
candidateId={resolveCandidateId}
onClose={closeResolveDialog}
onResolved={() => {
closeResolveDialog();
navigate({ to: "/collection", search: { tab: "planning" } });
}}
/>
)}
{/* Resolution Confirm Dialog */}
{resolveThreadId != null && resolveCandidateId != null && (
<ResolveDialog
threadId={resolveThreadId}
candidateId={resolveCandidateId}
onClose={closeResolveDialog}
onResolved={() => {
closeResolveDialog();
navigate({ to: "/collection", search: { tab: "planning" } });
}}
/>
)}
{/* Floating Add Button - only on collection gear tab */}
{showFab && (
<button
type="button"
onClick={openAddPanel}
className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
title="Add new item"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
)}
{/* Floating Add Button - only on collection gear tab */}
{showFab && (
<button
type="button"
onClick={openAddPanel}
className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
title="Add new item"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
)}
{/* Onboarding Wizard */}
{showWizard && (
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
)}
</div>
);
{/* Onboarding Wizard */}
{showWizard && (
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
)}
</div>
);
}
function CandidateDeleteDialog({
candidateId,
threadId,
onClose,
candidateId,
threadId,
onClose,
}: {
candidateId: number;
threadId: number;
onClose: () => void;
candidateId: number;
threadId: number;
onClose: () => void;
}) {
const deleteCandidate = useDeleteCandidate(threadId);
const { data: thread } = useThread(threadId);
const candidate = thread?.candidates.find((c) => c.id === candidateId);
const candidateName = candidate?.name ?? "this candidate";
const deleteCandidate = useDeleteCandidate(threadId);
const { data: thread } = useThread(threadId);
const candidate = thread?.candidates.find((c) => c.id === candidateId);
const candidateName = candidate?.name ?? "this candidate";
function handleDelete() {
deleteCandidate.mutate(candidateId, {
onSuccess: () => onClose(),
});
}
function handleDelete() {
deleteCandidate.mutate(candidateId, {
onSuccess: () => onClose(),
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
/>
<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 Candidate
</h3>
<p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "}
<span className="font-medium">{candidateName}</span>? This action
cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
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={deleteCandidate.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"
>
{deleteCandidate.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={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
/>
<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 Candidate
</h3>
<p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "}
<span className="font-medium">{candidateName}</span>? This action
cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
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={deleteCandidate.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"
>
{deleteCandidate.isPending ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
);
}
function ResolveDialog({
threadId,
candidateId,
onClose,
onResolved,
threadId,
candidateId,
onClose,
onResolved,
}: {
threadId: number;
candidateId: number;
onClose: () => void;
onResolved: () => void;
threadId: number;
candidateId: number;
onClose: () => void;
onResolved: () => void;
}) {
const resolveThread = useResolveThread();
const { data: thread } = useThread(threadId);
const candidate = thread?.candidates.find((c) => c.id === candidateId);
const candidateName = candidate?.name ?? "this candidate";
const resolveThread = useResolveThread();
const { data: thread } = useThread(threadId);
const candidate = thread?.candidates.find((c) => c.id === candidateId);
const candidateName = candidate?.name ?? "this candidate";
function handleResolve() {
resolveThread.mutate(
{ threadId, candidateId },
{ onSuccess: () => onResolved() },
);
}
function handleResolve() {
resolveThread.mutate(
{ threadId, candidateId },
{ onSuccess: () => onResolved() },
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
/>
<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">
Pick Winner
</h3>
<p className="text-sm text-gray-600 mb-6">
Pick <span className="font-medium">{candidateName}</span> as the
winner? This will add it to your collection and archive the thread.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
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={handleResolve}
disabled={resolveThread.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg transition-colors"
>
{resolveThread.isPending ? "Resolving..." : "Pick Winner"}
</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={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
/>
<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">
Pick Winner
</h3>
<p className="text-sm text-gray-600 mb-6">
Pick <span className="font-medium">{candidateName}</span> as the
winner? This will add it to your collection and archive the thread.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
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={handleResolve}
disabled={resolveThread.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg transition-colors"
>
{resolveThread.isPending ? "Resolving..." : "Pick Winner"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,93 +1,93 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const categories = sqliteTable("categories", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(),
icon: text("icon").notNull().default("package"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(),
icon: text("icon").notNull().default("package"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const items = sqliteTable("items", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
weightGrams: real("weight_grams"),
priceCents: integer("price_cents"),
categoryId: integer("category_id")
.notNull()
.references(() => categories.id),
notes: text("notes"),
productUrl: text("product_url"),
imageFilename: text("image_filename"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
weightGrams: real("weight_grams"),
priceCents: integer("price_cents"),
categoryId: integer("category_id")
.notNull()
.references(() => categories.id),
notes: text("notes"),
productUrl: text("product_url"),
imageFilename: text("image_filename"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const threads = sqliteTable("threads", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
status: text("status").notNull().default("active"),
resolvedCandidateId: integer("resolved_candidate_id"),
categoryId: integer("category_id")
.notNull()
.references(() => categories.id),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
status: text("status").notNull().default("active"),
resolvedCandidateId: integer("resolved_candidate_id"),
categoryId: integer("category_id")
.notNull()
.references(() => categories.id),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const threadCandidates = sqliteTable("thread_candidates", {
id: integer("id").primaryKey({ autoIncrement: true }),
threadId: integer("thread_id")
.notNull()
.references(() => threads.id, { onDelete: "cascade" }),
name: text("name").notNull(),
weightGrams: real("weight_grams"),
priceCents: integer("price_cents"),
categoryId: integer("category_id")
.notNull()
.references(() => categories.id),
notes: text("notes"),
productUrl: text("product_url"),
imageFilename: text("image_filename"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
id: integer("id").primaryKey({ autoIncrement: true }),
threadId: integer("thread_id")
.notNull()
.references(() => threads.id, { onDelete: "cascade" }),
name: text("name").notNull(),
weightGrams: real("weight_grams"),
priceCents: integer("price_cents"),
categoryId: integer("category_id")
.notNull()
.references(() => categories.id),
notes: text("notes"),
productUrl: text("product_url"),
imageFilename: text("image_filename"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const setups = sqliteTable("setups", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const setupItems = sqliteTable("setup_items", {
id: integer("id").primaryKey({ autoIncrement: true }),
setupId: integer("setup_id")
.notNull()
.references(() => setups.id, { onDelete: "cascade" }),
itemId: integer("item_id")
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
id: integer("id").primaryKey({ autoIncrement: true }),
setupId: integer("setup_id")
.notNull()
.references(() => setups.id, { onDelete: "cascade" }),
itemId: integer("item_id")
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
});
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
key: text("key").primaryKey(),
value: text("value").notNull(),
});

View File

@@ -2,13 +2,13 @@ import { db } from "./index.ts";
import { categories } from "./schema.ts";
export function seedDefaults() {
const existing = db.select().from(categories).all();
if (existing.length === 0) {
db.insert(categories)
.values({
name: "Uncategorized",
icon: "package",
})
.run();
}
const existing = db.select().from(categories).all();
if (existing.length === 0) {
db.insert(categories)
.values({
name: "Uncategorized",
icon: "package",
})
.run();
}
}

View File

@@ -1,13 +1,13 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { seedDefaults } from "../db/seed.ts";
import { itemRoutes } from "./routes/items.ts";
import { categoryRoutes } from "./routes/categories.ts";
import { totalRoutes } from "./routes/totals.ts";
import { imageRoutes } from "./routes/images.ts";
import { itemRoutes } from "./routes/items.ts";
import { settingsRoutes } from "./routes/settings.ts";
import { threadRoutes } from "./routes/threads.ts";
import { setupRoutes } from "./routes/setups.ts";
import { threadRoutes } from "./routes/threads.ts";
import { totalRoutes } from "./routes/totals.ts";
// Seed default data on startup
seedDefaults();
@@ -16,7 +16,7 @@ const app = new Hono();
// Health check
app.get("/api/health", (c) => {
return c.json({ status: "ok" });
return c.json({ status: "ok" });
});
// API routes
@@ -33,8 +33,8 @@ app.use("/uploads/*", serveStatic({ root: "./" }));
// Serve Vite-built SPA in production
if (process.env.NODE_ENV === "production") {
app.use("/*", serveStatic({ root: "./dist/client" }));
app.get("*", serveStatic({ path: "./dist/client/index.html" }));
app.use("/*", serveStatic({ root: "./dist/client" }));
app.get("*", serveStatic({ path: "./dist/client/index.html" }));
}
export default { port: 3000, fetch: app.fetch };

View File

@@ -1,14 +1,14 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import {
createCategorySchema,
updateCategorySchema,
createCategorySchema,
updateCategorySchema,
} from "../../shared/schemas.ts";
import {
getAllCategories,
createCategory,
updateCategory,
deleteCategory,
createCategory,
deleteCategory,
getAllCategories,
updateCategory,
} from "../services/category.service.ts";
type Env = { Variables: { db?: any } };
@@ -16,44 +16,44 @@ type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", (c) => {
const db = c.get("db");
const cats = getAllCategories(db);
return c.json(cats);
const db = c.get("db");
const cats = getAllCategories(db);
return c.json(cats);
});
app.post("/", zValidator("json", createCategorySchema), (c) => {
const db = c.get("db");
const data = c.req.valid("json");
const cat = createCategory(db, data);
return c.json(cat, 201);
const db = c.get("db");
const data = c.req.valid("json");
const cat = createCategory(db, data);
return c.json(cat, 201);
});
app.put(
"/:id",
zValidator("json", updateCategorySchema.omit({ id: true })),
(c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const data = c.req.valid("json");
const cat = updateCategory(db, id, data);
if (!cat) return c.json({ error: "Category not found" }, 404);
return c.json(cat);
},
"/:id",
zValidator("json", updateCategorySchema.omit({ id: true })),
(c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const data = c.req.valid("json");
const cat = updateCategory(db, id, data);
if (!cat) return c.json({ error: "Category not found" }, 404);
return c.json(cat);
},
);
app.delete("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const result = deleteCategory(db, id);
const db = c.get("db");
const id = Number(c.req.param("id"));
const result = deleteCategory(db, id);
if (!result.success) {
if (result.error === "Cannot delete the Uncategorized category") {
return c.json({ error: result.error }, 400);
}
return c.json({ error: result.error }, 404);
}
if (!result.success) {
if (result.error === "Cannot delete the Uncategorized category") {
return c.json({ error: result.error }, 400);
}
return c.json({ error: result.error }, 404);
}
return c.json({ success: true });
return c.json({ success: true });
});
export { app as categoryRoutes };

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { Hono } from "hono";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
@@ -9,38 +9,39 @@ const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const app = new Hono();
app.post("/", async (c) => {
const body = await c.req.parseBody();
const file = body["image"];
const body = await c.req.parseBody();
const file = body.image;
if (!file || typeof file === "string") {
return c.json({ error: "No image file provided" }, 400);
}
if (!file || typeof file === "string") {
return c.json({ error: "No image file provided" }, 400);
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return c.json(
{ error: "Invalid file type. Accepted: jpeg, png, webp" },
400,
);
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return c.json(
{ error: "Invalid file type. Accepted: jpeg, png, webp" },
400,
);
}
// Validate file size
if (file.size > MAX_SIZE) {
return c.json({ error: "File too large. Maximum size is 5MB" }, 400);
}
// Validate file size
if (file.size > MAX_SIZE) {
return c.json({ error: "File too large. Maximum size is 5MB" }, 400);
}
// Generate unique filename
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
// Generate unique filename
const ext =
file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
// Ensure uploads directory exists
await mkdir("uploads", { recursive: true });
// Ensure uploads directory exists
await mkdir("uploads", { recursive: true });
// Write file
const buffer = await file.arrayBuffer();
await Bun.write(join("uploads", filename), buffer);
// Write file
const buffer = await file.arrayBuffer();
await Bun.write(join("uploads", filename), buffer);
return c.json({ filename }, 201);
return c.json({ filename }, 201);
});
export { app as imageRoutes };

View File

@@ -1,66 +1,70 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
import {
getAllItems,
getItemById,
createItem,
updateItem,
deleteItem,
} from "../services/item.service.ts";
import { unlink } from "node:fs/promises";
import { join } from "node:path";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
import {
createItem,
deleteItem,
getAllItems,
getItemById,
updateItem,
} from "../services/item.service.ts";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", (c) => {
const db = c.get("db");
const items = getAllItems(db);
return c.json(items);
const db = c.get("db");
const items = getAllItems(db);
return c.json(items);
});
app.get("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const item = getItemById(db, id);
if (!item) return c.json({ error: "Item not found" }, 404);
return c.json(item);
const db = c.get("db");
const id = Number(c.req.param("id"));
const item = getItemById(db, id);
if (!item) return c.json({ error: "Item not found" }, 404);
return c.json(item);
});
app.post("/", zValidator("json", createItemSchema), (c) => {
const db = c.get("db");
const data = c.req.valid("json");
const item = createItem(db, data);
return c.json(item, 201);
const db = c.get("db");
const data = c.req.valid("json");
const item = createItem(db, data);
return c.json(item, 201);
});
app.put("/:id", zValidator("json", updateItemSchema.omit({ id: true })), (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const data = c.req.valid("json");
const item = updateItem(db, id, data);
if (!item) return c.json({ error: "Item not found" }, 404);
return c.json(item);
});
app.put(
"/:id",
zValidator("json", updateItemSchema.omit({ id: true })),
(c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const data = c.req.valid("json");
const item = updateItem(db, id, data);
if (!item) return c.json({ error: "Item not found" }, 404);
return c.json(item);
},
);
app.delete("/:id", async (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const deleted = deleteItem(db, id);
if (!deleted) return c.json({ error: "Item not found" }, 404);
const db = c.get("db");
const id = Number(c.req.param("id"));
const deleted = deleteItem(db, id);
if (!deleted) return c.json({ error: "Item not found" }, 404);
// Clean up image file if exists
if (deleted.imageFilename) {
try {
await unlink(join("uploads", deleted.imageFilename));
} catch {
// File missing is not an error worth failing the delete over
}
}
// Clean up image file if exists
if (deleted.imageFilename) {
try {
await unlink(join("uploads", deleted.imageFilename));
} catch {
// File missing is not an error worth failing the delete over
}
}
return c.json({ success: true });
return c.json({ success: true });
});
export { app as itemRoutes };

View File

@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { db as prodDb } from "../../db/index.ts";
import { settings } from "../../db/schema.ts";
@@ -8,30 +8,38 @@ type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/:key", (c) => {
const database = c.get("db") ?? prodDb;
const key = c.req.param("key");
const row = database.select().from(settings).where(eq(settings.key, key)).get();
if (!row) return c.json({ error: "Setting not found" }, 404);
return c.json(row);
const database = c.get("db") ?? prodDb;
const key = c.req.param("key");
const row = database
.select()
.from(settings)
.where(eq(settings.key, key))
.get();
if (!row) return c.json({ error: "Setting not found" }, 404);
return c.json(row);
});
app.put("/:key", async (c) => {
const database = c.get("db") ?? prodDb;
const key = c.req.param("key");
const body = await c.req.json<{ value: string }>();
const database = c.get("db") ?? prodDb;
const key = c.req.param("key");
const body = await c.req.json<{ value: string }>();
if (!body.value && body.value !== "") {
return c.json({ error: "value is required" }, 400);
}
if (!body.value && body.value !== "") {
return c.json({ error: "value is required" }, 400);
}
database
.insert(settings)
.values({ key, value: body.value })
.onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
.run();
database
.insert(settings)
.values({ key, value: body.value })
.onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
.run();
const row = database.select().from(settings).where(eq(settings.key, key)).get();
return c.json(row);
const row = database
.select()
.from(settings)
.where(eq(settings.key, key))
.get();
return c.json(row);
});
export { app as settingsRoutes };

View File

@@ -1,18 +1,18 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import {
createSetupSchema,
updateSetupSchema,
syncSetupItemsSchema,
createSetupSchema,
syncSetupItemsSchema,
updateSetupSchema,
} from "../../shared/schemas.ts";
import {
getAllSetups,
getSetupWithItems,
createSetup,
updateSetup,
deleteSetup,
syncSetupItems,
removeSetupItem,
createSetup,
deleteSetup,
getAllSetups,
getSetupWithItems,
removeSetupItem,
syncSetupItems,
updateSetup,
} from "../services/setup.service.ts";
type Env = { Variables: { db?: any } };
@@ -22,63 +22,63 @@ const app = new Hono<Env>();
// Setup CRUD
app.get("/", (c) => {
const db = c.get("db");
const setups = getAllSetups(db);
return c.json(setups);
const db = c.get("db");
const setups = getAllSetups(db);
return c.json(setups);
});
app.post("/", zValidator("json", createSetupSchema), (c) => {
const db = c.get("db");
const data = c.req.valid("json");
const setup = createSetup(db, data);
return c.json(setup, 201);
const db = c.get("db");
const data = c.req.valid("json");
const setup = createSetup(db, data);
return c.json(setup, 201);
});
app.get("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const setup = getSetupWithItems(db, id);
if (!setup) return c.json({ error: "Setup not found" }, 404);
return c.json(setup);
const db = c.get("db");
const id = Number(c.req.param("id"));
const setup = getSetupWithItems(db, id);
if (!setup) return c.json({ error: "Setup not found" }, 404);
return c.json(setup);
});
app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const data = c.req.valid("json");
const setup = updateSetup(db, id, data);
if (!setup) return c.json({ error: "Setup not found" }, 404);
return c.json(setup);
const db = c.get("db");
const id = Number(c.req.param("id"));
const data = c.req.valid("json");
const setup = updateSetup(db, id, data);
if (!setup) return c.json({ error: "Setup not found" }, 404);
return c.json(setup);
});
app.delete("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const deleted = deleteSetup(db, id);
if (!deleted) return c.json({ error: "Setup not found" }, 404);
return c.json({ success: true });
const db = c.get("db");
const id = Number(c.req.param("id"));
const deleted = deleteSetup(db, id);
if (!deleted) return c.json({ error: "Setup not found" }, 404);
return c.json({ success: true });
});
// Setup Items
app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const { itemIds } = c.req.valid("json");
const db = c.get("db");
const id = Number(c.req.param("id"));
const { itemIds } = c.req.valid("json");
const setup = getSetupWithItems(db, id);
if (!setup) return c.json({ error: "Setup not found" }, 404);
const setup = getSetupWithItems(db, id);
if (!setup) return c.json({ error: "Setup not found" }, 404);
syncSetupItems(db, id, itemIds);
return c.json({ success: true });
syncSetupItems(db, id, itemIds);
return c.json({ success: true });
});
app.delete("/:id/items/:itemId", (c) => {
const db = c.get("db");
const setupId = Number(c.req.param("id"));
const itemId = Number(c.req.param("itemId"));
removeSetupItem(db, setupId, itemId);
return c.json({ success: true });
const db = c.get("db");
const setupId = Number(c.req.param("id"));
const itemId = Number(c.req.param("itemId"));
removeSetupItem(db, setupId, itemId);
return c.json({ success: true });
});
export { app as setupRoutes };

View File

@@ -1,25 +1,25 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import {
createThreadSchema,
updateThreadSchema,
createCandidateSchema,
updateCandidateSchema,
resolveThreadSchema,
} from "../../shared/schemas.ts";
import {
getAllThreads,
getThreadWithCandidates,
createThread,
updateThread,
deleteThread,
createCandidate,
updateCandidate,
deleteCandidate,
resolveThread,
} from "../services/thread.service.ts";
import { unlink } from "node:fs/promises";
import { join } from "node:path";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import {
createCandidateSchema,
createThreadSchema,
resolveThreadSchema,
updateCandidateSchema,
updateThreadSchema,
} from "../../shared/schemas.ts";
import {
createCandidate,
createThread,
deleteCandidate,
deleteThread,
getAllThreads,
getThreadWithCandidates,
resolveThread,
updateCandidate,
updateThread,
} from "../services/thread.service.ts";
type Env = { Variables: { db?: any } };
@@ -28,109 +28,113 @@ const app = new Hono<Env>();
// Thread CRUD
app.get("/", (c) => {
const db = c.get("db");
const includeResolved = c.req.query("includeResolved") === "true";
const threads = getAllThreads(db, includeResolved);
return c.json(threads);
const db = c.get("db");
const includeResolved = c.req.query("includeResolved") === "true";
const threads = getAllThreads(db, includeResolved);
return c.json(threads);
});
app.post("/", zValidator("json", createThreadSchema), (c) => {
const db = c.get("db");
const data = c.req.valid("json");
const thread = createThread(db, data);
return c.json(thread, 201);
const db = c.get("db");
const data = c.req.valid("json");
const thread = createThread(db, data);
return c.json(thread, 201);
});
app.get("/:id", (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const thread = getThreadWithCandidates(db, id);
if (!thread) return c.json({ error: "Thread not found" }, 404);
return c.json(thread);
const db = c.get("db");
const id = Number(c.req.param("id"));
const thread = getThreadWithCandidates(db, id);
if (!thread) return c.json({ error: "Thread not found" }, 404);
return c.json(thread);
});
app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const data = c.req.valid("json");
const thread = updateThread(db, id, data);
if (!thread) return c.json({ error: "Thread not found" }, 404);
return c.json(thread);
const db = c.get("db");
const id = Number(c.req.param("id"));
const data = c.req.valid("json");
const thread = updateThread(db, id, data);
if (!thread) return c.json({ error: "Thread not found" }, 404);
return c.json(thread);
});
app.delete("/:id", async (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
const deleted = deleteThread(db, id);
if (!deleted) return c.json({ error: "Thread not found" }, 404);
const db = c.get("db");
const id = Number(c.req.param("id"));
const deleted = deleteThread(db, id);
if (!deleted) return c.json({ error: "Thread not found" }, 404);
// Clean up candidate image files
for (const filename of deleted.candidateImages) {
try {
await unlink(join("uploads", filename));
} catch {
// File missing is not an error worth failing the delete over
}
}
// Clean up candidate image files
for (const filename of deleted.candidateImages) {
try {
await unlink(join("uploads", filename));
} catch {
// File missing is not an error worth failing the delete over
}
}
return c.json({ success: true });
return c.json({ success: true });
});
// Candidate CRUD (nested under thread)
app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => {
const db = c.get("db");
const threadId = Number(c.req.param("id"));
const db = c.get("db");
const threadId = Number(c.req.param("id"));
// Verify thread exists
const thread = getThreadWithCandidates(db, threadId);
if (!thread) return c.json({ error: "Thread not found" }, 404);
// Verify thread exists
const thread = getThreadWithCandidates(db, threadId);
if (!thread) return c.json({ error: "Thread not found" }, 404);
const data = c.req.valid("json");
const candidate = createCandidate(db, threadId, data);
return c.json(candidate, 201);
const data = c.req.valid("json");
const candidate = createCandidate(db, threadId, data);
return c.json(candidate, 201);
});
app.put("/:threadId/candidates/:candidateId", zValidator("json", updateCandidateSchema), (c) => {
const db = c.get("db");
const candidateId = Number(c.req.param("candidateId"));
const data = c.req.valid("json");
const candidate = updateCandidate(db, candidateId, data);
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
return c.json(candidate);
});
app.put(
"/:threadId/candidates/:candidateId",
zValidator("json", updateCandidateSchema),
(c) => {
const db = c.get("db");
const candidateId = Number(c.req.param("candidateId"));
const data = c.req.valid("json");
const candidate = updateCandidate(db, candidateId, data);
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
return c.json(candidate);
},
);
app.delete("/:threadId/candidates/:candidateId", async (c) => {
const db = c.get("db");
const candidateId = Number(c.req.param("candidateId"));
const deleted = deleteCandidate(db, candidateId);
if (!deleted) return c.json({ error: "Candidate not found" }, 404);
const db = c.get("db");
const candidateId = Number(c.req.param("candidateId"));
const deleted = deleteCandidate(db, candidateId);
if (!deleted) return c.json({ error: "Candidate not found" }, 404);
// Clean up image file if exists
if (deleted.imageFilename) {
try {
await unlink(join("uploads", deleted.imageFilename));
} catch {
// File missing is not an error
}
}
// Clean up image file if exists
if (deleted.imageFilename) {
try {
await unlink(join("uploads", deleted.imageFilename));
} catch {
// File missing is not an error
}
}
return c.json({ success: true });
return c.json({ success: true });
});
// Resolution
app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => {
const db = c.get("db");
const threadId = Number(c.req.param("id"));
const { candidateId } = c.req.valid("json");
const db = c.get("db");
const threadId = Number(c.req.param("id"));
const { candidateId } = c.req.valid("json");
const result = resolveThread(db, threadId, candidateId);
if (!result.success) {
return c.json({ error: result.error }, 400);
}
const result = resolveThread(db, threadId, candidateId);
if (!result.success) {
return c.json({ error: result.error }, 400);
}
return c.json({ success: true, item: result.item });
return c.json({ success: true, item: result.item });
});
export { app as threadRoutes };

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono";
import {
getCategoryTotals,
getGlobalTotals,
getCategoryTotals,
getGlobalTotals,
} from "../services/totals.service.ts";
type Env = { Variables: { db?: any } };
@@ -9,10 +9,10 @@ type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", (c) => {
const db = c.get("db");
const categoryTotals = getCategoryTotals(db);
const globalTotals = getGlobalTotals(db);
return c.json({ categories: categoryTotals, global: globalTotals });
const db = c.get("db");
const categoryTotals = getCategoryTotals(db);
const globalTotals = getGlobalTotals(db);
return c.json({ categories: categoryTotals, global: globalTotals });
});
export { app as totalRoutes };

View File

@@ -1,77 +1,80 @@
import { eq, asc } from "drizzle-orm";
import { categories, items } from "../../db/schema.ts";
import { asc, eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { categories, items } from "../../db/schema.ts";
type Db = typeof prodDb;
export function getAllCategories(db: Db = prodDb) {
return db.select().from(categories).orderBy(asc(categories.name)).all();
return db.select().from(categories).orderBy(asc(categories.name)).all();
}
export function createCategory(
db: Db = prodDb,
data: { name: string; icon?: string },
db: Db = prodDb,
data: { name: string; icon?: string },
) {
return db
.insert(categories)
.values({
name: data.name,
...(data.icon ? { icon: data.icon } : {}),
})
.returning()
.get();
return db
.insert(categories)
.values({
name: data.name,
...(data.icon ? { icon: data.icon } : {}),
})
.returning()
.get();
}
export function updateCategory(
db: Db = prodDb,
id: number,
data: { name?: string; icon?: string },
db: Db = prodDb,
id: number,
data: { name?: string; icon?: string },
) {
const existing = db
.select({ id: categories.id })
.from(categories)
.where(eq(categories.id, id))
.get();
const existing = db
.select({ id: categories.id })
.from(categories)
.where(eq(categories.id, id))
.get();
if (!existing) return null;
if (!existing) return null;
return db
.update(categories)
.set(data)
.where(eq(categories.id, id))
.returning()
.get();
return db
.update(categories)
.set(data)
.where(eq(categories.id, id))
.returning()
.get();
}
export function deleteCategory(
db: Db = prodDb,
id: number,
db: Db = prodDb,
id: number,
): { success: boolean; error?: string } {
// Guard: cannot delete Uncategorized (id=1)
if (id === 1) {
return { success: false, error: "Cannot delete the Uncategorized category" };
}
// Guard: cannot delete Uncategorized (id=1)
if (id === 1) {
return {
success: false,
error: "Cannot delete the Uncategorized category",
};
}
// Check if category exists
const existing = db
.select({ id: categories.id })
.from(categories)
.where(eq(categories.id, id))
.get();
// Check if category exists
const existing = db
.select({ id: categories.id })
.from(categories)
.where(eq(categories.id, id))
.get();
if (!existing) {
return { success: false, error: "Category not found" };
}
if (!existing) {
return { success: false, error: "Category not found" };
}
// Reassign items to Uncategorized (id=1), then delete atomically
db.transaction(() => {
db.update(items)
.set({ categoryId: 1 })
.where(eq(items.categoryId, id))
.run();
// Reassign items to Uncategorized (id=1), then delete atomically
db.transaction(() => {
db.update(items)
.set({ categoryId: 1 })
.where(eq(items.categoryId, id))
.run();
db.delete(categories).where(eq(categories.id, id)).run();
});
db.delete(categories).where(eq(categories.id, id)).run();
});
return { success: true };
return { success: true };
}

View File

@@ -1,112 +1,112 @@
import { eq, sql } from "drizzle-orm";
import { items, categories } from "../../db/schema.ts";
import { eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { categories, items } from "../../db/schema.ts";
import type { CreateItem } from "../../shared/types.ts";
type Db = typeof prodDb;
export function getAllItems(db: Db = prodDb) {
return db
.select({
id: items.id,
name: items.name,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,
imageFilename: items.imageFilename,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
categoryName: categories.name,
categoryIcon: categories.icon,
})
.from(items)
.innerJoin(categories, eq(items.categoryId, categories.id))
.all();
return db
.select({
id: items.id,
name: items.name,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,
imageFilename: items.imageFilename,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
categoryName: categories.name,
categoryIcon: categories.icon,
})
.from(items)
.innerJoin(categories, eq(items.categoryId, categories.id))
.all();
}
export function getItemById(db: Db = prodDb, id: number) {
return (
db
.select({
id: items.id,
name: items.name,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,
imageFilename: items.imageFilename,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
})
.from(items)
.where(eq(items.id, id))
.get() ?? null
);
return (
db
.select({
id: items.id,
name: items.name,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,
imageFilename: items.imageFilename,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
})
.from(items)
.where(eq(items.id, id))
.get() ?? null
);
}
export function createItem(
db: Db = prodDb,
data: Partial<CreateItem> & { name: string; categoryId: number; imageFilename?: string },
db: Db = prodDb,
data: Partial<CreateItem> & {
name: string;
categoryId: number;
imageFilename?: string;
},
) {
return db
.insert(items)
.values({
name: data.name,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
categoryId: data.categoryId,
notes: data.notes ?? null,
productUrl: data.productUrl ?? null,
imageFilename: data.imageFilename ?? null,
})
.returning()
.get();
return db
.insert(items)
.values({
name: data.name,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
categoryId: data.categoryId,
notes: data.notes ?? null,
productUrl: data.productUrl ?? null,
imageFilename: data.imageFilename ?? null,
})
.returning()
.get();
}
export function updateItem(
db: Db = prodDb,
id: number,
data: Partial<{
name: string;
weightGrams: number;
priceCents: number;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string;
}>,
db: Db = prodDb,
id: number,
data: Partial<{
name: string;
weightGrams: number;
priceCents: number;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string;
}>,
) {
// Check if item exists first
const existing = db
.select({ id: items.id })
.from(items)
.where(eq(items.id, id))
.get();
// Check if item exists first
const existing = db
.select({ id: items.id })
.from(items)
.where(eq(items.id, id))
.get();
if (!existing) return null;
if (!existing) return null;
return db
.update(items)
.set({ ...data, updatedAt: new Date() })
.where(eq(items.id, id))
.returning()
.get();
return db
.update(items)
.set({ ...data, updatedAt: new Date() })
.where(eq(items.id, id))
.returning()
.get();
}
export function deleteItem(db: Db = prodDb, id: number) {
// Get item first (for image cleanup info)
const item = db
.select()
.from(items)
.where(eq(items.id, id))
.get();
// Get item first (for image cleanup info)
const item = db.select().from(items).where(eq(items.id, id)).get();
if (!item) return null;
if (!item) return null;
db.delete(items).where(eq(items.id, id)).run();
db.delete(items).where(eq(items.id, id)).run();
return item;
return item;
}

View File

@@ -1,111 +1,124 @@
import { eq, sql } from "drizzle-orm";
import { setups, setupItems, items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts";
import { categories, items, setupItems, setups } from "../../db/schema.ts";
import type { CreateSetup, UpdateSetup } from "../../shared/types.ts";
type Db = typeof prodDb;
export function createSetup(db: Db = prodDb, data: CreateSetup) {
return db
.insert(setups)
.values({ name: data.name })
.returning()
.get();
return db.insert(setups).values({ name: data.name }).returning().get();
}
export function getAllSetups(db: Db = prodDb) {
return db
.select({
id: setups.id,
name: setups.name,
createdAt: setups.createdAt,
updatedAt: setups.updatedAt,
itemCount: sql<number>`COALESCE((
return db
.select({
id: setups.id,
name: setups.name,
createdAt: setups.createdAt,
updatedAt: setups.updatedAt,
itemCount: sql<number>`COALESCE((
SELECT COUNT(*) FROM setup_items
WHERE setup_items.setup_id = setups.id
), 0)`.as("item_count"),
totalWeight: sql<number>`COALESCE((
totalWeight: sql<number>`COALESCE((
SELECT SUM(items.weight_grams) FROM setup_items
JOIN items ON items.id = setup_items.item_id
WHERE setup_items.setup_id = setups.id
), 0)`.as("total_weight"),
totalCost: sql<number>`COALESCE((
totalCost: sql<number>`COALESCE((
SELECT SUM(items.price_cents) FROM setup_items
JOIN items ON items.id = setup_items.item_id
WHERE setup_items.setup_id = setups.id
), 0)`.as("total_cost"),
})
.from(setups)
.all();
})
.from(setups)
.all();
}
export function getSetupWithItems(db: Db = prodDb, setupId: number) {
const setup = db.select().from(setups)
.where(eq(setups.id, setupId)).get();
if (!setup) return null;
const setup = db.select().from(setups).where(eq(setups.id, setupId)).get();
if (!setup) return null;
const itemList = db
.select({
id: items.id,
name: items.name,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,
imageFilename: items.imageFilename,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
categoryName: categories.name,
categoryIcon: categories.icon,
})
.from(setupItems)
.innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id))
.where(eq(setupItems.setupId, setupId))
.all();
const itemList = db
.select({
id: items.id,
name: items.name,
weightGrams: items.weightGrams,
priceCents: items.priceCents,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,
imageFilename: items.imageFilename,
createdAt: items.createdAt,
updatedAt: items.updatedAt,
categoryName: categories.name,
categoryIcon: categories.icon,
})
.from(setupItems)
.innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id))
.where(eq(setupItems.setupId, setupId))
.all();
return { ...setup, items: itemList };
return { ...setup, items: itemList };
}
export function updateSetup(db: Db = prodDb, setupId: number, data: UpdateSetup) {
const existing = db.select({ id: setups.id }).from(setups)
.where(eq(setups.id, setupId)).get();
if (!existing) return null;
export function updateSetup(
db: Db = prodDb,
setupId: number,
data: UpdateSetup,
) {
const existing = db
.select({ id: setups.id })
.from(setups)
.where(eq(setups.id, setupId))
.get();
if (!existing) return null;
return db
.update(setups)
.set({ name: data.name, updatedAt: new Date() })
.where(eq(setups.id, setupId))
.returning()
.get();
return db
.update(setups)
.set({ name: data.name, updatedAt: new Date() })
.where(eq(setups.id, setupId))
.returning()
.get();
}
export function deleteSetup(db: Db = prodDb, setupId: number) {
const existing = db.select({ id: setups.id }).from(setups)
.where(eq(setups.id, setupId)).get();
if (!existing) return false;
const existing = db
.select({ id: setups.id })
.from(setups)
.where(eq(setups.id, setupId))
.get();
if (!existing) return false;
db.delete(setups).where(eq(setups.id, setupId)).run();
return true;
db.delete(setups).where(eq(setups.id, setupId)).run();
return true;
}
export function syncSetupItems(db: Db = prodDb, setupId: number, itemIds: number[]) {
return db.transaction((tx) => {
// Delete all existing items for this setup
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
export function syncSetupItems(
db: Db = prodDb,
setupId: number,
itemIds: number[],
) {
return db.transaction((tx) => {
// Delete all existing items for this setup
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
// Re-insert new items
for (const itemId of itemIds) {
tx.insert(setupItems).values({ setupId, itemId }).run();
}
});
// Re-insert new items
for (const itemId of itemIds) {
tx.insert(setupItems).values({ setupId, itemId }).run();
}
});
}
export function removeSetupItem(db: Db = prodDb, setupId: number, itemId: number) {
db.delete(setupItems)
.where(
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`
)
.run();
export function removeSetupItem(
db: Db = prodDb,
setupId: number,
itemId: number,
) {
db.delete(setupItems)
.where(
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
)
.run();
}

View File

@@ -1,221 +1,261 @@
import { eq, desc, sql } from "drizzle-orm";
import { threads, threadCandidates, items, categories } from "../../db/schema.ts";
import { desc, eq, sql } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import type { CreateThread, UpdateThread, CreateCandidate } from "../../shared/types.ts";
import {
categories,
items,
threadCandidates,
threads,
} from "../../db/schema.ts";
import type { CreateCandidate, CreateThread } from "../../shared/types.ts";
type Db = typeof prodDb;
export function createThread(db: Db = prodDb, data: CreateThread) {
return db
.insert(threads)
.values({ name: data.name, categoryId: data.categoryId })
.returning()
.get();
return db
.insert(threads)
.values({ name: data.name, categoryId: data.categoryId })
.returning()
.get();
}
export function getAllThreads(db: Db = prodDb, includeResolved = false) {
const query = db
.select({
id: threads.id,
name: threads.name,
status: threads.status,
resolvedCandidateId: threads.resolvedCandidateId,
categoryId: threads.categoryId,
categoryName: categories.name,
categoryIcon: categories.icon,
createdAt: threads.createdAt,
updatedAt: threads.updatedAt,
candidateCount: sql<number>`(
const query = db
.select({
id: threads.id,
name: threads.name,
status: threads.status,
resolvedCandidateId: threads.resolvedCandidateId,
categoryId: threads.categoryId,
categoryName: categories.name,
categoryIcon: categories.icon,
createdAt: threads.createdAt,
updatedAt: threads.updatedAt,
candidateCount: sql<number>`(
SELECT COUNT(*) FROM thread_candidates
WHERE thread_candidates.thread_id = threads.id
)`.as("candidate_count"),
minPriceCents: sql<number | null>`(
minPriceCents: sql<number | null>`(
SELECT MIN(price_cents) FROM thread_candidates
WHERE thread_candidates.thread_id = threads.id
)`.as("min_price_cents"),
maxPriceCents: sql<number | null>`(
maxPriceCents: sql<number | null>`(
SELECT MAX(price_cents) FROM thread_candidates
WHERE thread_candidates.thread_id = threads.id
)`.as("max_price_cents"),
})
.from(threads)
.innerJoin(categories, eq(threads.categoryId, categories.id))
.orderBy(desc(threads.createdAt));
})
.from(threads)
.innerJoin(categories, eq(threads.categoryId, categories.id))
.orderBy(desc(threads.createdAt));
if (!includeResolved) {
return query.where(eq(threads.status, "active")).all();
}
return query.all();
if (!includeResolved) {
return query.where(eq(threads.status, "active")).all();
}
return query.all();
}
export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
const thread = db.select().from(threads)
.where(eq(threads.id, threadId)).get();
if (!thread) return null;
const thread = db
.select()
.from(threads)
.where(eq(threads.id, threadId))
.get();
if (!thread) return null;
const candidateList = db
.select({
id: threadCandidates.id,
threadId: threadCandidates.threadId,
name: threadCandidates.name,
weightGrams: threadCandidates.weightGrams,
priceCents: threadCandidates.priceCents,
categoryId: threadCandidates.categoryId,
notes: threadCandidates.notes,
productUrl: threadCandidates.productUrl,
imageFilename: threadCandidates.imageFilename,
createdAt: threadCandidates.createdAt,
updatedAt: threadCandidates.updatedAt,
categoryName: categories.name,
categoryIcon: categories.icon,
})
.from(threadCandidates)
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
.where(eq(threadCandidates.threadId, threadId))
.all();
const candidateList = db
.select({
id: threadCandidates.id,
threadId: threadCandidates.threadId,
name: threadCandidates.name,
weightGrams: threadCandidates.weightGrams,
priceCents: threadCandidates.priceCents,
categoryId: threadCandidates.categoryId,
notes: threadCandidates.notes,
productUrl: threadCandidates.productUrl,
imageFilename: threadCandidates.imageFilename,
createdAt: threadCandidates.createdAt,
updatedAt: threadCandidates.updatedAt,
categoryName: categories.name,
categoryIcon: categories.icon,
})
.from(threadCandidates)
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
.where(eq(threadCandidates.threadId, threadId))
.all();
return { ...thread, candidates: candidateList };
return { ...thread, candidates: candidateList };
}
export function updateThread(db: Db = prodDb, threadId: number, data: Partial<{ name: string; categoryId: number }>) {
const existing = db.select({ id: threads.id }).from(threads)
.where(eq(threads.id, threadId)).get();
if (!existing) return null;
export function updateThread(
db: Db = prodDb,
threadId: number,
data: Partial<{ name: string; categoryId: number }>,
) {
const existing = db
.select({ id: threads.id })
.from(threads)
.where(eq(threads.id, threadId))
.get();
if (!existing) return null;
return db
.update(threads)
.set({ ...data, updatedAt: new Date() })
.where(eq(threads.id, threadId))
.returning()
.get();
return db
.update(threads)
.set({ ...data, updatedAt: new Date() })
.where(eq(threads.id, threadId))
.returning()
.get();
}
export function deleteThread(db: Db = prodDb, threadId: number) {
const thread = db.select().from(threads)
.where(eq(threads.id, threadId)).get();
if (!thread) return null;
const thread = db
.select()
.from(threads)
.where(eq(threads.id, threadId))
.get();
if (!thread) return null;
// Collect candidate image filenames for cleanup
const candidatesWithImages = db
.select({ imageFilename: threadCandidates.imageFilename })
.from(threadCandidates)
.where(eq(threadCandidates.threadId, threadId))
.all()
.filter((c) => c.imageFilename != null);
// Collect candidate image filenames for cleanup
const candidatesWithImages = db
.select({ imageFilename: threadCandidates.imageFilename })
.from(threadCandidates)
.where(eq(threadCandidates.threadId, threadId))
.all()
.filter((c) => c.imageFilename != null);
db.delete(threads).where(eq(threads.id, threadId)).run();
db.delete(threads).where(eq(threads.id, threadId)).run();
return { ...thread, candidateImages: candidatesWithImages.map((c) => c.imageFilename!) };
return {
...thread,
candidateImages: candidatesWithImages.map((c) => c.imageFilename!),
};
}
export function createCandidate(
db: Db = prodDb,
threadId: number,
data: Partial<CreateCandidate> & { name: string; categoryId: number; imageFilename?: string },
db: Db = prodDb,
threadId: number,
data: Partial<CreateCandidate> & {
name: string;
categoryId: number;
imageFilename?: string;
},
) {
return db
.insert(threadCandidates)
.values({
threadId,
name: data.name,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
categoryId: data.categoryId,
notes: data.notes ?? null,
productUrl: data.productUrl ?? null,
imageFilename: data.imageFilename ?? null,
})
.returning()
.get();
return db
.insert(threadCandidates)
.values({
threadId,
name: data.name,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
categoryId: data.categoryId,
notes: data.notes ?? null,
productUrl: data.productUrl ?? null,
imageFilename: data.imageFilename ?? null,
})
.returning()
.get();
}
export function updateCandidate(
db: Db = prodDb,
candidateId: number,
data: Partial<{
name: string;
weightGrams: number;
priceCents: number;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string;
}>,
db: Db = prodDb,
candidateId: number,
data: Partial<{
name: string;
weightGrams: number;
priceCents: number;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string;
}>,
) {
const existing = db.select({ id: threadCandidates.id }).from(threadCandidates)
.where(eq(threadCandidates.id, candidateId)).get();
if (!existing) return null;
const existing = db
.select({ id: threadCandidates.id })
.from(threadCandidates)
.where(eq(threadCandidates.id, candidateId))
.get();
if (!existing) return null;
return db
.update(threadCandidates)
.set({ ...data, updatedAt: new Date() })
.where(eq(threadCandidates.id, candidateId))
.returning()
.get();
return db
.update(threadCandidates)
.set({ ...data, updatedAt: new Date() })
.where(eq(threadCandidates.id, candidateId))
.returning()
.get();
}
export function deleteCandidate(db: Db = prodDb, candidateId: number) {
const candidate = db.select().from(threadCandidates)
.where(eq(threadCandidates.id, candidateId)).get();
if (!candidate) return null;
const candidate = db
.select()
.from(threadCandidates)
.where(eq(threadCandidates.id, candidateId))
.get();
if (!candidate) return null;
db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run();
return candidate;
db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run();
return candidate;
}
export function resolveThread(
db: Db = prodDb,
threadId: number,
candidateId: number,
db: Db = prodDb,
threadId: number,
candidateId: number,
): { success: boolean; item?: any; error?: string } {
return db.transaction((tx) => {
// 1. Check thread is active
const thread = tx.select().from(threads)
.where(eq(threads.id, threadId)).get();
if (!thread || thread.status !== "active") {
return { success: false, error: "Thread not active" };
}
return db.transaction((tx) => {
// 1. Check thread is active
const thread = tx
.select()
.from(threads)
.where(eq(threads.id, threadId))
.get();
if (!thread || thread.status !== "active") {
return { success: false, error: "Thread not active" };
}
// 2. Get the candidate data
const candidate = tx.select().from(threadCandidates)
.where(eq(threadCandidates.id, candidateId)).get();
if (!candidate) {
return { success: false, error: "Candidate not found" };
}
if (candidate.threadId !== threadId) {
return { success: false, error: "Candidate not in thread" };
}
// 2. Get the candidate data
const candidate = tx
.select()
.from(threadCandidates)
.where(eq(threadCandidates.id, candidateId))
.get();
if (!candidate) {
return { success: false, error: "Candidate not found" };
}
if (candidate.threadId !== threadId) {
return { success: false, error: "Candidate not in thread" };
}
// 3. Verify categoryId still exists, fallback to Uncategorized (id=1)
const category = tx.select({ id: categories.id }).from(categories)
.where(eq(categories.id, candidate.categoryId)).get();
const safeCategoryId = category ? candidate.categoryId : 1;
// 3. Verify categoryId still exists, fallback to Uncategorized (id=1)
const category = tx
.select({ id: categories.id })
.from(categories)
.where(eq(categories.id, candidate.categoryId))
.get();
const safeCategoryId = category ? candidate.categoryId : 1;
// 4. Create collection item from candidate data
const newItem = tx
.insert(items)
.values({
name: candidate.name,
weightGrams: candidate.weightGrams,
priceCents: candidate.priceCents,
categoryId: safeCategoryId,
notes: candidate.notes,
productUrl: candidate.productUrl,
imageFilename: candidate.imageFilename,
})
.returning()
.get();
// 4. Create collection item from candidate data
const newItem = tx
.insert(items)
.values({
name: candidate.name,
weightGrams: candidate.weightGrams,
priceCents: candidate.priceCents,
categoryId: safeCategoryId,
notes: candidate.notes,
productUrl: candidate.productUrl,
imageFilename: candidate.imageFilename,
})
.returning()
.get();
// 5. Archive the thread
tx.update(threads)
.set({
status: "resolved",
resolvedCandidateId: candidateId,
updatedAt: new Date(),
})
.where(eq(threads.id, threadId))
.run();
// 5. Archive the thread
tx.update(threads)
.set({
status: "resolved",
resolvedCandidateId: candidateId,
updatedAt: new Date(),
})
.where(eq(threads.id, threadId))
.run();
return { success: true, item: newItem };
});
return { success: true, item: newItem };
});
}

View File

@@ -1,32 +1,32 @@
import { eq, sql } from "drizzle-orm";
import { items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts";
import { categories, items } from "../../db/schema.ts";
type Db = typeof prodDb;
export function getCategoryTotals(db: Db = prodDb) {
return db
.select({
categoryId: items.categoryId,
categoryName: categories.name,
categoryIcon: categories.icon,
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
itemCount: sql<number>`COUNT(*)`,
})
.from(items)
.innerJoin(categories, eq(items.categoryId, categories.id))
.groupBy(items.categoryId)
.all();
return db
.select({
categoryId: items.categoryId,
categoryName: categories.name,
categoryIcon: categories.icon,
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
itemCount: sql<number>`COUNT(*)`,
})
.from(items)
.innerJoin(categories, eq(items.categoryId, categories.id))
.groupBy(items.categoryId)
.all();
}
export function getGlobalTotals(db: Db = prodDb) {
return db
.select({
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
itemCount: sql<number>`COUNT(*)`,
})
.from(items)
.get();
return db
.select({
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
itemCount: sql<number>`COUNT(*)`,
})
.from(items)
.get();
}

View File

@@ -1,67 +1,67 @@
import { z } from "zod";
export const createItemSchema = z.object({
name: z.string().min(1, "Name is required"),
weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
categoryId: z.number().int().positive(),
notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(),
name: z.string().min(1, "Name is required"),
weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
categoryId: z.number().int().positive(),
notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(),
});
export const updateItemSchema = createItemSchema.partial().extend({
id: z.number().int().positive(),
id: z.number().int().positive(),
});
export const createCategorySchema = z.object({
name: z.string().min(1, "Category name is required"),
icon: z.string().min(1).max(50).default("package"),
name: z.string().min(1, "Category name is required"),
icon: z.string().min(1).max(50).default("package"),
});
export const updateCategorySchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).optional(),
icon: z.string().min(1).max(50).optional(),
id: z.number().int().positive(),
name: z.string().min(1).optional(),
icon: z.string().min(1).max(50).optional(),
});
// Thread schemas
export const createThreadSchema = z.object({
name: z.string().min(1, "Thread name is required"),
categoryId: z.number().int().positive(),
name: z.string().min(1, "Thread name is required"),
categoryId: z.number().int().positive(),
});
export const updateThreadSchema = z.object({
name: z.string().min(1).optional(),
categoryId: z.number().int().positive().optional(),
name: z.string().min(1).optional(),
categoryId: z.number().int().positive().optional(),
});
// Candidate schemas (same fields as items)
export const createCandidateSchema = z.object({
name: z.string().min(1, "Name is required"),
weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
categoryId: z.number().int().positive(),
notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(),
name: z.string().min(1, "Name is required"),
weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
categoryId: z.number().int().positive(),
notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(),
});
export const updateCandidateSchema = createCandidateSchema.partial();
export const resolveThreadSchema = z.object({
candidateId: z.number().int().positive(),
candidateId: z.number().int().positive(),
});
// Setup schemas
export const createSetupSchema = z.object({
name: z.string().min(1, "Setup name is required"),
name: z.string().min(1, "Setup name is required"),
});
export const updateSetupSchema = z.object({
name: z.string().min(1, "Setup name is required"),
name: z.string().min(1, "Setup name is required"),
});
export const syncSetupItemsSchema = z.object({
itemIds: z.array(z.number().int().positive()),
itemIds: z.array(z.number().int().positive()),
});

View File

@@ -1,19 +1,26 @@
import type { z } from "zod";
import type {
createItemSchema,
updateItemSchema,
createCategorySchema,
updateCategorySchema,
createThreadSchema,
updateThreadSchema,
createCandidateSchema,
updateCandidateSchema,
resolveThreadSchema,
createSetupSchema,
updateSetupSchema,
syncSetupItemsSchema,
categories,
items,
setupItems,
setups,
threadCandidates,
threads,
} from "../db/schema.ts";
import type {
createCandidateSchema,
createCategorySchema,
createItemSchema,
createSetupSchema,
createThreadSchema,
resolveThreadSchema,
syncSetupItemsSchema,
updateCandidateSchema,
updateCategorySchema,
updateItemSchema,
updateSetupSchema,
updateThreadSchema,
} from "./schemas.ts";
import type { items, categories, threads, threadCandidates, setups, setupItems } from "../db/schema.ts";
// Types inferred from Zod schemas
export type CreateItem = z.infer<typeof createItemSchema>;