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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user