Share image upload field across dialogs

This commit is contained in:
lemon
2026-05-06 19:40:46 -07:00
parent 35d1c34ed8
commit fd2a049d93
5 changed files with 304 additions and 311 deletions
+29 -75
View File
@@ -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<PendingMember[]>([]);
const [badgeImageUrl, setBadgeImageUrl] = useState('');
const [badgeImagePreview, setBadgeImagePreview] = useState('');
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const fileInputRef = useRef<HTMLInputElement>(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 && (
<div className="space-y-1.5">
<Label>
Member Badge Image
<span className="text-muted-foreground font-normal ml-1">(optional)</span>
</Label>
<div
role="button"
tabIndex={0}
onClick={() => 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 ? (
<img src={badgeImagePreview} alt="Badge preview" className="h-full object-contain" />
) : isUploading ? (
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="text-xs">Uploading...</span>
</div>
) : (
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
<Upload className="size-5 opacity-40" />
<span className="text-xs">Drop an image or click to upload</span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleBadgeFileSelect(file);
}}
/>
</div>
</div>
<ImageUploadField
id="member-badge-image"
label={<>Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
value={badgeImageUrl}
onChange={setBadgeImageUrl}
onUploadingChange={setIsBadgeImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Badge preview"
objectFit="contain"
dropAreaClassName="min-h-24"
/>
)}
{/* Submit button */}
<Button
onClick={handleSubmit}
disabled={pendingMembers.length === 0 || isPublishing || isUploading}
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
+31 -80
View File
@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Upload, Loader2 } from 'lucide-react';
import { Users, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
@@ -18,12 +18,13 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -62,13 +63,11 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [imagePreview, setImagePreview] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
// Derived
const effectiveSlug = isEditing && community ? community.dTag : slugify(name);
@@ -77,8 +76,8 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
setName(community?.name ?? '');
setDescription(community?.description ?? '');
setImageUrl(community?.image ?? '');
setImagePreview(community?.image ?? '');
setIsPublishing(false);
setIsImageUploading(false);
}, [community]);
const resetForm = useCallback(() => {
@@ -88,8 +87,8 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
setName('');
setDescription('');
setImageUrl('');
setImagePreview('');
setIsPublishing(false);
setIsImageUploading(false);
}
}, [isEditing, populateFromCommunity]);
@@ -104,32 +103,6 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
// ── Image upload ──────────────────────────────────────────────────────────
const handleFileSelect = 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) => setImagePreview(e.target?.result as string);
reader.readAsDataURL(file);
try {
const [[, url]] = await uploadFile(file);
setImageUrl(url);
toast({ title: 'Image uploaded' });
} catch {
setImagePreview('');
toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' });
}
}, [uploadFile, toast]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}, [handleFileSelect]);
const buildUpdatedCommunityTags = useCallback((baseTags: string[][]): string[][] => {
const tags = baseTags.filter(([name]) => !['d', 'name', 'description', 'image', 'alt'].includes(name));
const nextTags: string[][] = [
@@ -141,8 +114,9 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
nextTags.push(['description', description.trim()]);
}
if (imageUrl) {
nextTags.push(['image', imageUrl]);
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
nextTags.push(['image', sanitizedImage]);
}
nextTags.push(...tags);
@@ -155,6 +129,14 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
const handleCreate = useCallback(async () => {
if (!user || !name.trim() || !effectiveSlug) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
@@ -232,7 +214,7 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
setIsPublishing(false);
}
}, [
user, name, effectiveSlug, isEditing, communityEvent, community, nostr,
user, name, effectiveSlug, isEditing, communityEvent, community, nostr, isImageUploading, imageUrl,
publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate,
]);
@@ -240,7 +222,7 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
@@ -253,7 +235,7 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<ScrollArea className="max-h-[calc(100vh-9rem)] sm:max-h-none">
<div className="px-5 pb-5 space-y-4">
{/* Community name */}
<div className="space-y-1.5">
@@ -272,46 +254,15 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
)}
</div>
{/* Image upload */}
<div className="space-y-1.5">
<Label>
Community Image
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
</Label>
<div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
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-32 border-2 border-dashed border-border rounded-xl bg-secondary/5 hover:bg-secondary/10 transition-colors cursor-pointer overflow-hidden"
>
{imagePreview ? (
<img src={imagePreview} alt="Community image preview" className="w-full h-full object-cover" />
) : isUploading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<span className="text-xs">Uploading...</span>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="size-6 opacity-40" />
<span className="text-xs">Drop an image or click to upload</span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
</div>
</div>
<ImageUploadField
id="community-image"
label={<>Community Image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Community image preview"
dropAreaClassName="min-h-32"
/>
{/* Description */}
<div className="space-y-1.5">
@@ -331,7 +282,7 @@ export function CreateCommunityDialog({ open, onOpenChange, communityEvent, comm
{/* Submit button */}
<Button
onClick={handleCreate}
disabled={!name.trim() || !effectiveSlug || isPublishing || isUploading}
disabled={!name.trim() || !effectiveSlug || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
+22 -121
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { CalendarDays, ChevronLeft, Loader2, Upload, X } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { CalendarDays, ChevronLeft } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import {
@@ -15,13 +15,11 @@ import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { resizeImage } from '@/lib/resizeImage';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface CreateCommunityEventDialogProps {
@@ -56,13 +54,10 @@ function parseCommunityAuthor(communityATag: string): string | undefined {
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange }: CreateCommunityEventDialogProps) {
const { user } = useCurrentUser();
const { config } = useAppContext();
const { toast } = useToast();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: publishRSVP } = usePublishRSVP();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const imageInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<1 | 2>(1);
const [title, setTitle] = useState('');
@@ -74,6 +69,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange }
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
const [location, setLocation] = useState('');
const [isImageUploading, setIsImageUploading] = useState(false);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
@@ -92,6 +88,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange }
setEndDate('');
setEndTime('');
setLocation('');
setIsImageUploading(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
@@ -108,57 +105,15 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange }
}, [title, toast]);
const handleNext = useCallback(() => {
if (isUploading) return;
if (isImageUploading) return;
if (!validateInfoStep()) return;
setStep(2);
}, [isUploading, validateInfoStep]);
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);
setImageUrl(url);
toast({ title: 'Image uploaded' });
} catch (err) {
toast({
title: 'Upload failed',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
}, [config.imageQuality, toast, uploadFile]);
const handleImagePaste = useCallback((e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) 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;
}
}, [handleImageFile]);
const handleImageDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) void handleImageFile(file);
}, [handleImageFile]);
}, [isImageUploading, validateInfoStep]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
if (isUploading) {
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
@@ -295,7 +250,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange }
endTime,
handleOpenChange,
imageUrl,
isUploading,
isImageUploading,
location,
publishEvent,
publishRSVP,
@@ -326,7 +281,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange }
</DialogHeader>
<form onSubmit={handleSubmit}>
<ScrollArea className="max-h-[62vh]" onPaste={handleImagePaste}>
<ScrollArea className="max-h-[62vh]">
<div className="px-5 pb-5 space-y-4">
{step === 1 ? (
<>
@@ -352,68 +307,14 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange }
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-image">Image (recommended)</Label>
<div>
<div
role="button"
tabIndex={0}
onClick={() => imageInputRef.current?.click()}
onDrop={handleImageDrop}
onDragOver={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') imageInputRef.current?.click();
}}
className="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"
>
{isUploading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="text-xs">Uploading image...</span>
</div>
) : sanitizeUrl(imageUrl) ? (
<>
<img src={sanitizeUrl(imageUrl)} alt="Event image preview" className="absolute inset-0 h-full w-full object-cover" />
<button
type="button"
aria-label="Remove image"
onClick={(e) => {
e.stopPropagation();
setImageUrl('');
if (imageInputRef.current) imageInputRef.current.value = '';
}}
className="absolute right-2 top-2 rounded-full bg-background/90 p-1 text-muted-foreground shadow-sm transition-colors hover:text-destructive"
>
<X className="size-4" />
</button>
</>
) : (
<div className="flex flex-col items-center gap-2 px-4 text-muted-foreground">
<Upload className="size-5 opacity-50" />
<span className="text-xs">Paste, drop, or click to upload an image</span>
</div>
)}
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleImageFile(file);
}}
/>
</div>
<Input
id="community-event-image"
type="url"
placeholder="Paste an image URL, or upload above"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
className="rounded-t-none rounded-b-xl"
/>
</div>
</div>
<ImageUploadField
id="community-event-image"
label="Image (recommended)"
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Event image preview"
/>
</>
) : (
<>
@@ -499,8 +400,8 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange }
<Button type="button" variant="outline" className="flex-1" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button type="button" className="flex-1" onClick={handleNext} disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Next'}
<Button type="button" className="flex-1" onClick={handleNext} disabled={isImageUploading}>
{isImageUploading ? 'Uploading...' : 'Next'}
</Button>
</>
) : (
@@ -509,8 +410,8 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange }
<ChevronLeft className="size-4" />
Back
</Button>
<Button type="submit" className="flex-1" disabled={isPending || isUploading}>
{isPending ? 'Creating...' : isUploading ? 'Uploading...' : 'Create Event'}
<Button type="submit" className="flex-1" disabled={isPending || isImageUploading}>
{isPending ? 'Creating...' : isImageUploading ? 'Uploading...' : 'Create Event'}
</Button>
</>
)}
+44 -35
View File
@@ -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
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-amount">Target Amount (sats)</Label>
<Input
id="goal-amount"
type="number"
min="1"
placeholder="e.g. 100000"
value={amountSats}
onChange={(e) => setAmountSats(e.target.value)}
required
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="goal-amount">Amount (sats)</Label>
<Input
id="goal-amount"
type="number"
min="1"
placeholder="e.g. 100000"
value={amountSats}
onChange={(e) => setAmountSats(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-deadline">Deadline (optional)</Label>
<Input
id="goal-deadline"
type="datetime-local"
value={deadlineDate}
onChange={(e) => setDeadlineDate(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
@@ -187,29 +208,17 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-image">Image URL (optional)</Label>
<Input
id="goal-image"
type="url"
placeholder="https://..."
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
/>
</div>
<ImageUploadField
id="goal-image"
label="Image (recommended)"
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Fundraising goal image preview"
/>
<div className="space-y-2">
<Label htmlFor="goal-deadline">Deadline (optional)</Label>
<Input
id="goal-deadline"
type="datetime-local"
value={deadlineDate}
onChange={(e) => setDeadlineDate(e.target.value)}
/>
</div>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Goal'}
<Button type="submit" className="w-full" disabled={isPending || isImageUploading}>
{isPending ? 'Creating...' : isImageUploading ? 'Uploading...' : 'Create Goal'}
</Button>
</form>
</DialogContent>
+178
View File
@@ -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<HTMLInputElement>(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 (
<div className={cn('space-y-1.5', className)} onPaste={handleImagePaste}>
<Label htmlFor={id}>{label}</Label>
<div>
<div
role="button"
tabIndex={disabled ? -1 : 0}
onClick={() => {
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 ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="text-xs">{uploadingText}</span>
</div>
) : previewUrl ? (
<>
<img
src={previewUrl}
alt={previewAlt}
className={cn('absolute inset-0 h-full w-full', objectFit === 'contain' ? 'object-contain p-3' : 'object-cover')}
/>
<button
type="button"
aria-label="Remove image"
onClick={clearImage}
disabled={disabled}
className="absolute right-2 top-2 rounded-full bg-background/90 p-1 text-muted-foreground shadow-sm transition-colors hover:text-destructive disabled:opacity-60"
>
<X className="size-4" />
</button>
</>
) : (
<div className="flex flex-col items-center gap-2 px-4 text-muted-foreground">
<Upload className="size-5 opacity-50" />
<span className="text-xs">{uploadText}</span>
</div>
)}
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
disabled={disabled}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleImageFile(file);
}}
/>
</div>
<Input
id={id}
type="url"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-t-none rounded-b-xl"
disabled={disabled}
/>
</div>
</div>
);
}