Browse Source

calendar implemented

imwald
Silberengel 1 month ago
parent
commit
84f7313662
  1. 35
      src/PageManager.tsx
  2. 17
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  3. 12
      src/i18n/locales/cs.ts
  4. 12
      src/i18n/locales/de.ts
  5. 12
      src/i18n/locales/en.ts
  6. 12
      src/i18n/locales/es.ts
  7. 12
      src/i18n/locales/fr.ts
  8. 12
      src/i18n/locales/nl.ts
  9. 12
      src/i18n/locales/pl.ts
  10. 12
      src/i18n/locales/ru.ts
  11. 12
      src/i18n/locales/tr.ts
  12. 12
      src/i18n/locales/zh.ts
  13. 24
      src/lib/calendar-day-panel-cache.ts
  14. 10
      src/lib/calendar-event.ts
  15. 5
      src/lib/document-meta.ts
  16. 2
      src/lib/tag.ts
  17. 527
      src/pages/primary/CalendarPrimaryPage.tsx
  18. 101
      src/pages/secondary/CalendarDayEventsPage/index.tsx
  19. 3
      src/routes.tsx
  20. 6
      src/services/client-query.service.ts

35
src/PageManager.tsx

@ -87,6 +87,7 @@ const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage')) @@ -87,6 +87,7 @@ const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage'))
const FollowsLatestPageLazy = lazy(() => import('./pages/primary/FollowsLatestPage'))
const RssPageLazy = lazy(() => import('./pages/primary/RssPage'))
const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage'))
const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrimaryPage'))
/** Lazy chrome: Sidebar / bottom bar / dialogs import hooks from PageManager — must not be sync-imported here. */
const SidebarLazy = lazy(() => import('@/components/Sidebar'))
@ -129,7 +130,8 @@ const PRIMARY_PAGE_REF_MAP = { @@ -129,7 +130,8 @@ const PRIMARY_PAGE_REF_MAP = {
'follows-latest': createRef<TPageRef>(),
rss: createRef<TPageRef>(),
settings: createRef<TPageRef>(),
spells: createRef<TPageRef>()
spells: createRef<TPageRef>(),
calendar: createRef<TPageRef>()
}
// Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency
@ -184,6 +186,11 @@ const getPrimaryPageMap = () => ({ @@ -184,6 +186,11 @@ const getPrimaryPageMap = () => ({
<Suspense fallback={primaryPageLazyFallback}>
<SpellsPageLazy ref={PRIMARY_PAGE_REF_MAP.spells} />
</Suspense>
),
calendar: (
<Suspense fallback={primaryPageLazyFallback}>
<CalendarPrimaryPageLazy ref={PRIMARY_PAGE_REF_MAP.calendar} />
</Suspense>
)
})
@ -269,7 +276,8 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str @@ -269,7 +276,8 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
'spells',
'rss',
'explore',
'follows-latest'
'follows-latest',
'calendar'
]
if (currentPage && contextualPages.includes(currentPage)) {
@ -292,7 +300,8 @@ function buildRssArticleUrl( @@ -292,7 +300,8 @@ function buildRssArticleUrl(
'spells',
'rss',
'explore',
'follows-latest'
'follows-latest',
'calendar'
]
let path =
currentPage && contextualPages.includes(currentPage)
@ -409,7 +418,7 @@ function extractValidNoteId(raw: string): string | null { @@ -409,7 +418,7 @@ function extractValidNoteId(raw: string): string | null {
function parseNoteUrl(url: string): { noteId: string; context?: string } | null {
// Match patterns like /discussions/notes/{noteId} or /notes/{noteId}
const contextualMatch = url.match(
/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/
/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/
)
if (contextualMatch) {
const noteId = extractValidNoteId(contextualMatch[2])
@ -1227,7 +1236,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1227,7 +1236,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const pathname = window.location.pathname
// Check if this is a note URL - handle both /notes/{id} and /{context}/notes/{id}
const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/)
const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/)
const standardNoteMatch = pathname.match(/\/notes\/(.+)$/)
const noteUrlMatch = contextualNoteMatch || standardNoteMatch
@ -1290,7 +1299,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1290,7 +1299,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// RSS article in side panel: /{context}/rss-item/{key} or /rss-item/{key}
const contextualRssMatch = pathname.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/rss-item\/([^/?#]+)/
/^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/rss-item\/([^/?#]+)/
)
const standardRssMatch = pathname.match(/^\/rss-item\/([^/?#]+)/)
const rssArticleKey = contextualRssMatch?.[2] ?? standardRssMatch?.[1]
@ -1415,7 +1424,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1415,7 +1424,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Check if pathname matches a primary page name
// First, check if it's a contextual note URL (e.g., /discussions/notes/...)
const contextualNoteMatch = pathname.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\//
/^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\//
)
if (contextualNoteMatch) {
const pageContext = contextualNoteMatch[1]
@ -1485,7 +1494,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1485,7 +1494,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const urlToCheck = state?.url || window.location.pathname
// Check if it's a note URL (we'll update drawer after stack is synced)
const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/) ||
const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/) ||
urlToCheck.match(/\/notes\/(.+)$/)
const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null
@ -1507,7 +1516,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1507,7 +1516,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
/* keep pathname */
}
const ctxRssPop = rssPathSync.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/rss-item\/([^/?#]+)/
/^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/rss-item\/([^/?#]+)/
)
if (ctxRssPop) {
const resolvedPop = noteContextToPrimaryEntry(ctxRssPop[1])
@ -1542,7 +1551,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1542,7 +1551,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (topItemUrl) {
const topNoteUrlMatch =
topItemUrl.match(
/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/
/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/
) || topItemUrl.match(/\/notes\/(.+)$/)
if (topNoteUrlMatch) {
const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1]
@ -1638,7 +1647,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1638,7 +1647,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
// Check if navigating to a note URL (supports both /notes/{id} and /{context}/notes/{id})
const noteUrlMatch = state.url.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/) ||
const noteUrlMatch = state.url.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/) ||
state.url.match(/\/notes\/(.+)$/)
if (noteUrlMatch) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
@ -1695,7 +1704,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1695,7 +1704,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Extract noteId from top item's URL or from state.url
const topItemUrl = newStack[newStack.length - 1]?.url || state?.url
if (topItemUrl) {
const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/) ||
const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/) ||
topItemUrl.match(/\/notes\/(.+)$/)
if (topNoteUrlMatch) {
const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0]
@ -2374,7 +2383,7 @@ function cloneSecondaryRouteElement( @@ -2374,7 +2383,7 @@ function cloneSecondaryRouteElement(
/** Hex id segment from /notes/{id} or /{context}/notes/{id} (query/hash stripped). */
function noteHexIdFromSecondaryNoteUrl(url: string): string | null {
const contextual = url.match(
/\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/
/\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/
)
const standard = url.match(/\/notes\/(.+)$/)
const m = contextual || standard

17
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -9,6 +9,7 @@ import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } fro @@ -9,6 +9,7 @@ import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } fro
import { replaceableEventDedupeKey } from '@/lib/event'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSmartNoteNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useFollowListOptional } from '@/providers/follow-list-context'
@ -17,7 +18,7 @@ import client from '@/services/client.service' @@ -17,7 +18,7 @@ import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { CalendarDays, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { type Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -51,6 +52,7 @@ export default function SidebarCalendarWeekWidget() { @@ -51,6 +52,7 @@ export default function SidebarCalendarWeekWidget() {
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const followList = useFollowListOptional()
const { navigateToNote } = useSmartNoteNavigation()
const { navigate: navigatePrimary } = usePrimaryPage()
const [weekOffset, setWeekOffset] = useState(0)
const [rawEvents, setRawEvents] = useState<Event[]>([])
@ -206,7 +208,7 @@ export default function SidebarCalendarWeekWidget() { @@ -206,7 +208,7 @@ export default function SidebarCalendarWeekWidget() {
return (
<div className="max-xl:hidden w-full min-w-0 rounded-lg border border-border/60 bg-card/40 px-2 py-2 shadow-sm">
<div className="mb-1.5 flex items-center justify-between gap-1">
<div className="mb-1.5 flex items-center justify-between gap-0.5">
<Button
type="button"
variant="ghost"
@ -223,6 +225,17 @@ export default function SidebarCalendarWeekWidget() { @@ -223,6 +225,17 @@ export default function SidebarCalendarWeekWidget() {
>
{weekLabel}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t('sidebarCalendarOpenMonthView')}
title={t('sidebarCalendarOpenMonthView')}
onClick={() => navigatePrimary('calendar', { weekOffset })}
>
<CalendarDays className="size-4" />
</Button>
<Button
type="button"
variant="ghost"

12
src/i18n/locales/cs.ts

@ -760,6 +760,18 @@ export default { @@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/de.ts

@ -760,6 +760,18 @@ export default { @@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "Keine Kalender-Termine in dieser Woche.",
sidebarCalendarLoading: "Laden…",
sidebarCalendarNoRelays: "Lese-Relays in den Einstellungen eintragen, um Kalender-Termine zu laden.",
sidebarCalendarOpenMonthView: "Monatsansicht öffnen",
calendarPageTitle: "Kalender",
calendarPagePrevMonth: "Vorheriger Monat",
calendarPageNextMonth: "Nächster Monat",
calendarPageThisWeek: "Diese Woche",
calendarPageGridLabel: "Monatskalender",
calendarPageUntitledEvent: "Termin",
calendarPageMoreEventsAria: "{{count}} weitere Termine an diesem Tag anzeigen",
calendarPageDayEventCount: "{{count}} Termine",
calendarDayPanelEmpty:
"Keine zwischengespeicherten Termine. Panel schließen, Kalender erneut öffnen und „+N“ tippen.",
calendarDayPanelInvalidDate: "Ungültiges Datum.",
"Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Die Relays haben keine Ereignisse für diesen Feed geliefert. Sie können offline sein, langsam antworten oder diese Notizen nicht indexieren.",
"Per-relay timeline results ({{count}} connections)": "Ergebnis je Relay ({{count}} Verbindungen)",

12
src/i18n/locales/en.ts

@ -764,6 +764,18 @@ export default { @@ -764,6 +764,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/es.ts

@ -760,6 +760,18 @@ export default { @@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/fr.ts

@ -760,6 +760,18 @@ export default { @@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/nl.ts

@ -760,6 +760,18 @@ export default { @@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/pl.ts

@ -760,6 +760,18 @@ export default { @@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/ru.ts

@ -760,6 +760,18 @@ export default { @@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/tr.ts

@ -760,6 +760,18 @@ export default { @@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/zh.ts

@ -760,6 +760,18 @@ export default { @@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

24
src/lib/calendar-day-panel-cache.ts

@ -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
}
}

10
src/lib/calendar-event.ts

@ -201,6 +201,16 @@ export function calendarOccurrenceOverlapsRange( @@ -201,6 +201,16 @@ export function calendarOccurrenceOverlapsRange(
}
/** Monday 00:00 local through the following Monday 00:00 (exclusive), shifted by `weekOffset` weeks from the anchor week. */
/** Local midnight on the 1st through midnight on the 1st of the following month (exclusive). */
export function getLocalMonthRangeMs(
year: number,
monthIndex: number
): { startMs: number; endExclusiveMs: number } {
const start = new Date(year, monthIndex, 1, 0, 0, 0, 0)
const end = new Date(year, monthIndex + 1, 1, 0, 0, 0, 0)
return { startMs: start.getTime(), endExclusiveMs: end.getTime() }
}
export function getLocalMondayWeekBounds(
weekOffset: number,
anchor: Date = new Date()

5
src/lib/document-meta.ts

@ -76,7 +76,8 @@ const PRIMARY_PAGE_LABEL: Record<string, string> = { @@ -76,7 +76,8 @@ const PRIMARY_PAGE_LABEL: Record<string, string> = {
'follows-latest': 'Latest follows',
rss: 'RSS',
settings: 'Settings',
spells: 'Spells'
spells: 'Spells',
calendar: 'Calendar'
}
function relayHostnameFromPath(pathname: string): string | null {
@ -104,7 +105,7 @@ export function isNoteDetailPathname(pathname: string): boolean { @@ -104,7 +105,7 @@ export function isNoteDetailPathname(pathname: string): boolean {
const path = pathname.split('?')[0].split('#')[0]
return (
/\/notes\/[^/?#]+/.test(path) ||
/\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/[^/?#]+/.test(
/\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/[^/?#]+/.test(
path
)
)

2
src/lib/tag.ts

@ -313,7 +313,7 @@ export function getPubkeysFromPTags(tags: string[][]) { @@ -313,7 +313,7 @@ export function getPubkeysFromPTags(tags: string[][]) {
new Set(
tags
.filter(tagNameEquals('p'))
.map(([, pubkey]) => pubkey)
.map(([, pubkey]) => (pubkey ? pubkey.trim().toLowerCase() : ''))
.filter((pubkey) => !!pubkey && isValidPubkey(pubkey))
.reverse()
)

527
src/pages/primary/CalendarPrimaryPage.tsx

@ -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

101
src/pages/secondary/CalendarDayEventsPage/index.tsx

@ -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

3
src/routes.tsx

@ -36,6 +36,7 @@ const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage')) @@ -36,6 +36,7 @@ const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage'))
const WalletPageLazy = lazy(() => import('./pages/secondary/WalletPage'))
const FollowPacksRedirectLazy = lazy(() => import('./pages/secondary/FollowPacksRedirect'))
const RssArticlePageLazy = lazy(() => import('./pages/secondary/RssArticlePage'))
const CalendarDayEventsPageLazy = lazy(() => import('./pages/secondary/CalendarDayEventsPage'))
const routeSuspenseFallback = null
@ -59,6 +60,8 @@ const ROUTES = [ @@ -59,6 +60,8 @@ const ROUTES = [
{ path: '/feed/notes/:id', element: SR(NotePageLazy) },
{ path: '/spells/notes/:id', element: SR(NotePageLazy) },
{ path: '/rss/notes/:id', element: SR(NotePageLazy) },
{ path: '/calendar/notes/:id', element: SR(NotePageLazy) },
{ path: '/calendar/day/:ymd', element: SR(CalendarDayEventsPageLazy) },
{ path: '/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/rss/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/feed/rss-item/:articleKey', element: SR(RssArticlePageLazy) },

6
src/services/client-query.service.ts

@ -842,6 +842,12 @@ export class QueryService { @@ -842,6 +842,12 @@ export class QueryService {
const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url))
relays = relaysAfterSocialKindBlockedStrip(originalDedupedRelays, stripped)
if (relays.length === 0) {
const fallback = [...FAST_READ_RELAY_URLS].filter(
(url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)
)
relays = fallback.length > 0 ? fallback : [...FAST_READ_RELAY_URLS]
}
}
if (relayFiltersUseCapitalLetterTagKeys(filters)) {
relays = relayUrlsStripExtendedTagReqBlocked(relays)

Loading…
Cancel
Save