29 changed files with 2687 additions and 22 deletions
@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
|
||||
import { createCalendarRsvpDraftEvent } from '@/lib/draft-event' |
||||
import { |
||||
getCalendarEventMeta, |
||||
formatCalendarTime, |
||||
formatCalendarDate, |
||||
isCalendarEventKind |
||||
} from '@/lib/calendar-event' |
||||
import { useFetchCalendarRsvps } from '@/hooks/useFetchCalendarRsvps' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { Event } from 'nostr-tools' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { Button } from '../ui/button' |
||||
import { Calendar, Video, CheckCircle, HelpCircle, XCircle } from 'lucide-react' |
||||
import { cn } from '@/lib/utils' |
||||
import { |
||||
DropdownMenu, |
||||
DropdownMenuContent, |
||||
DropdownMenuItem, |
||||
DropdownMenuTrigger |
||||
} from '../ui/dropdown-menu' |
||||
import { toast } from 'sonner' |
||||
|
||||
type RsvpStatus = 'accepted' | 'tentative' | 'declined' |
||||
|
||||
export default function CalendarEventContent({ |
||||
event, |
||||
className, |
||||
showRsvp = true |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
showRsvp?: boolean |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { pubkey: myPubkey, publish } = useNostr() |
||||
const { rsvps, isFetching, getRsvpStatus: getStatus } = useFetchCalendarRsvps(event) |
||||
|
||||
if (!isCalendarEventKind(event.kind)) return null |
||||
|
||||
const { title, summary, image, start, end, startDate, endDate, isDateBased, joinUrl, topics } = |
||||
getCalendarEventMeta(event) |
||||
const description = summary || event.content?.trim() || '' |
||||
const myRsvp = myPubkey ? rsvps.find((r) => r.pubkey === myPubkey) : undefined |
||||
const myStatus = myRsvp ? getStatus(myRsvp) : undefined |
||||
|
||||
const handleRsvp = async (status: RsvpStatus) => { |
||||
if (!myPubkey) { |
||||
toast.error(t('You need to log in to RSVP')) |
||||
return |
||||
} |
||||
try { |
||||
const draft = createCalendarRsvpDraftEvent(event, status) |
||||
await publish(draft) |
||||
toast.success(t('RSVP updated')) |
||||
} catch (err) { |
||||
toast.error(err instanceof Error ? err.message : t('Failed to update RSVP')) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
className={cn('rounded-lg border bg-muted/40 p-3 text-sm min-w-0', className)} |
||||
data-calendar-event-content |
||||
onClick={(e) => e.stopPropagation()} |
||||
> |
||||
<div className="flex items-start gap-2 mb-2"> |
||||
{image ? ( |
||||
<img |
||||
src={image} |
||||
alt="" |
||||
className="size-12 shrink-0 rounded object-cover" |
||||
/> |
||||
) : ( |
||||
<Calendar className="size-4 shrink-0 mt-0.5 text-muted-foreground" /> |
||||
)} |
||||
<div className="min-w-0 flex-1"> |
||||
<span className="font-medium text-foreground truncate block"> |
||||
{title || t('Scheduled video call')} |
||||
</span> |
||||
{topics.length > 0 && ( |
||||
<div className="flex flex-wrap gap-1 mt-1"> |
||||
{topics.map((topic) => ( |
||||
<span |
||||
key={topic} |
||||
className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground" |
||||
> |
||||
#{topic} |
||||
</span> |
||||
))} |
||||
</div> |
||||
)} |
||||
</div> |
||||
</div> |
||||
{isDateBased ? ( |
||||
(startDate || endDate) && ( |
||||
<div className="text-muted-foreground text-xs mb-2"> |
||||
{startDate ? formatCalendarDate(startDate) : ''} |
||||
{endDate && endDate !== startDate && ( |
||||
<> – {formatCalendarDate(endDate)}</> |
||||
)} |
||||
</div> |
||||
) |
||||
) : ( |
||||
start != null && |
||||
!isNaN(start) && ( |
||||
<div className="text-muted-foreground text-xs mb-2"> |
||||
{formatCalendarTime(start)} |
||||
{end != null && !isNaN(end) && end > start && ( |
||||
<> – {formatCalendarTime(end)}</> |
||||
)} |
||||
</div> |
||||
) |
||||
)} |
||||
{description && ( |
||||
<p className="text-muted-foreground text-xs mb-2 whitespace-pre-wrap break-words"> |
||||
{description} |
||||
</p> |
||||
)} |
||||
<div className="flex flex-wrap items-center gap-2 mt-2"> |
||||
{joinUrl && ( |
||||
<Button variant="secondary" size="sm" className="gap-2" asChild> |
||||
<a href={joinUrl} target="_blank" rel="noopener noreferrer"> |
||||
<Video className="size-4" /> |
||||
{t('Join video call')} |
||||
</a> |
||||
</Button> |
||||
)} |
||||
{showRsvp && myPubkey && ( |
||||
<DropdownMenu> |
||||
<DropdownMenuTrigger asChild> |
||||
<Button |
||||
variant="outline" |
||||
size="sm" |
||||
className="gap-2" |
||||
disabled={isFetching} |
||||
> |
||||
{myStatus === 'accepted' && <CheckCircle className="size-4 text-green-600" />} |
||||
{myStatus === 'tentative' && <HelpCircle className="size-4 text-amber-600" />} |
||||
{myStatus === 'declined' && <XCircle className="size-4 text-muted-foreground" />} |
||||
{myStatus |
||||
? t('RSVP: {{status}}', { status: myStatus }) |
||||
: t('RSVP')} |
||||
</Button> |
||||
</DropdownMenuTrigger> |
||||
<DropdownMenuContent align="start"> |
||||
<DropdownMenuItem onClick={() => handleRsvp('accepted')}> |
||||
<CheckCircle className="size-4 mr-2 text-green-600" /> |
||||
{t('Accepted')} |
||||
</DropdownMenuItem> |
||||
<DropdownMenuItem onClick={() => handleRsvp('tentative')}> |
||||
<HelpCircle className="size-4 mr-2 text-amber-600" /> |
||||
{t('Tentative')} |
||||
</DropdownMenuItem> |
||||
<DropdownMenuItem onClick={() => handleRsvp('declined')}> |
||||
<XCircle className="size-4 mr-2" /> |
||||
{t('Declined')} |
||||
</DropdownMenuItem> |
||||
</DropdownMenuContent> |
||||
</DropdownMenu> |
||||
)} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { |
||||
getCalendarEventMeta, |
||||
formatCalendarTime, |
||||
formatCalendarDate, |
||||
isCalendarEventKind |
||||
} from '@/lib/calendar-event' |
||||
import { cn } from '@/lib/utils' |
||||
import { Event } from 'nostr-tools' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { Button } from '../ui/button' |
||||
import { Calendar, Video } from 'lucide-react' |
||||
|
||||
export function EmbeddedCalendarEvent({ |
||||
event, |
||||
className |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
}) { |
||||
const { t } = useTranslation() |
||||
if (!isCalendarEventKind(event.kind)) return null |
||||
const { title, summary, image, start, end, startDate, endDate, isDateBased, joinUrl, topics } = |
||||
getCalendarEventMeta(event) |
||||
const description = summary || event.content?.trim() || '' |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'rounded-lg border bg-muted/40 p-3 text-sm min-w-0', |
||||
className |
||||
)} |
||||
data-embedded-calendar-event |
||||
onClick={(e) => e.stopPropagation()} |
||||
> |
||||
<div className="flex items-start gap-2 mb-2"> |
||||
{image ? ( |
||||
<img |
||||
src={image} |
||||
alt="" |
||||
className="size-12 shrink-0 rounded object-cover" |
||||
/> |
||||
) : ( |
||||
<Calendar className="size-4 shrink-0 mt-0.5 text-muted-foreground" /> |
||||
)} |
||||
<div className="min-w-0 flex-1"> |
||||
<span className="font-medium text-foreground truncate block"> |
||||
{title || t('Scheduled video call')} |
||||
</span> |
||||
{topics.length > 0 && ( |
||||
<div className="flex flex-wrap gap-1 mt-1"> |
||||
{topics.map((topic) => ( |
||||
<span |
||||
key={topic} |
||||
className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground" |
||||
> |
||||
#{topic} |
||||
</span> |
||||
))} |
||||
</div> |
||||
)} |
||||
</div> |
||||
</div> |
||||
{isDateBased ? ( |
||||
(startDate || endDate) && ( |
||||
<div className="text-muted-foreground text-xs mb-2"> |
||||
{startDate ? formatCalendarDate(startDate) : ''} |
||||
{endDate && endDate !== startDate && ( |
||||
<> – {formatCalendarDate(endDate)}</> |
||||
)} |
||||
</div> |
||||
) |
||||
) : ( |
||||
start != null && |
||||
!isNaN(start) && ( |
||||
<div className="text-muted-foreground text-xs mb-2"> |
||||
{formatCalendarTime(start)} |
||||
{end != null && !isNaN(end) && end > start && ( |
||||
<> – {formatCalendarTime(end)}</> |
||||
)} |
||||
</div> |
||||
) |
||||
)} |
||||
{description && ( |
||||
<p className="text-muted-foreground text-xs mb-2 whitespace-pre-wrap break-words"> |
||||
{description} |
||||
</p> |
||||
)} |
||||
{joinUrl && ( |
||||
<Button |
||||
variant="secondary" |
||||
size="sm" |
||||
className="w-full gap-2 mt-1" |
||||
asChild |
||||
> |
||||
<a href={joinUrl} target="_blank" rel="noopener noreferrer"> |
||||
<Video className="size-4" /> |
||||
{t('Join video call')} |
||||
</a> |
||||
</Button> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
|
||||
import { Input } from '@/components/ui/input' |
||||
import { useSearchProfiles } from '@/hooks' |
||||
import { cn } from '@/lib/utils' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { X } from 'lucide-react' |
||||
import { useCallback, useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { SimpleUserAvatar } from '../UserAvatar' |
||||
import { SimpleUsername } from '../Username' |
||||
import Nip05 from '../Nip05' |
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300 |
||||
const SEARCH_LIMIT = 10 |
||||
|
||||
export function InviteePicker({ |
||||
value, |
||||
onChange, |
||||
placeholder, |
||||
className, |
||||
labelId, |
||||
max |
||||
}: { |
||||
value: string[] |
||||
onChange: (pubkeys: string[]) => void |
||||
placeholder?: string |
||||
className?: string |
||||
labelId?: string |
||||
/** Max number of invitees (e.g. MAX_CALENDAR_INVITEES). When reached, adding is disabled. */ |
||||
max?: number |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { pubkey: myPubkey } = useNostr() |
||||
const [search, setSearch] = useState('') |
||||
const [debouncedSearch, setDebouncedSearch] = useState('') |
||||
|
||||
useEffect(() => { |
||||
const id = setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS) |
||||
return () => clearTimeout(id) |
||||
}, [search]) |
||||
|
||||
const { profiles, isFetching } = useSearchProfiles(debouncedSearch, SEARCH_LIMIT) |
||||
const selectedSet = new Set(value) |
||||
const atLimit = max != null && value.length >= max |
||||
const filteredProfiles = profiles.filter((p) => !selectedSet.has(p.pubkey) && p.pubkey !== myPubkey) |
||||
|
||||
const addInvitee = useCallback( |
||||
(pubkey: string) => { |
||||
if (pubkey === myPubkey || selectedSet.has(pubkey)) return |
||||
if (max != null && value.length >= max) return |
||||
onChange([...value, pubkey]) |
||||
setSearch('') |
||||
}, |
||||
[value, onChange, myPubkey, selectedSet, max] |
||||
) |
||||
|
||||
const removeInvitee = useCallback( |
||||
(pubkey: string) => { |
||||
onChange(value.filter((p) => p !== pubkey)) |
||||
}, |
||||
[value, onChange] |
||||
) |
||||
|
||||
return ( |
||||
<div className={cn('space-y-2', className)}> |
||||
{value.length > 0 && ( |
||||
<div className="flex flex-wrap gap-1.5"> |
||||
{value.map((pubkey) => ( |
||||
<span |
||||
key={pubkey} |
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-sm" |
||||
> |
||||
<SimpleUserAvatar userId={pubkey} className="size-5 shrink-0" /> |
||||
<SimpleUsername userId={pubkey} className="max-w-[120px] truncate" /> |
||||
<button |
||||
type="button" |
||||
onClick={() => removeInvitee(pubkey)} |
||||
className="rounded-full p-0.5 hover:bg-muted-foreground/20" |
||||
aria-label={t('Remove')} |
||||
> |
||||
<X className="size-3.5" /> |
||||
</button> |
||||
</span> |
||||
))} |
||||
</div> |
||||
)} |
||||
<div className="relative"> |
||||
<Input |
||||
id={labelId} |
||||
type="text" |
||||
value={search} |
||||
onChange={(e) => setSearch(e.target.value)} |
||||
placeholder={placeholder ?? t('Search by name or npub…')} |
||||
className="mt-1" |
||||
autoComplete="off" |
||||
/> |
||||
{search.trim() && !atLimit && ( |
||||
<div |
||||
className={cn( |
||||
'absolute left-0 right-0 top-full z-10 mt-1 max-h-60 overflow-auto rounded-md border bg-popover shadow-md' |
||||
)} |
||||
> |
||||
{isFetching && filteredProfiles.length === 0 ? ( |
||||
<div className="p-3 text-sm text-muted-foreground">{t('Searching…')}</div> |
||||
) : filteredProfiles.length === 0 ? ( |
||||
<div className="p-3 text-sm text-muted-foreground">{t('No users found')}</div> |
||||
) : ( |
||||
<ul className="py-1"> |
||||
{filteredProfiles.map((profile) => ( |
||||
<li key={profile.pubkey}> |
||||
<button |
||||
type="button" |
||||
className="flex w-full cursor-pointer items-center gap-2 p-2 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground" |
||||
onClick={() => addInvitee(profile.pubkey)} |
||||
> |
||||
<SimpleUserAvatar userId={profile.pubkey} className="size-8 shrink-0" /> |
||||
<div className="min-w-0 flex-1"> |
||||
<SimpleUsername userId={profile.pubkey} className="font-medium truncate" /> |
||||
<Nip05 pubkey={profile.pubkey} /> |
||||
</div> |
||||
</button> |
||||
</li> |
||||
))} |
||||
</ul> |
||||
)} |
||||
</div> |
||||
)} |
||||
{atLimit && ( |
||||
<p className="text-xs text-muted-foreground"> |
||||
{t('Maximum {{max}} invitees', { max: max ?? 0 })} |
||||
</p> |
||||
)} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
import { EmbeddedCalendarEvent } from '@/components/Embedded/EmbeddedCalendarEvent' |
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' |
||||
import { TDraftEvent } from '@/types' |
||||
import { Event } from 'nostr-tools' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { cn } from '@/lib/utils' |
||||
|
||||
/** |
||||
* Converts a draft (no id/pubkey/sig) into an event-like object for preview rendering. |
||||
*/ |
||||
function draftToPreviewEvent(draft: TDraftEvent): Event { |
||||
return { |
||||
id: '', |
||||
pubkey: '', |
||||
sig: '', |
||||
kind: draft.kind, |
||||
created_at: draft.created_at, |
||||
tags: draft.tags, |
||||
content: draft.content |
||||
} |
||||
} |
||||
|
||||
export function CalendarEventPreview({ |
||||
draft, |
||||
className |
||||
}: { |
||||
draft: TDraftEvent |
||||
className?: string |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const previewEvent = draftToPreviewEvent(draft) |
||||
const jsonString = JSON.stringify( |
||||
{ kind: draft.kind, content: draft.content, tags: draft.tags, created_at: draft.created_at }, |
||||
null, |
||||
2 |
||||
) |
||||
|
||||
return ( |
||||
<div className={cn('space-y-2', className)}> |
||||
<Tabs defaultValue="rendered" className="w-full"> |
||||
<TabsList className="grid w-full grid-cols-2"> |
||||
<TabsTrigger value="rendered">{t('Rendered')}</TabsTrigger> |
||||
<TabsTrigger value="json">{t('JSON')}</TabsTrigger> |
||||
</TabsList> |
||||
<TabsContent value="rendered" className="mt-2"> |
||||
<div className="rounded-md border bg-muted/20 p-2"> |
||||
<EmbeddedCalendarEvent event={previewEvent} /> |
||||
</div> |
||||
</TabsContent> |
||||
<TabsContent value="json" className="mt-2"> |
||||
<pre className="max-h-[240px] overflow-auto rounded-md border bg-muted/20 p-3 text-xs"> |
||||
{jsonString} |
||||
</pre> |
||||
</TabsContent> |
||||
</Tabs> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,407 @@
@@ -0,0 +1,407 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogDescription, |
||||
DialogFooter, |
||||
DialogHeader, |
||||
DialogTitle |
||||
} from '@/components/ui/dialog' |
||||
import { InviteePicker } from '@/components/InviteePicker' |
||||
import { DateTimePicker } from '@/components/ui/DateTimePicker' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' |
||||
import { Textarea } from '@/components/ui/textarea' |
||||
import { |
||||
createInPersonCalendarEventDraftEvent, |
||||
createInPersonDateBasedCalendarEventDraftEvent, |
||||
createPublicMessageDraftEvent |
||||
} from '@/lib/draft-event' |
||||
import { MAX_CALENDAR_INVITEES } from '@/constants' |
||||
import { getNoteBech32Id } from '@/lib/event' |
||||
import { randomString } from '@/lib/random' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { MapPin } from 'lucide-react' |
||||
import { useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
import { CalendarEventPreview } from './CalendarEventPreview' |
||||
|
||||
function parseTopicTags(value: string): string[] { |
||||
return [ |
||||
...new Set( |
||||
value |
||||
.trim() |
||||
.split(/[\s,]+/) |
||||
.map((s) => s.replace(/^#+/, '').trim()) |
||||
.filter(Boolean) |
||||
) |
||||
] |
||||
} |
||||
|
||||
export function ScheduleInPersonMeetingDialog({ |
||||
open, |
||||
onOpenChange |
||||
}: { |
||||
open: boolean |
||||
onOpenChange: (open: boolean) => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { publish } = useNostr() |
||||
|
||||
const [eventType, setEventType] = useState<'time' | 'date'>('time') |
||||
const [title, setTitle] = useState('') |
||||
const [startDatetime, setStartDatetime] = useState('') |
||||
const [endDatetime, setEndDatetime] = useState('') |
||||
const [startDateStr, setStartDateStr] = useState('') |
||||
const [endDateStr, setEndDateStr] = useState('') |
||||
const [location, setLocation] = useState('') |
||||
const [summary, setSummary] = useState('') |
||||
const [topics, setTopics] = useState('') |
||||
const [image, setImage] = useState('') |
||||
const [inviteePubkeys, setInviteePubkeys] = useState<string[]>([]) |
||||
const [submitting, setSubmitting] = useState(false) |
||||
|
||||
const formValid = useMemo(() => { |
||||
if (inviteePubkeys.length === 0 || inviteePubkeys.length > MAX_CALENDAR_INVITEES) return false |
||||
if (eventType === 'date') { |
||||
if (!startDateStr.trim()) return false |
||||
if (endDateStr.trim() && endDateStr <= startDateStr) return false |
||||
return true |
||||
} |
||||
if (!startDatetime.trim()) return false |
||||
const startUnix = Math.floor(new Date(startDatetime).getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) return false |
||||
return true |
||||
}, [eventType, startDatetime, endDatetime, startDateStr, endDateStr, inviteePubkeys]) |
||||
|
||||
const previewDraft = useMemo(() => { |
||||
if (!formValid) return null |
||||
const d = 'preview' |
||||
if (eventType === 'date') { |
||||
if (!startDateStr.trim()) return null |
||||
if (endDateStr.trim() && endDateStr <= startDateStr) return null |
||||
return createInPersonDateBasedCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('In-person meeting'), |
||||
start: startDateStr, |
||||
end: endDateStr.trim() || undefined, |
||||
location: location.trim() || undefined, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: inviteePubkeys |
||||
}) |
||||
} |
||||
if (!startDatetime.trim()) return null |
||||
const startDate = new Date(startDatetime) |
||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) return null |
||||
return createInPersonCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('In-person meeting'), |
||||
start: startUnix, |
||||
end: endUnix, |
||||
location: location.trim() || undefined, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: inviteePubkeys |
||||
}) |
||||
}, [ |
||||
eventType, |
||||
title, |
||||
startDatetime, |
||||
endDatetime, |
||||
startDateStr, |
||||
endDateStr, |
||||
location, |
||||
summary, |
||||
topics, |
||||
image, |
||||
inviteePubkeys, |
||||
t, |
||||
formValid |
||||
]) |
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => { |
||||
e.preventDefault() |
||||
if (!formValid) return |
||||
if (inviteePubkeys.length === 0) { |
||||
toast.error(t('Add at least one invitee')) |
||||
return |
||||
} |
||||
if (inviteePubkeys.length > MAX_CALENDAR_INVITEES) { |
||||
toast.error(t('Maximum {{max}} invitees allowed', { max: MAX_CALENDAR_INVITEES })) |
||||
return |
||||
} |
||||
if (eventType === 'date') { |
||||
if (!startDateStr.trim()) { |
||||
toast.error(t('Please set a start date')) |
||||
return |
||||
} |
||||
if (endDateStr.trim() && endDateStr <= startDateStr) { |
||||
toast.error(t('End date must be after start date')) |
||||
return |
||||
} |
||||
} else { |
||||
if (!startDatetime.trim()) { |
||||
toast.error(t('Please set a start time')) |
||||
return |
||||
} |
||||
const startDate = new Date(startDatetime) |
||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) { |
||||
toast.error(t('End time must be after start time')) |
||||
return |
||||
} |
||||
} |
||||
|
||||
setSubmitting(true) |
||||
try { |
||||
const d = `jumble-inperson-${randomString(12)}` |
||||
const calendarDraft = |
||||
eventType === 'date' |
||||
? createInPersonDateBasedCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('In-person meeting'), |
||||
start: startDateStr, |
||||
end: endDateStr.trim() || undefined, |
||||
location: location.trim() || undefined, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: inviteePubkeys |
||||
}) |
||||
: createInPersonCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('In-person meeting'), |
||||
start: Math.floor(new Date(startDatetime).getTime() / 1000), |
||||
end: endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined, |
||||
location: location.trim() || undefined, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: inviteePubkeys |
||||
}) |
||||
|
||||
const calendarEvent = await publish(calendarDraft) |
||||
const naddr = getNoteBech32Id(calendarEvent) |
||||
const messageContent = `${t("You're invited to an in-person meeting.")} nostr:${naddr}` |
||||
|
||||
const pmDraft = await createPublicMessageDraftEvent( |
||||
messageContent, |
||||
inviteePubkeys, |
||||
{ addClientTag: true } |
||||
) |
||||
await publish(pmDraft) |
||||
|
||||
toast.success( |
||||
t('Meeting created and {{count}} invite(s) sent', { |
||||
count: inviteePubkeys.length |
||||
}) |
||||
) |
||||
onOpenChange(false) |
||||
setEventType('time') |
||||
setTitle('') |
||||
setStartDatetime('') |
||||
setEndDatetime('') |
||||
setStartDateStr('') |
||||
setEndDateStr('') |
||||
setLocation('') |
||||
setSummary('') |
||||
setTopics('') |
||||
setImage('') |
||||
setInviteePubkeys([]) |
||||
} catch (err) { |
||||
toast.error(err instanceof Error ? err.message : t('Failed to create meeting')) |
||||
} finally { |
||||
setSubmitting(false) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
<DialogContent className="sm:max-w-md"> |
||||
<DialogHeader> |
||||
<DialogTitle className="flex items-center gap-2"> |
||||
<MapPin className="size-5" /> |
||||
{t('Schedule in-person meeting')} |
||||
</DialogTitle> |
||||
<DialogDescription> |
||||
{t('Required: start (or start date), invitees. Optional: title, end, location, summary, topics, image.')} |
||||
</DialogDescription> |
||||
</DialogHeader> |
||||
<form onSubmit={handleSubmit} className="space-y-4"> |
||||
<div> |
||||
<Label>{t('Event type')}</Label> |
||||
<RadioGroup |
||||
value={eventType} |
||||
onValueChange={(v) => setEventType(v as 'time' | 'date')} |
||||
className="mt-2 flex gap-4" |
||||
> |
||||
<label className="flex items-center gap-2 cursor-pointer"> |
||||
<RadioGroupItem value="time" id="own-inperson-type-time" /> |
||||
<span className="text-sm">{t('Time-based')}</span> |
||||
</label> |
||||
<label className="flex items-center gap-2 cursor-pointer"> |
||||
<RadioGroupItem value="date" id="own-inperson-type-date" /> |
||||
<span className="text-sm">{t('Date-based (all-day)')}</span> |
||||
</label> |
||||
</RadioGroup> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="own-inperson-title"> |
||||
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="own-inperson-title" |
||||
value={title} |
||||
onChange={(e) => setTitle(e.target.value)} |
||||
placeholder={t('In-person meeting')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
{eventType === 'date' ? ( |
||||
<> |
||||
<div> |
||||
<Label htmlFor="own-inperson-start-date">{t('Start date')} *</Label> |
||||
<Input |
||||
id="own-inperson-start-date" |
||||
type="date" |
||||
value={startDateStr} |
||||
onChange={(e) => setStartDateStr(e.target.value)} |
||||
className="mt-1" |
||||
required={eventType === 'date'} |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="own-inperson-end-date"> |
||||
{t('End date')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="own-inperson-end-date" |
||||
type="date" |
||||
value={endDateStr} |
||||
onChange={(e) => setEndDateStr(e.target.value)} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
</> |
||||
) : ( |
||||
<> |
||||
<DateTimePicker |
||||
id="own-inperson-start" |
||||
value={startDatetime} |
||||
onChange={setStartDatetime} |
||||
label={t('Start')} |
||||
labelSuffix="*" |
||||
required={eventType === 'time'} |
||||
/> |
||||
<DateTimePicker |
||||
id="own-inperson-end" |
||||
value={endDatetime} |
||||
onChange={setEndDatetime} |
||||
label={t('End')} |
||||
labelSuffix={ |
||||
<span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
} |
||||
/> |
||||
</> |
||||
)} |
||||
<div> |
||||
<Label htmlFor="own-inperson-location"> |
||||
{t('Location')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="own-inperson-location" |
||||
value={location} |
||||
onChange={(e) => setLocation(e.target.value)} |
||||
placeholder={t('Address, venue, or place')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="own-inperson-summary"> |
||||
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Textarea |
||||
id="own-inperson-summary" |
||||
value={summary} |
||||
onChange={(e) => setSummary(e.target.value)} |
||||
placeholder={t('Brief description of the event')} |
||||
className="mt-1 min-h-[60px]" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="own-inperson-topics"> |
||||
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="own-inperson-topics" |
||||
value={topics} |
||||
onChange={(e) => setTopics(e.target.value)} |
||||
placeholder={t('e.g. meetup, conference')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="own-inperson-image"> |
||||
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="own-inperson-image" |
||||
type="url" |
||||
value={image} |
||||
onChange={(e) => setImage(e.target.value)} |
||||
placeholder={t('Optional image for the event')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="own-inperson-invitees">{t('Invitees')} *</Label> |
||||
<InviteePicker |
||||
labelId="own-inperson-invitees" |
||||
value={inviteePubkeys} |
||||
onChange={setInviteePubkeys} |
||||
placeholder={t('Search by name or npub…')} |
||||
className="mt-1" |
||||
max={MAX_CALENDAR_INVITEES} |
||||
/> |
||||
</div> |
||||
{formValid && previewDraft && ( |
||||
<div> |
||||
<Label className="mb-1 block">{t('Preview')}</Label> |
||||
<CalendarEventPreview draft={previewDraft} /> |
||||
</div> |
||||
)} |
||||
<DialogFooter> |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
onClick={() => onOpenChange(false)} |
||||
disabled={submitting} |
||||
> |
||||
{t('Cancel')} |
||||
</Button> |
||||
<Button type="submit" disabled={submitting || !formValid}> |
||||
{submitting ? t('Creating…') : t('Create and send invites')} |
||||
</Button> |
||||
</DialogFooter> |
||||
</form> |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
@ -0,0 +1,381 @@
@@ -0,0 +1,381 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogDescription, |
||||
DialogFooter, |
||||
DialogHeader, |
||||
DialogTitle |
||||
} from '@/components/ui/dialog' |
||||
import { DateTimePicker } from '@/components/ui/DateTimePicker' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' |
||||
import { Textarea } from '@/components/ui/textarea' |
||||
import { |
||||
createInPersonCalendarEventDraftEvent, |
||||
createInPersonDateBasedCalendarEventDraftEvent, |
||||
createPublicMessageDraftEvent |
||||
} from '@/lib/draft-event' |
||||
import { getNoteBech32Id } from '@/lib/event' |
||||
import { randomString } from '@/lib/random' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { MapPin } from 'lucide-react' |
||||
import { useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
import { CalendarEventPreview } from './CalendarEventPreview' |
||||
|
||||
function parseTopicTags(value: string): string[] { |
||||
return [ |
||||
...new Set( |
||||
value |
||||
.trim() |
||||
.split(/[\s,]+/) |
||||
.map((s) => s.replace(/^#+/, '').trim()) |
||||
.filter(Boolean) |
||||
) |
||||
] |
||||
} |
||||
|
||||
export function ScheduleInPersonMeetingSingleDialog({ |
||||
inviteePubkey, |
||||
open, |
||||
onOpenChange |
||||
}: { |
||||
inviteePubkey: string |
||||
open: boolean |
||||
onOpenChange: (open: boolean) => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { publish } = useNostr() |
||||
|
||||
const [eventType, setEventType] = useState<'time' | 'date'>('time') |
||||
const [title, setTitle] = useState('') |
||||
const [startDatetime, setStartDatetime] = useState('') |
||||
const [endDatetime, setEndDatetime] = useState('') |
||||
const [startDateStr, setStartDateStr] = useState('') |
||||
const [endDateStr, setEndDateStr] = useState('') |
||||
const [location, setLocation] = useState('') |
||||
const [summary, setSummary] = useState('') |
||||
const [topics, setTopics] = useState('') |
||||
const [image, setImage] = useState('') |
||||
const [submitting, setSubmitting] = useState(false) |
||||
|
||||
const formValid = useMemo(() => { |
||||
if (eventType === 'date') { |
||||
if (!startDateStr.trim()) return false |
||||
if (endDateStr.trim() && endDateStr <= startDateStr) return false |
||||
return true |
||||
} |
||||
if (!startDatetime.trim()) return false |
||||
const startUnix = Math.floor(new Date(startDatetime).getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) return false |
||||
return true |
||||
}, [eventType, startDatetime, endDatetime, startDateStr, endDateStr]) |
||||
|
||||
const previewDraft = useMemo(() => { |
||||
if (!formValid) return null |
||||
const d = 'preview' |
||||
if (eventType === 'date') { |
||||
if (!startDateStr.trim()) return null |
||||
if (endDateStr.trim() && endDateStr <= startDateStr) return null |
||||
return createInPersonDateBasedCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('In-person meeting'), |
||||
start: startDateStr, |
||||
end: endDateStr.trim() || undefined, |
||||
location: location.trim() || undefined, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: [inviteePubkey] |
||||
}) |
||||
} |
||||
if (!startDatetime.trim()) return null |
||||
const startDate = new Date(startDatetime) |
||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) return null |
||||
return createInPersonCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('In-person meeting'), |
||||
start: startUnix, |
||||
end: endUnix, |
||||
location: location.trim() || undefined, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: [inviteePubkey] |
||||
}) |
||||
}, [ |
||||
eventType, |
||||
title, |
||||
startDatetime, |
||||
endDatetime, |
||||
startDateStr, |
||||
endDateStr, |
||||
location, |
||||
summary, |
||||
topics, |
||||
image, |
||||
inviteePubkey, |
||||
t, |
||||
formValid |
||||
]) |
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => { |
||||
e.preventDefault() |
||||
if (!formValid) return |
||||
if (eventType === 'date') { |
||||
if (!startDateStr.trim()) { |
||||
toast.error(t('Please set a start date')) |
||||
return |
||||
} |
||||
if (endDateStr.trim() && endDateStr <= startDateStr) { |
||||
toast.error(t('End date must be after start date')) |
||||
return |
||||
} |
||||
} else { |
||||
if (!startDatetime.trim()) { |
||||
toast.error(t('Please set a start time')) |
||||
return |
||||
} |
||||
const startDate = new Date(startDatetime) |
||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) { |
||||
toast.error(t('End time must be after start time')) |
||||
return |
||||
} |
||||
} |
||||
|
||||
setSubmitting(true) |
||||
try { |
||||
const d = `jumble-inperson-${randomString(12)}` |
||||
const calendarDraft = |
||||
eventType === 'date' |
||||
? createInPersonDateBasedCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('In-person meeting'), |
||||
start: startDateStr, |
||||
end: endDateStr.trim() || undefined, |
||||
location: location.trim() || undefined, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: [inviteePubkey] |
||||
}) |
||||
: createInPersonCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('In-person meeting'), |
||||
start: Math.floor(new Date(startDatetime).getTime() / 1000), |
||||
end: endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined, |
||||
location: location.trim() || undefined, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: [inviteePubkey] |
||||
}) |
||||
|
||||
const calendarEvent = await publish(calendarDraft) |
||||
const naddr = getNoteBech32Id(calendarEvent) |
||||
const messageContent = `${t("You're invited to an in-person meeting.")} nostr:${naddr}` |
||||
|
||||
const pmDraft = await createPublicMessageDraftEvent( |
||||
messageContent, |
||||
[inviteePubkey], |
||||
{ addClientTag: true } |
||||
) |
||||
await publish(pmDraft) |
||||
|
||||
toast.success(t('Meeting created and invite sent')) |
||||
onOpenChange(false) |
||||
setEventType('time') |
||||
setTitle('') |
||||
setStartDatetime('') |
||||
setEndDatetime('') |
||||
setStartDateStr('') |
||||
setEndDateStr('') |
||||
setLocation('') |
||||
setSummary('') |
||||
setTopics('') |
||||
setImage('') |
||||
} catch (err) { |
||||
toast.error(err instanceof Error ? err.message : t('Failed to create meeting')) |
||||
} finally { |
||||
setSubmitting(false) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
<DialogContent className="sm:max-w-md"> |
||||
<DialogHeader> |
||||
<DialogTitle className="flex items-center gap-2"> |
||||
<MapPin className="size-5" /> |
||||
{t('Schedule in-person meeting')} |
||||
</DialogTitle> |
||||
<DialogDescription> |
||||
{t('Required: start time or start date. Optional: title, end, location, summary, topics, image.')} |
||||
</DialogDescription> |
||||
</DialogHeader> |
||||
<form onSubmit={handleSubmit} className="space-y-4"> |
||||
<div> |
||||
<Label>{t('Event type')}</Label> |
||||
<RadioGroup |
||||
value={eventType} |
||||
onValueChange={(v) => setEventType(v as 'time' | 'date')} |
||||
className="mt-2 flex gap-4" |
||||
> |
||||
<label className="flex items-center gap-2 cursor-pointer"> |
||||
<RadioGroupItem value="time" id="inperson-type-time" /> |
||||
<span className="text-sm">{t('Time-based')}</span> |
||||
</label> |
||||
<label className="flex items-center gap-2 cursor-pointer"> |
||||
<RadioGroupItem value="date" id="inperson-type-date" /> |
||||
<span className="text-sm">{t('Date-based (all-day)')}</span> |
||||
</label> |
||||
</RadioGroup> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="inperson-title"> |
||||
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="inperson-title" |
||||
value={title} |
||||
onChange={(e) => setTitle(e.target.value)} |
||||
placeholder={t('In-person meeting')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
{eventType === 'date' ? ( |
||||
<> |
||||
<div> |
||||
<Label htmlFor="inperson-start-date">{t('Start date')} *</Label> |
||||
<Input |
||||
id="inperson-start-date" |
||||
type="date" |
||||
value={startDateStr} |
||||
onChange={(e) => setStartDateStr(e.target.value)} |
||||
className="mt-1" |
||||
required={eventType === 'date'} |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="inperson-end-date"> |
||||
{t('End date')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="inperson-end-date" |
||||
type="date" |
||||
value={endDateStr} |
||||
onChange={(e) => setEndDateStr(e.target.value)} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
</> |
||||
) : ( |
||||
<> |
||||
<DateTimePicker |
||||
id="inperson-start" |
||||
value={startDatetime} |
||||
onChange={setStartDatetime} |
||||
label={t('Start')} |
||||
labelSuffix="*" |
||||
required={eventType === 'time'} |
||||
/> |
||||
<DateTimePicker |
||||
id="inperson-end" |
||||
value={endDatetime} |
||||
onChange={setEndDatetime} |
||||
label={t('End')} |
||||
labelSuffix={ |
||||
<span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
} |
||||
/> |
||||
</> |
||||
)} |
||||
<div> |
||||
<Label htmlFor="inperson-location"> |
||||
{t('Location')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="inperson-location" |
||||
value={location} |
||||
onChange={(e) => setLocation(e.target.value)} |
||||
placeholder={t('Address, venue, or place')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="inperson-summary"> |
||||
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Textarea |
||||
id="inperson-summary" |
||||
value={summary} |
||||
onChange={(e) => setSummary(e.target.value)} |
||||
placeholder={t('Brief description of the event')} |
||||
className="mt-1 min-h-[60px]" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="inperson-topics"> |
||||
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="inperson-topics" |
||||
value={topics} |
||||
onChange={(e) => setTopics(e.target.value)} |
||||
placeholder={t('e.g. meetup, conference')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="inperson-image"> |
||||
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="inperson-image" |
||||
type="url" |
||||
value={image} |
||||
onChange={(e) => setImage(e.target.value)} |
||||
placeholder={t('Optional image for the event')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
{formValid && previewDraft && ( |
||||
<div> |
||||
<Label className="mb-1 block">{t('Preview')}</Label> |
||||
<CalendarEventPreview draft={previewDraft} /> |
||||
</div> |
||||
)} |
||||
<DialogFooter> |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
onClick={() => onOpenChange(false)} |
||||
disabled={submitting} |
||||
> |
||||
{t('Cancel')} |
||||
</Button> |
||||
<Button type="submit" disabled={submitting || !formValid}> |
||||
{submitting ? t('Creating…') : t('Create and send invite')} |
||||
</Button> |
||||
</DialogFooter> |
||||
</form> |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
@ -0,0 +1,318 @@
@@ -0,0 +1,318 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogDescription, |
||||
DialogFooter, |
||||
DialogHeader, |
||||
DialogTitle |
||||
} from '@/components/ui/dialog' |
||||
import { InviteePicker } from '@/components/InviteePicker' |
||||
import { DateTimePicker } from '@/components/ui/DateTimePicker' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { Textarea } from '@/components/ui/textarea' |
||||
import { |
||||
createCalendarEventDraftEvent, |
||||
createPublicMessageDraftEvent |
||||
} from '@/lib/draft-event' |
||||
import { MAX_CALENDAR_INVITEES } from '@/constants' |
||||
import { getNoteBech32Id } from '@/lib/event' |
||||
import { buildHiveTalkJoinUrl, roomIdForScheduledCall } from '@/lib/hivetalk' |
||||
import { randomString } from '@/lib/random' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { Calendar } from 'lucide-react' |
||||
import { useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
import { CalendarEventPreview } from './CalendarEventPreview' |
||||
|
||||
function parseTopicTags(value: string): string[] { |
||||
return [ |
||||
...new Set( |
||||
value |
||||
.trim() |
||||
.split(/[\s,]+/) |
||||
.map((s) => s.replace(/^#+/, '').trim()) |
||||
.filter(Boolean) |
||||
) |
||||
] |
||||
} |
||||
|
||||
export function ScheduleVideoCallDialog({ |
||||
open, |
||||
onOpenChange |
||||
}: { |
||||
open: boolean |
||||
onOpenChange: (open: boolean) => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { publish } = useNostr() |
||||
|
||||
const [title, setTitle] = useState('') |
||||
const [startDatetime, setStartDatetime] = useState('') |
||||
const [endDatetime, setEndDatetime] = useState('') |
||||
const [locationUrl, setLocationUrl] = useState('') |
||||
const [summary, setSummary] = useState('') |
||||
const [topics, setTopics] = useState('') |
||||
const [image, setImage] = useState('') |
||||
const [inviteePubkeys, setInviteePubkeys] = useState<string[]>([]) |
||||
const [submitting, setSubmitting] = useState(false) |
||||
|
||||
const formValid = useMemo(() => { |
||||
if (!startDatetime.trim()) return false |
||||
const startDate = new Date(startDatetime) |
||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) return false |
||||
if (inviteePubkeys.length === 0 || inviteePubkeys.length > MAX_CALENDAR_INVITEES) return false |
||||
return true |
||||
}, [startDatetime, endDatetime, inviteePubkeys]) |
||||
|
||||
const previewDraft = useMemo(() => { |
||||
if (!formValid) return null |
||||
if (!startDatetime.trim()) return null |
||||
const startDate = new Date(startDatetime) |
||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) return null |
||||
const d = 'preview' |
||||
const roomId = roomIdForScheduledCall(d) |
||||
const defaultJoinUrl = buildHiveTalkJoinUrl({ room: roomId, name: 'Guest' }) |
||||
const joinUrl = locationUrl.trim() || defaultJoinUrl |
||||
return createCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('Video call'), |
||||
start: startUnix, |
||||
end: endUnix, |
||||
locationUrl: joinUrl, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: inviteePubkeys |
||||
}) |
||||
}, [ |
||||
title, |
||||
startDatetime, |
||||
endDatetime, |
||||
locationUrl, |
||||
summary, |
||||
topics, |
||||
image, |
||||
inviteePubkeys, |
||||
t, |
||||
formValid |
||||
]) |
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => { |
||||
e.preventDefault() |
||||
if (!formValid) return |
||||
if (!startDatetime.trim()) { |
||||
toast.error(t('Please set a start time')) |
||||
return |
||||
} |
||||
if (inviteePubkeys.length === 0) { |
||||
toast.error(t('Add at least one invitee')) |
||||
return |
||||
} |
||||
if (inviteePubkeys.length > MAX_CALENDAR_INVITEES) { |
||||
toast.error(t('Maximum {{max}} invitees allowed', { max: MAX_CALENDAR_INVITEES })) |
||||
return |
||||
} |
||||
|
||||
setSubmitting(true) |
||||
try { |
||||
const startDate = new Date(startDatetime) |
||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) { |
||||
toast.error(t('End time must be after start time')) |
||||
setSubmitting(false) |
||||
return |
||||
} |
||||
|
||||
const d = `jumble-cal-${randomString(12)}` |
||||
const roomId = roomIdForScheduledCall(d) |
||||
const defaultJoinUrl = buildHiveTalkJoinUrl({ |
||||
room: roomId, |
||||
name: 'Guest' |
||||
}) |
||||
const joinUrl = locationUrl.trim() || defaultJoinUrl |
||||
|
||||
const calendarDraft = createCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('Video call'), |
||||
start: startUnix, |
||||
end: endUnix, |
||||
locationUrl: joinUrl, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: inviteePubkeys |
||||
}) |
||||
|
||||
const calendarEvent = await publish(calendarDraft) |
||||
const naddr = getNoteBech32Id(calendarEvent) |
||||
const messageContent = `${t("You're invited to a scheduled video call.")} nostr:${naddr}` |
||||
|
||||
const pmDraft = await createPublicMessageDraftEvent( |
||||
messageContent, |
||||
inviteePubkeys, |
||||
{ addClientTag: true } |
||||
) |
||||
await publish(pmDraft) |
||||
|
||||
toast.success( |
||||
t('Scheduled call created and {{count}} invite(s) sent', { |
||||
count: inviteePubkeys.length |
||||
}) |
||||
) |
||||
onOpenChange(false) |
||||
setTitle('') |
||||
setStartDatetime('') |
||||
setEndDatetime('') |
||||
setLocationUrl('') |
||||
setSummary('') |
||||
setTopics('') |
||||
setImage('') |
||||
setInviteePubkeys([]) |
||||
} catch (err) { |
||||
toast.error(err instanceof Error ? err.message : t('Failed to schedule call')) |
||||
} finally { |
||||
setSubmitting(false) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
<DialogContent className="sm:max-w-md"> |
||||
<DialogHeader> |
||||
<DialogTitle className="flex items-center gap-2"> |
||||
<Calendar className="size-5" /> |
||||
{t('Schedule a video call')} |
||||
</DialogTitle> |
||||
<DialogDescription> |
||||
{t('Required: start time, invitees. Join link defaults to HiveTalk. Optional: title, end, summary, topics, image.')} |
||||
</DialogDescription> |
||||
</DialogHeader> |
||||
<form onSubmit={handleSubmit} className="space-y-4"> |
||||
<div> |
||||
<Label htmlFor="own-call-title"> |
||||
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="own-call-title" |
||||
value={title} |
||||
onChange={(e) => setTitle(e.target.value)} |
||||
placeholder={t('Video call')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<DateTimePicker |
||||
id="own-call-start" |
||||
value={startDatetime} |
||||
onChange={setStartDatetime} |
||||
label={t('Start')} |
||||
labelSuffix="*" |
||||
required |
||||
/> |
||||
<DateTimePicker |
||||
id="own-call-end" |
||||
value={endDatetime} |
||||
onChange={setEndDatetime} |
||||
label={t('End')} |
||||
labelSuffix={ |
||||
<span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
} |
||||
/> |
||||
<div> |
||||
<Label htmlFor="own-call-location"> |
||||
{t('Join link')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="own-call-location" |
||||
type="url" |
||||
value={locationUrl} |
||||
onChange={(e) => setLocationUrl(e.target.value)} |
||||
placeholder={t('Leave empty for HiveTalk, or paste Zoom / Teams / other link')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="own-call-summary"> |
||||
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Textarea |
||||
id="own-call-summary" |
||||
value={summary} |
||||
onChange={(e) => setSummary(e.target.value)} |
||||
placeholder={t('Brief description of the event')} |
||||
className="mt-1 min-h-[60px]" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="own-call-topics"> |
||||
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="own-call-topics" |
||||
value={topics} |
||||
onChange={(e) => setTopics(e.target.value)} |
||||
placeholder={t('e.g. meetup, conference')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="own-call-image"> |
||||
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="own-call-image" |
||||
type="url" |
||||
value={image} |
||||
onChange={(e) => setImage(e.target.value)} |
||||
placeholder={t('Optional image for the event')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="own-call-invitees">{t('Invitees')} *</Label> |
||||
<InviteePicker |
||||
labelId="own-call-invitees" |
||||
value={inviteePubkeys} |
||||
onChange={setInviteePubkeys} |
||||
placeholder={t('Search by name or npub…')} |
||||
className="mt-1" |
||||
max={MAX_CALENDAR_INVITEES} |
||||
/> |
||||
</div> |
||||
{formValid && previewDraft && ( |
||||
<div> |
||||
<Label className="mb-1 block">{t('Preview')}</Label> |
||||
<CalendarEventPreview draft={previewDraft} /> |
||||
</div> |
||||
)} |
||||
<DialogFooter> |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
onClick={() => onOpenChange(false)} |
||||
disabled={submitting} |
||||
> |
||||
{t('Cancel')} |
||||
</Button> |
||||
<Button type="submit" disabled={submitting || !formValid}> |
||||
{submitting ? t('Scheduling…') : t('Schedule and send invites')} |
||||
</Button> |
||||
</DialogFooter> |
||||
</form> |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
@ -0,0 +1,292 @@
@@ -0,0 +1,292 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogDescription, |
||||
DialogFooter, |
||||
DialogHeader, |
||||
DialogTitle |
||||
} from '@/components/ui/dialog' |
||||
import { DateTimePicker } from '@/components/ui/DateTimePicker' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { Textarea } from '@/components/ui/textarea' |
||||
import { |
||||
createCalendarEventDraftEvent, |
||||
createPublicMessageDraftEvent |
||||
} from '@/lib/draft-event' |
||||
import { getNoteBech32Id } from '@/lib/event' |
||||
import { buildHiveTalkJoinUrl, roomIdForScheduledCall } from '@/lib/hivetalk' |
||||
import { randomString } from '@/lib/random' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { Calendar } from 'lucide-react' |
||||
import { useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
import { CalendarEventPreview } from './CalendarEventPreview' |
||||
|
||||
function parseTopicTags(value: string): string[] { |
||||
return [ |
||||
...new Set( |
||||
value |
||||
.trim() |
||||
.split(/[\s,]+/) |
||||
.map((s) => s.replace(/^#+/, '').trim()) |
||||
.filter(Boolean) |
||||
) |
||||
] |
||||
} |
||||
|
||||
export function ScheduleVideoCallSingleDialog({ |
||||
inviteePubkey, |
||||
open, |
||||
onOpenChange |
||||
}: { |
||||
inviteePubkey: string |
||||
open: boolean |
||||
onOpenChange: (open: boolean) => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { publish } = useNostr() |
||||
const joinAsName = 'Guest' |
||||
|
||||
const [title, setTitle] = useState('') |
||||
const [startDatetime, setStartDatetime] = useState('') |
||||
const [endDatetime, setEndDatetime] = useState('') |
||||
const [locationUrl, setLocationUrl] = useState('') |
||||
const [summary, setSummary] = useState('') |
||||
const [topics, setTopics] = useState('') |
||||
const [image, setImage] = useState('') |
||||
const [submitting, setSubmitting] = useState(false) |
||||
|
||||
const formValid = useMemo(() => { |
||||
if (!startDatetime.trim()) return false |
||||
const startUnix = Math.floor(new Date(startDatetime).getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) return false |
||||
return true |
||||
}, [startDatetime, endDatetime]) |
||||
|
||||
const previewDraft = useMemo(() => { |
||||
if (!formValid) return null |
||||
if (!startDatetime.trim()) return null |
||||
const startDate = new Date(startDatetime) |
||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) return null |
||||
const d = 'preview' |
||||
const roomId = roomIdForScheduledCall(d) |
||||
const defaultJoinUrl = buildHiveTalkJoinUrl({ room: roomId, name: joinAsName }) |
||||
const joinUrl = locationUrl.trim() || defaultJoinUrl |
||||
return createCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('Video call'), |
||||
start: startUnix, |
||||
end: endUnix, |
||||
locationUrl: joinUrl, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: [inviteePubkey] |
||||
}) |
||||
}, [ |
||||
title, |
||||
startDatetime, |
||||
endDatetime, |
||||
locationUrl, |
||||
summary, |
||||
topics, |
||||
image, |
||||
inviteePubkey, |
||||
joinAsName, |
||||
t, |
||||
formValid |
||||
]) |
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => { |
||||
e.preventDefault() |
||||
if (!formValid) return |
||||
if (!startDatetime.trim()) { |
||||
toast.error(t('Please set a start time')) |
||||
return |
||||
} |
||||
setSubmitting(true) |
||||
try { |
||||
const startDate = new Date(startDatetime) |
||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
||||
const endUnix = endDatetime.trim() |
||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
||||
: undefined |
||||
if (endUnix != null && endUnix <= startUnix) { |
||||
toast.error(t('End time must be after start time')) |
||||
setSubmitting(false) |
||||
return |
||||
} |
||||
|
||||
const d = `jumble-cal-${randomString(12)}` |
||||
const roomId = roomIdForScheduledCall(d) |
||||
const defaultJoinUrl = buildHiveTalkJoinUrl({ |
||||
room: roomId, |
||||
name: joinAsName |
||||
}) |
||||
const joinUrl = locationUrl.trim() || defaultJoinUrl |
||||
|
||||
const calendarDraft = createCalendarEventDraftEvent({ |
||||
d, |
||||
title: title.trim() || t('Video call'), |
||||
start: startUnix, |
||||
end: endUnix, |
||||
locationUrl: joinUrl, |
||||
summary: summary.trim() || undefined, |
||||
image: image.trim() || undefined, |
||||
topics: parseTopicTags(topics), |
||||
participants: [inviteePubkey] |
||||
}) |
||||
|
||||
const calendarEvent = await publish(calendarDraft) |
||||
const naddr = getNoteBech32Id(calendarEvent) |
||||
const messageContent = `${t("You're invited to a scheduled video call.")} nostr:${naddr}` |
||||
|
||||
const pmDraft = await createPublicMessageDraftEvent( |
||||
messageContent, |
||||
[inviteePubkey], |
||||
{ addClientTag: true } |
||||
) |
||||
await publish(pmDraft) |
||||
|
||||
toast.success(t('Scheduled call created and invite sent')) |
||||
onOpenChange(false) |
||||
setTitle('') |
||||
setStartDatetime('') |
||||
setEndDatetime('') |
||||
setLocationUrl('') |
||||
setSummary('') |
||||
setTopics('') |
||||
setImage('') |
||||
} catch (err) { |
||||
toast.error(err instanceof Error ? err.message : t('Failed to schedule call')) |
||||
} finally { |
||||
setSubmitting(false) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
<DialogContent className="sm:max-w-md"> |
||||
<DialogHeader> |
||||
<DialogTitle className="flex items-center gap-2"> |
||||
<Calendar className="size-5" /> |
||||
{t('Schedule video call')} |
||||
</DialogTitle> |
||||
<DialogDescription> |
||||
{t('Required: start time. Join link defaults to HiveTalk. Optional: title, end, summary, topics, image.')} |
||||
</DialogDescription> |
||||
</DialogHeader> |
||||
<form onSubmit={handleSubmit} className="space-y-4"> |
||||
<div> |
||||
<Label htmlFor="schedule-call-title"> |
||||
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="schedule-call-title" |
||||
value={title} |
||||
onChange={(e) => setTitle(e.target.value)} |
||||
placeholder={t('Video call')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<DateTimePicker |
||||
id="schedule-call-start" |
||||
value={startDatetime} |
||||
onChange={setStartDatetime} |
||||
label={t('Start')} |
||||
labelSuffix="*" |
||||
required |
||||
/> |
||||
<DateTimePicker |
||||
id="schedule-call-end" |
||||
value={endDatetime} |
||||
onChange={setEndDatetime} |
||||
label={t('End')} |
||||
labelSuffix={ |
||||
<span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
} |
||||
/> |
||||
<div> |
||||
<Label htmlFor="schedule-call-location"> |
||||
{t('Join link')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="schedule-call-location" |
||||
type="url" |
||||
value={locationUrl} |
||||
onChange={(e) => setLocationUrl(e.target.value)} |
||||
placeholder={t('Leave empty for HiveTalk, or paste Zoom / Teams / other link')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="schedule-call-summary"> |
||||
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Textarea |
||||
id="schedule-call-summary" |
||||
value={summary} |
||||
onChange={(e) => setSummary(e.target.value)} |
||||
placeholder={t('Brief description of the event')} |
||||
className="mt-1 min-h-[60px]" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="schedule-call-topics"> |
||||
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="schedule-call-topics" |
||||
value={topics} |
||||
onChange={(e) => setTopics(e.target.value)} |
||||
placeholder={t('e.g. meetup, conference')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
<div> |
||||
<Label htmlFor="schedule-call-image"> |
||||
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||
</Label> |
||||
<Input |
||||
id="schedule-call-image" |
||||
type="url" |
||||
value={image} |
||||
onChange={(e) => setImage(e.target.value)} |
||||
placeholder={t('Optional image for the event')} |
||||
className="mt-1" |
||||
/> |
||||
</div> |
||||
{formValid && previewDraft && ( |
||||
<div> |
||||
<Label className="mb-1 block">{t('Preview')}</Label> |
||||
<CalendarEventPreview draft={previewDraft} /> |
||||
</div> |
||||
)} |
||||
<DialogFooter> |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
onClick={() => onOpenChange(false)} |
||||
disabled={submitting} |
||||
> |
||||
{t('Cancel')} |
||||
</Button> |
||||
<Button type="submit" disabled={submitting || !formValid}> |
||||
{submitting ? t('Scheduling…') : t('Schedule and send invite')} |
||||
</Button> |
||||
</DialogFooter> |
||||
</form> |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
export { ScheduleVideoCallDialog } from './ScheduleVideoCallDialog' |
||||
export { ScheduleVideoCallSingleDialog } from './ScheduleVideoCallSingleDialog' |
||||
export { ScheduleInPersonMeetingDialog } from './ScheduleInPersonMeetingDialog' |
||||
export { ScheduleInPersonMeetingSingleDialog } from './ScheduleInPersonMeetingSingleDialog' |
||||
@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
|
||||
import * as React from 'react' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { TimePicker } from '@/components/ui/TimePicker' |
||||
import { cn } from '@/lib/utils' |
||||
|
||||
/** Value is "YYYY-MM-DDTHH:mm" (same as datetime-local). */ |
||||
export interface DateTimePickerProps { |
||||
value: string |
||||
onChange: (value: string) => void |
||||
id?: string |
||||
label?: React.ReactNode |
||||
/** Start * or (optional) etc. */ |
||||
labelSuffix?: React.ReactNode |
||||
required?: boolean |
||||
className?: string |
||||
disabled?: boolean |
||||
} |
||||
|
||||
function datePart(dt: string): string { |
||||
if (!dt || dt.length < 10) return '' |
||||
return dt.slice(0, 10) |
||||
} |
||||
|
||||
function timePart(dt: string): string { |
||||
if (!dt || dt.length < 16) return '09:00' |
||||
return dt.slice(11, 16) |
||||
} |
||||
|
||||
export function DateTimePicker({ |
||||
value, |
||||
onChange, |
||||
id, |
||||
label, |
||||
labelSuffix, |
||||
required, |
||||
className, |
||||
disabled |
||||
}: DateTimePickerProps) { |
||||
const date = datePart(value) |
||||
const time = timePart(value) |
||||
|
||||
const handleDateChange = React.useCallback( |
||||
(e: React.ChangeEvent<HTMLInputElement>) => { |
||||
const d = e.target.value |
||||
onChange(d ? `${d}T${time}` : '') |
||||
}, |
||||
[time, onChange] |
||||
) |
||||
|
||||
const handleTimeChange = React.useCallback( |
||||
(t: string) => { |
||||
if (!date) { |
||||
const today = new Date().toISOString().slice(0, 10) |
||||
onChange(`${today}T${t}`) |
||||
} else { |
||||
onChange(`${date}T${t}`) |
||||
} |
||||
}, |
||||
[date, onChange] |
||||
) |
||||
|
||||
return ( |
||||
<div className={cn('space-y-2', className)}> |
||||
{label != null && ( |
||||
<Label htmlFor={id}> |
||||
{label} {labelSuffix} |
||||
</Label> |
||||
)} |
||||
<div className="flex flex-wrap items-end gap-3"> |
||||
<div className="flex-1 min-w-[140px]"> |
||||
<Input |
||||
id={id} |
||||
type="date" |
||||
value={date} |
||||
onChange={handleDateChange} |
||||
required={required} |
||||
disabled={disabled} |
||||
className="h-9" |
||||
/> |
||||
</div> |
||||
<TimePicker |
||||
value={time} |
||||
onChange={handleTimeChange} |
||||
disabled={disabled} |
||||
id={id ? `${id}-time` : undefined} |
||||
/> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
|
||||
import * as React from 'react' |
||||
import { |
||||
Select, |
||||
SelectContent, |
||||
SelectItem, |
||||
SelectTrigger, |
||||
SelectValue |
||||
} from '@/components/ui/select' |
||||
import { cn } from '@/lib/utils' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
/** Value is always 24-hour "HH:mm" */ |
||||
export interface TimePickerProps { |
||||
value: string |
||||
onChange: (value: string) => void |
||||
/** When true, show 12-hour with AM/PM; when false, show 24-hour. Default from locale (en-US -> 12h). */ |
||||
hour12?: boolean |
||||
onHour12Change?: (hour12: boolean) => void |
||||
className?: string |
||||
id?: string |
||||
disabled?: boolean |
||||
} |
||||
|
||||
function parseHHmm(value: string): { hour: number; minute: number } { |
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(value) |
||||
if (!match) return { hour: 0, minute: 0 } |
||||
const hour = Math.min(23, Math.max(0, parseInt(match[1]!, 10))) |
||||
const minute = Math.min(59, Math.max(0, parseInt(match[2]!, 10))) |
||||
return { hour, minute } |
||||
} |
||||
|
||||
function toHHmm(hour: number, minute: number): string { |
||||
const h = Math.min(23, Math.max(0, hour)) |
||||
const m = Math.min(59, Math.max(0, minute)) |
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` |
||||
} |
||||
|
||||
/** 24h hour (0-23) to 12h display: { displayHour 1-12, pm: boolean } */ |
||||
function to12h(hour24: number): { displayHour: number; pm: boolean } { |
||||
if (hour24 === 0) return { displayHour: 12, pm: false } |
||||
if (hour24 < 12) return { displayHour: hour24, pm: false } |
||||
if (hour24 === 12) return { displayHour: 12, pm: true } |
||||
return { displayHour: hour24 - 12, pm: true } |
||||
} |
||||
|
||||
/** 12h + AM/PM to 24h hour (0-23) */ |
||||
function to24h(displayHour: number, pm: boolean): number { |
||||
if (pm) return displayHour === 12 ? 12 : displayHour + 12 |
||||
return displayHour === 12 ? 0 : displayHour |
||||
} |
||||
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => i) |
||||
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i) |
||||
const HOURS_12 = Array.from({ length: 12 }, (_, i) => i + 1) |
||||
|
||||
/** Default: use 12h for en-US, 24h otherwise */ |
||||
function defaultHour12(): boolean { |
||||
try { |
||||
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en-US' |
||||
return lang.startsWith('en-US') |
||||
} catch { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
export function TimePicker({ |
||||
value, |
||||
onChange, |
||||
hour12: controlledHour12, |
||||
onHour12Change, |
||||
className, |
||||
id, |
||||
disabled |
||||
}: TimePickerProps) { |
||||
const { t } = useTranslation() |
||||
const [internalHour12, setInternalHour12] = React.useState(defaultHour12) |
||||
const hour12 = controlledHour12 ?? internalHour12 |
||||
const setHour12 = React.useCallback( |
||||
(v: boolean) => { |
||||
if (onHour12Change) onHour12Change(v) |
||||
else setInternalHour12(v) |
||||
}, |
||||
[onHour12Change] |
||||
) |
||||
|
||||
const { hour: hour24, minute } = parseHHmm(value) |
||||
const { displayHour: hour12Val, pm } = to12h(hour24) |
||||
|
||||
const handleMinuteChange = React.useCallback( |
||||
(m: number) => { |
||||
onChange(toHHmm(hour24, m)) |
||||
}, |
||||
[hour24, onChange] |
||||
) |
||||
|
||||
const handleHourChange = React.useCallback( |
||||
(newHour: number) => { |
||||
if (hour12) { |
||||
const new24 = to24h(newHour, pm) |
||||
onChange(toHHmm(new24, minute)) |
||||
} else { |
||||
onChange(toHHmm(newHour, minute)) |
||||
} |
||||
}, |
||||
[hour12, minute, pm, onChange] |
||||
) |
||||
|
||||
const handleAmPmChange = React.useCallback( |
||||
(newPm: boolean) => { |
||||
const new24 = to24h(hour12Val, newPm) |
||||
onChange(toHHmm(new24, minute)) |
||||
}, |
||||
[hour12Val, minute, onChange] |
||||
) |
||||
|
||||
return ( |
||||
<div className={cn('flex flex-wrap items-center gap-2', className)}> |
||||
<div className="flex items-center gap-1"> |
||||
<Select |
||||
value={hour12 ? String(hour12Val) : String(hour24)} |
||||
onValueChange={(v) => handleHourChange(parseInt(v, 10))} |
||||
disabled={disabled} |
||||
> |
||||
<SelectTrigger id={id} className="w-[72px]"> |
||||
<SelectValue /> |
||||
</SelectTrigger> |
||||
<SelectContent> |
||||
{(hour12 ? HOURS_12 : HOURS_24).map((h) => ( |
||||
<SelectItem key={h} value={String(h)}> |
||||
{hour12 ? h : String(h).padStart(2, '0')} |
||||
</SelectItem> |
||||
))} |
||||
</SelectContent> |
||||
</Select> |
||||
<span className="text-muted-foreground">:</span> |
||||
<Select |
||||
value={String(minute)} |
||||
onValueChange={(v) => handleMinuteChange(parseInt(v, 10))} |
||||
disabled={disabled} |
||||
> |
||||
<SelectTrigger className="w-[72px]"> |
||||
<SelectValue /> |
||||
</SelectTrigger> |
||||
<SelectContent> |
||||
{MINUTES.map((m) => ( |
||||
<SelectItem key={m} value={String(m)}> |
||||
{String(m).padStart(2, '0')} |
||||
</SelectItem> |
||||
))} |
||||
</SelectContent> |
||||
</Select> |
||||
</div> |
||||
{hour12 && ( |
||||
<Select value={pm ? 'pm' : 'am'} onValueChange={(v) => handleAmPmChange(v === 'pm')} disabled={disabled}> |
||||
<SelectTrigger className="w-[72px]"> |
||||
<SelectValue /> |
||||
</SelectTrigger> |
||||
<SelectContent> |
||||
<SelectItem value="am">{t('AM')}</SelectItem> |
||||
<SelectItem value="pm">{t('PM')}</SelectItem> |
||||
</SelectContent> |
||||
</Select> |
||||
)} |
||||
<button |
||||
type="button" |
||||
onClick={() => setHour12(!hour12)} |
||||
className="text-xs text-muted-foreground hover:text-foreground underline" |
||||
disabled={disabled} |
||||
> |
||||
{hour12 ? t('24-hour') : t('12-hour (AM/PM)')} |
||||
</button> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { getReplaceableCoordinateFromEvent } from '@/lib/event' |
||||
import { isCalendarEventKind } from '@/lib/calendar-event' |
||||
import client from '@/services/client.service' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { Event } from 'nostr-tools' |
||||
import { useEffect, useState } from 'react' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import { FAST_READ_RELAY_URLS } from '@/constants' |
||||
import { tagNameEquals } from '@/lib/tag' |
||||
|
||||
function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | undefined { |
||||
const status = rsvp.tags.find(tagNameEquals('status'))?.[1] |
||||
if (status === 'accepted' || status === 'tentative' || status === 'declined') return status |
||||
return undefined |
||||
} |
||||
|
||||
export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { |
||||
const { relayList } = useNostr() |
||||
const [rsvps, setRsvps] = useState<Event[]>([]) |
||||
const [isFetching, setIsFetching] = useState(false) |
||||
|
||||
useEffect(() => { |
||||
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) { |
||||
setRsvps([]) |
||||
return |
||||
} |
||||
|
||||
let cancelled = false |
||||
setIsFetching(true) |
||||
|
||||
const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) |
||||
const userRead = relayList?.read ?? [] |
||||
const relayUrls = Array.from( |
||||
new Set([ |
||||
...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url), |
||||
...userRead.map((url) => normalizeUrl(url) || url) |
||||
]) |
||||
).filter(Boolean) as string[] |
||||
|
||||
client |
||||
.fetchEvents(relayUrls, { |
||||
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP], |
||||
'#a': [coordinate], |
||||
limit: 200 |
||||
}) |
||||
.then((events) => { |
||||
if (cancelled) return |
||||
setRsvps(events) |
||||
}) |
||||
.finally(() => { |
||||
if (!cancelled) setIsFetching(false) |
||||
}) |
||||
|
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [calendarEvent?.id, calendarEvent?.kind, relayList?.read]) |
||||
|
||||
return { |
||||
rsvps, |
||||
isFetching, |
||||
getRsvpStatus |
||||
} |
||||
} |
||||
@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { tagNameEquals } from '@/lib/tag' |
||||
import { Event } from 'nostr-tools' |
||||
|
||||
export interface CalendarEventMeta { |
||||
title: string |
||||
summary: string |
||||
image: string |
||||
/** Time-based: Unix seconds. Date-based: undefined. */ |
||||
start: number | undefined |
||||
/** Time-based: Unix seconds. Date-based: undefined. */ |
||||
end: number | undefined |
||||
/** Date-based: YYYY-MM-DD. Time-based: undefined. */ |
||||
startDate: string |
||||
/** Date-based: YYYY-MM-DD (exclusive end). Time-based: undefined. */ |
||||
endDate: string |
||||
isDateBased: boolean |
||||
joinUrl: string |
||||
topics: string[] |
||||
} |
||||
|
||||
export function getCalendarEventMeta(event: Event): CalendarEventMeta { |
||||
const title = event.tags.find(tagNameEquals('title'))?.[1] ?? '' |
||||
const summary = event.tags.find(tagNameEquals('summary'))?.[1] ?? '' |
||||
const image = event.tags.find(tagNameEquals('image'))?.[1] ?? '' |
||||
const startStr = event.tags.find(tagNameEquals('start'))?.[1] |
||||
const endStr = event.tags.find(tagNameEquals('end'))?.[1] |
||||
const location = event.tags.find(tagNameEquals('location'))?.[1] |
||||
const rTag = event.tags.find(tagNameEquals('r'))?.[1] |
||||
const joinUrl = rTag || location || '' |
||||
const topics = event.tags.filter(tagNameEquals('t')).map((t) => t[1]?.trim()).filter(Boolean) |
||||
const isDateBased = event.kind === ExtendedKind.CALENDAR_EVENT_DATE |
||||
if (isDateBased) { |
||||
return { |
||||
title, |
||||
summary, |
||||
image, |
||||
start: undefined, |
||||
end: undefined, |
||||
startDate: startStr ?? '', |
||||
endDate: endStr ?? '', |
||||
isDateBased: true, |
||||
joinUrl, |
||||
topics |
||||
} |
||||
} |
||||
const start = startStr ? parseInt(startStr, 10) : undefined |
||||
const end = endStr ? parseInt(endStr, 10) : undefined |
||||
return { |
||||
title, |
||||
summary, |
||||
image, |
||||
start, |
||||
end, |
||||
startDate: '', |
||||
endDate: '', |
||||
isDateBased: false, |
||||
joinUrl, |
||||
topics |
||||
} |
||||
} |
||||
|
||||
export function formatCalendarTime(ts: number): string { |
||||
const d = new Date(ts * 1000) |
||||
return d.toLocaleString(undefined, { |
||||
dateStyle: 'medium', |
||||
timeStyle: 'short' |
||||
}) |
||||
} |
||||
|
||||
/** Format a YYYY-MM-DD date string for display. */ |
||||
export function formatCalendarDate(dateStr: string): string { |
||||
if (!dateStr) return '' |
||||
const d = new Date(dateStr + 'T00:00:00') |
||||
return d.toLocaleDateString(undefined, { dateStyle: 'long' }) |
||||
} |
||||
|
||||
export function isCalendarEventKind(kind: number): boolean { |
||||
return kind === ExtendedKind.CALENDAR_EVENT_DATE || kind === ExtendedKind.CALENDAR_EVENT_TIME |
||||
} |
||||
Loading…
Reference in new issue