From 5582c178dbf3309300a0287fb491400a234f568d Mon Sep 17 00:00:00 2001 From: lemon Date: Fri, 22 May 2026 22:56:28 -0700 Subject: [PATCH] Add calendar event country selection --- src/components/CountrySelect.tsx | 126 ++++++++++++++++++ src/components/CreateCommunityEventDialog.tsx | 50 ++++++- src/pages/CreateEventPage.tsx | 36 ++++- 3 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 src/components/CountrySelect.tsx diff --git a/src/components/CountrySelect.tsx b/src/components/CountrySelect.tsx new file mode 100644 index 00000000..b6252ad6 --- /dev/null +++ b/src/components/CountrySelect.tsx @@ -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 ( +
+
+ + { + 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) && ( + + )} + + {showResults && ( +
+ {results.map((country, index) => ( + + ))} +
+ )} +
+ + {selectedCountry && ( +

+ Publishes i: iso3166:{selectedCode} for country sorting. +

+ )} +
+ ); +} diff --git a/src/components/CreateCommunityEventDialog.tsx b/src/components/CreateCommunityEventDialog.tsx index b50e6996..5fece354 100644 --- a/src/components/CreateCommunityEventDialog.tsx +++ b/src/components/CreateCommunityEventDialog.tsx @@ -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, )}
- + + { + 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(''); + }} + /> +
+ +
+ 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() { )} - + + { + 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(''); + }} + /> + + +