chore: auto-fix Biome formatting and configure lint rules
All checks were successful
CI / ci (push) Successful in 15s
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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user