diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index c259fa9..90e74b8 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -6,6 +6,10 @@ import { Skeleton } from "@/components/ui/skeleton"; // Lazy-loaded pages: only downloaded when the user navigates to them const EventDetail = lazy(() => import("@/pages/EventDetail").then(m => ({ default: m.EventDetail }))); const CreateEvent = lazy(() => import("@/pages/CreateEvent").then(m => ({ default: m.CreateEvent }))); +const CreateCalendar = lazy(() => import("@/pages/CreateCalendar").then(m => ({ default: m.CreateCalendar }))); +const EditCalendar = lazy(() => import("@/pages/EditCalendar").then(m => ({ default: m.EditCalendar }))); +const CalendarView = lazy(() => import("@/pages/CalendarView").then(m => ({ default: m.CalendarView }))); +const CalendarsFeed = lazy(() => import("@/pages/CalendarsFeed").then(m => ({ default: m.CalendarsFeed }))); const Profile = lazy(() => import("@/pages/Profile").then(m => ({ default: m.Profile }))); const MyTickets = lazy(() => import("@/pages/MyTickets").then(m => ({ default: m.MyTickets }))); const SocialFeed = lazy(() => import("@/pages/SocialFeed").then(m => ({ default: m.SocialFeed }))); @@ -32,6 +36,10 @@ export default function AppRouter() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/AddToGroupCalendarDialog.tsx b/src/components/AddToGroupCalendarDialog.tsx new file mode 100644 index 0000000..7dd72c7 --- /dev/null +++ b/src/components/AddToGroupCalendarDialog.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNostr } from "@/hooks/useNostr"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { useNostrPublish } from "@/hooks/useNostrPublish"; +import { useUserCalendars, addEventToCalendar, removeEventFromCalendar, createCoordinate } from "@/lib/calendarUtils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { CalendarDays, Loader2, Plus, Check, Minus } from "lucide-react"; +import { Link } from "react-router-dom"; + +export interface AddToGroupCalendarDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + eventCoordinate: string; // The NIP-19 naddr, nevent, or raw id/coord to be added +} + +export function AddToGroupCalendarDialog({ open, onOpenChange, eventCoordinate }: AddToGroupCalendarDialogProps) { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { data: userCalendars = [], isLoading: isLoadingCalendars } = useUserCalendars(user?.pubkey); + const { mutate: createEvent } = useNostrPublish(); + const queryClient = useQueryClient(); + + const [processingId, setProcessingId] = useState(null); + const [localStatus, setLocalStatus] = useState>({}); + + const handleToggleCalendar = async (calendarCoordinate: string, currentlyAdded: boolean) => { + if (!nostr || !user) return; + + setProcessingId(calendarCoordinate); + + try { + if (currentlyAdded) { + await removeEventFromCalendar(nostr, createEvent, calendarCoordinate, eventCoordinate); + setLocalStatus(prev => ({ ...prev, [calendarCoordinate]: false })); + toast.info("Event removed from calendar."); + } else { + await addEventToCalendar(nostr, createEvent, calendarCoordinate, eventCoordinate); + setLocalStatus(prev => ({ ...prev, [calendarCoordinate]: true })); + toast.success("Event added to calendar!"); + } + + // Invalidate the specific calendar query so it refreshes its event list + queryClient.invalidateQueries({ queryKey: ['calendarEvents', calendarCoordinate] }); + queryClient.invalidateQueries({ queryKey: ['calendars'] }); // Update the calendar map + + } catch (error: any) { + if (error.message === "Event is already in this calendar") { + toast.info(error.message); + setLocalStatus(prev => ({ ...prev, [calendarCoordinate]: true })); + } else if (error.message === "Event is not in this calendar") { + toast.info(error.message); + setLocalStatus(prev => ({ ...prev, [calendarCoordinate]: false })); + } else { + console.error("Failed to modify calendar:", error); + toast.error(error.message || "Failed to modify calendar"); + } + } finally { + setProcessingId(null); + } + }; + + return ( + + + +
+ +
+ Add to Group Calendar + + Feature this event on one of your community calendars. + +
+ + {isLoadingCalendars ? ( +
+ +

Loading your calendars...

+
+ ) : userCalendars.length === 0 ? ( +
+ +

No Calendars Found

+

You haven't created any group calendars yet.

+ +
+ ) : ( +
+ {userCalendars.map((cal) => { + const calendarCoordinate = createCoordinate(31924, cal.pubkey, cal.d); + const isProcessing = processingId === calendarCoordinate; + // Check local overrides first, otherwise fall back to fetched calendar states. + const isAdded = localStatus[calendarCoordinate] ?? cal.events.includes(eventCoordinate); + + return ( +
+
+ {cal.title} +
+

{cal.title}

+
+
+ + +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/AppNavigation.tsx b/src/components/AppNavigation.tsx index 57b2fbd..70e3827 100644 --- a/src/components/AppNavigation.tsx +++ b/src/components/AppNavigation.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from "react-router-dom"; -import { Search, Plus, Ticket, User, Heart, QrCode } from "lucide-react"; +import { Search, Plus, Ticket, User, Heart, QrCode, CalendarDays } from "lucide-react"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { LoginArea } from "@/components/auth/LoginArea"; @@ -57,6 +57,12 @@ export function AppNavigation({ children }: AppNavigationProps) { isActive: location.pathname === "/", onClick: handleDiscoverClick, }, + { + href: "/calendars", + label: "Calendars", + icon: CalendarDays, + isActive: location.pathname === "/calendars", + }, { href: "/feed", label: "Feed", diff --git a/src/components/CalendarOptions.tsx b/src/components/CalendarOptions.tsx index 4dc17b4..1b3ae7c 100644 --- a/src/components/CalendarOptions.tsx +++ b/src/components/CalendarOptions.tsx @@ -6,8 +6,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Calendar, Download, ExternalLink } from "lucide-react"; +import { Calendar, Download, ExternalLink, CalendarDays } from "lucide-react"; import { downloadICS, openInCalendar, getCalendarOptions } from "@/lib/icsExport"; +import { createEventIdentifier } from "@/lib/nip19Utils"; +import { AddToGroupCalendarDialog } from "./AddToGroupCalendarDialog"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; import { openUrl } from "@/lib/utils"; import type { DateBasedEvent, TimeBasedEvent, LiveEvent, RoomMeeting } from "@/lib/eventTypes"; @@ -18,6 +21,8 @@ interface CalendarOptionsProps { export function CalendarOptions({ event, className }: CalendarOptionsProps) { const [isOpen, setIsOpen] = useState(false); + const [groupCalendarDialogOpen, setGroupCalendarDialogOpen] = useState(false); + const { user } = useCurrentUser(); const handleQuickAdd = () => { try { @@ -45,70 +50,97 @@ export function CalendarOptions({ event, className }: CalendarOptionsProps) { } }; + const isReplaceable = event.kind >= 30000 && event.kind < 40000; + const dTag = event.tags.find(t => t[0] === 'd')?.[1]; + const eventCoordinate = isReplaceable && dTag ? `${event.kind}:${event.pubkey}:${dTag}` : event.id; + return ( - - - - - - - - Quick Add (Auto-detect) - - -
- - handleCalendarProvider('google')} className="cursor-pointer"> -
-
- G + <> + + + + + + + + Quick Add (Auto-detect) + + + {user && ( + { + setIsOpen(false); + setGroupCalendarDialogOpen(true); + }} + className="cursor-pointer font-semibold text-primary" + > + + Group Calendar + + )} + +
+ + handleCalendarProvider('google')} className="cursor-pointer"> +
+
+ G +
+ Google Calendar
- Google Calendar -
- - - handleCalendarProvider('outlook')} className="cursor-pointer"> -
-
- O + + + handleCalendarProvider('outlook')} className="cursor-pointer"> +
+
+ O +
+ Outlook Calendar
- Outlook Calendar -
- - - handleCalendarProvider('yahoo')} className="cursor-pointer"> -
-
- Y + + + handleCalendarProvider('yahoo')} className="cursor-pointer"> +
+
+ Y +
+ Yahoo Calendar
- Yahoo Calendar -
- - - handleCalendarProvider('apple')} className="cursor-pointer"> -
-
- 🍎 + + + handleCalendarProvider('apple')} className="cursor-pointer"> +
+
+ 🍎 +
+ Apple Calendar
- Apple Calendar -
- - -
- - - - Download .ics file - - - + + +
+ + + + Download .ics file + + + + + {user && ( + + )} + ); } diff --git a/src/components/LocationDisplay.tsx b/src/components/LocationDisplay.tsx index 48fab63..97cdbd5 100644 --- a/src/components/LocationDisplay.tsx +++ b/src/components/LocationDisplay.tsx @@ -27,24 +27,24 @@ const isURL = (text: string): boolean => { const getLocationIcon = (location: string) => { const lowerLocation = location.toLowerCase(); - + // Check if it's a URL if (isURL(location)) { - if (lowerLocation.includes("maps.google") || - lowerLocation.includes("goo.gl/maps") || - lowerLocation.includes("openstreetmap") || - lowerLocation.includes("mapquest") || - lowerLocation.includes("bing.com/maps")) { + if (lowerLocation.includes("maps.google") || + lowerLocation.includes("goo.gl/maps") || + lowerLocation.includes("openstreetmap") || + lowerLocation.includes("mapquest") || + lowerLocation.includes("bing.com/maps")) { return ; } return ; } - + // Check if it looks like a file path if (location.includes("/") && !location.includes(" ")) { return ; } - + // Default to map pin for addresses return ; }; @@ -53,12 +53,12 @@ const formatURL = (location: string): string => { if (location.startsWith("http://") || location.startsWith("https://")) { return location; } - + // Add https:// prefix if it looks like a domain if (/^[a-zA-Z0-9-]+\.[a-zA-Z]{2,}/.test(location.trim())) { return `https://${location}`; } - + return location; }; @@ -70,7 +70,7 @@ export function LocationDisplay({ location, className }: LocationDisplayProps) { const locationIsClickable = isURL(location); const icon = getLocationIcon(location); const displayLocation = location.trim(); - + if (locationIsClickable) { return ( + {icon} {displayLocation} -
+ + ); } \ No newline at end of file diff --git a/src/components/LocationSearch.tsx b/src/components/LocationSearch.tsx index 77f9a8e..f677512 100644 --- a/src/components/LocationSearch.tsx +++ b/src/components/LocationSearch.tsx @@ -97,8 +97,8 @@ export function LocationSearch({ >
{value ? ( - {value} @@ -116,7 +116,7 @@ export function LocationSearch({ className="w-[--radix-popover-trigger-width] p-0 max-w-[90vw]" align="start" > - + ()); - + // Only use calendar-optimized data loading if no events are passed const { data: calendarEvents, isLoading } = useCalendarEvents(currentDate); - + // Prioritize passed events, fallback to loaded calendar events (filtering out RSVPs) - const events = passedEvents ?? (calendarEvents?.filter((event) => + const events = passedEvents ?? (calendarEvents?.filter((event) => event.kind === 31922 || event.kind === 31923 || event.kind === 30311 || event.kind === 30312 || event.kind === 30313 ) as (DateBasedEvent | TimeBasedEvent | LiveEvent | RoomMeeting | InteractiveRoom)[] ?? []); @@ -56,28 +56,28 @@ export function MonthlyCalendarView({ events: passedEvents, className }: Monthly } } else { let timestamp = parseInt(startTime); - + // Handle both seconds and milliseconds timestamps if (timestamp < 10000000000) { // Likely in seconds, convert to milliseconds timestamp = timestamp * 1000; } - + eventDate = new Date(timestamp); } - return eventDate.getFullYear() === currentDate.getFullYear() && - eventDate.getMonth() === currentDate.getMonth(); + return eventDate.getFullYear() === currentDate.getFullYear() && + eventDate.getMonth() === currentDate.getMonth(); }); // Get the first day of the current month const firstDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); const lastDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0); - + // Get the first day of the calendar grid (might be from previous month) const firstDayOfCalendar = new Date(firstDayOfMonth); firstDayOfCalendar.setDate(firstDayOfCalendar.getDate() - firstDayOfMonth.getDay()); - + // Get the last day of the calendar grid (might be from next month) const lastDayOfCalendar = new Date(lastDayOfMonth); lastDayOfCalendar.setDate(lastDayOfCalendar.getDate() + (6 - lastDayOfMonth.getDay())); @@ -90,9 +90,16 @@ export function MonthlyCalendarView({ events: passedEvents, className }: Monthly currentDay.setDate(currentDay.getDate() + 1); } + const getLocalDateKey = (d: Date) => { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + // Group events by date const eventsByDate = new Map(); - + events.forEach((event) => { const startTime = (event.kind === 30311 || event.kind === 30312 || event.kind === 30313) ? event.tags.find((tag) => tag[0] === "starts")?.[1] @@ -101,7 +108,7 @@ export function MonthlyCalendarView({ events: passedEvents, className }: Monthly const title = event.tags.find((tag) => tag[0] === "title")?.[1] || "Untitled"; const eventTimezone = getEventTimezone(event); - + let eventDate: Date; let formattedTime: string | undefined; @@ -119,28 +126,28 @@ export function MonthlyCalendarView({ events: passedEvents, className }: Monthly } else { // Time-based event, live event, or room meeting let timestamp = parseInt(startTime); - + // Handle both seconds and milliseconds timestamps if (timestamp < 10000000000) { // Likely in seconds, convert to milliseconds timestamp = timestamp * 1000; } - + eventDate = new Date(timestamp); - + // Format the time in the event's timezone formattedTime = formatEventTime(timestamp, eventTimezone); } if (isNaN(eventDate.getTime())) return; - // Create date key (YYYY-MM-DD) - const dateKey = eventDate.toISOString().split('T')[0]; - + // Create date key (YYYY-MM-DD) natively in the browser's local timezone + const dateKey = getLocalDateKey(eventDate); + if (!eventsByDate.has(dateKey)) { eventsByDate.set(dateKey, []); } - + eventsByDate.get(dateKey)!.push({ event, title, @@ -245,7 +252,7 @@ export function MonthlyCalendarView({ events: passedEvents, className }: Monthly {/* Calendar Days */} {calendarDays.map((date, index) => { - const dateKey = date.toISOString().split('T')[0]; + const dateKey = getLocalDateKey(date); const dayEvents = eventsByDate.get(dateKey) || []; const isCurrentMonthDay = isCurrentMonth(date); const isTodayDate = isToday(date); @@ -280,8 +287,8 @@ export function MonthlyCalendarView({ events: passedEvents, className }: Monthly calendarEvent.isLiveEvent ? "bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900 dark:text-red-200" : calendarEvent.isTimeEvent - ? "bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-200" - : "bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900 dark:text-green-200" + ? "bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-200" + : "bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900 dark:text-green-200" )}> {calendarEvent.startTime && ( @@ -294,7 +301,7 @@ export function MonthlyCalendarView({ events: passedEvents, className }: Monthly
))} - + {/* Show "more" indicator if there are additional events */} {dayEvents.length > 3 && (
@@ -323,7 +330,7 @@ export function MonthlyCalendarView({ events: passedEvents, className }: Monthly All-day events
- + {/* Show load more hint if no events in current month and we haven't requested more */} {!hasEventsInCurrentMonth && !requestedMonths.has(currentMonthKey) && !passedEvents && ( +
+ ); + }) + ) : ( +
+ +

You haven't created any events yet.

+ +
+ )} +
+ + + ); +} diff --git a/src/components/TicketQRCode.tsx b/src/components/TicketQRCode.tsx index 37a5fac..b1775b2 100644 --- a/src/components/TicketQRCode.tsx +++ b/src/components/TicketQRCode.tsx @@ -15,14 +15,11 @@ export function TicketQRCode({ ticket }: TicketQRCodeProps) { const [copied, setCopied] = useState(false); const [qrCodeDataUrl, setQrCodeDataUrl] = useState(""); - // Create a ticket verification URL + // Create a ticket verification URL - optimized to only contain essential IDs + // Verification page will fetch the rest of the metadata from Nostr const ticketData = { eventId: ticket.event.id, receiptId: ticket.zapReceipt.id, - amount: ticket.amount, - buyerPubkey: ticket.zapReceipt.pubkey, - eventTitle: ticket.eventTitle, - purchaseTime: ticket.zapReceipt.created_at, }; const ticketUrl = `${window.location.origin}/verify-ticket?data=${encodeURIComponent(JSON.stringify(ticketData))}`; @@ -87,9 +84,9 @@ export function TicketQRCode({ ticket }: TicketQRCodeProps) {
Scan QR Code at Event
{qrCodeDataUrl ? ( - Ticket QR Code ) : ( diff --git a/src/lib/calendarUtils.ts b/src/lib/calendarUtils.ts new file mode 100644 index 0000000..3d2214a --- /dev/null +++ b/src/lib/calendarUtils.ts @@ -0,0 +1,340 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@/hooks/useNostr'; +import { CalendarEvent, BaseEvent } from './eventTypes'; + +// Helpers to cleanly interact with kind: 31924 NIP-52 events +export interface CalendarData { + id: string; + pubkey: string; + created_at: number; + kind: number; + d: string; + title: string; + description: string; + image?: string; + events: string[]; // List of reference coordinates or ids representing events included + rejected?: string[]; // List of blocked coordinates + hashtags?: string[]; // Auto-include events with these hashtags + locations?: string[]; // Auto-include events matching these locations + matchType?: 'any' | 'all'; // Determine if filters are joined by OR (any) or AND (all) +} + +export function parseCalendarEvent(event: any): CalendarData | null { + if (event.kind !== 31924) return null; + + const getTag = (key: string) => event.tags.find((t: any) => t[0] === key)?.[1] || ''; + + const d = getTag('d'); + const title = getTag('title'); + const image = getTag('image'); + + // Extract all the target events referenced within this calendar + const includedEvents = event.tags + .filter((t: any) => t[0] === 'a' || t[0] === 'e') + .map((t: any) => t[1]); + + // Extract blocked/rejected events + const rejectedEvents = event.tags + .filter((t: any) => t[0] === 'rejected' || t[0] === '-') // fallback to `-` just in case + .map((t: any) => t[1]); + + // Extract auto-include filters + const hashtags = event.tags + .filter((t: any) => t[0] === 't') + .map((t: any) => t[1]); + + const locations = event.tags + .filter((t: any) => t[0] === 'location') + .map((t: any) => t[1]); + + const matchTypeTag = event.tags.find((t: any) => t[0] === 'match_type'); + const matchType = matchTypeTag && matchTypeTag[1] === 'all' ? 'all' : 'any'; + + if (!d || !title) return null; + + return { + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + d, + title, + description: event.content || '', + image, + events: includedEvents, + rejected: rejectedEvents, + hashtags, + locations, + matchType + }; +} + +export function useUserCalendars(pubkey?: string) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['calendars', 'user', pubkey], + enabled: !!pubkey && !!nostr, + queryFn: async () => { + if (!pubkey) return []; + + const events = await nostr.query([ + { + kinds: [31924], + authors: [pubkey], + limit: 100 + } + ]); + + const parsed = events + .map(parseCalendarEvent) + .filter((c): c is CalendarData => c !== null); + + return parsed.sort((a, b) => b.created_at - a.created_at); + } + }); +} + +// Coordinate format is commonly required for NIP-52 (kind:pubkey:d-identifier) +// and `a` tag resolution +export function createCoordinate(kind: number, pubkey: string, d: string) { + return `${kind}:${pubkey}:${d}`; +} + +export async function addEventToCalendar( + nostr: any, + createEvent: any, + calendarCoordinate: string, + eventCoordinate: string +) { + // 1. Fetch the exact calendar event from relays + const parts = calendarCoordinate.split(':'); + if (parts.length !== 3) throw new Error("Invalid calendar coordinate"); + + const [, pubkey, dTag] = parts; + + const events = await nostr.query([ + { + kinds: [31924], + authors: [pubkey], + '#d': [dTag], + } + ]); + + if (events.length === 0) { + throw new Error("Calendar not found"); + } + + // 2. Extract existing tags + const calendarEvent = events[0]; + const existingTags = [...calendarEvent.tags]; + + // 3. Check if the event is already in the calendar + const isDuplicate = existingTags.some( + (tag: any) => (tag[0] === 'a' || tag[0] === 'e') && tag[1] === eventCoordinate + ); + + if (isDuplicate) { + throw new Error("Event is already in this calendar"); + } + + // 4. Determine if we are adding an 'a' tag (replaceable) or 'e' tag (regular) + const isReplaceable = eventCoordinate.includes(':'); + const tagType = isReplaceable ? 'a' : 'e'; + + // 5. Append the new tag + existingTags.push([tagType, eventCoordinate]); + + // 6. Republish the calendar event + return new Promise((resolve, reject) => { + createEvent({ + kind: 31924, + content: calendarEvent.content, + tags: existingTags, + }, { + onSuccess: resolve, + onError: reject + }); + }); +} + +export async function removeEventFromCalendar( + nostr: any, + createEvent: any, + calendarCoordinate: string, + eventCoordinate: string +) { + // 1. Fetch the exact calendar event from relays + const parts = calendarCoordinate.split(':'); + if (parts.length !== 3) throw new Error("Invalid calendar coordinate"); + + const [, pubkey, dTag] = parts; + + const events = await nostr.query([ + { + kinds: [31924], + authors: [pubkey], + '#d': [dTag], + } + ]); + + if (events.length === 0) { + throw new Error("Calendar not found"); + } + + // 2. Extract existing tags and remove the target + const calendarEvent = events[0]; + const originalCount = calendarEvent.tags.length; + + const newTags = calendarEvent.tags.filter((tag: any) => { + if (tag[0] === 'a' || tag[0] === 'e') { + return tag[1] !== eventCoordinate; + } + return true; + }); + + if (newTags.length === originalCount) { + throw new Error("Event is not in this calendar"); + } + + // 3. Republish the calendar event + return new Promise((resolve, reject) => { + createEvent({ + kind: 31924, + content: calendarEvent.content, + tags: newTags, + }, { + onSuccess: resolve, + onError: reject + }); + }); +} + +export async function deleteCalendarEvent( + nostr: any, + createEvent: any, + calendarCoordinate: string +) { + // 1. Fetch the exact calendar event from relays to get its ID + const parts = calendarCoordinate.split(':'); + if (parts.length !== 3) throw new Error("Invalid calendar coordinate"); + + const [, pubkey, dTag] = parts; + + const events = await nostr.query([ + { + kinds: [31924], + authors: [pubkey], + '#d': [dTag], + } + ]); + + if (events.length === 0) { + throw new Error("Calendar not found"); + } + + const eventId = events[0].id; + + // 2. Discard the calendar using a NIP-09 deletion event indicating both its ID and coordinate + return new Promise((resolve, reject) => { + createEvent({ + kind: 5, + content: "Deleted group calendar", + tags: [ + ['e', eventId], + ['a', calendarCoordinate] + ], + }, { + onSuccess: resolve, + onError: reject + }); + }); +} + +export async function rejectEventFromCalendar( + nostr: any, + createEvent: any, + calendarCoordinate: string, + eventCoordinate: string +) { + // 1. Fetch the exact calendar event from relays + const parts = calendarCoordinate.split(':'); + if (parts.length !== 3) throw new Error("Invalid calendar coordinate"); + + const [, pubkey, dTag] = parts; + + const events = await nostr.query([ + { + kinds: [31924], + authors: [pubkey], + '#d': [dTag], + } + ]); + + if (events.length === 0) { + throw new Error("Calendar not found"); + } + + // 2. Extract existing tags + const calendarEvent = events[0]; + const existingTags = [...calendarEvent.tags]; + + // 3. Prevent duplicate rejections + const isDuplicate = existingTags.some( + (tag: any) => (tag[0] === 'rejected' || tag[0] === '-') && tag[1] === eventCoordinate + ); + + if (isDuplicate) { + throw new Error("Event is already rejected"); + } + + // 4. Append the new rejection tag + existingTags.push(['rejected', eventCoordinate]); + + // 5. Republish the calendar event + return new Promise((resolve, reject) => { + createEvent({ + kind: 31924, + content: calendarEvent.content, + tags: existingTags, + }, { + onSuccess: resolve, + onError: reject + }); + }); +} + +export function useAllCalendars() { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['calendars', 'all'], + enabled: !!nostr, + queryFn: async () => { + const events = await nostr.query([ + { + kinds: [31924], + limit: 250 + } + ]); + + const parsed = events + .map(parseCalendarEvent) + .filter((c): c is CalendarData => c !== null); + + // Deduplicate replaceable events (kind 31924) by author and d tag, keeping the newest + parsed.sort((a, b) => b.created_at - a.created_at); + const uniqueCalendars: CalendarData[] = []; + const seenCoordinates = new Set(); + for (const cal of parsed) { + const coordinate = `${cal.pubkey}:${cal.d}`; + if (!seenCoordinates.has(coordinate)) { + seenCoordinates.add(coordinate); + uniqueCalendars.push(cal); + } + } + + return uniqueCalendars; + } + }); +} diff --git a/src/lib/eventUtils.ts b/src/lib/eventUtils.ts index 4c058a6..00eec77 100644 --- a/src/lib/eventUtils.ts +++ b/src/lib/eventUtils.ts @@ -111,19 +111,48 @@ export function useEvents(options?: { filters.push(rsvpFilter); } - const events = await nostr.query(filters, { signal }); + try { + const events = await nostr.query(filters, { signal }); + + if (!events || events.length === 0) { + // If no new events, just use the cache + const [cachedEvents, cachedRsvps] = await Promise.all([ + getCachedEvents(), + includeRSVPs ? getCachedRSVPs() : Promise.resolve([]), + ]); + + if (cachedEvents.length > 0 || cachedRsvps.length > 0) { + const allCached = [...cachedEvents, ...cachedRsvps] as AllEventTypes[]; + return deduplicateEvents(allCached); + } + return []; + } - if (!events || events.length === 0) { - return []; - } + const typedEvents = events as unknown as AllEventTypes[]; + + // Cache new events in background (non-blocking) + Promise.all(typedEvents.map(event => cacheEvent(event))).catch(() => { }); - const typedEvents = events as unknown as AllEventTypes[]; - const result = deduplicateEvents(typedEvents); + // Fetch current cache to merge with new events + const [cachedEvents, cachedRsvps] = await Promise.all([ + getCachedEvents(), + includeRSVPs ? getCachedRSVPs() : Promise.resolve([]), + ]); - // Cache events in background (non-blocking) - Promise.all(result.map(event => cacheEvent(event))).catch(() => {}); + // Merge and deduplicate + const allCached = [...cachedEvents, ...cachedRsvps] as AllEventTypes[]; + const combined = [...allCached, ...typedEvents]; - return result; + return deduplicateEvents(combined); + } catch { + // If query fails, fall back to cache + const [cachedEvents, cachedRsvps] = await Promise.all([ + getCachedEvents(), + includeRSVPs ? getCachedRSVPs() : Promise.resolve([]), + ]); + const allCached = [...cachedEvents, ...cachedRsvps] as AllEventTypes[]; + return deduplicateEvents(allCached); + } }, // Use cached data as placeholder for instant display placeholderData: cachedData, diff --git a/src/pages/CalendarView.tsx b/src/pages/CalendarView.tsx new file mode 100644 index 0000000..00a4aa7 --- /dev/null +++ b/src/pages/CalendarView.tsx @@ -0,0 +1,693 @@ +import { useState } from "react"; +import { useParams, Link, useNavigate } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNostr } from "@/hooks/useNostr"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { useNostrPublish } from "@/hooks/useNostrPublish"; +import { nip19 } from "nostr-tools"; +import { createEventIdentifier } from "@/lib/nip19Utils"; +import { CalendarDays, MapPin, Plus, LayoutGrid, Trash2, Loader2, AlertCircle, FileUp, Inbox, X, Edit } from "lucide-react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { TimezoneDisplay } from "@/components/TimezoneDisplay"; +import { MonthlyCalendarView } from "@/components/MonthlyCalendarView"; +import { SubmitToGroupCalendarDialog } from "@/components/SubmitToGroupCalendarDialog"; +import { toast } from "sonner"; +import { parseCalendarEvent, deleteCalendarEvent, addEventToCalendar, rejectEventFromCalendar } from "@/lib/calendarUtils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +export function CalendarView() { + const { naddr } = useParams(); // URL param should be the 'd' identifier or coordinate + const navigate = useNavigate(); + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + const { mutate: createEvent } = useNostrPublish(); + const [viewMode, setViewMode] = useState<"list" | "calendar">("list"); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isSubmitDialogOpen, setIsSubmitDialogOpen] = useState(false); + + // Parse pubkey and d tag from the url param + let queryPubkey: string | null = null; + let queryD: string | null = null; + + try { + if (naddr && naddr.startsWith('naddr')) { + const { type, data } = nip19.decode(naddr); + if (type === 'naddr') { + queryPubkey = data.pubkey; + queryD = data.identifier; + } + } else { + const parts = naddr?.split(':') || []; + if (parts.length === 3) { + queryPubkey = parts[1]; + queryD = parts[2]; + } else if (parts.length === 2) { + queryPubkey = parts[0]; + queryD = parts[1]; + } else { + queryD = naddr || null; + } + } + } catch (error) { + console.error("Failed to parse calendar coordinate:", error); + } + + const isOwner = user?.pubkey === queryPubkey; + const [showPending, setShowPending] = useState(false); + const [approvingId, setApprovingId] = useState(null); + const [rejectingId, setRejectingId] = useState(null); + const [localApproved, setLocalApproved] = useState([]); + const [localRejected, setLocalRejected] = useState([]); + + const handleDeleteCalendar = async () => { + if (!calendarCoordinate || !isOwner) return; + + setIsDeleting(true); + try { + await deleteCalendarEvent(nostr, createEvent, calendarCoordinate); + toast.success("Calendar deleted successfully"); + setIsDeleteDialogOpen(false); + + // Clear the cache for the calendars so the profile page refetches immediately + queryClient.invalidateQueries({ queryKey: ['calendars'] }); + + // Navigate back to the user's profile using npub or fallback to pubkey + let targetProfile = (user as any)?.npub; + if (!targetProfile && user?.pubkey) { + targetProfile = nip19.npubEncode(user.pubkey); + } + + if (targetProfile) { + navigate(`/profile/${targetProfile}`); + } else { + navigate('/profile'); + } + } catch (error: any) { + console.error("Failed to delete calendar:", error); + toast.error(error.message || "Failed to delete calendar"); + } finally { + setIsDeleting(false); + } + }; + + const handleApproveEvent = async (eventCoordinateToApprove: string) => { + if (!calendarCoordinate || !isOwner) return; + setApprovingId(eventCoordinateToApprove); + + try { + await addEventToCalendar(nostr, createEvent, calendarCoordinate, eventCoordinateToApprove); + + // Optimistic UI update: instantly merge the newly approved coordinate into local display state + setLocalApproved(prev => [...prev, eventCoordinateToApprove]); + + toast.success("Event officially added to your calendar!"); + + // Still invalidate to ensure eventual consistency + queryClient.invalidateQueries({ queryKey: ['calendarEvents', calendarCoordinate] }); + queryClient.invalidateQueries({ queryKey: ['calendar', naddr] }); + } catch (error: any) { + console.error("Failed to approve event:", error); + toast.error(error.message || "Failed to approve event"); + } finally { + setApprovingId(null); + } + }; + + const handleRejectEvent = async (eventCoordinateToReject: string) => { + if (!calendarCoordinate || !isOwner) return; + setRejectingId(eventCoordinateToReject); + + try { + await rejectEventFromCalendar(nostr, createEvent, calendarCoordinate, eventCoordinateToReject); + + // Optimistic UI update: instantly merge the newly rejected coordinate into local display state + setLocalRejected(prev => [...prev, eventCoordinateToReject]); + + toast.success("Event rejected and removed from inbox."); + + // Still invalidate to ensure eventual consistency + queryClient.invalidateQueries({ queryKey: ['calendarEvents', calendarCoordinate] }); + queryClient.invalidateQueries({ queryKey: ['calendar', naddr] }); + } catch (error: any) { + console.error("Failed to reject event:", error); + toast.error(error.message || "Failed to reject event"); + } finally { + setRejectingId(null); + } + }; + + const { data: calendarData, isLoading: isLoadingCalendar } = useQuery({ + queryKey: ['calendar', naddr], + enabled: !!nostr && !!queryD, + queryFn: async () => { + const filter: any = { kinds: [31924] }; + + if (queryD) { + filter['#d'] = [queryD]; + } + if (queryPubkey) { + filter.authors = [queryPubkey]; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + let events: any[] = []; + try { + events = await nostr.query([filter], { signal: controller.signal }); + } catch (err) { + console.warn("Calendar query failed:", err); + } finally { + clearTimeout(timeoutId); + } + + if (!events || events.length === 0) return null; + + // Sort to get the latest version of the calendar data + events.sort((a: any, b: any) => b.created_at - a.created_at); + + return parseCalendarEvent(events[0]); + } + }); + + const calendarCoordinate = calendarData + ? `31924:${calendarData.pubkey}:${calendarData.d}` + : null; + + // Query events that reference this calendar via an `a` tag OR events specifically included by the calendar + const { data = { approved: [], pending: [] }, isLoading: isLoadingEvents } = useQuery({ + queryKey: ['calendarEvents', calendarCoordinate, calendarData?.events, calendarData?.locations, calendarData?.hashtags], + enabled: !!nostr && !!calendarCoordinate && !!calendarData, + queryFn: async () => { + const explicitRefs = calendarData!.events || []; + const rejectedRefs = calendarData!.rejected || []; + + const hasHashtagFilters = !!(calendarData!.hashtags && calendarData!.hashtags.length > 0); + const hasLocationFilters = !!(calendarData!.locations && calendarData!.locations.length > 0); + + // ── Query A: Targeted – events that explicitly reference this calendar (#a tag) + // These are the ONLY events eligible for the pending/inbox queue. + const targetedFilters: any[] = [ + { kinds: [31922, 31923], '#a': [calendarCoordinate!] } + ]; + + // Also fetch explicitly approved events by coordinate or id + const explicitIds: string[] = []; + for (const ref of explicitRefs) { + if (ref.includes(':')) { + const parts = ref.split(':'); + if (parts.length === 3) { + targetedFilters.push({ + kinds: [parseInt(parts[0])], + authors: [parts[1]], + '#d': [parts[2]] + }); + } + } else { + explicitIds.push(ref); + } + } + if (explicitIds.length > 0) { + targetedFilters.push({ kinds: [31922, 31923], ids: explicitIds }); + } + + // ── Query B: Broad – auto-include candidates (approved only, NEVER inbox) + // Hashtags use relay-indexed #t tag. Locations require a broad pool + client-side filter. + const broadFilters: any[] = []; + if (hasHashtagFilters) { + broadFilters.push({ kinds: [31922, 31923], '#t': calendarData!.hashtags }); + } + if (hasLocationFilters) { + // `#location` is a multi-word tag and NOT indexed by most relays. + // Fetch a broad recent pool and rely on client-side matching below. + broadFilters.push({ kinds: [31922, 31923], limit: 500 }); + } + + console.log("Calendar View: targeted filters:", targetedFilters); + console.log("Calendar View: broad filters:", broadFilters); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 8000); + + let targetedEvents: any[] = []; + let broadEvents: any[] = []; + try { + [targetedEvents, broadEvents] = await Promise.all([ + nostr.query(targetedFilters, { signal: controller.signal }), + broadFilters.length > 0 + ? nostr.query(broadFilters, { signal: controller.signal }) + : Promise.resolve([]) + ]); + } catch (err) { + console.warn("Calendar View query failed or timed out:", err); + } finally { + clearTimeout(timeoutId); + } + + // Track which event IDs came from the targeted query (inbox-eligible) + const targetedIds = new Set(targetedEvents.map((e: any) => e.id)); + + // Deduplicate the full set by ID + const uniqueEventsMap = new Map(); + [...targetedEvents, ...broadEvents].forEach((e: any) => uniqueEventsMap.set(e.id, e)); + const deduplicatedEvents = Array.from(uniqueEventsMap.values()); + + // Sort chronological by start tag + const sortedEvents = deduplicatedEvents.sort((a: any, b: any) => { + const timeA = a.tags.find((t: any) => t[0] === 'start')?.[1]; + const timeB = b.tags.find((t: any) => t[0] === 'start')?.[1]; + + const parseTime = (val: string | undefined, created: number) => { + if (!val) return created * 1000; + if (val.includes('-')) { + const [y, m, d] = val.split('-'); + return new Date(parseInt(y), parseInt(m) - 1, parseInt(d)).getTime(); + } + return parseInt(val) * 1000; + }; + + return parseTime(timeA, a.created_at) - parseTime(timeB, b.created_at); + }); + + const approved: any[] = []; + const pending: any[] = []; + + sortedEvents.forEach((event: any) => { + const isReplaceable = event.kind >= 30000 && event.kind < 40000; + const dTag = event.tags.find((t: string[]) => t[0] === 'd')?.[1]; + const coord = isReplaceable && dTag ? `${event.kind}:${event.pubkey}:${dTag}` : event.id; + + const isExplicitlyApproved = explicitRefs.includes(coord) || explicitRefs.includes(event.id); + const isExplicitlyRejected = rejectedRefs.includes(coord) || rejectedRefs.includes(event.id); + + // Client-side auto-include matching + let hasHashtagMatch = !hasHashtagFilters; // true by default if no filter + let hasLocationMatch = !hasLocationFilters; // true by default if no filter + + if (hasHashtagFilters) { + const eventHashtags = event.tags + .filter((t: any) => t[0] === 't') + .map((t: any) => t[1].toLowerCase()); + hasHashtagMatch = calendarData!.hashtags!.some((tag: string) => + eventHashtags.includes(tag.toLowerCase()) + ); + } + + if (hasLocationFilters) { + const eventLocation = event.tags.find((t: any) => t[0] === 'location')?.[1]?.toLowerCase(); + hasLocationMatch = !!( + eventLocation && + calendarData!.locations!.some((loc: string) => + eventLocation.includes(loc.toLowerCase()) + ) + ); + } + + let isAutoIncluded = false; + if (hasHashtagFilters || hasLocationFilters) { + if (calendarData!.matchType === 'all') { + isAutoIncluded = hasHashtagMatch && hasLocationMatch; + } else { + // "any" mode – default + isAutoIncluded = Boolean( + (hasHashtagFilters && hasHashtagMatch) || + (hasLocationFilters && hasLocationMatch) + ); + } + } + + if (isExplicitlyApproved || (isAutoIncluded && !isExplicitlyRejected)) { + approved.push(event); + } else if (!isExplicitlyRejected && targetedIds.has(event.id)) { + // Only show in the inbox if the event explicitly referenced this calendar. + // Events from the broad location pool that don't match are silently discarded. + pending.push(event); + } + }); + + return { approved, pending }; + } + }); + + // Apply local optimistic overrides to bypass relay lag + const displayApproved = data.approved.concat( + data.pending.filter((e: any) => { + const isReplaceable = e.kind >= 30000 && e.kind < 40000; + const dTag = e.tags.find((t: string[]) => t[0] === 'd')?.[1]; + const coord = isReplaceable && dTag ? `${e.kind}:${e.pubkey}:${dTag}` : e.id; + return localApproved.includes(coord) || localApproved.includes(e.id); + }) + ); + + const displayPending = data.pending.filter((e: any) => { + const isReplaceable = e.kind >= 30000 && e.kind < 40000; + const dTag = e.tags.find((t: string[]) => t[0] === 'd')?.[1]; + const coord = isReplaceable && dTag ? `${e.kind}:${e.pubkey}:${dTag}` : e.id; + return !localApproved.includes(coord) && !localApproved.includes(e.id) && + !localRejected.includes(coord) && !localRejected.includes(e.id); + }); + + const activeEventsList = showPending ? displayPending : displayApproved; + + if (isLoadingCalendar) { + return ( +
+
+
+ ); + } + + if (!calendarData) { + return ( +
+

Calendar not found

+
+ ); + } + + return ( +
+ + {calendarData.image ? ( +
+ {calendarData.title} +
+
+

+ {calendarData.title} +

+
+
+ ) : ( +
+
+
+ +
+
+

+ {calendarData.title} +

+
+
+
+ )} + + {calendarData.description && ( + +

+ {calendarData.description} +

+
+ )} + + {isOwner && ( +
+ + +
+ )} + + +
+
+

+ + Upcoming Events +

+
+
+ + + {isOwner && ( + + )} +
+ + + + {user && ( + + )} +
+
+ + {isLoadingEvents ? ( +
+
+
+ ) : activeEventsList.length > 0 ? ( + viewMode === "calendar" && !showPending ? ( + + ) : ( +
+ {activeEventsList.map((event: any) => { + const title = event.tags.find((tag: string[]) => tag[0] === "title")?.[1] || "Untitled"; + const description = event.content; + const startTime = event.tags.find((tag: string[]) => tag[0] === "start")?.[1]; + const location = event.tags.find((tag: string[]) => tag[0] === "location")?.[1]; + const imageUrl = event.tags.find((tag: string[]) => tag[0] === "image")?.[1]; + const eventIdentifier = createEventIdentifier(event); + + const isReplaceable = event.kind >= 30000 && event.kind < 40000; + const dTag = event.tags.find((t: string[]) => t[0] === 'd')?.[1]; + const activeEventCoordinate = isReplaceable && dTag ? `${event.kind}:${event.pubkey}:${dTag}` : event.id; + + const isApproving = approvingId === activeEventCoordinate; + const isRejecting = rejectingId === activeEventCoordinate; + + const CardContentBlock = ( + +
+ {title} +
+ + {showPending && ( +
+ + Pending Review + +
+ )} +
+ + + {title} + + {startTime && ( +
+ +
+ )} +
+ +
+

+ {description} +

+ {location && ( +
+ 📍 + {location} +
+ )} + {showPending && event.pubkey && ( +
+ Submitted by:{" "} + e.stopPropagation()} + > + {nip19.npubEncode(event.pubkey).slice(0, 16)}... + +
+ )} +
+ + {showPending && ( +
+ + +
+ )} +
+ + ); + + if (showPending) { + return
{CardContentBlock}
; + } + + return ( + + {CardContentBlock} + + ); + })} +
+ ) + ) : ( +
+ +

No Events Yet

+

This calendar is currently empty.

+
+ )} +
+ + + + + + + Delete Calendar + + + Are you sure you want to permanently delete "{calendarData.title}"? This action cannot be undone. + Only the list will be deleted, the events themselves will remain intact. + + + + + + + + + + {calendarCoordinate && ( + + )} +
+ ); +} diff --git a/src/pages/CalendarsFeed.tsx b/src/pages/CalendarsFeed.tsx new file mode 100644 index 0000000..5bda210 --- /dev/null +++ b/src/pages/CalendarsFeed.tsx @@ -0,0 +1,296 @@ +import { useState, useMemo } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { nip19 } from "nostr-tools"; +import { CalendarDays, Search, X, Loader2, Compass, Tag, MapPin, Sparkles } from "lucide-react"; +import { useAllCalendars } from "@/lib/calendarUtils"; +import { useAuthorsMetadata } from "@/hooks/useAuthorsMetadata"; +import { genUserName } from "@/lib/genUserName"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +export function CalendarsFeed() { + const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(""); + + const { data: calendars = [], isLoading, error, refetch } = useAllCalendars(); + + // Get unique pubkeys for metadata lookup + const uniquePubkeys = useMemo(() => { + return Array.from(new Set(calendars.map((cal) => cal.pubkey))); + }, [calendars]); + + const { data: authorsMetadata = {} } = useAuthorsMetadata(uniquePubkeys); + + // Combine calendars with creator metadata + const calendarsWithMetadata = useMemo(() => { + return calendars.map((cal) => { + const meta = authorsMetadata[cal.pubkey]; + const creatorName = + meta?.name || meta?.display_name || genUserName(cal.pubkey); + const creatorPicture = meta?.picture; + + // Safe npub encoding + let npub = ""; + try { + npub = nip19.npubEncode(cal.pubkey); + } catch (err) { + console.error("Failed to encode pubkey to npub:", err); + } + + return { + ...cal, + creatorName, + creatorPicture, + npub, + }; + }); + }, [calendars, authorsMetadata]); + + // Filter calendars by search query + const filteredCalendars = useMemo(() => { + if (!searchQuery.trim()) return calendarsWithMetadata; + const query = searchQuery.toLowerCase(); + return calendarsWithMetadata.filter((cal) => { + return ( + cal.title.toLowerCase().includes(query) || + cal.description.toLowerCase().includes(query) || + cal.creatorName.toLowerCase().includes(query) || + (cal.hashtags && + cal.hashtags.some((tag) => tag.toLowerCase().includes(query))) || + (cal.locations && + cal.locations.some((loc) => loc.toLowerCase().includes(query))) + ); + }); + }, [calendarsWithMetadata, searchQuery]); + + return ( +
+ {/* Header section */} +
+
+
+

+ Community Calendars 📅 +

+ {isLoading && calendars.length > 0 && ( + + )} +
+

+ Discover custom calendars created by the community for meetups, bar events, and more. +

+
+ + +
+ + {/* Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 pr-10 h-12 rounded-2xl border-2 border-border focus-visible:ring-primary focus-visible:border-primary transition-all duration-200" + /> + {searchQuery && ( + + )} +
+
+ + {/* Loading State */} + {isLoading && calendars.length === 0 && ( + + + +
+

Loading community calendars...

+

+ Connecting to relays and fetching group calendars +

+
+
+
+ )} + + {/* Error State */} + {!isLoading && error && ( + + +
😕
+
+

Unable to load calendars

+

+ {error instanceof Error ? error.message : "Something went wrong loading community calendars"} +

+
+ +
+
+ )} + + {/* Empty State */} + {!isLoading && !error && filteredCalendars.length === 0 && ( + + +
+ +
+
+

No calendars found

+

+ {searchQuery + ? `No calendars matched the search term "${searchQuery}". Try a different keyword.` + : "No community calendars have been published to the relays yet. Be the first to create one!"} +

+
+ {searchQuery ? ( + + ) : ( + + )} +
+
+ )} + + {/* Grid Content */} + {!isLoading && !error && filteredCalendars.length > 0 && ( +
+ {filteredCalendars.map((cal) => ( +
navigate(`/calendar/${cal.pubkey}:${cal.d}`)} + > + {/* Image banner */} +
+ {cal.title} { + // fallback if image fails to load + e.currentTarget.src = "/default-calendar.png"; + }} + /> +
+ + {/* Event count badge overlaid */} +
+ + + + {cal.events.length} {cal.events.length === 1 ? "Event" : "Events"} + + +
+
+ + {/* Card Body */} +
+
+

+ {cal.title} +

+ {cal.description ? ( +

+ {cal.description} +

+ ) : ( +

+ No description provided. +

+ )} +
+ + {/* Tags & Filters (Hashtags and Locations) */} + {(cal.hashtags?.length || cal.locations?.length) ? ( +
+ {cal.locations?.slice(0, 2).map((loc, idx) => ( + + + {loc} + + ))} + {cal.hashtags?.slice(0, 2).map((tag, idx) => ( + + + #{tag} + + ))} +
+ ) : null} + + {/* Creator profile footer */} +
e.stopPropagation()} // Prevent card navigation + > + + + + + {cal.creatorName.slice(0, 2).toUpperCase()} + + +
+ Created by + + {cal.creatorName} + +
+ + + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/pages/CreateCalendar.tsx b/src/pages/CreateCalendar.tsx new file mode 100644 index 0000000..7b59dbb --- /dev/null +++ b/src/pages/CreateCalendar.tsx @@ -0,0 +1,263 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { useNostrPublish } from "@/hooks/useNostrPublish"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { ImageUpload } from "@/components/ImageUpload"; +import { toast } from "sonner"; +import { CalendarDays, Rocket, Target, FileText, Hash, MapPin, Filter } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { nip19 } from "nostr-tools"; + +export function CreateCalendar() { + const navigate = useNavigate(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + const { mutate: createEvent } = useNostrPublish(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formData, setFormData] = useState({ + title: "", + description: "", + imageUrl: "", + hashtags: "", + location: "", + matchType: "any" as "any" | "all" + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user) return; + + if (!formData.title.trim()) { + toast.error("Calendar Name is required"); + return; + } + + setIsSubmitting(true); + try { + const uniqueId = formData.title.toLowerCase().replace(/\s+/g, "-") + "-" + Date.now(); + + const tags = [ + ["d", uniqueId], // Unique identifier representing the Calendar + ["title", formData.title] + ]; + + if (formData.imageUrl) { + tags.push(["image", formData.imageUrl]); + } + + // Add auto-include filters + if (formData.hashtags) { + const parsedHashtags = formData.hashtags.split(",").map(t => t.trim().toLowerCase()).filter(Boolean); + parsedHashtags.forEach(tag => { + // Remove # if user added it manually + const cleanTag = tag.startsWith('#') ? tag.substring(1) : tag; + tags.push(["t", cleanTag]); + }); + } + + if (formData.location) { + const parsedLocations = formData.location.split("+").map(loc => loc.trim()).filter(Boolean); + parsedLocations.forEach(loc => { + tags.push(["location", loc]); + }); + } + + if (formData.hashtags || formData.location.trim()) { + tags.push(["match_type", formData.matchType]); + } + + createEvent({ + kind: 31924, + content: formData.description, // Calendar description is stored in content for 31924 + tags, + }, { + onSuccess: () => { + // Invalidate calendar caches so it shows up immediately + queryClient.invalidateQueries({ queryKey: ['calendars'] }); + // Go to user's profile to see the new calendar in their list + navigate(`/profile/${nip19.npubEncode(user.pubkey)}`); + }, + onError: (error) => { + console.error("Error creating calendar:", error); + toast.error("Failed to create calendar"); + } + }); + + } catch (error) { + toast.error("An unexpected error occurred"); + console.error(error); + } finally { + setIsSubmitting(false); + } + }; + + if (!user) { + return ( +
+
+
🗓️
+
+

+ Create a Group Calendar +

+

+ Please log in to organize events for your community. +

+
+
+
+ ); + } + + return ( +
+
+
+
+

+ Create a Group Calendar +

+

+ Make a dedicated feed for your community or meetup group +

+
+
+ +
+
+ + + setFormData((prev) => ({ ...prev, title: e.target.value })) + } + placeholder="e.g. Austin Bitcoin Meetup" + className="text-lg py-3 rounded-2xl border-2 focus:border-primary transition-all duration-200" + required + /> +
+ +
+ +