20 changed files with 832 additions and 18 deletions
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
const KEY_PREFIX = 'jumble:calendarDayPanel:' |
||||
|
||||
/** Persist calendar events for a day so the secondary panel can load them without a giant URL. */ |
||||
export function setCalendarDayPanelEvents(ymd: string, events: Event[]): void { |
||||
try { |
||||
sessionStorage.setItem(KEY_PREFIX + ymd, JSON.stringify(events)) |
||||
} catch { |
||||
/* quota or private mode */ |
||||
} |
||||
} |
||||
|
||||
export function readCalendarDayPanelEvents(ymd: string): Event[] | null { |
||||
try { |
||||
const raw = sessionStorage.getItem(KEY_PREFIX + ymd) |
||||
if (!raw) return null |
||||
const parsed = JSON.parse(raw) as unknown |
||||
if (!Array.isArray(parsed)) return null |
||||
return parsed.filter((x): x is Event => x && typeof x === 'object' && typeof (x as Event).id === 'string') |
||||
} catch { |
||||
return null |
||||
} |
||||
} |
||||
@ -0,0 +1,527 @@
@@ -0,0 +1,527 @@
|
||||
import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' |
||||
import { |
||||
calendarOccurrenceOverlapsRange, |
||||
getCalendarEventMeta, |
||||
getLocalMondayWeekBounds, |
||||
getLocalMonthRangeMs |
||||
} from '@/lib/calendar-event' |
||||
import { replaceableEventDedupeKey } from '@/lib/event' |
||||
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' |
||||
import { setCalendarDayPanelEvents } from '@/lib/calendar-day-panel-cache' |
||||
import { toNote } from '@/lib/link' |
||||
import { cn } from '@/lib/utils' |
||||
import { useSecondaryPage } from '@/contexts/secondary-page-context' |
||||
import { useSmartNoteNavigation } from '@/PageManager' |
||||
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useFollowListOptional } from '@/providers/follow-list-context' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||
import client from '@/services/client.service' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' |
||||
import { TPageRef } from '@/types' |
||||
import { RefreshButton } from '@/components/RefreshButton' |
||||
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react' |
||||
import { type Event } from 'nostr-tools' |
||||
import { |
||||
forwardRef, |
||||
useCallback, |
||||
useEffect, |
||||
useImperativeHandle, |
||||
useMemo, |
||||
useRef, |
||||
useState |
||||
} from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { Button } from '@/components/ui/button' |
||||
|
||||
const FETCH_LIMIT = 1200 |
||||
const FOLLOWING_CALENDAR_AUTHORS_CAP = 200 |
||||
const FOLLOWING_CALENDAR_AUTHORS_CHUNK = 80 |
||||
const FOLLOWING_CALENDAR_CHUNK_LIMIT = 350 |
||||
const SESSION_CALENDAR_MERGE_CAP = 5000 |
||||
const SIDEBAR_CALENDAR_MAX_RELAYS = 24 |
||||
const MONTH_IDB_MAX_SCAN = 12_000 |
||||
const PAD_DAYS = 7 |
||||
|
||||
export type CalendarPrimaryPageProps = { |
||||
/** Week offset from the current local week (same as sidebar widget). */ |
||||
weekOffset?: number |
||||
} |
||||
|
||||
function dedupeCalendarEvents(events: Event[]): Event[] { |
||||
const map = new Map<string, Event>() |
||||
for (const e of events) { |
||||
const k = replaceableEventDedupeKey(e) |
||||
const prev = map.get(k) |
||||
if (!prev || e.created_at > prev.created_at) map.set(k, e) |
||||
} |
||||
return [...map.values()] |
||||
} |
||||
|
||||
function mondayFirstOffsetFromMonthStart(year: number, monthIndex: number): number { |
||||
const first = new Date(year, monthIndex, 1, 0, 0, 0, 0) |
||||
const dow = first.getDay() |
||||
return dow === 0 ? 6 : dow - 1 |
||||
} |
||||
|
||||
function daysInMonth(year: number, monthIndex: number): number { |
||||
return new Date(year, monthIndex + 1, 0).getDate() |
||||
} |
||||
|
||||
function ymdForLocalDay(year: number, monthIndex: number, day: number): string { |
||||
return `${year}-${String(monthIndex + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` |
||||
} |
||||
|
||||
const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(function CalendarPrimaryPage( |
||||
{ weekOffset: weekOffsetProp = 0 }, |
||||
ref |
||||
) { |
||||
const { t, i18n } = useTranslation() |
||||
const { relayList, pubkey } = useNostr() |
||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||
const followList = useFollowListOptional() |
||||
const { navigateToNote } = useSmartNoteNavigation() |
||||
const { push } = useSecondaryPage() |
||||
const { isSmallScreen } = useScreenSize() |
||||
const layoutRef = useRef<TPrimaryPageLayoutRef>(null) |
||||
const [refreshKey, setRefreshKey] = useState(0) |
||||
|
||||
const [activeWeekOffset, setActiveWeekOffset] = useState(weekOffsetProp) |
||||
useEffect(() => { |
||||
setActiveWeekOffset(weekOffsetProp) |
||||
}, [weekOffsetProp]) |
||||
|
||||
const highlightBounds = useMemo( |
||||
() => getLocalMondayWeekBounds(activeWeekOffset), |
||||
[activeWeekOffset] |
||||
) |
||||
|
||||
const anchorMonday = useMemo(() => new Date(highlightBounds.weekStartMs), [highlightBounds.weekStartMs]) |
||||
const [viewYear, setViewYear] = useState(() => anchorMonday.getFullYear()) |
||||
const [viewMonth, setViewMonth] = useState(() => anchorMonday.getMonth()) |
||||
|
||||
useEffect(() => { |
||||
const d = new Date(highlightBounds.weekStartMs) |
||||
setViewYear(d.getFullYear()) |
||||
setViewMonth(d.getMonth()) |
||||
}, [highlightBounds.weekStartMs]) |
||||
|
||||
const [rawEvents, setRawEvents] = useState<Event[]>([]) |
||||
const [loading, setLoading] = useState(false) |
||||
|
||||
const relayUrls = useMemo(() => { |
||||
const base = getRelayUrlsWithFavoritesFastReadAndInbox( |
||||
favoriteRelays, |
||||
blockedRelays, |
||||
userReadRelaysWithHttp(relayList), |
||||
{ |
||||
userWriteRelays: relayList?.write ?? [], |
||||
applySocialKindBlockedFilter: false |
||||
} |
||||
) |
||||
return appendCuratedReadOnlyRelays(base, blockedRelays).slice(0, SIDEBAR_CALENDAR_MAX_RELAYS) |
||||
}, [favoriteRelays, blockedRelays, relayList]) |
||||
|
||||
const relayKey = useMemo(() => [...relayUrls].sort().join('|'), [relayUrls]) |
||||
|
||||
const followAuthorsKey = useMemo(() => { |
||||
const raw = followList?.followings ?? [] |
||||
if (!raw.length && !pubkey) return '' |
||||
const set = new Set<string>() |
||||
for (const p of raw) { |
||||
const k = p?.trim().toLowerCase() |
||||
if (k) set.add(k) |
||||
} |
||||
if (pubkey) set.add(pubkey.toLowerCase()) |
||||
return [...set].sort().join('|') |
||||
}, [followList?.followings, pubkey]) |
||||
|
||||
const paddedMonthRange = useMemo(() => { |
||||
const { startMs, endExclusiveMs } = getLocalMonthRangeMs(viewYear, viewMonth) |
||||
const pad = PAD_DAYS * 86_400_000 |
||||
return { rangeStartMs: startMs - pad, rangeEndExclusiveMs: endExclusiveMs + pad } |
||||
}, [viewYear, viewMonth]) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
let lateMergeTimer: number | null = null |
||||
setLoading(true) |
||||
void (async () => { |
||||
try { |
||||
const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange |
||||
const fromIdb = await indexedDb.getCalendarEventsForOccurrenceWindow( |
||||
rangeStartMs, |
||||
rangeEndExclusiveMs, |
||||
MONTH_IDB_MAX_SCAN |
||||
) |
||||
|
||||
if (!relayUrls.length) { |
||||
if (cancelled) return |
||||
const fromSession = client.getSessionEventsMatchingSearch( |
||||
'', |
||||
SESSION_CALENDAR_MERGE_CAP, |
||||
[...CALENDAR_EVENT_KINDS] |
||||
) |
||||
setRawEvents(dedupeCalendarEvents([...fromIdb, ...fromSession])) |
||||
lateMergeTimer = window.setTimeout(() => { |
||||
lateMergeTimer = null |
||||
if (cancelled) return |
||||
const later = client.getSessionEventsMatchingSearch( |
||||
'', |
||||
SESSION_CALENDAR_MERGE_CAP, |
||||
[...CALENDAR_EVENT_KINDS] |
||||
) |
||||
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...fromIdb])) |
||||
}, 2500) |
||||
return |
||||
} |
||||
|
||||
const batch = await client.fetchEvents( |
||||
relayUrls, |
||||
{ |
||||
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], |
||||
limit: FETCH_LIMIT |
||||
}, |
||||
{ |
||||
cache: true, |
||||
globalTimeout: 22_000, |
||||
eoseTimeout: 3500, |
||||
firstRelayResultGraceMs: false |
||||
} |
||||
) |
||||
if (cancelled) return |
||||
|
||||
const fromFollowing: Event[] = [] |
||||
if (followAuthorsKey) { |
||||
const authorList = followAuthorsKey.split('|').filter(Boolean).slice(0, FOLLOWING_CALENDAR_AUTHORS_CAP) |
||||
for (let i = 0; i < authorList.length; i += FOLLOWING_CALENDAR_AUTHORS_CHUNK) { |
||||
const authors = authorList.slice(i, i + FOLLOWING_CALENDAR_AUTHORS_CHUNK) |
||||
const chunk = await client.fetchEvents( |
||||
relayUrls, |
||||
{ |
||||
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], |
||||
authors, |
||||
limit: FOLLOWING_CALENDAR_CHUNK_LIMIT |
||||
}, |
||||
{ |
||||
cache: true, |
||||
globalTimeout: 16_000, |
||||
eoseTimeout: 2800, |
||||
firstRelayResultGraceMs: false |
||||
} |
||||
) |
||||
if (cancelled) return |
||||
fromFollowing.push(...chunk) |
||||
} |
||||
} |
||||
|
||||
const fromSession = client.getSessionEventsMatchingSearch( |
||||
'', |
||||
SESSION_CALENDAR_MERGE_CAP, |
||||
[...CALENDAR_EVENT_KINDS] |
||||
) |
||||
setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...fromIdb])) |
||||
lateMergeTimer = window.setTimeout(() => { |
||||
lateMergeTimer = null |
||||
if (cancelled) return |
||||
const later = client.getSessionEventsMatchingSearch( |
||||
'', |
||||
SESSION_CALENDAR_MERGE_CAP, |
||||
[...CALENDAR_EVENT_KINDS] |
||||
) |
||||
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later])) |
||||
}, 2500) |
||||
} catch { |
||||
if (!cancelled) setRawEvents([]) |
||||
} finally { |
||||
if (!cancelled) setLoading(false) |
||||
} |
||||
})() |
||||
return () => { |
||||
cancelled = true |
||||
if (lateMergeTimer != null) window.clearTimeout(lateMergeTimer) |
||||
} |
||||
}, [relayKey, followAuthorsKey, paddedMonthRange, relayUrls.length, refreshKey]) |
||||
|
||||
const weekdayLabels = useMemo(() => { |
||||
const fmt = new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }) |
||||
const base = new Date(2024, 0, 1) |
||||
while (base.getDay() !== 1) base.setDate(base.getDate() + 1) |
||||
return Array.from({ length: 7 }, (_, i) => { |
||||
const d = new Date(base) |
||||
d.setDate(base.getDate() + i) |
||||
return fmt.format(d) |
||||
}) |
||||
}, [i18n.language]) |
||||
|
||||
const monthTitle = useMemo(() => { |
||||
const d = new Date(viewYear, viewMonth, 1) |
||||
return new Intl.DateTimeFormat(i18n.language, { month: 'long', year: 'numeric' }).format(d) |
||||
}, [viewYear, viewMonth, i18n.language]) |
||||
|
||||
const gridCells = useMemo(() => { |
||||
const offset = mondayFirstOffsetFromMonthStart(viewYear, viewMonth) |
||||
const dim = daysInMonth(viewYear, viewMonth) |
||||
const total = offset + dim |
||||
const rows = Math.ceil(total / 7) |
||||
const cells: { day: number | null; inMonth: boolean }[] = [] |
||||
for (let i = 0; i < rows * 7; i++) { |
||||
const dayNum = i - offset + 1 |
||||
if (dayNum < 1 || dayNum > dim) cells.push({ day: null, inMonth: false }) |
||||
else cells.push({ day: dayNum, inMonth: true }) |
||||
} |
||||
return cells |
||||
}, [viewYear, viewMonth]) |
||||
|
||||
const eventsForDay = useCallback( |
||||
(day: number) => { |
||||
const dayStart = new Date(viewYear, viewMonth, day, 0, 0, 0, 0).getTime() |
||||
const dayEnd = dayStart + 86_400_000 |
||||
return rawEvents.filter((ev) => calendarOccurrenceOverlapsRange(ev, dayStart, dayEnd)) |
||||
}, |
||||
[rawEvents, viewYear, viewMonth] |
||||
) |
||||
|
||||
const isDayInHighlightWeek = useCallback( |
||||
(day: number) => { |
||||
const dayStart = new Date(viewYear, viewMonth, day, 0, 0, 0, 0).getTime() |
||||
const dayEnd = dayStart + 86_400_000 |
||||
return ( |
||||
dayStart < highlightBounds.weekEndExclusiveMs && dayEnd > highlightBounds.weekStartMs |
||||
) |
||||
}, |
||||
[viewYear, viewMonth, highlightBounds] |
||||
) |
||||
|
||||
const openDayEventsPanel = useCallback( |
||||
(day: number) => { |
||||
const list = eventsForDay(day) |
||||
const ymd = ymdForLocalDay(viewYear, viewMonth, day) |
||||
setCalendarDayPanelEvents(ymd, list) |
||||
push(`/calendar/day/${ymd}`) |
||||
}, |
||||
[eventsForDay, viewYear, viewMonth, push] |
||||
) |
||||
|
||||
const goPrevMonth = () => { |
||||
setViewMonth((m) => { |
||||
if (m === 0) { |
||||
setViewYear((y) => y - 1) |
||||
return 11 |
||||
} |
||||
return m - 1 |
||||
}) |
||||
} |
||||
|
||||
const goNextMonth = () => { |
||||
setViewMonth((m) => { |
||||
if (m === 11) { |
||||
setViewYear((y) => y + 1) |
||||
return 0 |
||||
} |
||||
return m + 1 |
||||
}) |
||||
} |
||||
|
||||
const jumpToThisWeek = () => { |
||||
setActiveWeekOffset(0) |
||||
const { weekStartMs } = getLocalMondayWeekBounds(0) |
||||
const d = new Date(weekStartMs) |
||||
setViewYear(d.getFullYear()) |
||||
setViewMonth(d.getMonth()) |
||||
} |
||||
|
||||
useImperativeHandle( |
||||
ref, |
||||
() => ({ |
||||
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior), |
||||
refresh: () => setRefreshKey((k) => k + 1) |
||||
}), |
||||
[] |
||||
) |
||||
|
||||
return ( |
||||
<PrimaryPageLayout |
||||
ref={layoutRef} |
||||
pageName="calendar" |
||||
titlebar={<CalendarPageTitlebar onRefresh={() => setRefreshKey((k) => k + 1)} />} |
||||
displayScrollToTopButton |
||||
> |
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 p-3 md:p-4"> |
||||
<div className="flex flex-wrap items-center justify-between gap-2"> |
||||
<div className="flex items-center gap-1"> |
||||
<Button type="button" variant="outline" size="icon" className="size-9" onClick={goPrevMonth} aria-label={t('calendarPagePrevMonth')}> |
||||
<ChevronLeft className="size-4" /> |
||||
</Button> |
||||
<Button type="button" variant="outline" size="icon" className="size-9" onClick={goNextMonth} aria-label={t('calendarPageNextMonth')}> |
||||
<ChevronRight className="size-4" /> |
||||
</Button> |
||||
</div> |
||||
<h2 className="text-center text-lg font-semibold tracking-tight text-foreground md:text-xl">{monthTitle}</h2> |
||||
<Button type="button" variant="secondary" size="sm" className="shrink-0" onClick={jumpToThisWeek}> |
||||
{t('calendarPageThisWeek')} |
||||
</Button> |
||||
</div> |
||||
|
||||
{loading && rawEvents.length === 0 ? ( |
||||
<p className="text-center text-sm text-muted-foreground">{t('sidebarCalendarLoading')}</p> |
||||
) : null} |
||||
|
||||
{isSmallScreen ? ( |
||||
<div className="flex min-w-0 flex-col gap-2" aria-label={t('calendarPageGridLabel')}> |
||||
{Array.from({ length: daysInMonth(viewYear, viewMonth) }, (_, idx) => { |
||||
const day = idx + 1 |
||||
const list = eventsForDay(day) |
||||
const inWeek = isDayInHighlightWeek(day) |
||||
const weekdayShort = new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }).format( |
||||
new Date(viewYear, viewMonth, day, 12, 0, 0, 0) |
||||
) |
||||
const excess = list.length > 4 ? list.length - 4 : 0 |
||||
return ( |
||||
<div |
||||
key={`m-${viewYear}-${viewMonth}-${day}`} |
||||
className={cn( |
||||
'flex min-w-0 flex-col gap-1 rounded-lg border border-border bg-card p-3', |
||||
inWeek && 'bg-primary/10 ring-1 ring-inset ring-primary/35' |
||||
)} |
||||
> |
||||
<div className="flex items-baseline justify-between gap-2 border-b border-border/50 pb-1"> |
||||
<span className="text-sm font-semibold text-foreground"> |
||||
{weekdayShort} · {day} |
||||
</span> |
||||
{list.length > 0 ? ( |
||||
<span className="text-xs tabular-nums text-muted-foreground"> |
||||
{t('calendarPageDayEventCount', { count: list.length })} |
||||
</span> |
||||
) : null} |
||||
</div> |
||||
<ul className="min-w-0 space-y-0.5"> |
||||
{list.slice(0, 4).map((ev) => { |
||||
const meta = getCalendarEventMeta(ev) |
||||
const title = meta.title?.trim() || t('calendarPageUntitledEvent') |
||||
return ( |
||||
<li key={replaceableEventDedupeKey(ev)} className="min-w-0"> |
||||
<button |
||||
type="button" |
||||
onClick={() => navigateToNote(toNote(ev), ev)} |
||||
className={cn( |
||||
'w-full truncate rounded-md px-2 py-1.5 text-left text-xs font-medium leading-snug text-primary', |
||||
'hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' |
||||
)} |
||||
> |
||||
{title} |
||||
</button> |
||||
</li> |
||||
) |
||||
})} |
||||
</ul> |
||||
{excess > 0 ? ( |
||||
<Button |
||||
type="button" |
||||
variant="secondary" |
||||
size="sm" |
||||
className="h-9 w-full shrink-0 text-xs font-semibold" |
||||
aria-label={t('calendarPageMoreEventsAria', { count: excess })} |
||||
onClick={() => openDayEventsPanel(day)} |
||||
> |
||||
+{excess} |
||||
</Button> |
||||
) : null} |
||||
</div> |
||||
) |
||||
})} |
||||
</div> |
||||
) : ( |
||||
<div |
||||
className="grid min-w-0 gap-px rounded-lg border border-border bg-border" |
||||
style={{ gridTemplateColumns: 'repeat(7, minmax(0, 1fr))' }} |
||||
role="grid" |
||||
aria-label={t('calendarPageGridLabel')} |
||||
> |
||||
{weekdayLabels.map((label) => ( |
||||
<div |
||||
key={label} |
||||
className="bg-muted/80 px-1 py-2 text-center text-[10px] font-semibold uppercase tracking-wide text-muted-foreground md:text-xs" |
||||
role="columnheader" |
||||
> |
||||
{label} |
||||
</div> |
||||
))} |
||||
{gridCells.map((cell, idx) => { |
||||
if (cell.day == null) { |
||||
return <div key={`pad-${idx}`} className="min-h-[72px] bg-muted/20 md:min-h-[96px]" /> |
||||
} |
||||
const day = cell.day |
||||
const list = eventsForDay(day) |
||||
const inWeek = isDayInHighlightWeek(day) |
||||
const excess = list.length > 4 ? list.length - 4 : 0 |
||||
return ( |
||||
<div |
||||
key={`${viewYear}-${viewMonth}-${day}`} |
||||
className={cn( |
||||
'flex min-h-[72px] min-w-0 flex-col gap-0.5 bg-card p-1 md:min-h-[96px] md:p-1.5', |
||||
inWeek && 'bg-primary/10 ring-1 ring-inset ring-primary/35' |
||||
)} |
||||
role="gridcell" |
||||
> |
||||
<span className="text-[11px] font-semibold tabular-nums text-foreground md:text-xs">{day}</span> |
||||
<ul className="min-w-0 flex-1 space-y-0.5 overflow-hidden"> |
||||
{list.slice(0, 4).map((ev) => { |
||||
const meta = getCalendarEventMeta(ev) |
||||
const title = meta.title?.trim() || t('calendarPageUntitledEvent') |
||||
return ( |
||||
<li key={replaceableEventDedupeKey(ev)} className="min-w-0"> |
||||
<button |
||||
type="button" |
||||
onClick={() => navigateToNote(toNote(ev), ev)} |
||||
className={cn( |
||||
'w-full truncate rounded px-0.5 py-px text-left text-[9px] font-medium leading-tight text-primary underline-offset-2', |
||||
'hover:bg-muted/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-[10px]' |
||||
)} |
||||
title={title} |
||||
> |
||||
{title} |
||||
</button> |
||||
</li> |
||||
) |
||||
})} |
||||
</ul> |
||||
{excess > 0 ? ( |
||||
<Button |
||||
type="button" |
||||
variant="secondary" |
||||
size="sm" |
||||
className="mt-0.5 h-7 w-full shrink-0 px-1 text-[10px] font-semibold md:h-8 md:text-[11px]" |
||||
aria-label={t('calendarPageMoreEventsAria', { count: excess })} |
||||
onClick={() => openDayEventsPanel(day)} |
||||
> |
||||
+{excess} |
||||
</Button> |
||||
) : null} |
||||
</div> |
||||
) |
||||
})} |
||||
</div> |
||||
)} |
||||
</div> |
||||
</PrimaryPageLayout> |
||||
) |
||||
}) |
||||
|
||||
function CalendarPageTitlebar({ onRefresh }: { onRefresh: () => void }) { |
||||
const { t } = useTranslation() |
||||
return ( |
||||
<div className="flex h-full w-full items-center justify-between gap-2 pr-1"> |
||||
<div className="flex items-center gap-2 pl-3"> |
||||
<CalendarDays className="size-5 shrink-0" aria-hidden /> |
||||
<div className="app-chrome-title">{t('calendarPageTitle')}</div> |
||||
</div> |
||||
<RefreshButton onClick={onRefresh} /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
CalendarPrimaryPage.displayName = 'CalendarPrimaryPage' |
||||
export default CalendarPrimaryPage |
||||
@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
|
||||
import { getCalendarEventMeta, getCalendarOccurrenceWindowMs } from '@/lib/calendar-event' |
||||
import { readCalendarDayPanelEvents } from '@/lib/calendar-day-panel-cache' |
||||
import { replaceableEventDedupeKey } from '@/lib/event' |
||||
import { toNote } from '@/lib/link' |
||||
import { cn } from '@/lib/utils' |
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||
import { useSmartNoteNavigation } from '@/PageManager' |
||||
import { TPageRef } from '@/types' |
||||
import { type Event } from 'nostr-tools' |
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { Button } from '@/components/ui/button' |
||||
|
||||
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/ |
||||
|
||||
const CalendarDayEventsPage = forwardRef<TPageRef, { ymd: string; index?: number }>(function CalendarDayEventsPage( |
||||
{ ymd, index }, |
||||
ref |
||||
) { |
||||
const { t, i18n } = useTranslation() |
||||
const { navigateToNote } = useSmartNoteNavigation() |
||||
const [events, setEvents] = useState<Event[]>([]) |
||||
|
||||
const validYmd = typeof ymd === 'string' && YMD_RE.test(ymd) |
||||
|
||||
useEffect(() => { |
||||
if (!validYmd) { |
||||
setEvents([]) |
||||
return |
||||
} |
||||
setEvents(readCalendarDayPanelEvents(ymd) ?? []) |
||||
}, [ymd, validYmd]) |
||||
|
||||
const title = useMemo(() => { |
||||
if (!validYmd) return t('calendarPageTitle') |
||||
const [y, m, d] = ymd.split('-').map(Number) |
||||
const dt = new Date(y, (m ?? 1) - 1, d ?? 1, 12, 0, 0, 0) |
||||
return new Intl.DateTimeFormat(i18n.language, { |
||||
weekday: 'long', |
||||
month: 'long', |
||||
day: 'numeric', |
||||
year: 'numeric' |
||||
}).format(dt) |
||||
}, [ymd, validYmd, i18n.language]) |
||||
|
||||
const sorted = useMemo(() => { |
||||
return [...events].sort((a, b) => { |
||||
const wa = getCalendarOccurrenceWindowMs(a)?.startMs ?? a.created_at * 1000 |
||||
const wb = getCalendarOccurrenceWindowMs(b)?.startMs ?? b.created_at * 1000 |
||||
return wa - wb |
||||
}) |
||||
}, [events]) |
||||
|
||||
useImperativeHandle(ref, () => ({ |
||||
scrollToTop: (behavior?: ScrollBehavior) => { |
||||
window.scrollTo({ top: 0, behavior: behavior ?? 'smooth' }) |
||||
} |
||||
})) |
||||
|
||||
if (!validYmd) { |
||||
return ( |
||||
<SecondaryPageLayout ref={ref} index={index} title={t('calendarPageTitle')}> |
||||
<p className="px-4 py-6 text-sm text-muted-foreground">{t('calendarDayPanelInvalidDate')}</p> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton> |
||||
<div className="min-w-0 px-3 py-3 md:px-4"> |
||||
{sorted.length === 0 ? ( |
||||
<p className="text-sm text-muted-foreground leading-relaxed">{t('calendarDayPanelEmpty')}</p> |
||||
) : ( |
||||
<ul className="min-w-0 space-y-1"> |
||||
{sorted.map((ev) => { |
||||
const meta = getCalendarEventMeta(ev) |
||||
const label = meta.title?.trim() || t('calendarPageUntitledEvent') |
||||
return ( |
||||
<li key={replaceableEventDedupeKey(ev)}> |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
className={cn( |
||||
'h-auto min-h-10 w-full justify-start whitespace-normal px-3 py-2 text-left text-sm font-medium' |
||||
)} |
||||
onClick={() => navigateToNote(toNote(ev), ev)} |
||||
> |
||||
{label} |
||||
</Button> |
||||
</li> |
||||
) |
||||
})} |
||||
</ul> |
||||
)} |
||||
</div> |
||||
</SecondaryPageLayout> |
||||
) |
||||
}) |
||||
|
||||
CalendarDayEventsPage.displayName = 'CalendarDayEventsPage' |
||||
export default CalendarDayEventsPage |
||||
Loading…
Reference in new issue