Files
eranos/src/components/CreateCommunityEventDialog.tsx
T
2026-05-22 23:37:32 -07:00

608 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { CalendarDays, ChevronLeft } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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 { CountrySelect } from '@/components/CountrySelect';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { COUNTRIES } from '@/lib/countries';
import { createCountryIdentifier, parseCountryIdentifier } from '@/lib/countryIdentifiers';
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { withAgoraTag } from '@/lib/agoraNoteTags';
import { getEditableContentTags, parseContentTagInput } from '@/lib/contentTags';
interface CreateCommunityEventDialogProps {
communityATag?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
event?: NostrEvent;
}
const MANAGED_EDIT_TAGS = new Set([
'd',
'title',
'alt',
'summary',
'location',
'image',
'start',
'end',
'D',
'start_tzid',
'end_tzid',
'A',
'K',
'P',
't',
]);
function isManagedEditTag([name, value]: string[]): boolean {
if (MANAGED_EDIT_TAGS.has(name)) return true;
return name === 'i' && !!value && !!parseCountryIdentifier(value);
}
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function addDays(date: string, days: number): string {
const parsed = new Date(`${date}T00:00:00Z`);
parsed.setUTCDate(parsed.getUTCDate() + days);
return parsed.toISOString().slice(0, 10);
}
function formatLocalDateTimeFields(timestamp: string): { date: string; time: string } {
const parsed = parseInt(timestamp, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return { date: '', time: '' };
const date = new Date(parsed * 1000);
const pad = (n: number) => n.toString().padStart(2, '0');
return {
date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
time: `${pad(date.getHours())}:${pad(date.getMinutes())}`,
};
}
function toLocalTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}:00`).getTime() / 1000);
}
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange, event }: CreateCommunityEventDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: publishRSVP } = usePublishRSVP();
const [step, setStep] = useState<1 | 2>(1);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [allDay, setAllDay] = useState(true);
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
const [countryCode, setCountryCode] = useState('');
const [countryQuery, setCountryQuery] = useState('');
const [location, setLocation] = useState('');
const [tagInput, setTagInput] = useState('');
const [isImageUploading, setIsImageUploading] = useState(false);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
[],
);
const isEditing = !!event;
const effectiveCommunityATag = communityATag ?? event?.tags.find(([name]) => name === 'A')?.[1];
const isCommunityEvent = !!effectiveCommunityATag;
const minEndDate = startDate || undefined;
const resetForm = useCallback(() => {
setStep(1);
setTitle('');
setDescription('');
setImageUrl('');
setAllDay(true);
setStartDate('');
setStartTime('');
setEndDate('');
setEndTime('');
setCountryCode('');
setCountryQuery('');
setLocation('');
setTagInput('');
setIsImageUploading(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
useEffect(() => {
if (!open || !event) return;
const titleTag = event.tags.find(([name]) => name === 'title')?.[1] ?? '';
const summaryTag = event.tags.find(([name]) => name === 'summary')?.[1] ?? '';
const imageTag = event.tags.find(([name]) => name === 'image')?.[1] ?? '';
const locationTag = event.tags.find(([name]) => name === 'location')?.[1] ?? '';
const editCountryCode = event.tags
.map(([name, value]) => name === 'i' && value ? parseCountryIdentifier(value) : undefined)
.find((code): code is string => !!code && /^[A-Z]{2}$/.test(code)) ?? '';
const startTag = event.tags.find(([name]) => name === 'start')?.[1] ?? '';
const endTag = event.tags.find(([name]) => name === 'end')?.[1] ?? '';
const isAllDay = event.kind === 31922;
setStep(1);
setTitle(titleTag);
setDescription(summaryTag || event.content);
setImageUrl(imageTag);
setLocation(locationTag);
setCountryCode(editCountryCode);
setCountryQuery(editCountryCode ? COUNTRIES[editCountryCode]?.name ?? editCountryCode : '');
setTagInput(getEditableContentTags(event.tags).join(', '));
setAllDay(isAllDay);
setIsImageUploading(false);
if (isAllDay) {
setStartDate(startTag);
setStartTime('');
setEndDate(endTag ? addDays(endTag, -1) : '');
setEndTime('');
return;
}
const startFields = formatLocalDateTimeFields(startTag);
const endFields = formatLocalDateTimeFields(endTag);
setStartDate(startFields.date);
setStartTime(startFields.time);
setEndDate(endFields.date);
setEndTime(endFields.time);
}, [event, open]);
const validateInfoStep = useCallback((): boolean => {
if (!title.trim()) {
toast({ title: 'Enter an event title', variant: 'destructive' });
return false;
}
return true;
}, [title, toast]);
const handleNext = useCallback((e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (isImageUploading) return;
if (!validateInfoStep()) return;
setStep(2);
}, [isImageUploading, validateInfoStep]);
const handleAllDayChange = useCallback((checked: boolean) => {
setAllDay(checked);
if (checked && startDate && endDate && endDate < startDate) {
setEndDate(startDate);
}
}, [endDate, startDate]);
const handleStartDateChange = useCallback((nextStartDate: string) => {
setStartDate(nextStartDate);
if (!nextStartDate || !endDate) return;
const nextMinEndDate = nextStartDate;
if (endDate < nextMinEndDate) {
setEndDate(nextMinEndDate);
}
}, [endDate]);
const seedEndDate = useCallback(() => {
if (!endDate && minEndDate) {
setEndDate(minEndDate);
}
}, [endDate, minEndDate]);
const handleSubmit = useCallback(async () => {
if (!user) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (!validateInfoStep()) return;
if (!startDate) {
toast({ title: 'Choose a start date', variant: 'destructive' });
return;
}
if (!allDay && !startTime) {
toast({ title: 'Choose a start time or turn on all-day', variant: 'destructive' });
return;
}
const trimmedTitle = title.trim();
const contentTags = parseContentTagInput(tagInput);
const dTag = event?.tags.find(([name]) => name === 'd')?.[1] || `${slugify(trimmedTitle) || 'event'}-${Date.now()}`;
let kind = isEditing && event ? event.kind : 31922;
try {
const prev = isEditing && event
? await fetchFreshEvent(nostr, {
kinds: [event.kind],
authors: [event.pubkey],
'#d': [dTag],
})
: undefined;
const preservedTags = isEditing
? (prev?.tags ?? event?.tags ?? []).filter((tag) => !isManagedEditTag(tag))
: [];
const tags: string[][] = [
['d', dTag],
['title', trimmedTitle],
['alt', `${isCommunityEvent ? 'Group event' : 'Calendar event'}: ${trimmedTitle}`],
...preservedTags,
];
if (effectiveCommunityATag) {
tags.push(...createOrganizationAssociationTags(effectiveCommunityATag));
}
if (description.trim()) {
tags.push(['summary', description.trim()]);
}
if (location.trim()) {
tags.push(['location', location.trim()]);
}
if (countryCode) {
tags.push(['i', createCountryIdentifier(countryCode)]);
}
for (const tag of contentTags) tags.push(['t', tag]);
if (imageUrl.trim()) {
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (!sanitizedImage) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
tags.push(['image', sanitizedImage]);
}
if (allDay) {
tags.push(['start', startDate]);
if (endDate) {
if (endDate < startDate) {
toast({ title: 'End date must be on or after the start date', variant: 'destructive' });
return;
}
if (endDate > startDate) {
tags.push(['end', addDays(endDate, 1)]);
}
}
} else {
if (endDate && endDate < startDate) {
toast({ title: 'End date must be on or after the start date', variant: 'destructive' });
return;
}
if (!isEditing) kind = 31923;
const startTs = toLocalTimestamp(startDate, startTime);
if (!Number.isFinite(startTs) || startTs <= 0) {
toast({ title: 'Start date or time is invalid', variant: 'destructive' });
return;
}
tags.push(['start', String(startTs)]);
tags.push(['D', String(Math.floor(startTs / 86400))]);
tags.push(['start_tzid', timezone]);
if (endDate || endTime) {
const effectiveEndDate = endDate || startDate;
const effectiveEndTime = endTime || startTime;
const endTs = toLocalTimestamp(effectiveEndDate, effectiveEndTime);
if (!Number.isFinite(endTs) || endTs <= startTs) {
toast({ title: 'End time must be after the start time', variant: 'destructive' });
return;
}
tags.push(['end', String(endTs)]);
tags.push(['end_tzid', timezone]);
}
}
const publishedEvent = await publishEvent({
kind,
content: description.trim(),
tags: withAgoraTag(tags),
prev: prev ?? undefined,
});
if (!isEditing) {
// Auto-RSVP the author as "accepted" so they appear in the attendees list.
// Best-effort: don't block on failure -- the event itself is already published.
const eventCoord = `${kind}:${user.pubkey}:${dTag}`;
publishRSVP({
eventCoord,
eventAuthorPubkey: user.pubkey,
status: 'accepted',
}).catch(() => {
// Silently ignore -- user can manually RSVP from the detail page if needed.
});
}
queryClient.setQueryData(['addr-event', kind, publishedEvent.pubkey, dTag], publishedEvent);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['feed'] }),
queryClient.invalidateQueries({ queryKey: ['addr-event', kind, publishedEvent.pubkey, dTag] }),
...(effectiveCommunityATag ? [
queryClient.invalidateQueries({ queryKey: ['community-events', effectiveCommunityATag] }),
queryClient.invalidateQueries({ queryKey: ['organization-activity', effectiveCommunityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(effectiveCommunityATag);
},
}),
] : []),
]);
toast({ title: isEditing ? 'Event updated!' : 'Event created!' });
handleOpenChange(false);
} catch (err) {
toast({
title: isEditing ? 'Failed to update event' : 'Failed to create event',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
}, [
allDay,
countryCode,
description,
endDate,
endTime,
effectiveCommunityATag,
handleOpenChange,
imageUrl,
isImageUploading,
isEditing,
location,
nostr,
publishEvent,
publishRSVP,
queryClient,
startDate,
startTime,
timezone,
tagInput,
title,
toast,
user,
validateInfoStep,
isCommunityEvent,
event,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<CalendarDays className="size-5 text-primary" />
{isEditing ? 'Edit Event' : 'Create Event'}
</DialogTitle>
<DialogDescription>
Step {step} of 2 · {step === 1 ? 'What is happening?' : 'When and where?'}
</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => e.preventDefault()}>
<ScrollArea className="max-h-[62vh]">
<div className="px-5 pb-5 space-y-4">
{step === 1 ? (
<>
<div className="space-y-1.5">
<Label htmlFor="community-event-title">Title *</Label>
<Input
id="community-event-title"
placeholder="e.g. Neighborhood cleanup"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-description">Description (recommended)</Label>
<Textarea
id="community-event-description"
placeholder="Tell people what to expect..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
<ImageUploadField
id="community-event-image"
label="Image (recommended)"
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Event image preview"
/>
<div className="space-y-1.5">
<Label htmlFor="community-event-tags">Tags (recommended)</Label>
<Input
id="community-event-tags"
placeholder="mutual-aid, workshop, local-news"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
/>
</div>
</>
) : (
<>
<div className="flex items-center justify-between gap-4 rounded-xl border border-border px-3 py-3">
<div className="space-y-0.5">
<Label htmlFor="community-event-all-day">All-day event</Label>
<p className="text-xs text-muted-foreground">
{isEditing ? "Event type can't be changed while editing." : 'Turn off to add start and end times.'}
</p>
</div>
<Switch
id="community-event-all-day"
checked={allDay}
onCheckedChange={handleAllDayChange}
disabled={isEditing}
/>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<div className="space-y-1.5">
<Label htmlFor="community-event-start-date">Start date *</Label>
<Input
id="community-event-start-date"
type="date"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={startDate}
onChange={(e) => handleStartDateChange(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-end-date">End date (optional)</Label>
<Input
id="community-event-end-date"
type="date"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={endDate}
min={minEndDate}
onFocus={seedEndDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
{!allDay && (
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<div className="space-y-1.5">
<Label htmlFor="community-event-start-time">Start time *</Label>
<Input
id="community-event-start-time"
type="time"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-end-time">End time (optional)</Label>
<Input
id="community-event-end-time"
type="time"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="community-event-country">Country (recommended)</Label>
<CountrySelect
id="community-event-country"
query={countryQuery}
selectedCode={countryCode}
onQueryChange={(value) => {
setCountryQuery(value);
const selectedCountry = countryCode ? COUNTRIES[countryCode] : undefined;
if (selectedCountry && value !== selectedCountry.name && value.toUpperCase() !== countryCode) {
setCountryCode('');
}
}}
onSelect={(country) => {
setCountryCode(country.code);
setCountryQuery(country.name);
}}
onClear={() => {
setCountryCode('');
setCountryQuery('');
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-location">Location details (recommended)</Label>
<Input
id="community-event-location"
placeholder="Address, venue, or video call link"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
<div className="flex items-center gap-2 border-t border-border px-5 py-4 bg-background">
{step === 1 ? (
<>
<Button type="button" variant="outline" className="flex-1" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button type="button" className="flex-1" onClick={handleNext} disabled={isImageUploading}>
{isImageUploading ? 'Uploading...' : 'Next'}
</Button>
</>
) : (
<>
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => setStep(1)}>
<ChevronLeft className="size-4" />
Back
</Button>
<Button type="button" className="flex-1" onClick={handleSubmit} disabled={isPending || isImageUploading}>
{isPending ? (isEditing ? 'Saving...' : 'Creating...') : isImageUploading ? 'Uploading...' : isEditing ? 'Save Event' : 'Create Event'}
</Button>
</>
)}
</div>
</form>
</DialogContent>
</Dialog>
);
}