mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
show calendar details on click
This commit is contained in:
parent
8aa0c1763a
commit
f1d3b7b825
1 changed files with 323 additions and 59 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react'
|
import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { SettingsDialog } from '@/components/settings-dialog'
|
import { SettingsDialog } from '@/components/settings-dialog'
|
||||||
import { formatRelativeTime } from '@/lib/relative-time'
|
import { formatRelativeTime } from '@/lib/relative-time'
|
||||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||||
|
|
@ -40,14 +41,32 @@ type RawCalendarEvent = {
|
||||||
start?: { dateTime?: string; date?: string }
|
start?: { dateTime?: string; date?: string }
|
||||||
end?: { dateTime?: string; date?: string }
|
end?: { dateTime?: string; date?: string }
|
||||||
location?: string
|
location?: string
|
||||||
|
description?: string
|
||||||
htmlLink?: string
|
htmlLink?: string
|
||||||
status?: string
|
status?: string
|
||||||
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>
|
creator?: CalendarPerson
|
||||||
|
organizer?: CalendarPerson
|
||||||
|
attendees?: CalendarAttendee[]
|
||||||
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
|
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
|
||||||
hangoutLink?: string
|
hangoutLink?: string
|
||||||
conferenceLink?: string
|
conferenceLink?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CalendarPerson = {
|
||||||
|
email?: string
|
||||||
|
displayName?: string
|
||||||
|
self?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarAttendee = CalendarPerson & {
|
||||||
|
responseStatus?: string
|
||||||
|
optional?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type DescriptionPart =
|
||||||
|
| { type: 'text'; text: string }
|
||||||
|
| { type: 'link'; text: string; href: string }
|
||||||
|
|
||||||
type UpcomingEvent = {
|
type UpcomingEvent = {
|
||||||
id: string
|
id: string
|
||||||
summary: string
|
summary: string
|
||||||
|
|
@ -55,8 +74,12 @@ type UpcomingEvent = {
|
||||||
end: Date | null
|
end: Date | null
|
||||||
isAllDay: boolean
|
isAllDay: boolean
|
||||||
location: string | null
|
location: string | null
|
||||||
|
description: string | null
|
||||||
htmlLink: string | null
|
htmlLink: string | null
|
||||||
conferenceLink: string | null
|
conferenceLink: string | null
|
||||||
|
creator: CalendarPerson | null
|
||||||
|
organizer: CalendarPerson | null
|
||||||
|
attendees: CalendarAttendee[]
|
||||||
source: string // workspace path to the calendar_sync JSON
|
source: string // workspace path to the calendar_sync JSON
|
||||||
rawStart: { dateTime?: string; date?: string } | undefined
|
rawStart: { dateTime?: string; date?: string } | undefined
|
||||||
rawEnd: { dateTime?: string; date?: string } | undefined
|
rawEnd: { dateTime?: string; date?: string } | undefined
|
||||||
|
|
@ -125,8 +148,12 @@ function normalizeEvent(raw: RawCalendarEvent, sourcePath: string): UpcomingEven
|
||||||
end,
|
end,
|
||||||
isAllDay,
|
isAllDay,
|
||||||
location: raw.location?.trim() || null,
|
location: raw.location?.trim() || null,
|
||||||
|
description: raw.description?.trim() || null,
|
||||||
htmlLink: raw.htmlLink ?? null,
|
htmlLink: raw.htmlLink ?? null,
|
||||||
conferenceLink,
|
conferenceLink,
|
||||||
|
creator: raw.creator ?? null,
|
||||||
|
organizer: raw.organizer ?? null,
|
||||||
|
attendees: raw.attendees ?? [],
|
||||||
source: sourcePath,
|
source: sourcePath,
|
||||||
rawStart: raw.start,
|
rawStart: raw.start,
|
||||||
rawEnd: raw.end,
|
rawEnd: raw.end,
|
||||||
|
|
@ -185,6 +212,115 @@ function formatEventTimeRange(event: UpcomingEvent): string {
|
||||||
return `${start} – ${end}`
|
return `${start} – ${end}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatEventDetailTime(event: UpcomingEvent): string {
|
||||||
|
if (!event.isAllDay) {
|
||||||
|
const date = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
|
||||||
|
return `${date}, ${formatEventTimeRange(event)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
|
||||||
|
if (!event.end) return `${start}, all day`
|
||||||
|
|
||||||
|
const exclusiveEnd = addDays(event.end, -1)
|
||||||
|
if (localDateKey(exclusiveEnd) === localDateKey(event.start)) return `${start}, all day`
|
||||||
|
|
||||||
|
const end = exclusiveEnd.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
|
||||||
|
return `${start} – ${end}, all day`
|
||||||
|
}
|
||||||
|
|
||||||
|
function personLabel(person: CalendarPerson | null | undefined): string | null {
|
||||||
|
if (!person) return null
|
||||||
|
return person.displayName?.trim() || person.email?.trim() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function attendeeLabel(attendee: CalendarAttendee): string | null {
|
||||||
|
const label = personLabel(attendee)
|
||||||
|
if (!label) return null
|
||||||
|
if (attendee.self) return `${label} (you)`
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDescriptionParts(parts: DescriptionPart[]): DescriptionPart[] {
|
||||||
|
const normalized: DescriptionPart[] = []
|
||||||
|
for (const part of parts) {
|
||||||
|
const text = part.text.replace(/\n{3,}/g, '\n\n')
|
||||||
|
if (!text) continue
|
||||||
|
const previous = normalized[normalized.length - 1]
|
||||||
|
if (previous?.type === 'text' && part.type === 'text') {
|
||||||
|
previous.text += text
|
||||||
|
} else if (part.type === 'link') {
|
||||||
|
normalized.push({ ...part, text })
|
||||||
|
} else {
|
||||||
|
normalized.push({ type: 'text', text })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeDescriptionHref(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value, window.location.href)
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkifyText(value: string): DescriptionPart[] {
|
||||||
|
const parts: DescriptionPart[] = []
|
||||||
|
const urlRe = /\bhttps?:\/\/[^\s<>"')\]]+|\bwww\.[^\s<>"')\]]+/gi
|
||||||
|
let lastIndex = 0
|
||||||
|
for (const match of value.matchAll(urlRe)) {
|
||||||
|
const raw = match[0]
|
||||||
|
const index = match.index ?? 0
|
||||||
|
if (index > lastIndex) parts.push({ type: 'text', text: value.slice(lastIndex, index) })
|
||||||
|
const href = raw.startsWith('www.') ? `https://${raw}` : raw
|
||||||
|
parts.push({ type: 'link', text: raw, href })
|
||||||
|
lastIndex = index + raw.length
|
||||||
|
}
|
||||||
|
if (lastIndex < value.length) parts.push({ type: 'text', text: value.slice(lastIndex) })
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDescriptionParts(value: string): DescriptionPart[] {
|
||||||
|
const withLineBreaks = value.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/\s*(p|div|li|tr|h[1-6])\s*>/gi, '\n')
|
||||||
|
if (typeof DOMParser === 'undefined') {
|
||||||
|
return normalizeDescriptionParts(linkifyText(withLineBreaks.replace(/<[^>]*>/g, '').trim()))
|
||||||
|
}
|
||||||
|
const doc = new DOMParser().parseFromString(withLineBreaks, 'text/html')
|
||||||
|
const parts: DescriptionPart[] = []
|
||||||
|
|
||||||
|
const visit = (node: Node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
parts.push(...linkifyText(node.textContent ?? ''))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(node instanceof HTMLElement)) return
|
||||||
|
if (node.tagName === 'A') {
|
||||||
|
const href = node.getAttribute('href') ?? ''
|
||||||
|
const text = node.textContent?.trim() || href
|
||||||
|
if (href && isSafeDescriptionHref(href)) {
|
||||||
|
parts.push({ type: 'link', text, href })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.tagName === 'BR') {
|
||||||
|
parts.push({ type: 'text', text: '\n' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node.childNodes.forEach(visit)
|
||||||
|
if (/^(P|DIV|LI|TR|H[1-6])$/.test(node.tagName)) {
|
||||||
|
parts.push({ type: 'text', text: '\n' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.body.childNodes.forEach(visit)
|
||||||
|
return normalizeDescriptionParts(parts).map((part, index, all) => {
|
||||||
|
if (index === 0 || index === all.length - 1) return { ...part, text: part.text.trim() }
|
||||||
|
return part
|
||||||
|
}).filter((part) => part.text.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
function UpcomingEvents() {
|
function UpcomingEvents() {
|
||||||
const [events, setEvents] = useState<UpcomingEvent[]>([])
|
const [events, setEvents] = useState<UpcomingEvent[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -430,74 +566,202 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpcomingEventItem({ event }: { event: UpcomingEvent }) {
|
function UpcomingEventItem({ event }: { event: UpcomingEvent }) {
|
||||||
const handleOpen = useCallback(() => {
|
const [open, setOpen] = useState(false)
|
||||||
if (event.htmlLink) window.open(event.htmlLink, '_blank')
|
|
||||||
}, [event.htmlLink])
|
|
||||||
|
|
||||||
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
|
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
role="button"
|
<PopoverTrigger asChild>
|
||||||
tabIndex={0}
|
<div
|
||||||
onClick={handleOpen}
|
role="button"
|
||||||
onKeyDown={(e) => {
|
tabIndex={0}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
title={titleAndLocation}
|
||||||
e.preventDefault()
|
className={cn(
|
||||||
handleOpen()
|
'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer',
|
||||||
}
|
)}
|
||||||
}}
|
style={{ color: 'var(--gm-text)', minHeight: 40 }}
|
||||||
title={titleAndLocation}
|
|
||||||
className={cn(
|
|
||||||
'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer',
|
|
||||||
)}
|
|
||||||
style={{ color: 'var(--gm-text)', minHeight: 40 }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="self-stretch rounded-full"
|
|
||||||
style={{ width: 3, background: 'var(--gm-accent)', opacity: 0.55 }}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span
|
|
||||||
className="block truncate"
|
|
||||||
style={{ fontSize: 14, fontWeight: 500, color: 'var(--gm-text-strong)' }}
|
|
||||||
>
|
>
|
||||||
{event.summary}
|
<span
|
||||||
</span>
|
aria-hidden
|
||||||
<span
|
className="self-stretch rounded-full"
|
||||||
className="mt-0.5 block truncate"
|
style={{ width: 3, background: 'var(--gm-accent)', opacity: 0.55 }}
|
||||||
style={{ fontSize: 12, color: 'var(--gm-text-muted)' }}
|
|
||||||
>
|
|
||||||
{formatEventTimeRange(event)}
|
|
||||||
{event.location ? <span style={{ color: 'var(--gm-text-faint)' }}> · {event.location}</span> : null}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
|
||||||
{event.conferenceLink ? (
|
|
||||||
<SplitJoinButton
|
|
||||||
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
|
||||||
onNotesOnly={() => triggerMeetingCapture(event, false)}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
<span className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className="block truncate"
|
||||||
|
style={{ fontSize: 14, fontWeight: 500, color: 'var(--gm-text-strong)' }}
|
||||||
|
>
|
||||||
|
{event.summary}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mt-0.5 block truncate"
|
||||||
|
style={{ fontSize: 12, color: 'var(--gm-text-muted)' }}
|
||||||
|
>
|
||||||
|
{formatEventTimeRange(event)}
|
||||||
|
{event.location ? <span style={{ color: 'var(--gm-text-faint)' }}> · {event.location}</span> : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||||
|
{event.conferenceLink ? (
|
||||||
|
<SplitJoinButton
|
||||||
|
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
||||||
|
onNotesOnly={() => triggerMeetingCapture(event, false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); triggerMeetingCapture(event, false) }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
|
||||||
|
style={{
|
||||||
|
background: 'var(--gm-bg-pill)',
|
||||||
|
color: 'var(--gm-text)',
|
||||||
|
border: '1px solid var(--gm-border)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
|
||||||
|
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
|
||||||
|
>
|
||||||
|
<Mic className="size-3" />
|
||||||
|
Take notes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose: () => void }) {
|
||||||
|
const organizer = personLabel(event.organizer) ?? personLabel(event.creator)
|
||||||
|
const attendees = event.attendees.map(attendeeLabel).filter((label): label is string => Boolean(label))
|
||||||
|
const descriptionParts = event.description ? parseDescriptionParts(event.description) : []
|
||||||
|
const handleMeetingCapture = (openConference: boolean) => {
|
||||||
|
onClose()
|
||||||
|
triggerMeetingCapture(event, openConference)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
sideOffset={6}
|
||||||
|
className="w-[min(380px,calc(100vw-32px))] rounded-lg p-0 shadow-xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--popover, #fff)',
|
||||||
|
borderColor: 'var(--border, #e4e4e7)',
|
||||||
|
color: 'var(--popover-foreground, #09090b)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end gap-1 border-b px-3 py-2" style={{ borderColor: 'var(--border, #e4e4e7)' }}>
|
||||||
|
{event.htmlLink ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); triggerMeetingCapture(event, false) }}
|
onClick={() => window.open(event.htmlLink!, '_blank')}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
className="inline-flex size-8 items-center justify-center rounded-md transition-colors"
|
||||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
|
style={{ color: 'var(--muted-foreground, #71717a)' }}
|
||||||
style={{
|
aria-label="Open in Google Calendar"
|
||||||
background: 'var(--gm-bg-pill)',
|
title="Open in Google Calendar"
|
||||||
color: 'var(--gm-text)',
|
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--muted, #f4f4f5)' }}
|
||||||
border: '1px solid var(--gm-border)',
|
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
|
|
||||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
|
|
||||||
>
|
>
|
||||||
<Mic className="size-3" />
|
<ExternalLink className="size-4" />
|
||||||
Take notes
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex size-8 items-center justify-center rounded-md transition-colors"
|
||||||
|
style={{ color: 'var(--muted-foreground, #71717a)' }}
|
||||||
|
aria-label="Close event details"
|
||||||
|
title="Close"
|
||||||
|
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--muted, #f4f4f5)' }}
|
||||||
|
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-4 px-5 py-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="mt-1.5 h-3 w-3 shrink-0 rounded-sm"
|
||||||
|
style={{ background: 'var(--primary, #18181b)' }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h4 className="break-words text-[20px] font-normal leading-6" style={{ color: 'var(--foreground, #09090b)' }}>
|
||||||
|
{event.summary}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventDetailRow icon={<Clock className="size-4" />} value={formatEventDetailTime(event)} />
|
||||||
|
{event.location ? <EventDetailRow icon={<MapPin className="size-4" />} value={event.location} /> : null}
|
||||||
|
{organizer ? <EventDetailRow icon={<UserRound className="size-4" />} value={`Organizer: ${organizer}`} /> : null}
|
||||||
|
{attendees.length > 0 ? (
|
||||||
|
<EventDetailRow
|
||||||
|
icon={<UsersRound className="size-4" />}
|
||||||
|
value={attendees.slice(0, 8).join(', ') + (attendees.length > 8 ? `, +${attendees.length - 8} more` : '')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{event.conferenceLink ? (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Video className="mt-1 size-4 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }} />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" size="sm" onClick={() => handleMeetingCapture(true)}>
|
||||||
|
Join & take notes
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => handleMeetingCapture(false)}>
|
||||||
|
Take notes only
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Mic className="mt-1 size-4 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }} />
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => handleMeetingCapture(false)}>
|
||||||
|
Take notes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{descriptionParts.length > 0 ? (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="mt-1 size-4 shrink-0" />
|
||||||
|
<div className="max-h-40 overflow-auto whitespace-pre-wrap break-words text-sm leading-5" style={{ color: 'var(--foreground, #27272a)' }}>
|
||||||
|
{descriptionParts.map((part, index) => {
|
||||||
|
if (part.type === 'text') return <span key={index}>{part.text}</span>
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={part.href}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
window.open(part.href, '_blank')
|
||||||
|
}}
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
style={{ color: 'var(--primary, #18181b)' }}
|
||||||
|
>
|
||||||
|
{part.text}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventDetailRow({ icon, value }: { icon: React.ReactNode; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 text-sm leading-5">
|
||||||
|
<span className="mt-0.5 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }}>{icon}</span>
|
||||||
|
<span className="min-w-0 break-words" style={{ color: 'var(--foreground, #27272a)' }}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue