You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
637 lines
24 KiB
637 lines
24 KiB
import storage from '@/services/local-storage.service' |
|
import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' |
|
import { |
|
calendarOccurrenceOverlapsRange, |
|
dedupeCalendarEventsPreferringOccurrenceRange, |
|
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 { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' |
|
import { RefreshButton } from '@/components/RefreshButton' |
|
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react' |
|
import { type Event as NostrEvent } 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 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 [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode()) |
|
const layoutRef = useRef<TPrimaryPageLayoutRef>(null) |
|
const [refreshKey, setRefreshKey] = useState(0) |
|
|
|
useEffect(() => { |
|
const onPanelMode = (ev: Event) => { |
|
const d = (ev as CustomEvent<{ mode: 'single' | 'double' }>).detail?.mode |
|
if (d === 'single' || d === 'double') setPanelMode(d) |
|
} |
|
window.addEventListener('panelModeChanged', onPanelMode) |
|
return () => window.removeEventListener('panelModeChanged', onPanelMode) |
|
}, []) |
|
|
|
/** Month grid is unreadable in the narrow primary column of double-pane; use the same vertical layout as mobile. */ |
|
const useVerticalMonthCalendar = isSmallScreen || panelMode === 'double' |
|
|
|
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<NostrEvent[]>([]) |
|
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 |
|
const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange |
|
|
|
/** Same-tick paint from in-memory session (no await) — IDB + relays merge in the async block below. */ |
|
const fromSessionSync = client.getSessionEventsMatchingSearch( |
|
'', |
|
SESSION_CALENDAR_MERGE_CAP, |
|
[...CALENDAR_EVENT_KINDS] |
|
) |
|
const sessionOnly = dedupeCalendarEventsPreferringOccurrenceRange( |
|
fromSessionSync, |
|
rangeStartMs, |
|
rangeEndExclusiveMs |
|
) |
|
setRawEvents(sessionOnly) |
|
setLoading(false) |
|
|
|
const scheduleLateSessionMerge = (mergeWithIdb: NostrEvent[]) => { |
|
lateMergeTimer = window.setTimeout(() => { |
|
lateMergeTimer = null |
|
if (cancelled) return |
|
const later = client.getSessionEventsMatchingSearch( |
|
'', |
|
SESSION_CALENDAR_MERGE_CAP, |
|
[...CALENDAR_EVENT_KINDS] |
|
) |
|
setRawEvents((prev) => |
|
dedupeCalendarEventsPreferringOccurrenceRange( |
|
[...prev, ...later, ...mergeWithIdb], |
|
rangeStartMs, |
|
rangeEndExclusiveMs |
|
) |
|
) |
|
}, 2500) |
|
} |
|
|
|
void (async () => { |
|
try { |
|
const idbP = Promise.all([ |
|
indexedDb.getCalendarEventsForOccurrenceWindow( |
|
rangeStartMs, |
|
rangeEndExclusiveMs, |
|
MONTH_IDB_MAX_SCAN |
|
), |
|
indexedDb.getArchivedCalendarEventsOverlappingWindow( |
|
rangeStartMs, |
|
rangeEndExclusiveMs, |
|
55_000, |
|
2500 |
|
) |
|
]) |
|
.then(([fromIdb, fromArchive]) => |
|
dedupeCalendarEventsPreferringOccurrenceRange( |
|
[...fromIdb, ...fromArchive], |
|
rangeStartMs, |
|
rangeEndExclusiveMs |
|
) |
|
) |
|
.catch((): NostrEvent[] => []) |
|
|
|
void idbP.then((localBaseline) => { |
|
if (cancelled) return |
|
const s2 = client.getSessionEventsMatchingSearch( |
|
'', |
|
SESSION_CALENDAR_MERGE_CAP, |
|
[...CALENDAR_EVENT_KINDS] |
|
) |
|
setRawEvents( |
|
dedupeCalendarEventsPreferringOccurrenceRange( |
|
[...localBaseline, ...s2], |
|
rangeStartMs, |
|
rangeEndExclusiveMs |
|
) |
|
) |
|
}) |
|
|
|
if (!relayUrls.length) { |
|
void idbP.then((lb) => { |
|
if (!cancelled) scheduleLateSessionMerge(lb) |
|
}) |
|
return |
|
} |
|
|
|
const mainFetchOpts = { |
|
cache: true as const, |
|
globalTimeout: 22_000, |
|
eoseTimeout: 3500, |
|
firstRelayResultGraceMs: false as const |
|
} |
|
const chunkFetchOpts = { |
|
cache: true as const, |
|
globalTimeout: 12_000, |
|
eoseTimeout: 2200, |
|
firstRelayResultGraceMs: false as const |
|
} |
|
|
|
const authorList = followAuthorsKey |
|
? followAuthorsKey.split('|').filter(Boolean).slice(0, FOLLOWING_CALENDAR_AUTHORS_CAP) |
|
: [] |
|
const authorChunks: string[][] = [] |
|
for (let i = 0; i < authorList.length; i += FOLLOWING_CALENDAR_AUTHORS_CHUNK) { |
|
authorChunks.push(authorList.slice(i, i + FOLLOWING_CALENDAR_AUTHORS_CHUNK)) |
|
} |
|
|
|
const mainReq = client.fetchEvents( |
|
relayUrls, |
|
{ |
|
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], |
|
limit: FETCH_LIMIT |
|
}, |
|
mainFetchOpts |
|
) |
|
const chunkReqs = authorChunks.map((authors) => |
|
client.fetchEvents( |
|
relayUrls, |
|
{ |
|
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], |
|
authors, |
|
limit: FOLLOWING_CALENDAR_CHUNK_LIMIT |
|
}, |
|
chunkFetchOpts |
|
) |
|
) |
|
|
|
const relayMergedP = Promise.all([mainReq, ...chunkReqs]) |
|
.then((merged) => { |
|
const batch = merged[0] ?? [] |
|
const fromFollowing: NostrEvent[] = [] |
|
for (let i = 1; i < merged.length; i++) { |
|
fromFollowing.push(...(merged[i] ?? [])) |
|
} |
|
return { batch, fromFollowing } |
|
}) |
|
.catch(() => ({ batch: [] as NostrEvent[], fromFollowing: [] as NostrEvent[] })) |
|
|
|
const [{ batch, fromFollowing }, localBaseline] = await Promise.all([relayMergedP, idbP]) |
|
if (cancelled) return |
|
|
|
const fromSession = client.getSessionEventsMatchingSearch( |
|
'', |
|
SESSION_CALENDAR_MERGE_CAP, |
|
[...CALENDAR_EVENT_KINDS] |
|
) |
|
setRawEvents( |
|
dedupeCalendarEventsPreferringOccurrenceRange( |
|
[...batch, ...fromFollowing, ...fromSession, ...localBaseline], |
|
rangeStartMs, |
|
rangeEndExclusiveMs |
|
) |
|
) |
|
lateMergeTimer = window.setTimeout(() => { |
|
lateMergeTimer = null |
|
if (cancelled) return |
|
const later = client.getSessionEventsMatchingSearch( |
|
'', |
|
SESSION_CALENDAR_MERGE_CAP, |
|
[...CALENDAR_EVENT_KINDS] |
|
) |
|
setRawEvents((prev) => |
|
dedupeCalendarEventsPreferringOccurrenceRange( |
|
[...prev, ...later, ...localBaseline], |
|
rangeStartMs, |
|
rangeEndExclusiveMs |
|
) |
|
) |
|
}, 2500) |
|
} catch { |
|
if (!cancelled) { |
|
try { |
|
const rs = rangeStartMs |
|
const re = rangeEndExclusiveMs |
|
const [idb, arc] = await Promise.all([ |
|
indexedDb.getCalendarEventsForOccurrenceWindow(rs, re, MONTH_IDB_MAX_SCAN), |
|
indexedDb.getArchivedCalendarEventsOverlappingWindow(rs, re, 55_000, 2500) |
|
]) |
|
const salvage = dedupeCalendarEventsPreferringOccurrenceRange([...idb, ...arc], rs, re) |
|
const fromSession = client.getSessionEventsMatchingSearch( |
|
'', |
|
SESSION_CALENDAR_MERGE_CAP, |
|
[...CALENDAR_EVENT_KINDS] |
|
) |
|
setRawEvents(dedupeCalendarEventsPreferringOccurrenceRange([...salvage, ...fromSession], rs, re)) |
|
} catch { |
|
setRawEvents([]) |
|
} |
|
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-2 p-2 md:p-3"> |
|
<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} |
|
|
|
{useVerticalMonthCalendar ? ( |
|
<div className="flex min-w-0 flex-col gap-1.5" 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-0.5 rounded-lg border border-border bg-card p-2', |
|
inWeek && 'bg-primary/10 ring-1 ring-inset ring-primary/35' |
|
)} |
|
> |
|
<div className="flex items-baseline justify-between gap-2 border-b border-border/40 pb-0.5"> |
|
<span className="text-[13px] font-semibold leading-tight 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"> |
|
{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( |
|
'flex w-full min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-[11px] font-medium leading-snug text-primary', |
|
'hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' |
|
)} |
|
> |
|
<CalendarEventCoverImage |
|
coverUrl={meta.image} |
|
pubkey={ev.pubkey} |
|
className="size-7 shrink-0 rounded-md ring-1 ring-border/50" |
|
iconClassName="size-3.5" |
|
/> |
|
<span className="min-w-0 truncate">{title}</span> |
|
</button> |
|
</li> |
|
) |
|
})} |
|
</ul> |
|
{excess > 0 ? ( |
|
<button |
|
type="button" |
|
className="mt-0.5 w-full shrink-0 rounded-md py-1 text-center text-[11px] font-semibold text-primary underline-offset-2 hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" |
|
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( |
|
'flex w-full min-w-0 items-center gap-0.5 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} |
|
> |
|
<CalendarEventCoverImage |
|
coverUrl={meta.image} |
|
pubkey={ev.pubkey} |
|
className="size-3.5 shrink-0 rounded-sm ring-1 ring-border/40 md:size-4" |
|
iconClassName="size-2.5 md:size-3" |
|
/> |
|
<span className="min-w-0 flex-1 truncate">{title}</span> |
|
</button> |
|
</li> |
|
) |
|
})} |
|
</ul> |
|
{excess > 0 ? ( |
|
<button |
|
type="button" |
|
className="mt-0.5 w-full shrink-0 rounded py-0.5 text-center text-[9px] font-semibold text-primary underline-offset-2 hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-[10px]" |
|
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
|
|
|