+
+ {createdEvents.length}
+ Total Events
-
-
+
+
{createdEvents.filter((event) => {
const startTag = event.tags.find((t) => t[0] === "start")?.[1];
if (!startTag) return false;
@@ -304,7 +322,34 @@ export function Profile() {
}
}).length}
- Current Events
+ Current Events
+
+
+ {isLoadingRSVPs ? (
+
+ ) : (
+
+ {rsvps.filter(r => r.tags.find(t => t[0] === "status")?.[1] === "accepted").length}
+
+ )}
+ Attending
+
+
+ {isLoadingReceivedRsvps ? (
+
+ ) : (
+
+ {
+ // Filter down to accepted RSVPs and get unique pubkeys
+ Array.from(new Set(
+ receivedRsvps
+ .filter(r => r.tags.find(t => t[0] === "status")?.[1] === "accepted")
+ .map(r => r.pubkey)
+ )).length
+ }
+
+ )}
+ Total Attendees
)}
From 33fe1b79e42048e6a829031b042f95d6af55b365 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 08:22:02 -0600
Subject: [PATCH 08/42] Update stats card
---
src/pages/Home.tsx | 23 +++++++++++++++++++----
1 file changed, 19 insertions(+), 4 deletions(-)
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 659152b..d5a85bb 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,5 +1,7 @@
import { useEvents } from "@/lib/eventUtils";
import { useMuteList } from "@/hooks/useMuteList";
+import { useNostr } from "@nostrify/react";
+import { useQuery } from "@tanstack/react-query";
import { useAuthorsMetadata } from "@/hooks/useAuthorsMetadata";
import {
Card,
@@ -59,10 +61,23 @@ export function Home() {
// State for client-side pagination in grid view
const [displayedEventCount, setDisplayedEventCount] = useState(50);
+ const { nostr } = useNostr();
+ const { data: globalRsvps = [] } = useQuery({
+ queryKey: ["globalTotalRsvps"],
+ queryFn: async ({ signal }) => {
+ const events = await nostr.query(
+ [{ kinds: [31925], limit: 5000 }],
+ { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }
+ );
+ return events;
+ },
+ staleTime: 60000,
+ });
+
const allEvents = useMemo(() => allEventsData || [], [allEventsData]);
const totalRSVPs = useMemo(() => {
- return allEvents.filter(e => e.kind === 31925 && e.tags.find(t => t[0] === "status")?.[1] === "accepted").length;
- }, [allEvents]);
+ return globalRsvps.filter((e: any) => e.tags.find((t: any) => t[0] === "status")?.[1] === "accepted").length;
+ }, [globalRsvps]);
// Get unique pubkeys from all events for metadata lookup
const uniquePubkeys = useMemo(() => {
@@ -958,8 +973,8 @@ export function Home() {
{/* Attendee count (show for any count, but with different styling) */}
{attendeeCount > 0 && (
5
- ? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
- : 'bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800'
+ ? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
+ : 'bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800'
}`}>
5 ? 'text-green-600 dark:text-green-400' : 'text-blue-600 dark:text-blue-400'}`}>👥
5 ? 'text-green-800 dark:text-green-200' : 'text-blue-800 dark:text-blue-200'}`}>
From 57101360ec2227307cf781d659d1f15a2f7f842e Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 08:37:22 -0600
Subject: [PATCH 09/42] stats card -
---
src/pages/Home.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index d5a85bb..05ab605 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -62,7 +62,7 @@ export function Home() {
const [displayedEventCount, setDisplayedEventCount] = useState(50);
const { nostr } = useNostr();
- const { data: globalRsvps = [] } = useQuery({
+ const { data: globalRsvps = [], isLoading: isLoadingGlobalRsvps } = useQuery({
queryKey: ["globalTotalRsvps"],
queryFn: async ({ signal }) => {
const events = await nostr.query(
@@ -550,7 +550,11 @@ export function Home() {
- {totalRSVPs}
+ {isLoadingGlobalRsvps ? (
+ -
+ ) : (
+ totalRSVPs
+ )}
Total RSVPs
From f64b75c119e7d65d0ab44362670440a5bf47fef4 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 09:48:42 -0600
Subject: [PATCH 10/42] group cal
---
src/components/CalendarOptions.tsx | 154 +++++++++++++++++------------
1 file changed, 93 insertions(+), 61 deletions(-)
diff --git a/src/components/CalendarOptions.tsx b/src/components/CalendarOptions.tsx
index 24fe412..726a166 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 type { DateBasedEvent, TimeBasedEvent, LiveEvent, RoomMeeting } from "@/lib/eventTypes";
interface CalendarOptionsProps {
@@ -17,6 +20,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 {
@@ -44,70 +49,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 (
-
-
-
-
- Add to Calendar
- Calendar
-
-
-
-
-
- Quick Add (Auto-detect)
-
-
-
-
- handleCalendarProvider('google')} className="cursor-pointer">
-
-
-
G
+ <>
+
+
+
+
+ Add to Calendar
+ Calendar
+
+
+
+
+
+ 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">
+
- Yahoo Calendar
-
-
-
-
handleCalendarProvider('apple')} className="cursor-pointer">
-
-
-
🍎
+
+
+
handleCalendarProvider('apple')} className="cursor-pointer">
+
- Apple Calendar
-
-
-
-
-
-
-
- Download .ics file
-
-
-
+
+
+
+
+
+
+ Download .ics file
+
+
+
+
+ {user && (
+
+ )}
+ >
);
}
From 7a07f039c27b890f897a22d66220ceab3cd2a315 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 09:49:32 -0600
Subject: [PATCH 11/42] group cal
---
src/components/AddToGroupCalendarDialog.tsx | 156 ++++++++++++++++++++
1 file changed, 156 insertions(+)
create mode 100644 src/components/AddToGroupCalendarDialog.tsx
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.
+
+ onOpenChange(false)}>
+
+ Create a Calendar
+
+
+
+ ) : (
+
+ {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 (
+
+
+
+
+
+
+
handleToggleCalendar(calendarCoordinate, isAdded)}
+ >
+ {isProcessing ? (
+
+ ) : isAdded ? (
+
+
+
+ Added
+ Remove
+
+ ) : (
+ <> Add>
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
From c818a4912df74c36ffec21b452f6dcd8f13e4b8c Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 09:50:22 -0600
Subject: [PATCH 12/42] group cal
---
src/pages/CreateEvent.tsx | 68 ++++++++++++++++++++++++++++-----------
1 file changed, 50 insertions(+), 18 deletions(-)
diff --git a/src/pages/CreateEvent.tsx b/src/pages/CreateEvent.tsx
index 6b630de..3fcda5a 100644
--- a/src/pages/CreateEvent.tsx
+++ b/src/pages/CreateEvent.tsx
@@ -30,8 +30,8 @@ import {
} from "@/lib/eventTimezone";
import { encodeGeohash } from "@/lib/geolocation";
import { generateRecurringEventDates } from "@/lib/recurringEventUtils";
-import { PartyPopper, Target, FileText, Calendar as CalendarIcon, Flag, Clock, Globe, Rocket } from "lucide-react";
-
+import { useUserCalendars, createCoordinate } from "@/lib/calendarUtils";
+import { PartyPopper, Target, FileText, Calendar as CalendarIcon, Flag, Clock, Globe, Rocket, CalendarDays } from "lucide-react";
export function CreateEvent() {
const navigate = useNavigate();
const { user } = useCurrentUser();
@@ -53,6 +53,7 @@ export function CreateEvent() {
endDate: "",
endTime: "",
imageUrl: "",
+ selectedCalendarCoordinate: "",
categories: [] as EventCategory[],
ticketInfo: {
enabled: false,
@@ -77,7 +78,7 @@ export function CreateEvent() {
} as EventbriteRecurringConfig,
});
-
+ const { data: userCalendars = [], isLoading: isLoadingCalendars } = useUserCalendars(user?.pubkey);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -151,8 +152,8 @@ export function CreateEvent() {
// Use Eventbrite-style config
const recurringConfig = {
enabled: true,
- pattern: (formData.eventbriteRecurringConfig.repeatUnit === 'day' ? 'daily' :
- formData.eventbriteRecurringConfig.repeatUnit === 'week' ? 'weekly' : 'monthly') as 'daily' | 'weekly' | 'monthly',
+ pattern: (formData.eventbriteRecurringConfig.repeatUnit === 'day' ? 'daily' :
+ formData.eventbriteRecurringConfig.repeatUnit === 'week' ? 'weekly' : 'monthly') as 'daily' | 'weekly' | 'monthly',
interval: formData.eventbriteRecurringConfig.repeatEvery,
maxOccurrences: formData.eventbriteRecurringConfig.maxOccurrences || 6,
weeklyDays: formData.eventbriteRecurringConfig.repeatOnDays,
@@ -162,7 +163,7 @@ export function CreateEvent() {
} : undefined,
timeMode: formData.eventbriteRecurringConfig.timeMode,
};
-
+
eventDates = generateRecurringEventDates(
formData.startDate,
formData.endDate,
@@ -179,7 +180,7 @@ export function CreateEvent() {
maxOccurrences: 1,
timeMode: 'single' as const,
};
-
+
eventDates = generateRecurringEventDates(
formData.startDate,
formData.endDate,
@@ -236,7 +237,7 @@ export function CreateEvent() {
// Create a unique identifier for the event
const uniqueId = formData.title.toLowerCase().replace(/\s+/g, "-") + "-" + Date.now() + "-" + index;
-
+
const tags = [
["d", uniqueId], // Unique identifier
["title", formData.title],
@@ -253,11 +254,11 @@ export function CreateEvent() {
9 // 9 characters gives ~4.8m precision
);
tags.push(["g", geohash]);
-
+
// Also store raw coordinates for backwards compatibility
tags.push(["lat", formData.locationDetails.lat.toString()]);
tags.push(["lon", formData.locationDetails.lng.toString()]);
-
+
if (formData.locationDetails.placeId) {
tags.push(["place_id", formData.locationDetails.placeId]);
}
@@ -286,6 +287,11 @@ export function CreateEvent() {
tags.push(["image", formData.imageUrl]);
}
+ // Associate with Calendar if selected
+ if (formData.selectedCalendarCoordinate) {
+ tags.push(["a", formData.selectedCalendarCoordinate]);
+ }
+
// Add categories as 't' tags if provided
if (formData.categories.length > 0) {
for (const category of formData.categories) {
@@ -318,7 +324,7 @@ export function CreateEvent() {
// Wait for all events to be created
const results = await Promise.allSettled(createEventPromises);
-
+
const successful = results.filter(result => result.status === 'fulfilled').length;
const failed = results.filter(result => result.status === 'rejected').length;
@@ -328,17 +334,17 @@ export function CreateEvent() {
} else {
toast.success("Event created successfully! It should appear on the home page shortly.");
}
-
+
if (failed > 0) {
toast.warning(`${failed} events failed to create. Please try again.`);
}
-
+
// Navigate back to home page where the user can see their new events
navigate("/");
} else {
toast.error("Failed to create events. Please try again.");
}
-
+
setIsSubmitting(false);
} catch (error) {
toast.error("Failed to create event");
@@ -440,6 +446,32 @@ export function CreateEvent() {
}
/>
+ {userCalendars.length > 0 && (
+
+
+ Add to Calendar (Optional)
+
+
+ setFormData((prev) => ({ ...prev, selectedCalendarCoordinate: value === "none" ? "" : value }))
+ }
+ >
+
+
+
+
+ None
+ {userCalendars.map((cal) => (
+
+ {cal.title}
+
+ ))}
+
+
+
+ )}
+
@@ -617,8 +649,8 @@ export function CreateEvent() {
timezone={formData.timezone}
recurringConfig={{
enabled: true,
- pattern: formData.eventbriteRecurringConfig.repeatUnit === 'day' ? 'daily' :
- formData.eventbriteRecurringConfig.repeatUnit === 'week' ? 'weekly' : 'monthly',
+ pattern: formData.eventbriteRecurringConfig.repeatUnit === 'day' ? 'daily' :
+ formData.eventbriteRecurringConfig.repeatUnit === 'week' ? 'weekly' : 'monthly',
interval: formData.eventbriteRecurringConfig.repeatEvery,
maxOccurrences: formData.eventbriteRecurringConfig.maxOccurrences || 6,
weeklyDays: formData.eventbriteRecurringConfig.repeatOnDays,
@@ -632,8 +664,8 @@ export function CreateEvent() {
)}
-
From 6683efddc76d27f4043fce30bdac3e8f364f52f1 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 09:51:12 -0600
Subject: [PATCH 13/42] group cal
---
src/pages/Profile.tsx | 70 +++++++++++++++++++++++++++++++++++++++++--
1 file changed, 68 insertions(+), 2 deletions(-)
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx
index ae2c2fc..a15d90c 100644
--- a/src/pages/Profile.tsx
+++ b/src/pages/Profile.tsx
@@ -21,7 +21,8 @@ import { Link } from "react-router-dom";
import { UserActionsMenu } from "@/components/UserActionsMenu";
import { ZappableLightningAddress } from "@/components/ZappableLightningAddress";
import { EditProfileForm } from "@/components/EditProfileForm";
-import { ExternalLink, Loader2, Settings, PartyPopper, Users } from "lucide-react";
+import { ExternalLink, Loader2, Settings, PartyPopper, Users, CalendarDays, Plus } from "lucide-react";
+import { useUserCalendars } from "@/lib/calendarUtils";
import { TimezoneDisplay } from "@/components/TimezoneDisplay";
import type {
DateBasedEvent,
@@ -135,6 +136,11 @@ export function Profile() {
staleTime: 30000,
});
+ const {
+ data: userCalendars = [],
+ isLoading: isLoadingCalendars,
+ } = useUserCalendars(pubkey);
+
const metadata = author.data?.metadata;
const displayName =
metadata?.name || metadata?.display_name || pubkey?.slice(0, 8) || "";
@@ -361,9 +367,10 @@ export function Profile() {
{/* Events Section: Tabs for Created and RSVP'd Events */}
-
+
Created Events
RSVP'd Events
+ My Calendars
{isLoadingCreated ? (
@@ -423,6 +430,65 @@ export function Profile() {
)}
+
+ {isLoadingCalendars ? (
+
+
+ Loading calendars...
+
+ ) : userCalendars.length === 0 ? (
+
+
+
No Calendars Yet
+
You haven't created any group calendars yet.
+ {isOwnProfile && (
+
+
+
+ Create Calendar
+
+
+ )}
+
+ ) : (
+
+ {isOwnProfile && (
+
+
+
+
+ New Calendar
+
+
+
+ )}
+
+ {userCalendars.map((cal) => (
+
+
+
+
+
+
+
{cal.title}
+
+
+
+
+ {cal.description || "No description provided."}
+
+
+
+
+ ))}
+
+
+ )}
+
{isLoadingRSVPs ? (
From 304e7f56212780c9d31023e886703cafded186ff Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 09:51:56 -0600
Subject: [PATCH 14/42] group cal
---
src/pages/CalendarView.tsx | 240 +++++++++++++++++++++++++++++++++++
src/pages/CreateCalendar.tsx | 166 ++++++++++++++++++++++++
2 files changed, 406 insertions(+)
create mode 100644 src/pages/CalendarView.tsx
create mode 100644 src/pages/CreateCalendar.tsx
diff --git a/src/pages/CalendarView.tsx b/src/pages/CalendarView.tsx
new file mode 100644
index 0000000..2cdfa4f
--- /dev/null
+++ b/src/pages/CalendarView.tsx
@@ -0,0 +1,240 @@
+import { useParams, Link } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import { useNostr } from "@/hooks/useNostr";
+import { parseCalendarEvent } from "@/lib/calendarUtils";
+import { createEventIdentifier } from "@/lib/nip19Utils";
+import { CalendarDays, MapPin, Plus } from "lucide-react";
+import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { TimezoneDisplay } from "@/components/TimezoneDisplay";
+
+export function CalendarView() {
+ const { naddr } = useParams(); // URL param should be the 'd' identifier or coordinate
+ const { nostr } = useNostr();
+
+ // Parse pubkey and d tag from the url param (assuming format pubkey:d or just d)
+ const parts = naddr?.split(':') || [];
+ const queryPubkey = parts.length > 1 ? parts[0] : null;
+ const queryD = parts.length > 1 ? parts[1] : naddr;
+
+ 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 events = await nostr.query([filter]);
+ if (events.length === 0) return null;
+ 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: calendarEvents = [], isLoading: isLoadingEvents } = useQuery({
+ queryKey: ['calendarEvents', calendarCoordinate, calendarData?.events],
+ enabled: !!nostr && !!calendarCoordinate,
+ queryFn: async () => {
+ // 1. Find events that declare they belong to this calendar
+ const filters: any[] = [
+ {
+ kinds: [31922, 31923],
+ '#a': [calendarCoordinate!]
+ }
+ ];
+
+ // 2. Map explicit events that this calendar references
+ if (calendarData?.events && calendarData.events.length > 0) {
+ const explicitIds: string[] = [];
+
+ for (const ref of calendarData.events) {
+ if (ref.includes(':')) {
+ const parts = ref.split(':');
+ if (parts.length === 3) {
+ filters.push({
+ kinds: [parseInt(parts[0])],
+ authors: [parts[1]],
+ '#d': [parts[2]]
+ });
+ }
+ } else {
+ explicitIds.push(ref);
+ }
+ }
+
+ if (explicitIds.length > 0) {
+ filters.push({
+ kinds: [31922, 31923],
+ ids: explicitIds
+ });
+ }
+ }
+
+ const events = await nostr.query(filters);
+
+ // Deduplicate raw nostr events by their ID
+ const uniqueEventsMap = new Map();
+ events.forEach((e: any) => uniqueEventsMap.set(e.id, e));
+ const deduplicatedEvents = Array.from(uniqueEventsMap.values());
+
+ return 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);
+ });
+ }
+ });
+
+ if (isLoadingCalendar) {
+ return (
+
+ );
+ }
+
+ if (!calendarData) {
+ return (
+
+
Calendar not found
+
+ );
+ }
+
+ return (
+
+
+ {calendarData.image ? (
+
+
+
+
+
+ {calendarData.title}
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ {calendarData.title}
+
+
+
+
+ )}
+
+ {calendarData.description && (
+
+
+ {calendarData.description}
+
+
+ )}
+
+
+
+
+
+
+ Upcoming Events
+
+
+
+
+ Add Event
+
+
+
+
+ {isLoadingEvents ? (
+
+ ) : calendarEvents.length > 0 ? (
+
+ {calendarEvents.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);
+
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+ {startTime && (
+
+
+
+ )}
+
+
+
+ {description}
+
+ {location && (
+
+ 📍
+ {location}
+
+ )}
+
+
+
+ );
+ })}
+
+ ) : (
+
+
+
No Events Yet
+
This calendar is currently empty.
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/CreateCalendar.tsx b/src/pages/CreateCalendar.tsx
new file mode 100644
index 0000000..8c25d08
--- /dev/null
+++ b/src/pages/CreateCalendar.tsx
@@ -0,0 +1,166 @@
+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 } from "lucide-react";
+
+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: "",
+ });
+
+ 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]);
+ }
+
+ 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
+ const npub = import("nostr-tools").then(({ nip19 }) => {
+ 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
+
+
+
+
+
+
+ );
+}
From fe94f2e4baf6b702ccfdfb25c6c682e57912eaea Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 09:53:12 -0600
Subject: [PATCH 15/42] group cal
---
src/lib/calendarUtils.ts | 186 +++++++++++++++++++++++++++++++++++++++
1 file changed, 186 insertions(+)
create mode 100644 src/lib/calendarUtils.ts
diff --git a/src/lib/calendarUtils.ts b/src/lib/calendarUtils.ts
new file mode 100644
index 0000000..1afd750
--- /dev/null
+++ b/src/lib/calendarUtils.ts
@@ -0,0 +1,186 @@
+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
+}
+
+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]);
+
+ 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
+ };
+}
+
+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
+ });
+ });
+}
From 34c1b35d4dc53de3423fb4e014d2e7e725830d46 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 09:53:49 -0600
Subject: [PATCH 16/42] group cal
---
src/AppRouter.tsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 74092a9..82febfa 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -6,6 +6,8 @@ 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 CalendarView = lazy(() => import("@/pages/CalendarView").then(m => ({ default: m.CalendarView })));
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 })));
@@ -31,6 +33,8 @@ export default function AppRouter() {
} />
} />
} />
+
} />
+
} />
} />
} />
} />
From cd3160834507c20ff277191d1adda817789cf258 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 10:24:09 -0600
Subject: [PATCH 17/42] group cal update
---
src/pages/CalendarView.tsx | 249 ++++++++++++++++++++++++++++---------
1 file changed, 192 insertions(+), 57 deletions(-)
diff --git a/src/pages/CalendarView.tsx b/src/pages/CalendarView.tsx
index 2cdfa4f..2f4f889 100644
--- a/src/pages/CalendarView.tsx
+++ b/src/pages/CalendarView.tsx
@@ -1,22 +1,76 @@
-import { useParams, Link } from "react-router-dom";
-import { useQuery } from "@tanstack/react-query";
+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 { parseCalendarEvent } from "@/lib/calendarUtils";
+import { useCurrentUser } from "@/hooks/useCurrentUser";
+import { useNostrPublish } from "@/hooks/useNostrPublish";
+import { parseCalendarEvent, deleteCalendarEvent } from "@/lib/calendarUtils";
+import { nip19 } from "nostr-tools";
import { createEventIdentifier } from "@/lib/nip19Utils";
-import { CalendarDays, MapPin, Plus } from "lucide-react";
+import { CalendarDays, MapPin, Plus, LayoutGrid, Trash2, Loader2, AlertCircle } 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 { toast } from "sonner";
+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);
// Parse pubkey and d tag from the url param (assuming format pubkey:d or just d)
const parts = naddr?.split(':') || [];
const queryPubkey = parts.length > 1 ? parts[0] : null;
const queryD = parts.length > 1 ? parts[1] : naddr;
+ const isOwner = user?.pubkey === queryPubkey;
+
+ 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 { data: calendarData, isLoading: isLoadingCalendar } = useQuery({
queryKey: ['calendar', naddr],
enabled: !!nostr && !!queryD,
@@ -160,20 +214,56 @@ export function CalendarView() {
)}
+
+ {isOwner && (
+
+ setIsDeleteDialogOpen(true)}
+ className="text-destructive hover:bg-destructive hover:text-white transition-colors"
+ >
+
+ Delete Calendar
+
+
+ )}
-
+
Upcoming Events
-
-
-
- Add Event
-
-
+
+
+ setViewMode("list")}
+ >
+
+ Feed
+
+ setViewMode("calendar")}
+ >
+
+ Month
+
+
+
+
+
+ Add Event
+
+
+
{isLoadingEvents ? (
@@ -181,52 +271,56 @@ export function CalendarView() {
) : calendarEvents.length > 0 ? (
-
- {calendarEvents.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);
-
- return (
-
-
-
-
-
-
-
-
- {title}
-
- {startTime && (
-
-
-
- )}
-
-
-
- {description}
-
- {location && (
-
- 📍
- {location}
-
- )}
-
-
-
- );
- })}
-
+ viewMode === "calendar" ? (
+
+ ) : (
+
+ {calendarEvents.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);
+
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+ {startTime && (
+
+
+
+ )}
+
+
+
+ {description}
+
+ {location && (
+
+ 📍
+ {location}
+
+ )}
+
+
+
+ );
+ })}
+
+ )
) : (
@@ -235,6 +329,47 @@ export function CalendarView() {
)}
+
+
+
+
+
+
+ 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.
+
+
+
+ setIsDeleteDialogOpen(false)}
+ disabled={isDeleting}
+ >
+ Cancel
+
+
+ {isDeleting ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ <>
+
+ Delete Calendar
+ >
+ )}
+
+
+
+
);
}
From 6b7f14d12cd19e4979755199f4de81e21674e77f Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 10:24:41 -0600
Subject: [PATCH 18/42] group cal update
---
src/lib/calendarUtils.ts | 41 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 41 insertions(+)
diff --git a/src/lib/calendarUtils.ts b/src/lib/calendarUtils.ts
index 1afd750..f0f2966 100644
--- a/src/lib/calendarUtils.ts
+++ b/src/lib/calendarUtils.ts
@@ -184,3 +184,44 @@ export async function removeEventFromCalendar(
});
});
}
+
+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
+ });
+ });
+}
From 8dc014a47238a717e70e5d48b32a0da933ac2d6f Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 10:25:22 -0600
Subject: [PATCH 19/42] group cal update
---
src/pages/Profile.tsx | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx
index a15d90c..1b491fd 100644
--- a/src/pages/Profile.tsx
+++ b/src/pages/Profile.tsx
@@ -42,9 +42,14 @@ export function Profile() {
useEffect(() => {
try {
if (npub) {
- const decoded = nip19.decode(npub);
- if (decoded.type === "npub") {
- setPubkey(decoded.data);
+ if (npub.startsWith("npub1")) {
+ const decoded = nip19.decode(npub);
+ if (decoded.type === "npub") {
+ setPubkey(decoded.data);
+ }
+ } else if (npub.length === 64) {
+ // Fallback if a raw hex string was passed instead of an encoded npub
+ setPubkey(npub);
}
}
} catch (error) {
From ca326dc9e0d3bc63de7bc6fede5576bf15e8f380 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 11:21:26 -0600
Subject: [PATCH 20/42] shared cal
---
src/pages/CalendarView.tsx | 247 ++++++++++++++++++++++++++++---------
1 file changed, 187 insertions(+), 60 deletions(-)
diff --git a/src/pages/CalendarView.tsx b/src/pages/CalendarView.tsx
index 2f4f889..07ba8bc 100644
--- a/src/pages/CalendarView.tsx
+++ b/src/pages/CalendarView.tsx
@@ -4,15 +4,16 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useNostr } from "@/hooks/useNostr";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useNostrPublish } from "@/hooks/useNostrPublish";
-import { parseCalendarEvent, deleteCalendarEvent } from "@/lib/calendarUtils";
import { nip19 } from "nostr-tools";
import { createEventIdentifier } from "@/lib/nip19Utils";
-import { CalendarDays, MapPin, Plus, LayoutGrid, Trash2, Loader2, AlertCircle } from "lucide-react";
+import { CalendarDays, MapPin, Plus, LayoutGrid, Trash2, Loader2, AlertCircle, FileUp, Inbox } 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 } from "@/lib/calendarUtils";
import {
Dialog,
DialogContent,
@@ -32,6 +33,7 @@ export function CalendarView() {
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 (assuming format pubkey:d or just d)
const parts = naddr?.split(':') || [];
@@ -39,6 +41,8 @@ export function CalendarView() {
const queryD = parts.length > 1 ? parts[1] : naddr;
const isOwner = user?.pubkey === queryPubkey;
+ const [showPending, setShowPending] = useState(false);
+ const [approvingId, setApprovingId] = useState(null);
const handleDeleteCalendar = async () => {
if (!calendarCoordinate || !isOwner) return;
@@ -71,6 +75,23 @@ export function CalendarView() {
}
};
+ const handleApproveEvent = async (eventCoordinateToApprove: string) => {
+ if (!calendarCoordinate || !isOwner) return;
+ setApprovingId(eventCoordinateToApprove);
+
+ try {
+ await addEventToCalendar(nostr, createEvent, calendarCoordinate, eventCoordinateToApprove);
+ toast.success("Event officially added to your calendar!");
+ queryClient.invalidateQueries({ queryKey: ['calendarEvents', calendarCoordinate] });
+ queryClient.invalidateQueries({ queryKey: ['calendar', naddr] }); // Fetch the newly refreshed root calendar
+ } catch (error: any) {
+ console.error("Failed to approve event:", error);
+ toast.error(error.message || "Failed to approve event");
+ } finally {
+ setApprovingId(null);
+ }
+ };
+
const { data: calendarData, isLoading: isLoadingCalendar } = useQuery({
queryKey: ['calendar', naddr],
enabled: !!nostr && !!queryD,
@@ -95,9 +116,10 @@ export function CalendarView() {
: null;
// Query events that reference this calendar via an `a` tag OR events specifically included by the calendar
- const { data: calendarEvents = [], isLoading: isLoadingEvents } = useQuery({
+ // 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],
- enabled: !!nostr && !!calendarCoordinate,
+ enabled: !!nostr && !!calendarCoordinate && !!calendarData,
queryFn: async () => {
// 1. Find events that declare they belong to this calendar
const filters: any[] = [
@@ -108,30 +130,29 @@ export function CalendarView() {
];
// 2. Map explicit events that this calendar references
- if (calendarData?.events && calendarData.events.length > 0) {
- const explicitIds: string[] = [];
-
- for (const ref of calendarData.events) {
- if (ref.includes(':')) {
- const parts = ref.split(':');
- if (parts.length === 3) {
- filters.push({
- kinds: [parseInt(parts[0])],
- authors: [parts[1]],
- '#d': [parts[2]]
- });
- }
- } else {
- explicitIds.push(ref);
+ const explicitRefs = calendarData!.events || [];
+ const explicitIds: string[] = [];
+
+ for (const ref of explicitRefs) {
+ if (ref.includes(':')) {
+ const parts = ref.split(':');
+ if (parts.length === 3) {
+ filters.push({
+ kinds: [parseInt(parts[0])],
+ authors: [parts[1]],
+ '#d': [parts[2]]
+ });
}
+ } else {
+ explicitIds.push(ref);
}
+ }
- if (explicitIds.length > 0) {
- filters.push({
- kinds: [31922, 31923],
- ids: explicitIds
- });
- }
+ if (explicitIds.length > 0) {
+ filters.push({
+ kinds: [31922, 31923],
+ ids: explicitIds
+ });
}
const events = await nostr.query(filters);
@@ -141,7 +162,8 @@ export function CalendarView() {
events.forEach((e: any) => uniqueEventsMap.set(e.id, e));
const deduplicatedEvents = Array.from(uniqueEventsMap.values());
- return deduplicatedEvents.sort((a: any, b: any) => {
+ // Sort chronological
+ 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];
@@ -156,9 +178,31 @@ export function CalendarView() {
return parseTime(timeA, a.created_at) - parseTime(timeB, b.created_at);
});
+
+ // Segregate into Approved vs Pending
+ // Approved = implicitly in the calendarData.events array
+ 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;
+
+ if (explicitRefs.includes(coord) || explicitRefs.includes(event.id)) {
+ approved.push(event);
+ } else {
+ pending.push(event);
+ }
+ });
+
+ return { approved, pending };
}
});
+ // Use either the approved list or the pending list depending on the state wrapper
+ const activeEventsList = showPending ? data.pending : data.approved;
+
if (isLoadingCalendar) {
return (
@@ -239,30 +283,65 @@ export function CalendarView() {
setViewMode("list")}
+ onClick={() => {
+ setViewMode("list");
+ setShowPending(false);
+ }}
>
Feed
setViewMode("calendar")}
+ onClick={() => {
+ setViewMode("calendar");
+ setShowPending(false);
+ }}
>
Month
+ {isOwner && (
+ setShowPending(true)}
+ >
+
+ Inbox
+ {data.pending.length > 0 && (
+
+ {data.pending.length}
+
+ )}
+
+ )}
+
-
+
Add Event
+
+ {user && (
+
setIsSubmitDialogOpen(true)}
+ >
+
+ Submit Existing
+
+ )}
@@ -270,12 +349,12 @@ export function CalendarView() {
- ) : calendarEvents.length > 0 ? (
- viewMode === "calendar" ? (
-
+ ) : activeEventsList.length > 0 ? (
+ viewMode === "calendar" && !showPending ? (
+
) : (
- {calendarEvents.map((event: any) => {
+ {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];
@@ -283,39 +362,79 @@ export function CalendarView() {
const imageUrl = event.tags.find((tag: string[]) => tag[0] === "image")?.[1];
const eventIdentifier = createEventIdentifier(event);
- return (
-
-
-
-
-
-
-
-
- {title}
-
- {startTime && (
-
-
-
- )}
-
-
+ 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 CardContentBlock = (
+
+
+
+
+
+ {showPending && (
+
+
+ Pending Review
+
+
+ )}
+
+
+
+ {title}
+
+ {startTime && (
+
+
+
+ )}
+
+
+
{description}
{location && (
📍
- {location}
+ {location}
)}
-
-
+
+
+ {showPending && (
+
+ {
+ e.preventDefault(); // prevent navigation
+ handleApproveEvent(activeEventCoordinate);
+ }}
+ >
+ {isApproving ? : "Approve Event"}
+
+
+ )}
+
+
+ );
+
+ if (showPending) {
+ return {CardContentBlock}
;
+ }
+
+ return (
+
+ {CardContentBlock}
);
})}
@@ -370,6 +489,14 @@ export function CalendarView() {
+
+ {calendarCoordinate && (
+
+ )}
);
}
From 2352ad71a5b4acdc5107eecd5ccbe35c5a0375e9 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 11:21:52 -0600
Subject: [PATCH 21/42] shared cal
---
src/pages/CreateEvent.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/pages/CreateEvent.tsx b/src/pages/CreateEvent.tsx
index 3fcda5a..49d3edf 100644
--- a/src/pages/CreateEvent.tsx
+++ b/src/pages/CreateEvent.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { useNavigate } from "react-router-dom";
+import { useNavigate, useSearchParams } from "react-router-dom";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useNostrPublish } from "@/hooks/useNostrPublish";
import { Button } from "@/components/ui/button";
@@ -34,6 +34,8 @@ import { useUserCalendars, createCoordinate } from "@/lib/calendarUtils";
import { PartyPopper, Target, FileText, Calendar as CalendarIcon, Flag, Clock, Globe, Rocket, CalendarDays } from "lucide-react";
export function CreateEvent() {
const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const preselectedCalendar = searchParams.get("calendar");
const { user } = useCurrentUser();
const { mutate: createEvent } = useNostrPublish();
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -53,7 +55,7 @@ export function CreateEvent() {
endDate: "",
endTime: "",
imageUrl: "",
- selectedCalendarCoordinate: "",
+ selectedCalendarCoordinate: preselectedCalendar || "",
categories: [] as EventCategory[],
ticketInfo: {
enabled: false,
From 1164c269341a1445a3bab856f7eb670c5d9e2b2f Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 11:22:41 -0600
Subject: [PATCH 22/42] group cal
---
.../SubmitToGroupCalendarDialog.tsx | 185 ++++++++++++++++++
1 file changed, 185 insertions(+)
create mode 100644 src/components/SubmitToGroupCalendarDialog.tsx
diff --git a/src/components/SubmitToGroupCalendarDialog.tsx b/src/components/SubmitToGroupCalendarDialog.tsx
new file mode 100644
index 0000000..c981fbb
--- /dev/null
+++ b/src/components/SubmitToGroupCalendarDialog.tsx
@@ -0,0 +1,185 @@
+import { useState } from "react";
+import { useQueryClient, useQuery } from "@tanstack/react-query";
+import { useNostr } from "@/hooks/useNostr";
+import { useCurrentUser } from "@/hooks/useCurrentUser";
+import { useNostrPublish } from "@/hooks/useNostrPublish";
+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 SubmitToGroupCalendarDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ calendarCoordinate: string;
+}
+
+export function SubmitToGroupCalendarDialog({ open, onOpenChange, calendarCoordinate }: SubmitToGroupCalendarDialogProps) {
+ const { nostr } = useNostr();
+ const { user } = useCurrentUser();
+ const { mutate: createEvent } = useNostrPublish();
+ const queryClient = useQueryClient();
+
+ const [processingId, setProcessingId] = useState(null);
+
+ // Fetch all events by the current user
+ const { data: userEvents = [], isLoading: isLoadingEvents } = useQuery({
+ queryKey: ['userEvents', user?.pubkey],
+ enabled: !!nostr && !!user?.pubkey && open,
+ queryFn: async () => {
+ const events = await nostr.query([
+ {
+ kinds: [31922, 31923],
+ authors: [user!.pubkey],
+ limit: 50
+ }
+ ]);
+
+ // Deduplicate and parse
+ const uniqueEventsMap = new Map();
+ events.forEach((e: any) => uniqueEventsMap.set(e.id, e));
+ const deduplicatedEvents = Array.from(uniqueEventsMap.values());
+
+ return deduplicatedEvents.sort((a: any, b: any) => b.created_at - a.created_at);
+ }
+ });
+
+ const handleToggleEvent = async (event: any, isAlreadySubmitted: boolean) => {
+ if (!nostr || !user) return;
+ setProcessingId(event.id);
+
+ try {
+ const existingTags = [...event.tags];
+
+ if (isAlreadySubmitted) {
+ // Remove the 'a' tag mapping to the calendar
+ const newTags = existingTags.filter((tag: string[]) => {
+ if (tag[0] === 'a') return tag[1] !== calendarCoordinate;
+ return true;
+ });
+
+ await new Promise((resolve, reject) => {
+ createEvent({
+ kind: event.kind,
+ content: event.content,
+ tags: newTags,
+ }, { onSuccess: resolve, onError: reject });
+ });
+
+ toast.info("Event removed from group calendar.");
+
+ } else {
+ // Add the 'a' tag mapping to the calendar
+ existingTags.push(['a', calendarCoordinate]);
+
+ await new Promise((resolve, reject) => {
+ createEvent({
+ kind: event.kind,
+ content: event.content,
+ tags: existingTags,
+ }, { onSuccess: resolve, onError: reject });
+ });
+
+ toast.success("Event submitted to group calendar!");
+ }
+
+ // Invalidate queries so the UI updates
+ queryClient.invalidateQueries({ queryKey: ['calendarEvents', calendarCoordinate] });
+ queryClient.invalidateQueries({ queryKey: ['userEvents', user.pubkey] });
+
+ } catch (error: any) {
+ console.error("Failed to update event:", error);
+ toast.error(error.message || "Failed to update event");
+ } finally {
+ setProcessingId(null);
+ }
+ };
+
+ return (
+
+
+
+ Submit Event to Calendar
+
+ Select one of your existing events to embed it into this group calendar.
+
+
+
+
+ {isLoadingEvents ? (
+
+
+
+ ) : userEvents.length > 0 ? (
+ userEvents.map((event: any) => {
+ const title = event.tags.find((t: string[]) => t[0] === 'title')?.[1] || 'Untitled Event';
+ const isProcessing = processingId === event.id;
+
+ // Check if it already has the 'a' tag for this calendar
+ const isAlreadySubmitted = event.tags.some((t: string[]) => t[0] === 'a' && t[1] === calendarCoordinate);
+
+ return (
+
+
+
+
+
+
+
{title}
+
+ {isAlreadySubmitted ? 'Submitted' : 'Not submitted'}
+
+
+
+
+
handleToggleEvent(event, isAlreadySubmitted)}
+ disabled={isProcessing}
+ >
+ {isProcessing ? (
+
+ ) : isAlreadySubmitted ? (
+ <>
+
+ Remove
+ >
+ ) : (
+ <>
+
+ Submit
+ >
+ )}
+
+
+ );
+ })
+ ) : (
+
+
+
You haven't created any events yet.
+
+ onOpenChange(false)}>
+ Create an Event
+
+
+
+ )}
+
+
+
+ );
+}
From fff1d4213b3628ffea7d9674d06ca62ab92461c9 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 12:24:25 -0600
Subject: [PATCH 23/42] cal inbox deny
---
src/lib/calendarUtils.ts | 62 +++++++++++++++++++++++++++++++++++++++-
1 file changed, 61 insertions(+), 1 deletion(-)
diff --git a/src/lib/calendarUtils.ts b/src/lib/calendarUtils.ts
index f0f2966..9be21d4 100644
--- a/src/lib/calendarUtils.ts
+++ b/src/lib/calendarUtils.ts
@@ -13,6 +13,7 @@ export interface CalendarData {
description: string;
image?: string;
events: string[]; // List of reference coordinates or ids representing events included
+ rejected?: string[]; // List of blocked coordinates
}
export function parseCalendarEvent(event: any): CalendarData | null {
@@ -29,6 +30,11 @@ export function parseCalendarEvent(event: any): CalendarData | null {
.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]);
+
if (!d || !title) return null;
return {
@@ -40,7 +46,8 @@ export function parseCalendarEvent(event: any): CalendarData | null {
title,
description: event.content || '',
image,
- events: includedEvents
+ events: includedEvents,
+ rejected: rejectedEvents
};
}
@@ -225,3 +232,56 @@ export async function deleteCalendarEvent(
});
});
}
+
+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
+ });
+ });
+}
From 3fb183681cebdf388eb6e89ff403936c0ab1b0fc Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 12:25:01 -0600
Subject: [PATCH 24/42] cal inbox deny
---
src/pages/CalendarView.tsx | 95 ++++++++++++++++++++++++++++++++++----
1 file changed, 85 insertions(+), 10 deletions(-)
diff --git a/src/pages/CalendarView.tsx b/src/pages/CalendarView.tsx
index 07ba8bc..cf834db 100644
--- a/src/pages/CalendarView.tsx
+++ b/src/pages/CalendarView.tsx
@@ -6,14 +6,14 @@ 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 } from "lucide-react";
+import { CalendarDays, MapPin, Plus, LayoutGrid, Trash2, Loader2, AlertCircle, FileUp, Inbox, X } 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 } from "@/lib/calendarUtils";
+import { parseCalendarEvent, deleteCalendarEvent, addEventToCalendar, rejectEventFromCalendar } from "@/lib/calendarUtils";
import {
Dialog,
DialogContent,
@@ -43,6 +43,9 @@ export function CalendarView() {
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;
@@ -81,9 +84,15 @@ export function CalendarView() {
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] }); // Fetch the newly refreshed root calendar
+ queryClient.invalidateQueries({ queryKey: ['calendar', naddr] });
} catch (error: any) {
console.error("Failed to approve event:", error);
toast.error(error.message || "Failed to approve event");
@@ -92,6 +101,29 @@ export function CalendarView() {
}
};
+ 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,
@@ -131,6 +163,7 @@ export function CalendarView() {
// 2. Map explicit events that this calendar references
const explicitRefs = calendarData!.events || [];
+ const rejectedRefs = calendarData!.rejected || [];
const explicitIds: string[] = [];
for (const ref of explicitRefs) {
@@ -191,7 +224,7 @@ export function CalendarView() {
if (explicitRefs.includes(coord) || explicitRefs.includes(event.id)) {
approved.push(event);
- } else {
+ } else if (!rejectedRefs.includes(coord) && !rejectedRefs.includes(event.id)) {
pending.push(event);
}
});
@@ -200,8 +233,25 @@ export function CalendarView() {
}
});
- // Use either the approved list or the pending list depending on the state wrapper
- const activeEventsList = showPending ? data.pending : data.approved;
+ // 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 (
@@ -315,9 +365,9 @@ export function CalendarView() {
>
Inbox
- {data.pending.length > 0 && (
+ {displayPending.length > 0 && (
- {data.pending.length}
+ {displayPending.length}
)}
@@ -367,6 +417,7 @@ export function CalendarView() {
const activeEventCoordinate = isReplaceable && dTag ? `${event.kind}:${event.pubkey}:${dTag}` : event.id;
const isApproving = approvingId === activeEventCoordinate;
+ const isRejecting = rejectingId === activeEventCoordinate;
const CardContentBlock = (
@@ -407,6 +458,18 @@ export function CalendarView() {
{location}
)}
+ {showPending && event.pubkey && (
+
+ Submitted by:{" "}
+ e.stopPropagation()}
+ >
+ {nip19.npubEncode(event.pubkey).slice(0, 16)}...
+
+
+ )}
{showPending && (
@@ -414,13 +477,25 @@ export function CalendarView() {
{
e.preventDefault(); // prevent navigation
handleApproveEvent(activeEventCoordinate);
}}
>
- {isApproving ? : "Approve Event"}
+ {isApproving ? : "Approve"}
+
+ {
+ e.preventDefault(); // prevent navigation
+ handleRejectEvent(activeEventCoordinate);
+ }}
+ >
+ {isRejecting ? : }
)}
From df3f20b05e5022f5a6b0d8e83c37cb5ce7583a4f Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 12:38:16 -0600
Subject: [PATCH 25/42] cal inbox deny
---
.../SubmitToGroupCalendarDialog.tsx | 41 ++++++++++++++-----
1 file changed, 31 insertions(+), 10 deletions(-)
diff --git a/src/components/SubmitToGroupCalendarDialog.tsx b/src/components/SubmitToGroupCalendarDialog.tsx
index c981fbb..b34cf48 100644
--- a/src/components/SubmitToGroupCalendarDialog.tsx
+++ b/src/components/SubmitToGroupCalendarDialog.tsx
@@ -12,16 +12,17 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
-import { CalendarDays, Loader2, Plus, Check, Minus } from "lucide-react";
+import { CalendarDays, Loader2, Plus, Check, Minus, ShieldAlert } from "lucide-react";
import { Link } from "react-router-dom";
export interface SubmitToGroupCalendarDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
calendarCoordinate: string;
+ rejectedCoordinates?: string[];
}
-export function SubmitToGroupCalendarDialog({ open, onOpenChange, calendarCoordinate }: SubmitToGroupCalendarDialogProps) {
+export function SubmitToGroupCalendarDialog({ open, onOpenChange, calendarCoordinate, rejectedCoordinates = [] }: SubmitToGroupCalendarDialogProps) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutate: createEvent } = useNostrPublish();
@@ -122,36 +123,56 @@ export function SubmitToGroupCalendarDialog({ open, onOpenChange, calendarCoordi
const title = event.tags.find((t: string[]) => t[0] === 'title')?.[1] || 'Untitled Event';
const isProcessing = processingId === event.id;
+ // Calculate its coordinate
+ 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;
+
// Check if it already has the 'a' tag for this calendar
const isAlreadySubmitted = event.tags.some((t: string[]) => t[0] === 'a' && t[1] === calendarCoordinate);
+ // Check if the calendar owner has blacklisted it
+ const isDenied = rejectedCoordinates.includes(activeEventCoordinate) || rejectedCoordinates.includes(event.id);
+
return (
-
+
-
{title}
+
{title}
- {isAlreadySubmitted ? 'Submitted' : 'Not submitted'}
+ {isDenied ? 'Denied by Organizer' : isAlreadySubmitted ? 'Submitted' : 'Not submitted'}
handleToggleEvent(event, isAlreadySubmitted)}
- disabled={isProcessing}
+ className={`ml-3 shrink-0 ${isDenied ? 'text-destructive font-semibold cursor-not-allowed' : isAlreadySubmitted ? 'text-destructive hover:text-destructive' : ''}`}
+ onClick={() => {
+ if (!isDenied) handleToggleEvent(event, isAlreadySubmitted);
+ }}
+ disabled={isProcessing || isDenied}
>
{isProcessing ? (
+ ) : isDenied ? (
+ <>
+
+ Denied
+ >
) : isAlreadySubmitted ? (
<>
From 8d6b0d70af66412675f19b3ada2081a52a9139e5 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 12:39:05 -0600
Subject: [PATCH 26/42] cal inbox deny
---
src/pages/CalendarView.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/pages/CalendarView.tsx b/src/pages/CalendarView.tsx
index cf834db..f7f1b41 100644
--- a/src/pages/CalendarView.tsx
+++ b/src/pages/CalendarView.tsx
@@ -570,6 +570,7 @@ export function CalendarView() {
open={isSubmitDialogOpen}
onOpenChange={setIsSubmitDialogOpen}
calendarCoordinate={calendarCoordinate}
+ rejectedCoordinates={calendarData?.rejected || []}
/>
)}
From 712d7ba557939a90aaf497ed00e29935ec3f6ca5 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 13:13:17 -0600
Subject: [PATCH 27/42] auto calendars
---
src/pages/CalendarView.tsx | 77 ++++++++++++++++++++++++++++++++------
1 file changed, 65 insertions(+), 12 deletions(-)
diff --git a/src/pages/CalendarView.tsx b/src/pages/CalendarView.tsx
index f7f1b41..376581d 100644
--- a/src/pages/CalendarView.tsx
+++ b/src/pages/CalendarView.tsx
@@ -147,21 +147,40 @@ export function CalendarView() {
? `31924:${calendarData.pubkey}:${calendarData.d}`
: null;
- // Query events that reference this calendar via an `a` tag OR events specifically included by the calendar
// 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],
enabled: !!nostr && !!calendarCoordinate && !!calendarData,
- queryFn: async () => {
+ queryFn: async ({ signal }) => {
// 1. Find events that declare they belong to this calendar
- const filters: any[] = [
- {
+ const baseFilter: any = {
+ kinds: [31922, 31923],
+ '#a': [calendarCoordinate!]
+ };
+
+ const filters: any[] = [baseFilter];
+
+ // 2. Add auto-include tag queries
+ if (calendarData!.hashtags && calendarData!.hashtags.length > 0) {
+ filters.push({
kinds: [31922, 31923],
- '#a': [calendarCoordinate!]
- }
- ];
+ '#t': calendarData!.hashtags
+ });
+ }
+
+ if (calendarData!.locations && calendarData!.locations.length > 0) {
+ // Send a separate filter for EACH location to avoid relay confusion with array of strings
+ calendarData!.locations.forEach((loc) => {
+ if (loc && loc.trim() !== '') {
+ filters.push({
+ kinds: [31922, 31923],
+ '#location': [loc]
+ });
+ }
+ });
+ }
- // 2. Map explicit events that this calendar references
+ // 3. Map explicit events that this calendar references
const explicitRefs = calendarData!.events || [];
const rejectedRefs = calendarData!.rejected || [];
const explicitIds: string[] = [];
@@ -188,7 +207,22 @@ export function CalendarView() {
});
}
- const events = await nostr.query(filters);
+ // Log the filters being sent out so we can debug them in the console
+ console.log("Calendar View: Fetching with filters:", filters);
+
+ // Only run the query if we have actual filters to fetch from
+ if (filters.length === 0) {
+ return { approved: [], pending: [] };
+ }
+
+ // Add a timeout to prevent infinite loading if relays are unresponsive
+ const fetchSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
+ let events: any[] = [];
+ try {
+ events = await nostr.query(filters, { signal: fetchSignal });
+ } catch (err) {
+ console.warn("Calendar View query timed out or failed:", err);
+ }
// Deduplicate raw nostr events by their ID
const uniqueEventsMap = new Map();
@@ -213,7 +247,7 @@ export function CalendarView() {
});
// Segregate into Approved vs Pending
- // Approved = implicitly in the calendarData.events array
+ // Approved = implicitly in the calendarData.events array or matching auto-include filters
const approved: any[] = [];
const pending: any[] = [];
@@ -222,9 +256,28 @@ export function CalendarView() {
const dTag = event.tags.find((t: string[]) => t[0] === 'd')?.[1];
const coord = isReplaceable && dTag ? `${event.kind}:${event.pubkey}:${dTag}` : event.id;
- if (explicitRefs.includes(coord) || explicitRefs.includes(event.id)) {
+ const isExplicitlyApproved = explicitRefs.includes(coord) || explicitRefs.includes(event.id);
+ const isExplicitlyRejected = rejectedRefs.includes(coord) || rejectedRefs.includes(event.id);
+
+ let isAutoIncluded = false;
+
+ if (calendarData!.hashtags && calendarData!.hashtags.length > 0) {
+ const eventHashtags = event.tags.filter((t: any) => t[0] === 't').map((t: any) => t[1].toLowerCase());
+ if (calendarData!.hashtags.some((tag: string) => eventHashtags.includes(tag.toLowerCase()))) {
+ isAutoIncluded = true;
+ }
+ }
+
+ if (calendarData!.locations && calendarData!.locations.length > 0) {
+ const eventLocation = event.tags.find((t: any) => t[0] === 'location')?.[1]?.toLowerCase();
+ if (eventLocation && calendarData!.locations.some((loc: string) => eventLocation.includes(loc.toLowerCase()))) {
+ isAutoIncluded = true;
+ }
+ }
+
+ if (isExplicitlyApproved || (isAutoIncluded && !isExplicitlyRejected)) {
approved.push(event);
- } else if (!rejectedRefs.includes(coord) && !rejectedRefs.includes(event.id)) {
+ } else if (!isExplicitlyRejected) {
pending.push(event);
}
});
From 602fdb5262c64b8f984432142c236b8c55cb7d2f Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 13:13:53 -0600
Subject: [PATCH 28/42] auto calendars
---
src/pages/CreateCalendar.tsx | 63 +++++++++++++++++++++++++++++++++++-
1 file changed, 62 insertions(+), 1 deletion(-)
diff --git a/src/pages/CreateCalendar.tsx b/src/pages/CreateCalendar.tsx
index 8c25d08..6897515 100644
--- a/src/pages/CreateCalendar.tsx
+++ b/src/pages/CreateCalendar.tsx
@@ -9,7 +9,7 @@ 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 } from "lucide-react";
+import { CalendarDays, Rocket, Target, FileText, Hash, MapPin } from "lucide-react";
export function CreateCalendar() {
const navigate = useNavigate();
@@ -21,6 +21,8 @@ export function CreateCalendar() {
title: "",
description: "",
imageUrl: "",
+ hashtags: "",
+ location: "",
});
const handleSubmit = async (e: React.FormEvent) => {
@@ -45,6 +47,20 @@ export function CreateCalendar() {
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.trim()) {
+ tags.push(["location", formData.location.trim()]);
+ }
+
createEvent({
kind: 31924,
content: formData.description, // Calendar description is stored in content for 31924
@@ -137,6 +153,51 @@ export function CreateCalendar() {
/>
+
+
+
+ Smart Filters (Optional)
+
+
+ Automatically pull events into this calendar if they match these criteria. No manual approval needed!
+
+
+
+
+
+
+ Auto-Include Hashtags
+
+
+ setFormData((prev) => ({ ...prev, hashtags: 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"
+ />
+
Comma-separated tags
+
+
+
+
+ Auto-Include Location
+
+
+ setFormData((prev) => ({ ...prev, location: e.target.value }))
+ }
+ placeholder="e.g. Austin, TX"
+ className="text-lg py-3 rounded-2xl border-2 focus:border-primary transition-all duration-200"
+ />
+
Exact match for location field
+
+
+
+
setFormData((prev) => ({ ...prev, imageUrl: url }))}
From 23191e7ad57cdd70f01e8a2e991891a2e40ae7f4 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 13:14:24 -0600
Subject: [PATCH 29/42] auto calendars
---
src/lib/calendarUtils.ts | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/src/lib/calendarUtils.ts b/src/lib/calendarUtils.ts
index 9be21d4..a167aef 100644
--- a/src/lib/calendarUtils.ts
+++ b/src/lib/calendarUtils.ts
@@ -14,6 +14,8 @@ export interface CalendarData {
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
}
export function parseCalendarEvent(event: any): CalendarData | null {
@@ -35,6 +37,15 @@ export function parseCalendarEvent(event: any): CalendarData | null {
.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]);
+
if (!d || !title) return null;
return {
@@ -47,7 +58,9 @@ export function parseCalendarEvent(event: any): CalendarData | null {
description: event.content || '',
image,
events: includedEvents,
- rejected: rejectedEvents
+ rejected: rejectedEvents,
+ hashtags,
+ locations
};
}
From ac53d4ddb5341ea203d315f0718cc82e141900c4 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 16:05:59 -0600
Subject: [PATCH 30/42] edit cal
---
src/pages/EditCalendar.tsx | 351 +++++++++++++++++++++++++++++++++++++
1 file changed, 351 insertions(+)
create mode 100644 src/pages/EditCalendar.tsx
diff --git a/src/pages/EditCalendar.tsx b/src/pages/EditCalendar.tsx
new file mode 100644
index 0000000..6bba9da
--- /dev/null
+++ b/src/pages/EditCalendar.tsx
@@ -0,0 +1,351 @@
+import { useState, useEffect } from "react";
+import { useNavigate, useParams } 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 { 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 { parseCalendarEvent } from "@/lib/calendarUtils";
+import { nip19 } from "nostr-tools";
+
+export function EditCalendar() {
+ 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 [isSubmitting, setIsSubmitting] = useState(false);
+
+ const [formData, setFormData] = useState({
+ title: "",
+ description: "",
+ imageUrl: "",
+ hashtags: "",
+ location: "",
+ matchType: "any" as "any" | "all"
+ });
+
+ // 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]; // pubkey is middle part
+ queryD = parts[2]; // d tag is last part
+ } 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 { data: calendarData, isLoading: isLoadingCalendar } = useQuery({
+ queryKey: ['calendar', naddr],
+ enabled: !!nostr && !!queryD && !!queryPubkey,
+ queryFn: async ({ signal }) => {
+ const filter: any = { kinds: [31924] };
+
+ if (queryD) {
+ filter['#d'] = [queryD];
+ }
+ if (queryPubkey) {
+ filter.authors = [queryPubkey];
+ }
+
+ // Add a timeout to prevent infinite loading if relays are unresponsive
+ const fetchSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
+ let events: any[] = [];
+ try {
+ events = await nostr.query([filter], { signal: fetchSignal });
+ } catch (err) {
+ console.warn("Edit Calendar query timed out or failed:", err);
+ }
+
+ if (events.length === 0) return null;
+
+ // Store the raw event so we can preserve tags we don't modify
+ return {
+ parsed: parseCalendarEvent(events[0]),
+ rawTags: events[0].tags
+ };
+ }
+ });
+
+ useEffect(() => {
+ if (calendarData?.parsed) {
+ if (!isOwner) {
+ toast.error("You don't have permission to edit this calendar.");
+ navigate("/");
+ return;
+ }
+
+ setFormData({
+ title: calendarData.parsed.title || "",
+ description: calendarData.parsed.description || "",
+ imageUrl: calendarData.parsed.image || "",
+ hashtags: calendarData.parsed.hashtags?.join(", ") || "",
+ location: calendarData.parsed.locations?.join(", ") || "",
+ matchType: calendarData.parsed.matchType || "any"
+ });
+ }
+ }, [calendarData, isOwner, navigate]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!user || !calendarData || !queryD) return;
+
+ if (!formData.title.trim()) {
+ toast.error("Calendar Name is required");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ // Keep all explicitly approved (`a` or `e`) and rejected tags so we don't drop events
+ const preservedTags = calendarData.rawTags.filter((t: any[]) =>
+ t[0] === 'a' || t[0] === 'e' || t[0] === 'rejected' || t[0] === '-'
+ );
+
+ const tags = [
+ ["d", queryD], // Must keep exactly the same `d` tag!
+ ["title", formData.title],
+ ...preservedTags
+ ];
+
+ 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.trim()) {
+ tags.push(["location", formData.location.trim()]);
+ }
+
+ if (formData.hashtags || formData.location.trim()) {
+ tags.push(["match_type", formData.matchType]);
+ }
+
+ createEvent({
+ kind: 31924,
+ content: formData.description,
+ tags,
+ }, {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['calendars'] });
+ queryClient.invalidateQueries({ queryKey: ['calendar', naddr] });
+ toast.success("Calendar updated successfully!");
+ navigate(`/calendar/${naddr}`);
+ },
+ onError: (error) => {
+ console.error("Error updating calendar:", error);
+ toast.error("Failed to update calendar");
+ }
+ });
+
+ } catch (error) {
+ toast.error("An unexpected error occurred");
+ console.error(error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (isLoadingCalendar) {
+ return (
+
+ );
+ }
+
+ if (!calendarData || !isOwner) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ Edit Calendar
+
+
+ Update your group's details and filters
+
+
+
+
+
+
+
+ Calendar Name
+
+
+ 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
+ />
+
+
+
+
+ Description
+
+
+ setFormData((prev) => ({ ...prev, description: e.target.value }))
+ }
+ placeholder="What kind of events belong in this calendar?"
+ className="min-h-32 rounded-2xl border-2 focus:border-primary transition-all duration-200 resize-none"
+ required
+ />
+
+
+
+
+
+ Smart Filters (Optional)
+
+
+ Automatically pull events into this calendar if they match these criteria. No manual approval needed!
+
+
+
+
+
+
+ Auto-Include Hashtags
+
+
+ setFormData((prev) => ({ ...prev, hashtags: 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"
+ />
+
Comma-separated tags
+
+
+
+
+ Auto-Include Location
+
+
+ setFormData((prev) => ({ ...prev, location: e.target.value }))
+ }
+ placeholder="e.g. Austin, TX"
+ className="text-lg py-3 rounded-2xl border-2 focus:border-primary transition-all duration-200"
+ />
+
Exact match for location field
+
+
+ {(formData.hashtags || formData.location) && (
+
+
+
+
+ Filter Logic
+
+
+ {formData.matchType === 'any'
+ ? 'Include events that match ANY of these tags or locations (OR)'
+ : 'Include events that must match ALL of these tags and locations (AND)'}
+
+
+
+ ANY
+
+ setFormData((prev) => ({ ...prev, matchType: checked ? 'all' : 'any' }))
+ }
+ />
+ ALL
+
+
+
+ )}
+
+
+
+ setFormData((prev) => ({ ...prev, imageUrl: url }))}
+ />
+
+
+
navigate(`/calendar/${naddr}`)}
+ className="px-8 py-4 text-lg font-semibold rounded-2xl transition-all duration-200 shadow"
+ >
+ Cancel
+
+
+ {isSubmitting ? (
+
+ ) : (
+
+ Save Changes
+
+ )}
+
+
+
+
+ );
+}
From fbf566faf58e2d1cda7d04b023ac5c6314e0e103 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 16:07:06 -0600
Subject: [PATCH 31/42] edit cal
---
src/pages/CreateCalendar.tsx | 36 +++++++++++++++++++++++++++++++++++-
1 file changed, 35 insertions(+), 1 deletion(-)
diff --git a/src/pages/CreateCalendar.tsx b/src/pages/CreateCalendar.tsx
index 6897515..5e2116b 100644
--- a/src/pages/CreateCalendar.tsx
+++ b/src/pages/CreateCalendar.tsx
@@ -9,7 +9,8 @@ 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 } from "lucide-react";
+import { CalendarDays, Rocket, Target, FileText, Hash, MapPin, Filter } from "lucide-react";
+import { Switch } from "@/components/ui/switch";
export function CreateCalendar() {
const navigate = useNavigate();
@@ -23,6 +24,7 @@ export function CreateCalendar() {
imageUrl: "",
hashtags: "",
location: "",
+ matchType: "any" as "any" | "all"
});
const handleSubmit = async (e: React.FormEvent) => {
@@ -61,6 +63,10 @@ export function CreateCalendar() {
tags.push(["location", formData.location.trim()]);
}
+ 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
@@ -195,6 +201,34 @@ export function CreateCalendar() {
/>
Exact match for location field
+
+ {(formData.hashtags || formData.location) && (
+
+
+
+
+ Filter Logic
+
+
+ {formData.matchType === 'any'
+ ? 'Include events that match ANY of these tags or locations (OR)'
+ : 'Include events that must match ALL of these tags and locations (AND)'}
+
+
+
+ ANY
+
+ setFormData((prev) => ({ ...prev, matchType: checked ? 'all' : 'any' }))
+ }
+ />
+ ALL
+
+
+
+ )}
From 3d80f0bb7bb86fdec10799343a1cc2960ea4175b Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 16:07:56 -0600
Subject: [PATCH 32/42] edit cal
---
src/pages/CalendarView.tsx | 77 +++++++++++++++++++++++++++++++-------
1 file changed, 63 insertions(+), 14 deletions(-)
diff --git a/src/pages/CalendarView.tsx b/src/pages/CalendarView.tsx
index 376581d..155eb06 100644
--- a/src/pages/CalendarView.tsx
+++ b/src/pages/CalendarView.tsx
@@ -6,7 +6,7 @@ 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 } from "lucide-react";
+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";
@@ -35,10 +35,32 @@ export function CalendarView() {
const [isDeleting, setIsDeleting] = useState(false);
const [isSubmitDialogOpen, setIsSubmitDialogOpen] = useState(false);
- // Parse pubkey and d tag from the url param (assuming format pubkey:d or just d)
- const parts = naddr?.split(':') || [];
- const queryPubkey = parts.length > 1 ? parts[0] : null;
- const queryD = parts.length > 1 ? parts[1] : naddr;
+ // 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);
@@ -259,19 +281,37 @@ export function CalendarView() {
const isExplicitlyApproved = explicitRefs.includes(coord) || explicitRefs.includes(event.id);
const isExplicitlyRejected = rejectedRefs.includes(coord) || rejectedRefs.includes(event.id);
- let isAutoIncluded = false;
+ let hasHashtagMatch = false;
+ let hasLocationMatch = false;
+
+ const hasHashtagFilters = !!(calendarData!.hashtags && calendarData!.hashtags.length > 0);
+ const hasLocationFilters = !!(calendarData!.locations && calendarData!.locations.length > 0);
- if (calendarData!.hashtags && calendarData!.hashtags.length > 0) {
+ if (hasHashtagFilters) {
const eventHashtags = event.tags.filter((t: any) => t[0] === 't').map((t: any) => t[1].toLowerCase());
- if (calendarData!.hashtags.some((tag: string) => eventHashtags.includes(tag.toLowerCase()))) {
- isAutoIncluded = true;
- }
+ hasHashtagMatch = calendarData!.hashtags!.some((tag: string) => eventHashtags.includes(tag.toLowerCase()));
+ } else {
+ // If no hashtag filters exist, we consider it a "match" for the sake of AND logic
+ hasHashtagMatch = true;
}
- if (calendarData!.locations && calendarData!.locations.length > 0) {
+ if (hasLocationFilters) {
const eventLocation = event.tags.find((t: any) => t[0] === 'location')?.[1]?.toLowerCase();
- if (eventLocation && calendarData!.locations.some((loc: string) => eventLocation.includes(loc.toLowerCase()))) {
- isAutoIncluded = true;
+ hasLocationMatch = !!(eventLocation && calendarData!.locations!.some((loc: string) => eventLocation.includes(loc.toLowerCase())));
+ } else {
+ // If no location filters exist, we consider it a "match" for the sake of AND logic
+ hasLocationMatch = true;
+ }
+
+ let isAutoIncluded = false;
+
+ // Only process auto-include logic if at least one filter actually exists
+ if (hasHashtagFilters || hasLocationFilters) {
+ if (calendarData!.matchType === 'all') {
+ isAutoIncluded = hasHashtagMatch && hasLocationMatch;
+ } else {
+ // "any" mode - default
+ isAutoIncluded = Boolean((hasHashtagFilters && hasHashtagMatch) || (hasLocationFilters && hasLocationMatch));
}
}
@@ -363,7 +403,16 @@ export function CalendarView() {
)}
{isOwner && (
-
+
+
navigate(`/edit-calendar/${calendarCoordinate}`)}
+ className="transition-colors"
+ >
+
+ Edit Calendar
+
Date: Mon, 23 Feb 2026 16:08:54 -0600
Subject: [PATCH 33/42] edit cal
---
src/AppRouter.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 82febfa..281e084 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -7,6 +7,7 @@ import { Skeleton } from "@/components/ui/skeleton";
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 Profile = lazy(() => import("@/pages/Profile").then(m => ({ default: m.Profile })));
const MyTickets = lazy(() => import("@/pages/MyTickets").then(m => ({ default: m.MyTickets })));
@@ -34,6 +35,7 @@ export default function AppRouter() {
} />
} />
} />
+ } />
} />
} />
} />
From 3feedd3a996dcfed1bb8622f9d52ff8835a33299 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 16:09:33 -0600
Subject: [PATCH 34/42] edit cal
---
src/lib/calendarUtils.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/lib/calendarUtils.ts b/src/lib/calendarUtils.ts
index a167aef..13f30da 100644
--- a/src/lib/calendarUtils.ts
+++ b/src/lib/calendarUtils.ts
@@ -16,6 +16,7 @@ export interface CalendarData {
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 {
@@ -46,6 +47,9 @@ export function parseCalendarEvent(event: any): CalendarData | null {
.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 {
@@ -60,7 +64,8 @@ export function parseCalendarEvent(event: any): CalendarData | null {
events: includedEvents,
rejected: rejectedEvents,
hashtags,
- locations
+ locations,
+ matchType
};
}
From e8bc46272815af02445d8fc006b13e19bedcf13a Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 16:50:43 -0600
Subject: [PATCH 35/42] edit cal fix
---
src/pages/CalendarView.tsx | 33 ++++++++++++++++++++++++++-------
1 file changed, 26 insertions(+), 7 deletions(-)
diff --git a/src/pages/CalendarView.tsx b/src/pages/CalendarView.tsx
index 155eb06..caacd05 100644
--- a/src/pages/CalendarView.tsx
+++ b/src/pages/CalendarView.tsx
@@ -159,8 +159,23 @@ export function CalendarView() {
filter.authors = [queryPubkey];
}
- const events = await nostr.query([filter]);
- if (events.length === 0) return null;
+ 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]);
}
});
@@ -173,7 +188,7 @@ export function CalendarView() {
const { data = { approved: [], pending: [] }, isLoading: isLoadingEvents } = useQuery({
queryKey: ['calendarEvents', calendarCoordinate, calendarData?.events],
enabled: !!nostr && !!calendarCoordinate && !!calendarData,
- queryFn: async ({ signal }) => {
+ queryFn: async () => {
// 1. Find events that declare they belong to this calendar
const baseFilter: any = {
kinds: [31922, 31923],
@@ -237,13 +252,17 @@ export function CalendarView() {
return { approved: [], pending: [] };
}
- // Add a timeout to prevent infinite loading if relays are unresponsive
- const fetchSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
+ // Add an AbortController timeout to stop waiting for EOSE if relays stall
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+
let events: any[] = [];
try {
- events = await nostr.query(filters, { signal: fetchSignal });
+ events = await nostr.query(filters, { signal: controller.signal });
} catch (err) {
- console.warn("Calendar View query timed out or failed:", err);
+ console.warn("Calendar View query failed or timed out:", err);
+ } finally {
+ clearTimeout(timeoutId);
}
// Deduplicate raw nostr events by their ID
From 65d3e9b2ab38147fa2059c041d3b83d2c3634fda Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 16:51:12 -0600
Subject: [PATCH 36/42] edit cal fix
---
src/pages/EditCalendar.tsx | 32 +++++++++++++++++++++-----------
1 file changed, 21 insertions(+), 11 deletions(-)
diff --git a/src/pages/EditCalendar.tsx b/src/pages/EditCalendar.tsx
index 6bba9da..aa61899 100644
--- a/src/pages/EditCalendar.tsx
+++ b/src/pages/EditCalendar.tsx
@@ -63,9 +63,9 @@ export function EditCalendar() {
const isOwner = user?.pubkey === queryPubkey;
const { data: calendarData, isLoading: isLoadingCalendar } = useQuery({
- queryKey: ['calendar', naddr],
+ queryKey: ['edit-calendar', naddr],
enabled: !!nostr && !!queryD && !!queryPubkey,
- queryFn: async ({ signal }) => {
+ queryFn: async () => {
const filter: any = { kinds: [31924] };
if (queryD) {
@@ -75,16 +75,22 @@ export function EditCalendar() {
filter.authors = [queryPubkey];
}
- // Add a timeout to prevent infinite loading if relays are unresponsive
- const fetchSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+
let events: any[] = [];
try {
- events = await nostr.query([filter], { signal: fetchSignal });
+ events = await nostr.query([filter], { signal: controller.signal });
} catch (err) {
- console.warn("Edit Calendar query timed out or failed:", err);
+ console.warn("Edit Calendar query failed:", err);
+ } finally {
+ clearTimeout(timeoutId);
}
- if (events.length === 0) return null;
+ if (!events || events.length === 0) return null;
+
+ // Sort to get the latest version if multiple relays returned different versions
+ events.sort((a: any, b: any) => b.created_at - a.created_at);
// Store the raw event so we can preserve tags we don't modify
return {
@@ -149,8 +155,11 @@ export function EditCalendar() {
});
}
- if (formData.location.trim()) {
- tags.push(["location", formData.location.trim()]);
+ 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()) {
@@ -165,6 +174,7 @@ export function EditCalendar() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars'] });
queryClient.invalidateQueries({ queryKey: ['calendar', naddr] });
+ queryClient.invalidateQueries({ queryKey: ['edit-calendar', naddr] });
toast.success("Calendar updated successfully!");
navigate(`/calendar/${naddr}`);
},
@@ -278,10 +288,10 @@ export function EditCalendar() {
onChange={(e) =>
setFormData((prev) => ({ ...prev, location: e.target.value }))
}
- placeholder="e.g. Austin, TX"
+ placeholder="e.g. Austin, Dallas, Houston"
className="text-lg py-3 rounded-2xl border-2 focus:border-primary transition-all duration-200"
/>
- Exact match for location field
+ Comma-separated locations
{(formData.hashtags || formData.location) && (
From 0327aad693b20cf97dbcf49abf82f3633b108279 Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 16:51:42 -0600
Subject: [PATCH 37/42] edit cal fix
---
src/pages/CreateCalendar.tsx | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/src/pages/CreateCalendar.tsx b/src/pages/CreateCalendar.tsx
index 5e2116b..9a94440 100644
--- a/src/pages/CreateCalendar.tsx
+++ b/src/pages/CreateCalendar.tsx
@@ -59,8 +59,11 @@ export function CreateCalendar() {
});
}
- if (formData.location.trim()) {
- tags.push(["location", formData.location.trim()]);
+ 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()) {
@@ -196,10 +199,10 @@ export function CreateCalendar() {
onChange={(e) =>
setFormData((prev) => ({ ...prev, location: e.target.value }))
}
- placeholder="e.g. Austin, TX"
+ placeholder="e.g. Austin, Dallas, Houston"
className="text-lg py-3 rounded-2xl border-2 focus:border-primary transition-all duration-200"
/>
-
Exact match for location field
+
Comma-separated locations
{(formData.hashtags || formData.location) && (
From b9a5ce99a1f39c3ad334ec8780e2fa462733907c Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 17:06:55 -0600
Subject: [PATCH 38/42] multiple locations for auto cal
---
src/pages/EditCalendar.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/pages/EditCalendar.tsx b/src/pages/EditCalendar.tsx
index aa61899..5c7774c 100644
--- a/src/pages/EditCalendar.tsx
+++ b/src/pages/EditCalendar.tsx
@@ -113,7 +113,7 @@ export function EditCalendar() {
description: calendarData.parsed.description || "",
imageUrl: calendarData.parsed.image || "",
hashtags: calendarData.parsed.hashtags?.join(", ") || "",
- location: calendarData.parsed.locations?.join(", ") || "",
+ location: calendarData.parsed.locations?.join(" + ") || "",
matchType: calendarData.parsed.matchType || "any"
});
}
@@ -156,7 +156,7 @@ export function EditCalendar() {
}
if (formData.location) {
- const parsedLocations = formData.location.split(",").map(loc => loc.trim()).filter(Boolean);
+ const parsedLocations = formData.location.split("+").map(loc => loc.trim()).filter(Boolean);
parsedLocations.forEach(loc => {
tags.push(["location", loc]);
});
@@ -288,10 +288,10 @@ export function EditCalendar() {
onChange={(e) =>
setFormData((prev) => ({ ...prev, location: e.target.value }))
}
- placeholder="e.g. Austin, Dallas, Houston"
+ placeholder="e.g. Austin, TX + Lexington, KY"
className="text-lg py-3 rounded-2xl border-2 focus:border-primary transition-all duration-200"
/>
- Comma-separated locations
+ Separate multiple locations with +
{(formData.hashtags || formData.location) && (
From 98ed2b4909352304c90ff10732aafcbcc1fe145a Mon Sep 17 00:00:00 2001
From: Mnpezz <93685835+Mnpezz@users.noreply.github.com>
Date: Mon, 23 Feb 2026 17:07:33 -0600
Subject: [PATCH 39/42] multiple locations for auto cal
---
src/pages/CreateCalendar.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/pages/CreateCalendar.tsx b/src/pages/CreateCalendar.tsx
index 9a94440..a56c697 100644
--- a/src/pages/CreateCalendar.tsx
+++ b/src/pages/CreateCalendar.tsx
@@ -60,7 +60,7 @@ export function CreateCalendar() {
}
if (formData.location) {
- const parsedLocations = formData.location.split(",").map(loc => loc.trim()).filter(Boolean);
+ const parsedLocations = formData.location.split("+").map(loc => loc.trim()).filter(Boolean);
parsedLocations.forEach(loc => {
tags.push(["location", loc]);
});
@@ -199,10 +199,10 @@ export function CreateCalendar() {
onChange={(e) =>
setFormData((prev) => ({ ...prev, location: e.target.value }))
}
- placeholder="e.g. Austin, Dallas, Houston"
+ placeholder="e.g. Austin, TX + Lexington, KY"
className="text-lg py-3 rounded-2xl border-2 focus:border-primary transition-all duration-200"
/>
-