Share image upload field across dialogs
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user