feat(21-02): replace slide-out panel with add-candidate modal on thread page

- Add local AddCandidateModal component with all candidate form fields
- Remove openCandidateAddPanel UIStore dependency
- Modal includes image upload, category picker, pros/cons, validation
This commit is contained in:
2026-04-06 15:02:13 +02:00
parent cecaf78ead
commit 47b416effd

View File

@@ -3,9 +3,12 @@ import { Reorder } from "framer-motion";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { CandidateCard } from "../../../components/CandidateCard"; import { CandidateCard } from "../../../components/CandidateCard";
import { CandidateListItem } from "../../../components/CandidateListItem"; import { CandidateListItem } from "../../../components/CandidateListItem";
import { CategoryPicker } from "../../../components/CategoryPicker";
import { ComparisonTable } from "../../../components/ComparisonTable"; import { ComparisonTable } from "../../../components/ComparisonTable";
import { ImageUpload } from "../../../components/ImageUpload";
import { SetupImpactSelector } from "../../../components/SetupImpactSelector"; import { SetupImpactSelector } from "../../../components/SetupImpactSelector";
import { import {
useCreateCandidate,
useReorderCandidates, useReorderCandidates,
useUpdateCandidate, useUpdateCandidate,
} from "../../../hooks/useCandidates"; } from "../../../hooks/useCandidates";
@@ -23,7 +26,6 @@ function ThreadDetailPage() {
const { threadId: threadIdParam } = Route.useParams(); const { threadId: threadIdParam } = Route.useParams();
const threadId = Number(threadIdParam); const threadId = Number(threadIdParam);
const { data: thread, isLoading, isError } = useThread(threadId); const { data: thread, isLoading, isError } = useThread(threadId);
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
const candidateViewMode = useUIStore((s) => s.candidateViewMode); const candidateViewMode = useUIStore((s) => s.candidateViewMode);
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode); const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
const selectedSetupId = useUIStore((s) => s.selectedSetupId); const selectedSetupId = useUIStore((s) => s.selectedSetupId);
@@ -36,6 +38,8 @@ function ThreadDetailPage() {
thread?.categoryId ?? 0, thread?.categoryId ?? 0,
); );
const [addCandidateOpen, setAddCandidateOpen] = useState(false);
const [tempItems, setTempItems] = useState< const [tempItems, setTempItems] = useState<
NonNullable<typeof thread>["candidates"] | null NonNullable<typeof thread>["candidates"] | null
>(null); >(null);
@@ -132,7 +136,7 @@ function ThreadDetailPage() {
{isActive && ( {isActive && (
<button <button
type="button" type="button"
onClick={openCandidateAddPanel} onClick={() => setAddCandidateOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
@@ -294,6 +298,332 @@ function ThreadDetailPage() {
))} ))}
</div> </div>
)} )}
{addCandidateOpen && (
<AddCandidateModal
threadId={threadId}
onClose={() => setAddCandidateOpen(false)}
/>
)}
</div>
);
}
interface AddCandidateModalProps {
threadId: number;
onClose: () => void;
}
interface ModalFormData {
name: string;
weightGrams: string;
priceDollars: string;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string | null;
pros: string;
cons: string;
}
const INITIAL_MODAL_FORM: ModalFormData = {
name: "",
weightGrams: "",
priceDollars: "",
categoryId: 1,
notes: "",
productUrl: "",
imageFilename: null,
pros: "",
cons: "",
};
function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
const createCandidate = useCreateCandidate(threadId);
const [form, setForm] = useState<ModalFormData>(INITIAL_MODAL_FORM);
const [errors, setErrors] = useState<Record<string, string>>({});
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;
createCandidate.mutate(
{
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,
pros: form.pros.trim() || undefined,
cons: form.cons.trim() || undefined,
},
{
onSuccess: () => {
setForm(INITIAL_MODAL_FORM);
onClose();
},
},
);
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
onKeyDown={(e) => e.key === "Escape" && onClose()}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/40" />
{/* Modal */}
<div
className="relative bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Add Candidate</h2>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors"
>
<LucideIcon name="x" size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
{/* Image */}
<ImageUpload
value={form.imageFilename}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
{/* Name */}
<div>
<label
htmlFor="modal-candidate-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name *
</label>
<input
id="modal-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-gray-400 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>
{/* Weight & Price row */}
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="modal-candidate-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
</label>
<input
id="modal-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-gray-400 focus:border-transparent"
placeholder="e.g. 680"
/>
{errors.weightGrams && (
<p className="mt-1 text-xs text-red-500">
{errors.weightGrams}
</p>
)}
</div>
<div>
<label
htmlFor="modal-candidate-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
Price ($)
</label>
<input
id="modal-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-gray-400 focus:border-transparent"
placeholder="e.g. 129.99"
/>
{errors.priceDollars && (
<p className="mt-1 text-xs text-red-500">
{errors.priceDollars}
</p>
)}
</div>
</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="modal-candidate-notes"
className="block text-sm font-medium text-gray-700 mb-1"
>
Notes
</label>
<textarea
id="modal-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-gray-400 focus:border-transparent resize-none"
placeholder="Any additional notes..."
/>
</div>
{/* Pros */}
<div>
<label
htmlFor="modal-candidate-pros"
className="block text-sm font-medium text-gray-700 mb-1"
>
Pros
</label>
<textarea
id="modal-candidate-pros"
value={form.pros}
onChange={(e) => setForm((f) => ({ ...f, pros: 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-gray-400 focus:border-transparent resize-none"
placeholder="One pro per line..."
/>
</div>
{/* Cons */}
<div>
<label
htmlFor="modal-candidate-cons"
className="block text-sm font-medium text-gray-700 mb-1"
>
Cons
</label>
<textarea
id="modal-candidate-cons"
value={form.cons}
onChange={(e) => setForm((f) => ({ ...f, cons: 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-gray-400 focus:border-transparent resize-none"
placeholder="One con per line..."
/>
</div>
{/* Product Link */}
<div>
<label
htmlFor="modal-candidate-url"
className="block text-sm font-medium text-gray-700 mb-1"
>
Product Link
</label>
<input
id="modal-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-gray-400 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={createCandidate.isPending}
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{createCandidate.isPending ? "Adding..." : "Add Candidate"}
</button>
<button
type="button"
onClick={onClose}
className="py-2.5 px-4 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div> </div>
); );
} }