Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions frontend/app/create/item/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import Link from "next/link";
import { BackButton } from "@/components/listings/detail/BackButton";
import { ListingForm } from "@/components/listings/ListingForm";
import { ItemForm } from "@/components/listings/form/ItemForm";

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

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

<ListingForm listingType="item" />
<ItemForm />
</div>
);
}
8 changes: 4 additions & 4 deletions frontend/app/create/sublet/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import Link from "next/link";
import { BackButton } from "@/components/listings/detail/BackButton";
import { ListingForm } from "@/components/listings/ListingForm";
import { SubletForm } from "@/components/listings/form/SubletForm";

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

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

<ListingForm listingType="sublet" />
<SubletForm />
</div>
);
}
18 changes: 12 additions & 6 deletions frontend/components/common/FormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,32 @@ interface FormFieldProps {
touched?: boolean;
optional?: boolean;
helperText?: string;
/** Rendered next to the label (e.g. info icon with hover message) */
labelSupplement?: ReactNode;
children: ReactNode;
}

export function FormField({
export const FormField = ({
label,
error,
touched = false,
optional = false,
helperText,
labelSupplement,
children,
}: FormFieldProps) {
}: FormFieldProps) => {
return (
<div>
<label className="mb-2 block text-sm font-medium">
{label}
{optional && <span className="font-normal text-gray-400"> (optional)</span>}
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium">
<span>
{label}
{optional && <span className="font-normal text-gray-400"> (optional)</span>}
</span>
{labelSupplement}
</label>
{children}
{helperText && !error && <p className="mt-1 text-xs text-gray-500">{helperText}</p>}
{error && touched && <p className="text-destructive mt-1 text-sm">{error}</p>}
</div>
);
}
};
47 changes: 22 additions & 25 deletions frontend/components/common/FormSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,60 @@
import { FormField } from "@/components/common/FormField";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FormField } from "@/components/common/FormField";

interface Option {
value: string;
label: string;
}

type FormSelectPropsBase = {
interface FormSelectPropsBase {
label: string;
options: Option[];
placeholder?: string;
error?: string;
touched?: boolean;
required?: boolean;
optional?: boolean;
helperText?: string;
};
}

type StringFormSelectProps = FormSelectPropsBase & {
valueType?: "string";
value: string | undefined;
onChange: (value: string) => void;
asNumber?: false;
};

type NumberFormSelectProps = FormSelectPropsBase & {
valueType: "number";
value: number | undefined;
onChange: (value: number) => void;
asNumber: true;
};

type FormSelectProps = StringFormSelectProps | NumberFormSelectProps;

export function FormSelect(props: FormSelectProps) {
const {
label,
options,
placeholder = "Select an option",
error,
touched,
required,
optional,
helperText,
} = props;

const stringValue = props.asNumber
? props.value !== undefined ? String(props.value) : ""
: props.value ?? "";
export const FormSelect = ({
label,
options,
placeholder = "Select an option",
error,
touched,
optional,
helperText,
valueType,
value,
onChange,
}: FormSelectProps) => {
const stringValue = value !== undefined ? String(value) : "";

const handleChange = (val: string) => {
if (props.asNumber) {
props.onChange(Number(val));
if (valueType === "number") {
onChange(Number(val));
} else {
props.onChange(val);
onChange(val);
}
};

Expand All @@ -83,4 +80,4 @@ export function FormSelect(props: FormSelectProps) {
</Select>
</FormField>
);
}
};
160 changes: 160 additions & 0 deletions frontend/components/common/ImageDropzone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"use client";

import { useRef, useId } from "react";
import { ImagePlus, AlertCircle } from "lucide-react";
import { ImagePreview } from "@/components/common/ImagePreview";
import type { ImageFile } from "@/hooks/useImageUpload";
import { cn } from "@/lib/utils";

interface ImageDropzoneProps {
images: ImageFile[];
isDragging: boolean;
canAddMore: boolean;
remainingSlots: number;
maxFiles: number;
label?: string;
error?: string;
acceptedFormats?: string;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
onRemove: (id: string) => void;
}

export const ImageDropzone = ({
images,
isDragging,
canAddMore,
remainingSlots,
maxFiles,
label = "Images",
error,
acceptedFormats = "image/png,image/jpeg,image/heic,image/webp",
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onFileSelect,
onRemove,
}: ImageDropzoneProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const dropzoneId = useId();
const errorId = useId();
const instructionsId = useId();

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
};

return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label id={`${dropzoneId}-label`} className="block text-sm font-medium">
{label}
</label>
<span className="text-muted-foreground text-xs">
{images.length}/{maxFiles} images
</span>
</div>

<div
role="button"
tabIndex={canAddMore ? 0 : -1}
aria-labelledby={`${dropzoneId}-label`}
aria-describedby={`${instructionsId} ${error ? errorId : ""}`}
aria-disabled={!canAddMore}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onKeyDown={handleKeyDown}
onClick={() => canAddMore && inputRef.current?.click()}
className={cn(
"cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-all",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isDragging && "scale-[1.02] border-blue-500 bg-blue-50",
!isDragging && !error && "border-gray-300 hover:border-blue-400 hover:bg-blue-50/30",
error && "border-red-300 bg-red-50/30",
!canAddMore && "cursor-not-allowed opacity-50"
)}
>
<input
ref={inputRef}
type="file"
multiple
accept={acceptedFormats}
className="sr-only"
onChange={onFileSelect}
disabled={!canAddMore}
aria-hidden="true"
tabIndex={-1}
/>

{images.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8">
<div
className={cn(
"mb-4 flex h-14 w-14 items-center justify-center rounded-lg transition-colors",
isDragging ? "bg-blue-100" : "bg-gray-100"
)}
>
<ImagePlus
className={cn(
"h-7 w-7 transition-colors",
isDragging ? "text-blue-500" : "text-gray-400"
)}
aria-hidden="true"
/>
</div>
<p className="mb-1 font-medium text-gray-600">
{isDragging ? "Drop images here" : "Drop images here or click to browse"}
</p>
<p id={instructionsId} className="text-xs text-gray-400">
Up to {maxFiles} images. PNG, JPG, HEIC supported.
</p>
</div>
) : (
<div className="space-y-4">
<div
className="grid gap-3"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(80px, 1fr))`,
}}
role="list"
aria-label="Uploaded images"
>
{images.map((image, index) => (
<ImagePreview
key={image.id}
image={image}
index={index}
onRemove={() => onRemove(image.id)}
/>
))}
</div>

{canAddMore && (
<p id={instructionsId} className="text-xs text-gray-500">
{remainingSlots === 1
? "You can add 1 more image"
: `You can add ${remainingSlots} more images`}
</p>
)}
</div>
)}
</div>

{error && (
<p id={errorId} role="alert" className="flex items-center gap-1 text-sm text-red-600">
<AlertCircle className="h-4 w-4" aria-hidden="true" />
{error}
</p>
)}
</div>
);
};
Loading