Add calendar event country selection
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user