From 84f7313662934cee99c3c97b7f21aa18d0eb700e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 6 May 2026 11:21:48 +0200 Subject: [PATCH] calendar implemented --- src/PageManager.tsx | 35 +- .../Sidebar/SidebarCalendarWeekWidget.tsx | 17 +- src/i18n/locales/cs.ts | 12 + src/i18n/locales/de.ts | 12 + src/i18n/locales/en.ts | 12 + src/i18n/locales/es.ts | 12 + src/i18n/locales/fr.ts | 12 + src/i18n/locales/nl.ts | 12 + src/i18n/locales/pl.ts | 12 + src/i18n/locales/ru.ts | 12 + src/i18n/locales/tr.ts | 12 + src/i18n/locales/zh.ts | 12 + src/lib/calendar-day-panel-cache.ts | 24 + src/lib/calendar-event.ts | 10 + src/lib/document-meta.ts | 5 +- src/lib/tag.ts | 2 +- src/pages/primary/CalendarPrimaryPage.tsx | 527 ++++++++++++++++++ .../secondary/CalendarDayEventsPage/index.tsx | 101 ++++ src/routes.tsx | 3 + src/services/client-query.service.ts | 6 + 20 files changed, 832 insertions(+), 18 deletions(-) create mode 100644 src/lib/calendar-day-panel-cache.ts create mode 100644 src/pages/primary/CalendarPrimaryPage.tsx create mode 100644 src/pages/secondary/CalendarDayEventsPage/index.tsx diff --git a/src/PageManager.tsx b/src/PageManager.tsx index df74cf77..2a2f6a27 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -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 = { 'follows-latest': createRef(), rss: createRef(), settings: createRef(), - spells: createRef() + spells: createRef(), + calendar: createRef() } // Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency @@ -184,6 +186,11 @@ const getPrimaryPageMap = () => ({ + ), + calendar: ( + + + ) }) @@ -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( 'spells', 'rss', 'explore', - 'follows-latest' + 'follows-latest', + 'calendar' ] let path = currentPage && contextualPages.includes(currentPage) @@ -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 }) { 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 }) { // 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 }) { // 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 }) { 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 }) { /* 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 }) { 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 }) { } // 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 }) { // 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( /** 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 diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index 51413fd7..0629bafb 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -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' 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() { const { favoriteRelays, blockedRelays } = useFavoriteRelays() const followList = useFollowListOptional() const { navigateToNote } = useSmartNoteNavigation() + const { navigate: navigatePrimary } = usePrimaryPage() const [weekOffset, setWeekOffset] = useState(0) const [rawEvents, setRawEvents] = useState([]) @@ -206,7 +208,7 @@ export default function SidebarCalendarWeekWidget() { return (
-
+
+ +
+

{monthTitle}

+ +
+ + {loading && rawEvents.length === 0 ? ( +

{t('sidebarCalendarLoading')}

+ ) : null} + + {isSmallScreen ? ( +
+ {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 ( +
+
+ + {weekdayShort} · {day} + + {list.length > 0 ? ( + + {t('calendarPageDayEventCount', { count: list.length })} + + ) : null} +
+
    + {list.slice(0, 4).map((ev) => { + const meta = getCalendarEventMeta(ev) + const title = meta.title?.trim() || t('calendarPageUntitledEvent') + return ( +
  • + +
  • + ) + })} +
+ {excess > 0 ? ( + + ) : null} +
+ ) + })} +
+ ) : ( +
+ {weekdayLabels.map((label) => ( +
+ {label} +
+ ))} + {gridCells.map((cell, idx) => { + if (cell.day == null) { + return
+ } + const day = cell.day + const list = eventsForDay(day) + const inWeek = isDayInHighlightWeek(day) + const excess = list.length > 4 ? list.length - 4 : 0 + return ( +
+ {day} +
    + {list.slice(0, 4).map((ev) => { + const meta = getCalendarEventMeta(ev) + const title = meta.title?.trim() || t('calendarPageUntitledEvent') + return ( +
  • + +
  • + ) + })} +
+ {excess > 0 ? ( + + ) : null} +
+ ) + })} +
+ )} +
+ + ) +}) + +function CalendarPageTitlebar({ onRefresh }: { onRefresh: () => void }) { + const { t } = useTranslation() + return ( +
+
+ +
{t('calendarPageTitle')}
+
+ +
+ ) +} + +CalendarPrimaryPage.displayName = 'CalendarPrimaryPage' +export default CalendarPrimaryPage diff --git a/src/pages/secondary/CalendarDayEventsPage/index.tsx b/src/pages/secondary/CalendarDayEventsPage/index.tsx new file mode 100644 index 00000000..23b93070 --- /dev/null +++ b/src/pages/secondary/CalendarDayEventsPage/index.tsx @@ -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(function CalendarDayEventsPage( + { ymd, index }, + ref +) { + const { t, i18n } = useTranslation() + const { navigateToNote } = useSmartNoteNavigation() + const [events, setEvents] = useState([]) + + 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 ( + +

{t('calendarDayPanelInvalidDate')}

+
+ ) + } + + return ( + +
+ {sorted.length === 0 ? ( +

{t('calendarDayPanelEmpty')}

+ ) : ( +
    + {sorted.map((ev) => { + const meta = getCalendarEventMeta(ev) + const label = meta.title?.trim() || t('calendarPageUntitledEvent') + return ( +
  • + +
  • + ) + })} +
+ )} +
+
+ ) +}) + +CalendarDayEventsPage.displayName = 'CalendarDayEventsPage' +export default CalendarDayEventsPage diff --git a/src/routes.tsx b/src/routes.tsx index 6f7e57ba..86bbd3f6 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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 = [ { 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) }, diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index d3add4f2..4adddd08 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -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)