Skip to content

Commit e46fb42

Browse files
committed
refactor listing form
1 parent d728985 commit e46fb42

18 files changed

Lines changed: 1176 additions & 622 deletions

frontend/app/create/item/page.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import Link from "next/link";
22
import { BackButton } from "@/components/listings/detail/BackButton";
3-
import { ListingForm } from "@/components/listings/ListingForm";
3+
import { ItemForm } from "@/components/listings/form/ItemForm";
44

55
export default function CreateItemPage() {
66
return (
7-
<div className="w-full mx-auto container max-w-[96rem] px-12 pt-4 pb-12">
7+
<div className="container mx-auto w-full max-w-[96rem] px-12 pt-4 pb-12">
88
<Link href="/items">
99
<BackButton />
1010
</Link>
1111

12-
<h1 className="text-3xl font-bold pt-2 mb-8">New Item</h1>
12+
<h1 className="mb-8 pt-2 text-3xl font-bold">New Item</h1>
1313

14-
<ListingForm listingType="item" />
14+
<ItemForm />
1515
</div>
1616
);
1717
}
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import Link from "next/link";
22
import { BackButton } from "@/components/listings/detail/BackButton";
3-
import { ListingForm } from "@/components/listings/ListingForm";
3+
import { SubletForm } from "@/components/listings/form/SubletForm";
44

55
export default function CreateSubletPage() {
66
return (
7-
<div className="w-full mx-auto container max-w-[96rem] px-12 pt-6 pb-12">
7+
<div className="container mx-auto w-full max-w-[96rem] px-12 pt-6 pb-12">
88
<Link href="/sublets">
99
<BackButton />
1010
</Link>
1111

12-
<h1 className="text-3xl font-bold pt-2 mb-8">New Sublet</h1>
12+
<h1 className="mb-8 pt-2 text-3xl font-bold">New Sublet</h1>
1313

14-
<ListingForm listingType="sublet" />
14+
<SubletForm />
1515
</div>
1616
);
1717
}

frontend/components/common/FormField.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,32 @@ interface FormFieldProps {
66
touched?: boolean;
77
optional?: boolean;
88
helperText?: string;
9+
/** Rendered next to the label (e.g. info icon with hover message) */
10+
labelSupplement?: ReactNode;
911
children: ReactNode;
1012
}
1113

12-
export function FormField({
14+
export const FormField = ({
1315
label,
1416
error,
1517
touched = false,
1618
optional = false,
1719
helperText,
20+
labelSupplement,
1821
children,
19-
}: FormFieldProps) {
22+
}: FormFieldProps) => {
2023
return (
2124
<div>
22-
<label className="mb-2 block text-sm font-medium">
23-
{label}
24-
{optional && <span className="font-normal text-gray-400"> (optional)</span>}
25+
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium">
26+
<span>
27+
{label}
28+
{optional && <span className="font-normal text-gray-400"> (optional)</span>}
29+
</span>
30+
{labelSupplement}
2531
</label>
2632
{children}
2733
{helperText && !error && <p className="mt-1 text-xs text-gray-500">{helperText}</p>}
2834
{error && touched && <p className="text-destructive mt-1 text-sm">{error}</p>}
2935
</div>
3036
);
31-
}
37+
};
Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,60 @@
1+
import { FormField } from "@/components/common/FormField";
12
import {
23
Select,
34
SelectContent,
45
SelectItem,
56
SelectTrigger,
67
SelectValue,
78
} from "@/components/ui/select";
8-
import { FormField } from "@/components/common/FormField";
99

1010
interface Option {
1111
value: string;
1212
label: string;
1313
}
1414

15-
type FormSelectPropsBase = {
15+
interface FormSelectPropsBase {
1616
label: string;
1717
options: Option[];
1818
placeholder?: string;
1919
error?: string;
2020
touched?: boolean;
21-
required?: boolean;
2221
optional?: boolean;
2322
helperText?: string;
24-
};
23+
}
2524

2625
type StringFormSelectProps = FormSelectPropsBase & {
26+
valueType?: "string";
2727
value: string | undefined;
2828
onChange: (value: string) => void;
29-
asNumber?: false;
3029
};
3130

3231
type NumberFormSelectProps = FormSelectPropsBase & {
32+
valueType: "number";
3333
value: number | undefined;
3434
onChange: (value: number) => void;
35-
asNumber: true;
3635
};
3736

3837
type FormSelectProps = StringFormSelectProps | NumberFormSelectProps;
3938

