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