Add calendar event country selection

This commit is contained in:
lemon
2026-05-22 22:56:28 -07:00
parent fad84655ef
commit 5582c178db
3 changed files with 209 additions and 3 deletions
+126
View File
@@ -0,0 +1,126 @@
import { useMemo, useState } from 'react';
import { MapPin, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { COUNTRIES, searchCountries, type CountryEntry } from '@/lib/countries';
import { cn } from '@/lib/utils';
interface CountrySelectProps {
id: string;
query: string;
selectedCode: string;
onQueryChange: (value: string) => void;
onSelect: (country: CountryEntry) => void;
onClear: () => void;
placeholder?: string;
}
export function CountrySelect({
id,
query,
selectedCode,
onQueryChange,
onSelect,
onClear,
placeholder = 'Search countries, e.g. Venezuela',
}: CountrySelectProps) {
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedCountry = selectedCode ? COUNTRIES[selectedCode] : undefined;
const results = useMemo(() => searchCountries(query), [query]);
const showResults = open && results.length > 0;
const resultsId = `${id}-results`;
const selectCountry = (country: CountryEntry) => {
onSelect(country);
setOpen(false);
setSelectedIndex(0);
};
return (
<div className="space-y-2">
<div className="relative">
<MapPin className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={id}
value={query}
onChange={(e) => {
onQueryChange(e.target.value);
setOpen(true);
setSelectedIndex(0);
}}
onFocus={() => setOpen(true)}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onKeyDown={(e) => {
if (!showResults) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
} else if (e.key === 'Enter') {
e.preventDefault();
selectCountry(results[selectedIndex]);
} else if (e.key === 'Escape') {
setOpen(false);
}
}}
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={placeholder}
autoComplete="off"
role="combobox"
aria-expanded={showResults}
aria-controls={resultsId}
/>
{(query || selectedCode) && (
<button
type="button"
onClick={onClear}
className="absolute right-2 top-1/2 rounded-full p-1 -translate-y-1/2 text-muted-foreground hover:bg-muted hover:text-foreground motion-safe:transition-colors"
aria-label="Clear country"
>
<X className="size-4" />
</button>
)}
{showResults && (
<div
id={resultsId}
role="listbox"
className="absolute z-20 mt-2 max-h-[200px] w-full overflow-y-auto rounded-xl border border-border bg-popover py-1 shadow-lg"
>
{results.map((country, index) => (
<button
key={country.code}
type="button"
role="option"
aria-selected={index === selectedIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectCountry(country)}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-secondary/60',
index === selectedIndex && 'bg-secondary/60',
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary text-lg leading-none" role="img" aria-label={`Flag of ${country.name}`}>
{country.flag}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{country.name}</span>
<span className="block text-xs text-muted-foreground">{country.code}</span>
</span>
</button>
))}
</div>
)}
</div>
{selectedCountry && (
<p className="text-xs text-muted-foreground">
Publishes <span className="font-mono text-foreground">i: iso3166:{selectedCode}</span> for country sorting.
</p>
)}
</div>
);
}
+48 -2
View File
@@ -17,12 +17,15 @@ 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';
@@ -53,6 +56,11 @@ const MANAGED_EDIT_TAGS = new Set([
'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()
@@ -101,6 +109,8 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
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);
@@ -124,6 +134,8 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
setStartTime('');
setEndDate('');
setEndTime('');
setCountryCode('');
setCountryQuery('');
setLocation('');
setTagInput('');
setIsImageUploading(false);
@@ -141,6 +153,9 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
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;
@@ -150,6 +165,8 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
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);
@@ -241,7 +258,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
})
: undefined;
const preservedTags = isEditing
? (prev?.tags ?? event?.tags ?? []).filter(([name]) => !MANAGED_EDIT_TAGS.has(name))
? (prev?.tags ?? event?.tags ?? []).filter((tag) => !isManagedEditTag(tag))
: [];
const tags: string[][] = [
['d', dTag],
@@ -262,6 +279,10 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
tags.push(['location', location.trim()]);
}
if (countryCode) {
tags.push(['i', createCountryIdentifier(countryCode)]);
}
for (const tag of contentTags) tags.push(['t', tag]);
if (imageUrl.trim()) {
@@ -363,6 +384,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
}
}, [
allDay,
countryCode,
description,
endDate,
endTime,
@@ -519,7 +541,31 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
)}
<div className="space-y-1.5">
<Label htmlFor="community-event-location">Location (recommended)</Label>
<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"
+35 -1
View File
@@ -6,6 +6,7 @@ import { nip19 } from 'nostr-tools';
import { AlertTriangle, ArrowLeft, CalendarDays, Clock, Loader2, Plus } from 'lucide-react';
import { CoverImageField } from '@/components/CoverImageField';
import { CountrySelect } from '@/components/CountrySelect';
import { FormSection } from '@/components/FormSection';
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
@@ -23,6 +24,8 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useToast } from '@/hooks/useToast';
import { getTodayDateInput } from '@/lib/dateInput';
import { COUNTRIES } from '@/lib/countries';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { unixSecondsInTimezone } from '@/lib/timezone';
@@ -60,6 +63,8 @@ export function CreateEventPage() {
[],
);
const minStartDate = useMemo(() => getTodayDateInput(), []);
const pageCountryCode = (searchParams.get('country') || '').toUpperCase();
const initialCountryCode = COUNTRIES[pageCountryCode] ? pageCountryCode : '';
const orgParam = searchParams.get('org');
const orgFromParam = useMemo(() => decodeOrganizationParam(orgParam), [orgParam]);
@@ -79,6 +84,8 @@ export function CreateEventPage() {
const [startTime, setStartTime] = useState('');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
const [countryCode, setCountryCode] = useState(initialCountryCode);
const [countryQuery, setCountryQuery] = useState(initialCountryCode ? COUNTRIES[initialCountryCode].name : '');
const [location, setLocation] = useState('');
const [tagInput, setTagInput] = useState('');
const [timezone, setTimezone] = useState(browserTimezone);
@@ -125,6 +132,10 @@ export function CreateEventPage() {
tags.push(['location', trimmedLocation]);
}
if (countryCode) {
tags.push(['i', createCountryIdentifier(countryCode)]);
}
for (const tag of contentTags) tags.push(['t', tag]);
const trimmedCoverImage = coverImage.trim();
@@ -403,7 +414,30 @@ export function CreateEventPage() {
)}
</FormSection>
<FormSection title="Location" requirement="Recommended">
<FormSection title="Country" requirement="Recommended">
<CountrySelect
id="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('');
}}
/>
</FormSection>
<FormSection title="Location details" requirement="Recommended">
<Input
placeholder="Address, venue, or video call link"
value={location}