40-
export function FormSelect(props: FormSelectProps) {
41-
const {
42-
label,
43-
options,
44-
placeholder = "Select an option",
45-
error,
46-
touched,
47-
required,
48-
optional,
49-
helperText,
50-
} = props;
51-
52-
const stringValue = props.asNumber
53-
? props.value !== undefined ? String(props.value) : ""
54-
: props.value ?? "";
39+
export const FormSelect = ({
40+
label,
41+
options,
42+
placeholder = "Select an option",
43+
error,
44+
touched,
45+
optional,
46+
helperText,
47+
valueType,
48+
value,
49+
onChange,
50+
}: FormSelectProps) => {
51+
const stringValue = value !== undefined ? String(value) : "";
5552

5653
const handleChange = (val: string) => {
57-
if (props.asNumber) {
58-
props.onChange(Number(val));
54+
if (valueType === "number") {
55+
onChange(Number(val));
5956
} else {
60-
props.onChange(val);
57+
onChange(val);
6158
}
6259
};
6360

@@ -83,4 +80,4 @@ export function FormSelect(props: FormSelectProps) {
8380
</Select>
8481
</FormField>
8582
);
86-
}
83+
};
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"use client";
2+
3+
import { useRef, useId } from "react";
4+
import { ImagePlus, AlertCircle } from "lucide-react";
5+
import { ImagePreview } from "@/components/common/ImagePreview";
6+
import type { ImageFile } from "@/hooks/useImageUpload";
7+
import { cn } from "@/lib/utils";
8+
9+
interface ImageDropzoneProps {
10+
images: ImageFile[];
11+
isDragging: boolean;
12+
canAddMore: boolean;
13+
remainingSlots: number;
14+
maxFiles: number;
15+
label?: string;
16+
error?: string;
17+
acceptedFormats?: string;
18+
onDragEnter: (e: React.DragEvent) => void;
19+
onDragLeave: (e: React.DragEvent) => void;
20+
onDragOver: (e: React.DragEvent) => void;
21+
onDrop: (e: React.DragEvent) => void;
22+
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
23+
onRemove: (id: string) => void;
24+
}
25+
26+
export const ImageDropzone = ({
27+
images,
28+
isDragging,
29+
canAddMore,
30+
remainingSlots,
31+
maxFiles,
32+
label = "Images",
33+
error,
34+
acceptedFormats = "image/png,image/jpeg,image/heic,image/webp",
35+
onDragEnter,
36+
onDragLeave,
37+
onDragOver,
38+
onDrop,
39+
onFileSelect,
40+
onRemove,
41+
}: ImageDropzoneProps) => {
42+
const inputRef = useRef<HTMLInputElement>(null);
43+
const dropzoneId = useId();
44+
const errorId = useId();
45+
const instructionsId = useId();
46+
47+
const handleKeyDown = (e: React.KeyboardEvent) => {
48+
if (e.key === "Enter" || e.key === " ") {
49+
e.preventDefault();
50+
inputRef.current?.click();
51+
}
52+
};
53+
54+
return (
55+
<div className="space-y-2">
56+
<div className="flex items-center justify-between">
57+
<label id={`${dropzoneId}-label`} className="block text-sm font-medium">
58+
{label}
59+
</label>
60+
<span className="text-muted-foreground text-xs">
61+
{images.length}/{maxFiles} images
62+
</span>
63+
</div>
64+
65+
<div
66+
role="button"
67+
tabIndex={canAddMore ? 0 : -1}
68+
aria-labelledby={`${dropzoneId}-label`}
69+
aria-describedby={`${instructionsId} ${error ? errorId : ""}`}
70+
aria-disabled={!canAddMore}
71+
onDragEnter={onDragEnter}
72+
onDragLeave={onDragLeave}
73+
onDragOver={onDragOver}
74+
onDrop={onDrop}
75+
onKeyDown={handleKeyDown}
76+
onClick={() => canAddMore && inputRef.current?.click()}
77+
className={cn(
78+
"cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-all",
79+
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
80+
isDragging && "scale-[1.02] border-blue-500 bg-blue-50",
81+
!isDragging && !error && "border-gray-300 hover:border-blue-400 hover:bg-blue-50/30",
82+
error && "border-red-300 bg-red-50/30",
83+
!canAddMore && "cursor-not-allowed opacity-50"
84+
)}
85+
>
86+
<input
87+
ref={inputRef}
88+
type="file"
89+
multiple
90+
accept={acceptedFormats}
91+
className="sr-only"
92+
onChange={onFileSelect}
93+
disabled={!canAddMore}
94+
aria-hidden="true"
95+
tabIndex={-1}
96+
/>
97+
98+
{images.length === 0 ? (
99+
<div className="flex flex-col items-center justify-center py-8">
100+
<div
101+
className={cn(
102+
"mb-4 flex h-14 w-14 items-center justify-center rounded-lg transition-colors",
103+
isDragging ? "bg-blue-100" : "bg-gray-100"
104+
)}
105+
>
106+
<ImagePlus
107+
className={cn(
108+
"h-7 w-7 transition-colors",
109+
isDragging ? "text-blue-500" : "text-gray-400"
110+
)}
111+
aria-hidden="true"
112+
/>
113+
</div>
114+
<p className="mb-1 font-medium text-gray-600">
115+
{isDragging ? "Drop images here" : "Drop images here or click to browse"}
116+
</p>
117+
<p id={instructionsId} className="text-xs text-gray-400">
118+
Up to {maxFiles} images. PNG, JPG, HEIC supported.
119+
</p>
120+
</div>
121+
) : (
122+
<div className="space-y-4">
123+
<div
124+
className="grid gap-3"
125+
style={{
126+
gridTemplateColumns: `repeat(auto-fill, minmax(80px, 1fr))`,
127+
}}
128+
role="list"
129+
aria-label="Uploaded images"
130+
>
131+
{images.map((image, index) => (
132+
<ImagePreview
133+
key={image.id}
134+
image={image}
135+
index={index}
136+
onRemove={() => onRemove(image.id)}
137+
/>
138+
))}
139+
</div>
140+
141+
{canAddMore && (
142+
<p id={instructionsId} className="text-xs text-gray-500">
143+
{remainingSlots === 1
144+
? "You can add 1 more image"
145+
: `You can add ${remainingSlots} more images`}
146+
</p>
147+
)}
148+
</div>
149+
)}
150+
</div>
151+
152+
{error && (
153+
<p id={errorId} role="alert" className="flex items-center gap-1 text-sm text-red-600">
154+
<AlertCircle className="h-4 w-4" aria-hidden="true" />
155+
{error}
156+
</p>
157+
)}
158+
</div>
159+
);
160+
};

0 commit comments

Comments
 (0)