feat(lab,spots): validate contact details + drag & drop photo upload

- Lab forms: a non-empty email must be valid and website/portfolio must
  resolve to an https URL; invalid input shows an inline hint and blocks
  publish. Adds isValidEmail() (reused by sanitizeEmail). social/instagram
  are exempt since they may be bare @handles.
- New PhotoDropzone component: drag & drop plus multi-select image input,
  emitting File[] to the caller. Wired into CreateSpotPage + EditSpotPage
  (drop several photos at once) and AddPhotoToSpot, replacing the old
  one-file-at-a-time inputs.
- Spot photo uploads run sequentially and dedupe URLs.
This commit is contained in:
SkateSpots 2026-09-27 09:46:37 +07:00
parent f73a360983
commit f5ce4002e9
12 changed files with 279 additions and 107 deletions

View file

@ -54,6 +54,14 @@ session with **no other memory of this project**. So:
`sanitizeUrl()` at render (the extra-field `url` type used to be dropped
outright). `MIN_TITLE_LEN` is **1**: a profile title falls back to the
display name, and a 1-char name must not fail validation and vanish.
Contact fields are validated on submit: a non-empty email must satisfy
`isValidEmail()`, and website/portfolio must resolve through
`sanitizeUrl(normalizeUrlInput(...))`. Invalid input shows an inline hint and
blocks publish (`social` is exempt — it may be a bare `@handle`).
- **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared
image input — **drag & drop + multi-select**, emits `File[]` to its caller
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
`AddPhotoToSpot`, replacing the old one-file-at-a-time inputs.
- `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in

View file

@ -1,8 +1,8 @@
import { useState } from 'react';
import { Camera, Upload, Loader2, X } from 'lucide-react';
import { Camera, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { PhotoDropzone } from '@/components/PhotoDropzone';
import {
Dialog,
DialogContent,
@ -39,16 +39,15 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`;
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const handleFiles = async (files: File[]) => {
const file = files[0];
if (!file) return;
try {
const file = files[0];
const tags = await uploadFile(file);
const url = tags.find(([name]) => name === 'url')?.[1];
const mimeType = tags.find(([name]) => name === 'm')?.[1];
if (url) {
setUploadedImage({ url, mimeType });
toast({
@ -63,8 +62,6 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
variant: 'destructive',
});
}
e.target.value = '';
};
const handleSubmit = async () => {
@ -166,31 +163,14 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
</Button>
</div>
) : (
<Card className="border-dashed">
<CardContent className="py-8">
<label className="flex flex-col items-center cursor-pointer">
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={isUploading}
/>
{isUploading ? (
<>
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">Uploading...</span>
</>
) : (
<>
<Upload className="h-10 w-10 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">Click to upload a photo</span>
<span className="text-xs text-muted-foreground mt-1">JPG, PNG, WebP</span>
</>
)}
</label>
</CardContent>
</Card>
<PhotoDropzone
onFiles={handleFiles}
uploading={isUploading}
multiple={false}
label="Upload a photo"
hint="Drag & drop or click · JPG, PNG, WebP"
className="py-10"
/>
)}
{/* Caption */}

View file

