From fd2a049d930ef7cc0e9cbd6fff80da602a45263c Mon Sep 17 00:00:00 2001 From: lemon Date: Wed, 6 May 2026 19:40:46 -0700 Subject: [PATCH] Share image upload field across dialogs --- src/components/AddMemberDialog.tsx | 104 +++------- src/components/CreateCommunityDialog.tsx | 111 +++-------- src/components/CreateCommunityEventDialog.tsx | 143 +++----------- src/components/CreateGoalDialog.tsx | 79 ++++---- src/components/ImageUploadField.tsx | 178 ++++++++++++++++++ 5 files changed, 304 insertions(+), 311 deletions(-) create mode 100644 src/components/ImageUploadField.tsx diff --git a/src/components/AddMemberDialog.tsx b/src/components/AddMemberDialog.tsx index ee68c165..9001ae14 100644 --- a/src/components/AddMemberDialog.tsx +++ b/src/components/AddMemberDialog.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; -import { UserPlus, Upload, Loader2, X, Search, Crown, Users } from 'lucide-react'; +import { UserPlus, Loader2, X, Search, Crown, Users } from 'lucide-react'; import { useNostr } from '@nostrify/react'; import { useQueryClient } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -17,16 +17,17 @@ import { Label } from '@/components/ui/label'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { ImageUploadField } from '@/components/ImageUploadField'; import { getAvatarShape } from '@/lib/avatarShape'; import { EmojifiedText } from '@/components/CustomEmoji'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; -import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles'; import { PortalContainerProvider } from '@/hooks/usePortalContainer'; import { genUserName } from '@/lib/genUserName'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { COMMUNITY_DEFINITION_KIND, BADGE_DEFINITION_KIND, @@ -82,10 +83,9 @@ export function AddMemberDialog({ // Form state const [pendingMembers, setPendingMembers] = useState([]); const [badgeImageUrl, setBadgeImageUrl] = useState(''); - const [badgeImagePreview, setBadgeImagePreview] = useState(''); + const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false); const [isPublishing, setIsPublishing] = useState(false); const [portalContainer, setPortalContainer] = useState(undefined); - const fileInputRef = useRef(null); const dialogContentRef = useCallback((node: HTMLElement | null) => { setPortalContainer(node ?? undefined); @@ -93,7 +93,6 @@ export function AddMemberDialog({ // Mutations const { mutateAsync: publishEvent } = useNostrPublish(); - const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); // Does this community already have a badge definition? const existingBadgeATag = community.ranks.find((r) => r.rank === 1)?.badgeATag; @@ -107,7 +106,7 @@ export function AddMemberDialog({ const resetForm = useCallback(() => { setPendingMembers([]); setBadgeImageUrl(''); - setBadgeImagePreview(''); + setIsBadgeImageUploading(false); setIsPublishing(false); }, []); @@ -195,36 +194,18 @@ export function AddMemberDialog({ }); }, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]); - // ── Badge image upload ──────────────────────────────────────────────────── - - const handleBadgeFileSelect = useCallback(async (file: File) => { - if (!file.type.startsWith('image/')) { - toast({ title: 'Invalid file', description: 'Please select an image file.', variant: 'destructive' }); - return; - } - const reader = new FileReader(); - reader.onload = (e) => setBadgeImagePreview(e.target?.result as string); - reader.readAsDataURL(file); - try { - const [[, url]] = await uploadFile(file); - setBadgeImageUrl(url); - toast({ title: 'Badge image uploaded' }); - } catch { - setBadgeImagePreview(''); - toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' }); - } - }, [uploadFile, toast]); - - const handleBadgeDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const file = e.dataTransfer.files[0]; - if (file) handleBadgeFileSelect(file); - }, [handleBadgeFileSelect]); - // ── Publish ─────────────────────────────────────────────────────────────── const handleSubmit = useCallback(async () => { if (!user || pendingMembers.length === 0) return; + if (isBadgeImageUploading) { + toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' }); + return; + } + if (badgeImageUrl.trim() && !sanitizeUrl(badgeImageUrl.trim())) { + toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' }); + return; + } setIsPublishing(true); try { @@ -241,8 +222,9 @@ export function AddMemberDialog({ ['name', 'Member'], ['description', `Member of ${community.name}`], ]; - if (badgeImageUrl) { - badgeTags.push(['image', badgeImageUrl, '1024x1024']); + const sanitizedBadgeImage = sanitizeUrl(badgeImageUrl.trim()); + if (sanitizedBadgeImage) { + badgeTags.push(['image', sanitizedBadgeImage, '1024x1024']); } badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]); @@ -331,7 +313,7 @@ export function AddMemberDialog({ } }, [ user, pendingMembers, existingBadgeATag, hasBadge, community, communityEvent, - badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, applyOptimisticMembership, + badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, applyOptimisticMembership, isBadgeImageUploading, ]); if (!user) return null; @@ -388,51 +370,23 @@ export function AddMemberDialog({ {/* Badge image — only shown when badge needs to be created */} {needsBadgeCreation && ( -
- -
fileInputRef.current?.click()} - onDrop={handleBadgeDrop} - onDragOver={(e) => e.preventDefault()} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); }} - className="relative flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-border rounded-xl bg-secondary/5 hover:bg-secondary/10 transition-colors cursor-pointer overflow-hidden" - > - {badgeImagePreview ? ( - Badge preview - ) : isUploading ? ( -
- - Uploading... -
- ) : ( -
- - Drop an image or click to upload -
- )} - { - const file = e.target.files?.[0]; - if (file) handleBadgeFileSelect(file); - }} - /> -
-
+ Member Badge Image (optional)} + value={badgeImageUrl} + onChange={setBadgeImageUrl} + onUploadingChange={setIsBadgeImageUploading} + uploadToastTitle="Badge image uploaded" + previewAlt="Badge preview" + objectFit="contain" + dropAreaClassName="min-h-24" + /> )} {/* Submit button */} - - ) : ( -
- - Paste, drop, or click to upload an image -
- )} - { - const file = e.target.files?.[0]; - if (file) void handleImageFile(file); - }} - /> - - setImageUrl(e.target.value)} - className="rounded-t-none rounded-b-xl" - /> - - + ) : ( <> @@ -499,8 +400,8 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange } - ) : ( @@ -509,8 +410,8 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange } Back - )} diff --git a/src/components/CreateGoalDialog.tsx b/src/components/CreateGoalDialog.tsx index 12af7941..dcc9612e 100644 --- a/src/components/CreateGoalDialog.tsx +++ b/src/components/CreateGoalDialog.tsx @@ -11,6 +11,7 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { ImageUploadField } from '@/components/ImageUploadField'; import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; @@ -45,6 +46,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen const [summary, setSummary] = useState(''); const [imageUrl, setImageUrl] = useState(''); const [deadlineDate, setDeadlineDate] = useState(''); + const [isImageUploading, setIsImageUploading] = useState(false); const resetForm = () => { setTitle(''); @@ -52,11 +54,16 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen setSummary(''); setImageUrl(''); setDeadlineDate(''); + setIsImageUploading(false); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!user) return; + if (isImageUploading) { + toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' }); + return; + } const sats = parseInt(amountSats, 10); if (isNaN(sats) || sats <= 0) { @@ -92,9 +99,11 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen } if (imageUrl.trim()) { const sanitizedImage = sanitizeUrl(imageUrl.trim()); - if (sanitizedImage) { - tags.push(['image', sanitizedImage]); + if (!sanitizedImage) { + toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' }); + return; } + tags.push(['image', sanitizedImage]); } if (deadlineDate) { const deadline = Math.floor(new Date(deadlineDate).getTime() / 1000); @@ -163,17 +172,29 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen /> -
- - setAmountSats(e.target.value)} - required - /> +
+
+ + setAmountSats(e.target.value)} + required + /> +
+ +
+ + setDeadlineDate(e.target.value)} + /> +
@@ -187,29 +208,17 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen />
-
- - setImageUrl(e.target.value)} - /> -
+ -
- - setDeadlineDate(e.target.value)} - /> -
- - diff --git a/src/components/ImageUploadField.tsx b/src/components/ImageUploadField.tsx new file mode 100644 index 00000000..80e66fc4 --- /dev/null +++ b/src/components/ImageUploadField.tsx @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useRef, type ReactNode } from 'react'; +import { Loader2, Upload, X } from 'lucide-react'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useToast } from '@/hooks/useToast'; +import { useUploadFile } from '@/hooks/useUploadFile'; +import { resizeImage } from '@/lib/resizeImage'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; + +interface ImageUploadFieldProps { + id: string; + label: ReactNode; + value: string; + onChange: (value: string) => void; + onUploadingChange?: (isUploading: boolean) => void; + placeholder?: string; + uploadText?: string; + uploadingText?: string; + uploadToastTitle?: string; + previewAlt?: string; + objectFit?: 'cover' | 'contain'; + className?: string; + dropAreaClassName?: string; + disabled?: boolean; +} + +export function ImageUploadField({ + id, + label, + value, + onChange, + onUploadingChange, + placeholder = 'Paste an image URL, or upload above', + uploadText = 'Paste, drop, or click to upload an image', + uploadingText = 'Uploading image...', + uploadToastTitle = 'Image uploaded', + previewAlt = 'Image preview', + objectFit = 'cover', + className, + dropAreaClassName, + disabled, +}: ImageUploadFieldProps) { + const { config } = useAppContext(); + const { toast } = useToast(); + const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + const imageInputRef = useRef(null); + const previewUrl = sanitizeUrl(value); + + useEffect(() => { + onUploadingChange?.(isUploading); + }, [isUploading, onUploadingChange]); + + const handleImageFile = useCallback(async (file: File) => { + if (!file.type.startsWith('image/')) { + toast({ title: 'Invalid file', description: 'Please choose an image file.', variant: 'destructive' }); + return; + } + + try { + const uploadableFile = config.imageQuality === 'compressed' + ? (await resizeImage(file)).file + : file; + const [[, url]] = await uploadFile(uploadableFile); + onChange(url); + toast({ title: uploadToastTitle }); + } catch (err) { + toast({ + title: 'Upload failed', + description: err instanceof Error ? err.message : 'Please try again.', + variant: 'destructive', + }); + } + }, [config.imageQuality, onChange, toast, uploadFile, uploadToastTitle]); + + const handleImagePaste = useCallback((e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items || disabled) return; + + for (const item of Array.from(items)) { + if (!item.type.startsWith('image/')) continue; + const file = item.getAsFile(); + if (!file) return; + e.preventDefault(); + void handleImageFile(file); + return; + } + }, [disabled, handleImageFile]); + + const handleImageDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + if (disabled) return; + const file = e.dataTransfer.files[0]; + if (file) void handleImageFile(file); + }, [disabled, handleImageFile]); + + const clearImage = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onChange(''); + if (imageInputRef.current) imageInputRef.current.value = ''; + }, [onChange]); + + return ( +
+ +
+
{ + if (!disabled) imageInputRef.current?.click(); + }} + onDrop={handleImageDrop} + onDragOver={(e) => e.preventDefault()} + onKeyDown={(e) => { + if (!disabled && (e.key === 'Enter' || e.key === ' ')) imageInputRef.current?.click(); + }} + className={cn( + 'relative flex min-h-28 w-full cursor-pointer flex-col items-center justify-center overflow-hidden rounded-t-xl border border-b-0 border-dashed border-border bg-secondary/20 text-center transition-colors hover:bg-secondary/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + disabled && 'cursor-not-allowed opacity-60', + dropAreaClassName, + )} + > + {isUploading ? ( +
+ + {uploadingText} +
+ ) : previewUrl ? ( + <> + {previewAlt} + + + ) : ( +
+ + {uploadText} +
+ )} + { + const file = e.target.files?.[0]; + if (file) void handleImageFile(file); + }} + /> +
+ onChange(e.target.value)} + className="rounded-t-none rounded-b-xl" + disabled={disabled} + /> +
+
+ ); +}