@ -23,7 +23,7 @@ import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { isValidNoffer, type EntityType } from '@/lib/types';
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react';
@ -205,9 +205,24 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const requiredSections = skillSections.filter((s) => s.required);
// Contact details are optional, but if provided they must be usable — an
// invalid email/URL would otherwise be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.'
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.'
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.'
: '';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
return;
}
// Enforce required skill sections
for (const section of requiredSections) {
const keys = section.entries.map(([k]) => k);
@ -510,6 +525,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<Input value={social} onChange={(e) => setSocial(e.target.value)} placeholder="Social (Instagram, etc.)" className="bg-white/5 border-white/10" />
<Input value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className="bg-white/5 border-white/10 sm:col-span-2" />
</div>
{contactError && <p className="text-xs text-red-400">{contactError}</p>}
</CardContent>
</Card>

View file

@ -0,0 +1,100 @@
import { useState } from 'react';
import { Loader2, Upload } from 'lucide-react';
import { cn } from '@/lib/utils';
interface PhotoDropzoneProps {
/** Called with the image files the user picked or dropped. */
onFiles: (files: File[]) => void | Promise<void>;
/** Show a spinner and block input while an upload is in flight. */
uploading?: boolean;
/** Disable interaction (e.g. while the form is submitting). */
disabled?: boolean;
/** Allow more than one file at a time. Defaults to true. */
multiple?: boolean;
/** Primary tile label. */
label?: string;
/** Secondary line under the label (hidden while dragging). */
hint?: string;
/** Wrapper classes — pass sizing such as `aspect-video` here. */
className?: string;
/** Accessible name for the file input. Defaults to `label`. */
inputLabel?: string;
}
const IMAGE_RE = /^image\//;
/**
* A drop target for images that also works as click-to-browse.
*
* Users can select or drop several photos at once, instead of adding them one
* at a time. Collecting the files is all this does — uploading is the caller's
* responsibility via `onFiles`, so each screen keeps its own toasts/progress.
*/
export function PhotoDropzone({
onFiles,
uploading = false,
disabled = false,
multiple = true,
label = 'Add Photos',
hint = 'Drag & drop or click',
className,
inputLabel,
}: PhotoDropzoneProps) {
const [dragging, setDragging] = useState(false);
const busy = uploading || disabled;
const emit = (fileList: FileList | null) => {
if (!fileList) return;
const files = Array.from(fileList).filter((f) => IMAGE_RE.test(f.type));
if (files.length > 0) void onFiles(files);
};
return (
<label
onDragEnter={(e) => {
e.preventDefault();
if (!busy) setDragging(true);
}}
onDragOver={(e) => {
e.preventDefault();
if (!busy) setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (!busy) emit(e.dataTransfer.files);
}}
className={cn(
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed px-2 text-center cursor-pointer transition-colors',
dragging ? 'border-[#ccff00] bg-[#ccff00]/10' : 'border-gray-600 hover:bg-muted/50 hover:border-gray-500',
busy && 'opacity-60 cursor-not-allowed',
className,
)}
>
<input
type="file"
accept="image/*"
multiple={multiple}
className="hidden"
disabled={busy}
aria-label={inputLabel ?? label}
onChange={(e) => {
emit(e.target.files);
e.target.value = '';
}}
/>
{busy ? (
<Loader2 className="h-6 w-6 animate-spin text-[#ccff00]" />
) : (
<>
<Upload className={cn('h-6 w-6 mb-1', dragging ? 'text-[#ccff00]' : 'text-muted-foreground')} />
<span className={cn('text-xs font-bold', dragging ? 'text-[#ccff00]' : 'text-muted-foreground')}>
{dragging ? 'Drop photos' : label}
</span>
{hint && <span className="mt-0.5 text-[10px] text-muted-foreground/80">{hint}</span>}
</>
)}
</label>
);
}

View file

@ -99,11 +99,15 @@ export function sanitizeTel(value: string | undefined | null): string | null {
return digits.length >= 5 ? `tel:${digits}` : null;
}
/** Basic email pattern: non-empty local@domain.tld. */
export function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
export function sanitizeEmail(value: string | undefined | null): string | null {
if (!value) return null;
const trimmed = value.trim();
// Basic email pattern
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
if (isValidEmail(trimmed)) {
return `mailto:${trimmed}`;
}
return null;

View file

@ -34,7 +34,7 @@ import {
isValidNoffer,
type EntityType,
} from '@/lib/types';
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -216,9 +216,24 @@ export default function BecomeBuilderPage() {
e.target.value = '';
};
// Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.'
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.'
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.'
: '';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
return;
}
if (selectedServices.length === 0) {
toast({ title: 'Build types required', description: 'Select at least one thing you build.', variant: 'destructive' });
return;
@ -546,6 +561,7 @@ export default function BecomeBuilderPage() {
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5">

View file

@ -24,7 +24,7 @@ import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -162,9 +162,22 @@ export default function BecomeCoachPage() {
e.target.value = '';
};
// Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.'
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.'
: '';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
return;
}
if (selectedSkills.length === 0) {
toast({ title: 'Skills required', description: 'Select at least one skill.', variant: 'destructive' });
return;
@ -449,6 +462,7 @@ export default function BecomeCoachPage() {
<Input id="instagram" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />

View file

@ -30,7 +30,7 @@ import {
} from '@/lib/types';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -166,9 +166,24 @@ export default function BecomeEditorPage() {
e.target.value = '';
};
// Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.'
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.'
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.'
: '';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
return;
}
if (selectedServices.length === 0) {
toast({ title: 'Services required', description: 'Select at least one edit type.', variant: 'destructive' });
return;
@ -485,6 +500,7 @@ export default function BecomeEditorPage() {
<Input placeholder="Website" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Instagram / Social handle" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />
</div>

View file

@ -24,7 +24,7 @@ import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -176,9 +176,24 @@ export default function BecomeFilmerPage() {
e.target.value = '';
};
// Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.'
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.'
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.'
: '';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
return;
}
if (selectedServices.length === 0) {
toast({ title: 'Services required', description: 'Select at least one service.', variant: 'destructive' });
return;
@ -513,6 +528,7 @@ export default function BecomeFilmerPage() {
<Input id="social" placeholder="@username" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />

View file

@ -36,7 +36,7 @@ import {
PHOTO_STYLE_LABELS,
isValidNoffer,
} from '@/lib/types';
import { normalizeUrlInput } from '@/lib/sanitizeUrl';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -227,9 +227,24 @@ export default function BecomePhotographerPage() {
e.target.value = '';
};
// Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.'
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.'
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.'
: '';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
return;
}
if (selectedServices.length === 0) {
toast({ title: 'Services required', description: 'Select at least one service you offer.', variant: 'destructive' });
return;
@ -572,6 +587,7 @@ export default function BecomePhotographerPage() {
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5">

View file

@ -15,13 +15,13 @@ import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { LoginArea } from '@/components/auth/LoginArea';
import { QualityMeter } from '@/components/QualityMeter';
import { PhotoDropzone } from '@/components/PhotoDropzone';
import { TagInput } from '@/components/TagInput';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
import { cn } from '@/lib/utils';
import {
MapPin,
Upload,
X,
Loader2,
ArrowLeft,
@ -154,19 +154,25 @@ export default function CreateSpotPage() {
);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
// Upload one or more selected/dropped photos. Uploads run in sequence so we
// don't hammer Blossom with parallel requests.
const uploadImageFiles = async (files: File[]) => {
if (files.length === 0) return;
let added = 0;
try {
const file = files[0];
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) {
setImages((prev) => [...prev, url]);
for (const file of files) {
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) {
setImages((prev) => (prev.includes(url) ? prev : [...prev, url]));
added += 1;
}
}
if (added > 0) {
toast({
title: 'Image uploaded',
description: 'Your image has been uploaded successfully.',
title: added > 1 ? `${added} photos uploaded` : 'Image uploaded',
description:
added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.',
});
}
} catch (error) {
@ -176,9 +182,6 @@ export default function CreateSpotPage() {
variant: 'destructive',
});
}
// Clear the input
e.target.value = '';
};
// Handle camera capture
@ -713,24 +716,14 @@ export default function CreateSpotPage() {
<Camera className="h-6 w-6 text-[#ff0040] mb-1" />
<span className="text-xs text-[#ff0040] font-bold">Take Photo</span>
</button>
{/* File upload button */}
<label className="aspect-video rounded-lg border-2 border-dashed border-gray-600 flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 hover:border-gray-500 transition-colors">
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={isUploading}
/>
{isUploading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<>
<Upload className="h-6 w-6 text-muted-foreground mb-1" />
<span className="text-xs text-muted-foreground">{t('createSpot.uploadPhotos')}</span>
</>
)}
</label>
{/* File upload / drop zone — accepts several photos at once */}
<PhotoDropzone
onFiles={uploadImageFiles}
uploading={isUploading}
label={t('createSpot.uploadPhotos')}
hint={t('createSpot.dragDrop')}
className="aspect-video"
/>
</div>
</div>

View file

@ -15,13 +15,13 @@ import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useSkateSpot } from '@/hooks/useSkateSpots';
import { LoginArea } from '@/components/auth/LoginArea';
import { PhotoDropzone } from '@/components/PhotoDropzone';
import { TagInput } from '@/components/TagInput';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
import { cn } from '@/lib/utils';
import {
MapPin,
Upload,
X,
Loader2,
ArrowLeft,
@ -152,19 +152,24 @@ export default function EditSpotPage() {
);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
// Upload one or more selected/dropped photos, in sequence.
const uploadImageFiles = async (files: File[]) => {
if (files.length === 0) return;
let added = 0;
try {
const file = files[0];
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) {
setImages((prev) => [...prev, url]);
for (const file of files) {
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) {
setImages((prev) => (prev.includes(url) ? prev : [...prev, url]));
added += 1;
}
}
if (added > 0) {
toast({
title: 'Image uploaded',
description: 'Your image has been uploaded successfully.',
title: added > 1 ? `${added} photos uploaded` : 'Image uploaded',
description:
added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.',
});
}
} catch (error) {
@ -174,8 +179,6 @@ export default function EditSpotPage() {
variant: 'destructive',
});
}
e.target.value = '';
};
const removeImage = (url: string) => {
@ -648,23 +651,13 @@ export default function EditSpotPage() {
</button>
</div>
))}
<label className="aspect-video rounded-lg border-2 border-dashed flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 transition-colors">
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={isUploading}
/>
{isUploading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<>
<Upload className="h-6 w-6 text-muted-foreground mb-1" />
<span className="text-xs text-muted-foreground">Upload</span>
</>
)}
</label>
<PhotoDropzone
onFiles={uploadImageFiles}
uploading={isUploading}
label="Upload"
hint="Drag & drop or click"
className="aspect-video"
/>
</div>
</div>