Adding calendars

This commit is contained in:
zmeyer44 2023-10-28 11:07:50 -04:00
parent b1ed914eb7
commit 963ee4cfc0
24 changed files with 1144 additions and 86 deletions

View File

@ -0,0 +1,19 @@
import { Button } from "@/components/ui/button";
import { useModal } from "@/app/_providers/modal/provider";
import CreateCalendarEventModal from "@/components/Modals/CreateCalendarEvent";
type CreateEventButtonProps = {
eventReference: string;
};
export default function CreateEventButton({
eventReference,
}: CreateEventButtonProps) {
const modal = useModal();
return (
<Button onClick={() => modal?.show(<CreateCalendarEventModal />)}>
Create Event
</Button>
);
}

View File

@ -0,0 +1,20 @@
import { Button } from "@/components/ui/button";
import { useModal } from "@/app/_providers/modal/provider";
import RSVPModal from "@/components/Modals/RSVP";
type RSVPButtonProps = {
eventReference: string;
};
export default function RSVPButton({ eventReference }: RSVPButtonProps) {
const modal = useModal();
return (
<Button
variant={"outline"}
onClick={() => modal?.show(<RSVPModal eventReference={eventReference} />)}
>
Edit
</Button>
);
}

View File

@ -0,0 +1,158 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import dynamic from "next/dynamic";
import { Button } from "@/components/ui/button";
import useProfile from "@/lib/hooks/useProfile";
import Spinner from "@/components/spinner";
import {
getTagAllValues,
getTagValues,
getTagsValues,
} from "@/lib/nostr/utils";
import ProfileInfo from "./ProfileInfo";
import useCurrentUser from "@/lib/hooks/useCurrentUser";
import { useNDK } from "@/app/_providers/ndk";
import { toast } from "sonner";
import {
sendZap,
checkPayment,
updateListUsersFromZaps,
} from "@/lib/actions/zap";
import { useModal } from "@/app/_providers/modal/provider";
import { type NDKEvent } from "@nostr-dev-kit/ndk";
import { btcToSats, formatNumber } from "@/lib/utils";
const CreateEventButton = dynamic(() => import("./CreateEventButton"), {
ssr: false,
});
const EditCalendarButton = dynamic(() => import("./EditCalendarButton"), {
ssr: false,
});
export default function Header({ event }: { event: NDKEvent }) {
const { currentUser } = useCurrentUser();
const modal = useModal();
const { ndk } = useNDK();
const [checkingPayment, setCheckingPayment] = useState(false);
const [hasValidPayment, setHasValidPayment] = useState(false);
const { pubkey, tags } = event;
const { profile } = useProfile(pubkey);
const eventReference = event.encode();
const name = getTagValues("name", tags) ?? "Untitled";
const image =
getTagValues("image", tags) ??
getTagValues("picture", tags) ??
getTagValues("banner", tags) ??
profile?.banner;
const description = event.content;
const rawEvent = event.rawEvent();
const priceInBTC = parseFloat(getTagValues("price", rawEvent.tags) ?? "0");
const isMember =
currentUser &&
getTagsValues("p", rawEvent.tags).includes(currentUser.pubkey);
useEffect(() => {
if (!currentUser || !false) return;
if (!isMember && !checkingPayment && !hasValidPayment) {
void handleCheckPayment();
}
}, [isMember, currentUser]);
async function handleCheckPayment() {
if (!event || !currentUser || !ndk) return;
setCheckingPayment(true);
console.log("Checking payment");
try {
const result = await checkPayment(
ndk,
event.tagId(),
currentUser.pubkey,
rawEvent,
);
console.log("Payment result", result);
if (result) {
setHasValidPayment(true);
}
} catch (err) {
console.log("error sending zap", err);
} finally {
setCheckingPayment(false);
}
}
async function handleSendZap() {
try {
const result = await sendZap(
ndk!,
btcToSats(priceInBTC),
rawEvent,
`Access payment: ${name}`,
);
toast.success("Payment Sent!");
void handleCheckPayment();
} catch (err) {
console.log("error sending zap", err);
} finally {
}
}
if (!event) {
return (
<div className="center pt-20 text-primary">
<Spinner />
</div>
);
}
return (
<div className="relative overflow-hidden rounded-[1rem] border bg-muted p-[0.5rem] @container">
<div className="overflow-hidden rounded-[0.5rem] p-0">
<div className="relative w-full overflow-hidden bg-gradient-to-b from-primary pb-[50%] @5xl:rounded-[20px] md:pb-[40%]">
{!!image && (
<Image
className="absolute inset-0 h-full w-full object-cover align-middle"
src={image}
width={400}
height={100}
alt="banner"
unoptimized
/>
)}
</div>
</div>
<div className="space-y-1 p-3 @sm:px-3.5 @sm:pb-2 @sm:pt-5">
<div className="flex items-start justify-between gap-x-1.5 @lg:gap-x-2.5">
<div className="shrink-0 space-y-1 @sm:space-y-2">
<h2 className="font-condensed text-2xl font-semibold sm:text-3xl lg:text-4xl">
{name}
</h2>
<div className="flex items-center">
<ProfileInfo pubkey={pubkey} />
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
{!!currentUser && currentUser.pubkey === pubkey && (
<>
<CreateEventButton eventReference={eventReference} />
<EditCalendarButton eventReference={eventReference} />
</>
)}
</div>
</div>
<div className="flex flex-col gap-x-6 gap-y-3 pt-1 @md:pt-2 @xl:flex-row">
<div className="flex-1">
{!!description && (
<p className="text-sm text-muted-foreground @md:text-sm">
{description}
</p>
)}
</div>
<div className="flex flex-1 @xl:justify-end">
<div className="flex flex-col gap-3 pr-3"></div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import Link from "next/link";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import useProfile from "@/lib/hooks/useProfile";
import { nip19 } from "nostr-tools";
import { getTwoLetters, getNameToShow } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { HiMiniChevronRight, HiCheckBadge } from "react-icons/hi2";
type ProfileInfoProps = {
pubkey: string;
};
export default function ProfileInfo({ pubkey }: ProfileInfoProps) {
const { profile } = useProfile(pubkey);
const npub = nip19.npubEncode(pubkey);
return (
<Link
href={`/${npub}`}
className="center group gap-x-2 rounded-sm rounded-r-full border bg-background/50 pl-0.5 pr-1 text-muted-foreground hover:shadow"
>
<Avatar className="center h-[16px] w-[16px] overflow-hidden rounded-[.25rem] bg-muted @sm:h-[18px] @sm:w-[18px]">
<AvatarImage src={profile?.image} alt={profile?.displayName} />
<AvatarFallback className="text-[8px]">
{getTwoLetters({ npub, profile })}
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-1">
<span className="text-[14px] ">{getNameToShow({ npub, profile })}</span>
{!!profile?.nip05 && <HiCheckBadge className="h-3 w-3 text-primary" />}
</div>
<HiMiniChevronRight className="h-4 w-4" />
</Link>
);
}
export function LoadingProfileInfo() {
return (
<div className="center group gap-x-1">
<Avatar className="center h-[16px] w-[16px] overflow-hidden rounded-[.25rem] bg-muted @sm:h-[18px] @sm:w-[18px]"></Avatar>
<div className="space-y-1">
<Skeleton className="h-2 w-[70px] bg-muted" />
</div>
</div>
);
}

View File

@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { nip19 } from "nostr-tools";
import useEvents from "@/lib/hooks/useEvents";
import Spinner from "@/components/spinner";
import {
getTagAllValues,
getTagValues,
getTagsAllValues,
getTagsValues,
} from "@/lib/nostr/utils";
import { type NDKKind } from "@nostr-dev-kit/ndk";
import Header from "./_components/Header";
import EventsFromCalendar from "@/containers/EventsTimeline/EventsFromCalendar";
export default function EventPage({
params: { naddr },
}: {
params: {
naddr: string;
};
}) {
const { type, data } = nip19.decode(naddr);
if (type !== "naddr") {
throw new Error("Invalid list");
}
const { identifier, kind, pubkey } = data;
const { events } = useEvents({
filter: {
authors: [pubkey],
kinds: [kind],
["#d"]: [identifier],
limit: 1,
},
});
const event = events[0];
if (!event) {
return (
<div className="center pt-20 text-primary">
<Spinner />
</div>
);
}
const { tags } = event;
const eventReference = event.encode();
return (
<div className="relative mx-auto max-w-5xl space-y-4 p-2 @container sm:p-4">
<Header event={event} />
<div className="flex flex-col pt-5 sm:gap-6">
<div className="flex items-center justify-between px-0">
<h2 className="font-condensed text-2xl font-bold">Upcoming Events</h2>
</div>
<div className="mx-auto w-full max-w-[900px] space-y-6">
<EventsFromCalendar
calendar={event}
empty={() => (
<div className="py-3 text-center text-sm text-muted-foreground">
<p>No upcoming events</p>
</div>
)}
/>
</div>
</div>
</div>
);
}

View File

@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import Feed from "@/containers/Feed";
import CreateKind1Modal from "@/components/Modals/Kind1";
import { useModal } from "@/app/_providers/modal/provider";
import { getTagValues } from "@/lib/nostr/utils";
type DiscussionContainerProps = {
eventReference: string;
@ -32,6 +33,7 @@ export default function DiscussionContainer({
</div>
<div className="w-full space-y-3">
<Feed
secondaryFilter={(e) => getTagValues("l", e.tags) !== "announcement"}
filter={{
kinds: [1],
["#a"]: [eventReference],

View File

@ -1,30 +1,6 @@
"use client";
import { NDKEvent, type NDKKind } from "@nostr-dev-kit/ndk";
import CalendarSection, {
CalendarSectionLoading,
} from "./_components/CalendarSection";
import useEvents from "@/lib/hooks/useEvents";
import { getTagValues } from "@/lib/nostr/utils";
import { fromUnix, daysOffset } from "@/lib/utils/dates";
export default function Page() {
const { events, isLoading } = useEvents({
filter: {
kinds: [31923 as NDKKind],
limit: 100,
},
});
const eventsByDay = groupEventsByDay(events);
if (isLoading && !eventsByDay.length) {
return (
<div className="relative flex-col px-5 pt-5 sm:pt-7">
<div className="mx-auto max-w-[900px] space-y-4">
<CalendarSectionLoading />
</div>
</div>
);
}
import { type NDKKind } from "@nostr-dev-kit/ndk";
import EventsTimeline from "@/containers/EventsTimeline";
export default function EventsPage() {
return (
<div className="relative flex-col space-y-6 px-5 pt-5 sm:pt-7">
<div className="flex items-center justify-between px-0 sm:px-5">
@ -33,40 +9,8 @@ export default function Page() {
</h2>
</div>
<div className="mx-auto max-w-[900px] space-y-6">
{eventsByDay.map((e) => (
<CalendarSection events={e} />
))}
<EventsTimeline filter={{ kinds: [31923 as NDKKind], limit: 100 }} />
</div>
</div>
);
}
function groupEventsByDay(events: NDKEvent[]) {
const eventDays: Record<string, NDKEvent[]> = {};
for (const event of events) {
const eventStartTime = getTagValues("start", event.tags);
if (!eventStartTime) continue;
const startDate = fromUnix(parseInt(eventStartTime));
const daysAway = daysOffset(startDate);
if (daysAway < 1) continue;
if (eventDays[`${daysAway}`]) {
eventDays[`${daysAway}`]!.push(event);
} else {
eventDays[`${daysAway}`] = [event];
}
}
const groupedArray = Object.entries(eventDays)
.sort(([aKey], [bKey]) => {
const aDay = parseInt(aKey);
const bDay = parseInt(bKey);
if (aDay > bDay) {
return 1;
} else if (aDay < bDay) {
return -1;
}
return 0;
})
.map(([_, events]) => events);
return groupedArray;
}

View File

@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { RiArrowRightLine } from "react-icons/ri";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { BANNER } from "@/constants/app";
import { getNameToShow } from "@/lib/utils";
import { nip19 } from "nostr-tools";
import useProfile from "@/lib/hooks/useProfile";
import useEvents from "@/lib/hooks/useEvents";
import { getTagAllValues, getTagValues } from "@/lib/nostr/utils";
import { useNDK } from "@/app/_providers/ndk";
type CalendarCardProps = {
calendar: NDKEvent;
};
export default function CalendarCard({ calendar }: CalendarCardProps) {
const { pubkey, tags, content } = calendar;
const { ndk } = useNDK();
const npub = nip19.npubEncode(pubkey);
const { profile } = useProfile(pubkey);
const encodedEvent = calendar.encode();
const [upcomingEvents, setUpcomingEvents] = useState<NDKEvent[]>([]);
const [isFetching, setIsFetching] = useState(false);
const name = getTagValues("name", tags);
const description = content ?? getTagValues("about", tags);
const calendarEvents = getTagAllValues("a", tags);
const calendarEventIdentifiers = calendarEvents
.map((e) => nip19.decode(e))
.filter(({ type }) => type === "naddr")
.map((e) => e.data as nip19.AddressPointer);
async function handleFetchEvents(data: nip19.AddressPointer[]) {
if (!ndk) return;
setIsFetching(true);
const events: NDKEvent[] = [];
const promiseArray = [];
for (const info of data) {
const calendarEventPromise = ndk
.fetchEvent({
authors: [info.pubkey],
["#d"]: [info.identifier],
kinds: [info.kind],
})
.then((e) => e && events.push(e))
.catch((err) => console.log("err"));
promiseArray.push(calendarEventPromise);
}
await Promise.all(promiseArray);
setUpcomingEvents(events);
setIsFetching(false);
}
useEffect(() => {
if (
!ndk ||
calendarEventIdentifiers.length === 0 ||
isFetching ||
upcomingEvents.length
)
return;
handleFetchEvents(calendarEventIdentifiers);
}, [ndk, calendarEventIdentifiers]);
return (
<Card className="relative h-[350px] w-[250px] min-w-[250] overflow-hidden">
<Image
alt="background"
src={profile?.banner ?? BANNER}
className="absolute inset-0 object-cover"
fill
unoptimized
/>
<div className="absolute inset-0 bg-zinc-800/20 backdrop-blur-lg transition-all">
<div className="group relative flex h-full w-full flex-col items-center justify-end transition-all">
<CardHeader className="absolute inset-x-0 top-[59%] transform pt-4 text-center transition-all duration-300 group-hover:top-[8px] group-hover:ml-[75px] group-hover:text-left">
<Link href={`/calendar/${encodedEvent}`}>
<CardTitle className="text-zinc-100 hover:underline">
{name}
</CardTitle>
</Link>
<CardDescription className="line-clamp-3 text-zinc-200 group-hover:text-xs">
{description}
</CardDescription>
</CardHeader>
<Image
alt="user"
src={
profile?.image ??
profile?.picture ??
`https://bitcoinfaces.xyz/api/get-image?name=${npub}&onchain=false`
}
className="absolute left-1/2 top-1/2 aspect-square -translate-x-1/2 -translate-y-[70%] transform overflow-hidden rounded-lg bg-muted object-cover transition-all duration-300 group-hover:left-[50px] group-hover:top-[65px] group-hover:w-[70px]"
height={100}
width={100}
unoptimized
/>
<Card className="absolute top-full min-h-full w-5/6 overflow-hidden transition-all duration-300 group-hover:top-1/3">
<CardHeader className="border-b p-4 pb-3">
<CardTitle>Upcoming Events:</CardTitle>
</CardHeader>
<CardContent className="overflow-hidden px-0">
<ul className="w-full">
{upcomingEvents.map((item) => {
const { tags, content } = item;
const encodedEvent = item.encode();
const name = getTagValues("name", tags);
const description = content;
return (
<li key={item.id} className="w-full overflow-hidden">
<Link
href={`/event/${encodedEvent}`}
className="flex max-w-full items-center justify-between overflow-hidden py-1.5 pl-4 pr-2 transition-colors hover:bg-muted hover:text-primary"
>
<div className="shrink overflow-x-hidden">
<h4 className="line-clamp-1 text-sm font-semibold text-card-foreground">
{name}
</h4>
<p className="line-clamp-2 text-[10px] leading-4 text-muted-foreground">
{description ?? ""}
</p>
</div>
<div className="center ml-auto shrink-0 pl-2">
<RiArrowRightLine className="h-5 w-5" />
</div>
</Link>
</li>
);
})}
</ul>
</CardContent>
</Card>
</div>
</div>
</Card>
);
}

View File

@ -2,7 +2,7 @@
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { useModal } from "@/app/_providers/modal/provider";
import CreateSubscriptionTier from "@/components/Modals/CreateSubscriptionTier";
import CreateCalendarEvent from "@/components/Modals/CreateCalendarEvent";
export default function BecomeACreator() {
const modal = useModal();
@ -26,8 +26,8 @@ export default function BecomeACreator() {
Start organizing your events an calendar on directly on Nostr.
Seamlessly collect payments and engage with your community.
</div>
<Button onClick={() => modal?.show(<CreateSubscriptionTier />)}>
Become a Creator
<Button onClick={() => modal?.show(<CreateCalendarEvent />)}>
Create an Event
</Button>
</div>
</div>

View File

@ -0,0 +1,72 @@
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { RiArrowRightLine } from "react-icons/ri";
import useEvents from "@/lib/hooks/useEvents";
import useProfile from "@/lib/hooks/useProfile";
import { nip19 } from "nostr-tools";
import { EXPLORE_CALENDARS } from "@/constants/app";
import { getTagValues } from "@/lib/nostr/utils";
import { NDKEvent, NDKKind, NDKUserProfile } from "@nostr-dev-kit/ndk";
import { useNDK } from "@nostr-dev-kit/ndk-react";
import CalendarCard from "../_components/CalendarCard";
export default function ExploreCalendars() {
return (
<section className="relative -mx-5 space-y-4 overflow-x-hidden sm:space-y-6">
<div className="flex items-center justify-between px-5 max-sm:pr-3">
<h2 className="font-condensed text-2xl font-bold sm:text-3xl">
Explore Calendars
</h2>
<Button variant={"ghost"}>
View all <RiArrowRightLine className="ml-1 h-4 w-4" />
</Button>
</div>
<HorizontalCarousel />
</section>
);
}
function HorizontalCarousel() {
const { events } = useEvents({
filter: {
kinds: [31924 as NDKKind],
limit: 5,
},
});
return (
<div className="scrollbar-thumb-rounded-full mr-auto flex min-w-0 max-w-full snap-x snap-mandatory overflow-x-auto pl-5 pr-[50vw] scrollbar-thin sm:pr-[200px]">
{events.map((calendar, index) => (
<div
key={index}
className={cn("snap-start pl-2 sm:pl-5", index === 0 && "pl-5")}
>
<Calendar encodedEvent={calendar.encode()} />
</div>
))}
</div>
);
}
function Calendar({ encodedEvent }: { encodedEvent: string }) {
const { type, data } = nip19.decode(encodedEvent);
if (type !== "naddr") {
throw new Error("impoper type");
}
const { pubkey, identifier, kind } = data;
const { events } = useEvents({
filter: {
authors: [pubkey],
["#d"]: [identifier],
kinds: [31924 as NDKKind],
limit: 1,
},
});
const event = events[0];
if (!event) {
return null;
}
return <CalendarCard calendar={event} key={event.id} />;
}

View File

@ -1,5 +1,6 @@
import dynamic from "next/dynamic";
import ExploreCreators from "./_sections/ExploreCreators";
import ExploreCalendars from "./_sections/ExploreCalendars";
import UpcomingEvents from "./_sections/UpcomingEvents";
import LongFormContentSection from "./_sections/LongFormContent";
import CreateEvents from "./_sections/CreateEvents";
@ -23,9 +24,9 @@ const NewEventButton = dynamic(() => import("./_components/NewEventButton"), {
export default function Page() {
return (
<div className="relative space-y-6 px-5 pt-5 sm:pt-7">
<ExploreCreators />
<ExploreCalendars />
<UpcomingEvents />
<LongFormContentSection />
{/* <LongFormContentSection /> */}
<CreateEvents />
<LiveStreamingSection />
<FeaturedListsSection />

View File

@ -56,7 +56,7 @@ export default function LandingPage() {
<div className="mx-auto max-w-2xl gap-x-14 lg:mx-0 lg:flex lg:max-w-none lg:items-center">
<div className="w-full max-w-xl lg:shrink-0 xl:max-w-2xl">
<h1 className="text-4xl font-bold tracking-tight text-zinc-900 sm:text-6xl">
Own your following. Only on Nostr.
Own your Events. Only on Nostr.
</h1>
<p className="relative mt-6 text-lg leading-8 text-zinc-600 sm:max-w-md lg:max-w-none">
We're bringing the creator economy onto Nostr. The days of

View File

@ -1,5 +1,5 @@
import { formatDate, fromUnix } from "@/lib/utils/dates";
import { BANNER } from "@/constants";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import Image from "next/image";
@ -13,7 +13,11 @@ import {
import SmallProfileLine from "@/components/ProfileContainers/SmallProfileLine";
import AvatarStack from "@/components/ProfileContainers/AvatarStack";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { getTagValues, getTagAllValues } from "@/lib/nostr/utils";
import {
getTagValues,
getTagAllValues,
getTagsValues,
} from "@/lib/nostr/utils";
import { HiOutlineMapPin, HiOutlineUserCircle } from "react-icons/hi2";
import { RxClock, RxCalendar } from "react-icons/rx";
import { Skeleton } from "@/components/ui/skeleton";
@ -28,7 +32,8 @@ export default function LargeFeedCard({ event }: LargeFeedCardProps) {
const image = getTagValues("image", tags);
const location =
getTagValues("location", tags) ?? getTagValues("address", tags);
const users = getTagAllValues("p", tags);
const users = getTagsValues("p", tags).filter(Boolean);
console.log("Users", users);
const startDate = getTagValues("start", tags)
? new Date(parseInt(getTagValues("start", tags) as string) * 1000)
: null;
@ -58,7 +63,7 @@ export default function LargeFeedCard({ event }: LargeFeedCardProps) {
<HiOutlineUserCircle className="h-4 w-4 text-primary" />
<AvatarStack
pubkeys={users.slice(0, 4)}
className="z-0 h-6 w-6 text-[9px]"
className="z-0 h-5 w-5 text-[9px]"
remaining={users.length - 4 > 2 ? users.length - 4 : 0}
/>
</div>
@ -89,9 +94,11 @@ export default function LargeFeedCard({ event }: LargeFeedCardProps) {
<div className="flex w-full flex-col justify-end self-start pl-2 pt-2">
<div className="flex w-3/4 items-center justify-stretch gap-3">
<Button className="flex-1">RSVP</Button>
<Button variant={"outline"} className="flex-1">
Share
</Button>
<Link href={`/event/${event.encode()}`}>
<Button variant={"outline"} className="flex-1">
Details
</Button>
</Link>
</div>
</div>
</CardHeader>

View File

@ -0,0 +1,88 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { HiChevronDown, HiCheck } from "react-icons/hi2";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
type PickerProps<T> = {
options: T[];
value: string | undefined;
noun: string;
placeholder: string;
onChange: (val: T) => void;
className?: string;
pre?: React.ReactNode;
};
export default function Picker<T extends { label: string; value: string }>({
value,
onChange,
options,
noun,
placeholder,
className,
pre,
}: PickerProps<T>) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn("flex items-center gap-2", className)}
>
{pre}
<span className="shrink truncate">
{value
? options.find((option) => option.value === value)?.label
: placeholder}
</span>
<HiChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="z-modal+ w-[250px] p-0">
<Command className="max-h-[200px]">
<CommandInput placeholder={`Search ${noun}...`} className="h-9" />
<CommandEmpty>{`No ${noun} Found.`}</CommandEmpty>
<CommandGroup className="overflow-y-auto">
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
onChange(option);
setOpen(false);
}}
>
{option.label}
<HiCheck
className={cn(
"ml-auto h-4 w-4 text-primary",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -3,6 +3,7 @@
import { useState, useRef, useEffect } from "react";
import Image from "next/image";
import { HiX } from "react-icons/hi";
import { HiOutlineCalendarDays } from "react-icons/hi2";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { randomId } from "@/lib/nostr";
@ -15,6 +16,7 @@ import { DatePicker } from "@/components/ui/date-picker";
import { TimePicker } from "@/components/ui/time-picker";
import { TimezoneSelector } from "@/components/ui/timezone";
import { Label } from "@/components/ui/label";
import Picker from "@/components/FormComponents/Picker";
import SmallCalendarIcon from "@/components/EventIcons/DateIcon";
import LocationIcon from "@/components/EventIcons/LocationIcon";
@ -24,10 +26,12 @@ import Spinner from "../spinner";
import useAutosizeTextArea from "@/lib/hooks/useAutoSizeTextArea";
import { useModal } from "@/app/_providers/modal/provider";
import { useRouter } from "next/navigation";
import { createEvent } from "@/lib/actions/create";
import { createEvent, updateList } from "@/lib/actions/create";
import { useNDK } from "@/app/_providers/ndk";
import useCurrentUser from "@/lib/hooks/useCurrentUser";
import useImageUpload from "@/lib/hooks/useImageUpload";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { getTagValues } from "@/lib/nostr/utils";
export default function CreateCalendarEventModal() {
const modal = useModal();
@ -66,6 +70,7 @@ export default function CreateCalendarEventModal() {
const [timezone, setTimezone] = useState(
Intl.DateTimeFormat().resolvedOptions().timeZone,
);
const [calendar, setCalendar] = useState<string>();
const [location, setLocation] = useState<{
address: string;
name: string;
@ -73,11 +78,10 @@ export default function CreateCalendarEventModal() {
geohash: string;
}>();
const { ndk } = useNDK();
const { currentUser } = useCurrentUser();
const { currentUser, calendars } = useCurrentUser();
const router = useRouter();
async function handleSubmit() {
console.log("CALLED", ndk, currentUser);
if (!ndk || !currentUser) {
alert("MISSING");
return;
@ -130,9 +134,21 @@ export default function CreateCalendarEventModal() {
};
const event = await createEvent(ndk, preEvent);
if (event) {
const encodedEvent = event.encode();
if (calendar) {
console.log("calendar", calendar);
const selectedCalendar = Array.from(calendars)
.find((option) => option.encode() === calendar)
?.rawEvent();
if (selectedCalendar) {
console.log("selectedCalendar", selectedCalendar);
await updateList(ndk, selectedCalendar, [["a", encodedEvent]]);
}
}
toast.success("Event Created!");
modal?.hide();
router.push(`/event/${event.encode()}`);
router.push(`/event/${encodedEvent}`);
} else {
toast.error("An error occured");
}
@ -279,6 +295,79 @@ export default function CreateCalendarEventModal() {
</div>
</div>
</div>
<div className="flex justify-between overflow-hidden p-0.5 px-1 pl-3">
<div className="flex-1 text-xs text-muted-foreground">
<div className="flex max-w-full justify-start bg-secondary">
<Picker
options={Array.from(calendars).map(
(o) =>
({
...o,
label: getTagValues("name", o.tags) as string,
value: o.encode(),
}) as NDKEvent & {
label: string;
value: string;
},
)}
noun="Calendar"
placeholder="Add to a Calendar"
pre={
<HiOutlineCalendarDays className="h-4 w-4 shrink-0" />
}
className="px-0 pr-1 font-normal"
value={calendar}
onChange={(calendar) => setCalendar(calendar.encode())}
/>
</div>
</div>
</div>
</div>
{/* Image Upload */}
<div className="hidden shrink justify-end sm:flex">
{imagePreview ? (
<div className="relative overflow-hidden rounded-xl">
<div className="">
<Image
alt="Image"
height="288"
width="288"
src={imagePreview}
className={cn(
"bg-bckground h-full rounded-xl object-cover object-center max-sm:max-h-[100px]",
imageStatus === "uploading" && "grayscale",
imageStatus === "error" && "blur-xl",
)}
/>
</div>
{imageStatus === "uploading" && (
<button className="center absolute left-1 top-1 rounded-full bg-foreground bg-opacity-70 p-1 text-background hover:bg-opacity-100">
<Spinner />
</button>
)}
{imageStatus === "success" && (
<button
onClick={clear}
className="center absolute left-1 top-1 rounded-full bg-foreground bg-opacity-70 p-1 hover:bg-opacity-100"
>
<HiX
className="block h-4 w-4 text-background"
aria-hidden="true"
/>
</button>
)}
</div>
) : (
<ImageUploadButton>
<Button
className=""
variant={"outline"}
loading={imageStatus === "uploading"}
>
{imageUrl ? "Uploaded!" : "Upload Image"}
</Button>
</ImageUploadButton>
)}
</div>
</div>
<div className="flex w-full items-start gap-x-3">
@ -307,7 +396,7 @@ export default function CreateCalendarEventModal() {
placeholder="Some into about this event..."
/>
</div>
<div className="flex justify-end">
<div className="flex justify-end sm:hidden">
{imagePreview ? (
<div className="relative overflow-hidden rounded-xl">
<div className="">

View File

@ -19,9 +19,13 @@ export default function AvatarStack({
}: AvatarStackProps) {
return (
<div className="isolate flex -space-x-2 overflow-hidden py-[2px]">
{pubkeys.map((p, idx) => (
<User key={p} pubkey={p} className={cn(zIndexes[idx], className)} />
))}
{pubkeys.map((p, idx) => {
if (p) {
return (
<User key={p} pubkey={p} className={cn(zIndexes[idx], className)} />
);
}
})}
{!!remaining && (
<Avatar
className={cn(

View File

@ -7,6 +7,15 @@ export const EXPLORE_CREATORS = [
"npub1dc9p7jzjhj86g2uqgltq4qvnpkyfqn9r72kdlddcgyat3j05gnjsgjc8rz",
"npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx",
];
export const EXPLORE_CALENDARS = [
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
"npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft",
"npub1u6qhg5ucu3xza4nlz94q90y720tr6l09avnq8y3yfp5qrv9v8sus3tnd7t",
"npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
"npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk",
"npub1dc9p7jzjhj86g2uqgltq4qvnpkyfqn9r72kdlddcgyat3j05gnjsgjc8rz",
"npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx",
];
export const NOTABLE_ACCOUNTS = [
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
"npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft",

View File

@ -0,0 +1,132 @@
"use client";
import { useRef, useEffect, useState } from "react";
import {
formatDate,
fromUnix,
relativeTime,
addMinutesToDate,
} from "@/lib/utils/dates";
import useCurrentUser from "@/lib/hooks/useCurrentUser";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { getTagValues } from "@/lib/nostr/utils";
import LargeFeedCard, {
LoadingCard,
} from "@/components/Cards/CalendarEvent/LargeFeedCard";
import { cn } from "@/lib/utils";
type CalendarSectionProps = {
events: NDKEvent[];
};
export default function CalendarSection({ events }: CalendarSectionProps) {
const { currentUser } = useCurrentUser();
const firstEvent = events.at(0);
if (!firstEvent) return null;
const startDateUnix = getTagValues("start", firstEvent?.tags);
if (!startDateUnix) return null;
const startDate = fromUnix(parseInt(startDateUnix));
return (
<div className="relative flex w-full items-start gap-x-3 @container">
{/* Date Indicator */}
<div className="sticky top-[calc(var(--header-height)_+_28px)] hidden w-[230px] shrink-0 md:block">
<CalendarIcon date={startDate} />
</div>
{/* Date Indicator Mobile */}
<div className="absolute inset-y-0 right-0 z-50 md:hidden">
<div className="sticky top-[calc(var(--header-height)_+_14px)] shrink-0">
<CalendarIconOpacity date={startDate} />
</div>
</div>
{/* Events */}
<div className="flex-1 space-y-4 max-md:pt-[60px]">
{events.map((e) => (
<LargeFeedCard key={e.id} event={e} />
))}
</div>
</div>
);
}
export function CalendarSectionLoading() {
const startDate = addMinutesToDate(new Date(), 60);
return (
<div className="relative flex w-full items-start gap-x-3 @container">
{/* Date Indicator */}
<div className="sticky top-[calc(var(--header-height)_+_28px)] hidden w-[230px] shrink-0 md:block">
<CalendarIcon date={startDate} />
</div>
{/* Date Indicator Mobile */}
<div className="absolute inset-y-0 right-0 z-50 md:hidden">
<div className="sticky top-[calc(var(--header-height)_+_14px)] shrink-0">
<CalendarIconOpacity date={startDate} />
</div>
</div>
{/* Events */}
<div className="flex-1 space-y-4 max-md:pt-[60px]">
<LoadingCard />
<LoadingCard />
<LoadingCard />
<LoadingCard />
</div>
</div>
);
}
function CalendarIcon({ date }: { date: Date }) {
return (
<div
className={cn(
"flex overflow-hidden rounded-md border bg-background shadow",
)}
>
<div className="center w-fit shrink-0 bg-primary p-1.5 px-3 text-primary-foreground @xl:p-2 @xl:px-3.5">
{/* <span className="text-2xl font-bold">24</span> */}
<span className="font-semibold">{formatDate(date, "ddd")}</span>
</div>
<div className="center flex whitespace-nowrap px-3 text-sm font-medium text-muted-foreground @lg:text-base @xl:p-1 @xl:px-3.5">
<div className="">
{formatDate(date, "MMMM Do")}
<span className="-mt-2 hidden text-[10px] font-normal @xl:block">
{relativeTime(date)}
</span>
</div>
</div>
</div>
);
}
function CalendarIconOpacity({ date }: { date: Date }) {
const ref = useRef<HTMLDivElement | null>(null);
const [top, setTop] = useState(false);
useEffect(() => {
// Add a scroll event listener to the window
const handleScroll = () => {
if (ref.current) {
// Get the position of the div relative to the viewport
const divRect = ref.current.getBoundingClientRect();
// Change the opacity when the div reaches the top of the screen
if (divRect.top <= 145) {
setTop(true);
} else {
setTop(false);
}
}
};
window.addEventListener("scroll", handleScroll);
return () => {
// Remove the scroll event listener when the component unmounts
window.removeEventListener("scroll", handleScroll);
};
}, []);
return (
<div className={cn(top && "opacity-80")}>
<CalendarIcon date={date} />
<div ref={ref}></div>
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { useState, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { getTagAllValues } from "@/lib/nostr/utils";
import { groupEventsByDay } from ".";
import { useNDK } from "@/app/_providers/ndk";
import { nip19 } from "nostr-tools";
import CalendarSection, { CalendarSectionLoading } from "./CalendarSection";
type EventsFromCalendar = {
calendar: NDKEvent;
loader?: () => JSX.Element;
empty?: () => JSX.Element;
};
export default function EventsFromCalendar({
calendar,
loader: Loader,
empty: Empty,
}: EventsFromCalendar) {
const calendarEvents = getTagAllValues("a", calendar.tags);
const { ndk } = useNDK();
const [events, setEvents] = useState<NDKEvent[]>([]);
const [isFetching, setIsFetching] = useState(false);
const calendarEventIdentifiers = calendarEvents
.map((e) => nip19.decode(e))
.filter(({ type }) => type === "naddr")
.map((e) => e.data as nip19.AddressPointer);
async function handleFetchEvents(data: nip19.AddressPointer[]) {
if (!ndk) return;
setIsFetching(true);
const events: NDKEvent[] = [];
const promiseArray = [];
for (const info of data) {
const calendarEventPromise = ndk
.fetchEvent({
authors: [info.pubkey],
["#d"]: [info.identifier],
kinds: [info.kind],
})
.then((e) => e && events.push(e))
.catch((err) => console.log("err"));
promiseArray.push(calendarEventPromise);
}
await Promise.all(promiseArray);
setEvents(events);
setIsFetching(false);
}
useEffect(() => {
if (
!ndk ||
calendarEventIdentifiers.length === 0 ||
isFetching ||
events.length
)
return;
handleFetchEvents(calendarEventIdentifiers);
}, [ndk, calendarEventIdentifiers]);
const eventsByDay = groupEventsByDay(events);
if (isFetching) {
if (Loader) {
return <Loader />;
}
return <CalendarSectionLoading />;
}
if (Empty && eventsByDay.length === 0) {
return <Empty />;
}
return (
<>
{eventsByDay.map((e) => (
<CalendarSection events={e} />
))}
</>
);
}

View File

@ -0,0 +1,73 @@
"use client";
import useEvents from "@/lib/hooks/useEvents";
import {
type NDKEvent,
type NDKKind,
type NDKFilter,
} from "@nostr-dev-kit/ndk";
import { getTagValues } from "@/lib/nostr/utils";
import { fromUnix, daysOffset } from "@/lib/utils/dates";
import Spinner from "@/components/spinner";
import CalendarSection, { CalendarSectionLoading } from "./CalendarSection";
type EventsTimelineProps = {
filter?: NDKFilter;
loader?: () => JSX.Element;
empty?: () => JSX.Element;
};
export default function EventsTimeline({
filter,
loader: Loader,
empty: Empty,
}: EventsTimelineProps) {
const { events, isLoading } = useEvents({
filter: { kinds: [31923 as NDKKind], ...filter },
});
const eventsByDay = groupEventsByDay(events);
if (isLoading) {
if (Loader) {
return <Loader />;
}
return <CalendarSectionLoading />;
}
if (Empty && eventsByDay.length === 0) {
return <Empty />;
}
return (
<>
{eventsByDay.map((e) => (
<CalendarSection events={e} />
))}
</>
);
}
export function groupEventsByDay(events: NDKEvent[]) {
const eventDays: Record<string, NDKEvent[]> = {};
for (const event of events) {
const eventStartTime = getTagValues("start", event.tags);
if (!eventStartTime) continue;
const startDate = fromUnix(parseInt(eventStartTime));
const daysAway = daysOffset(startDate);
if (daysAway < 1) continue;
if (eventDays[`${daysAway}`]) {
eventDays[`${daysAway}`]!.push(event);
} else {
eventDays[`${daysAway}`] = [event];
}
}
const groupedArray = Object.entries(eventDays)
.sort(([aKey], [bKey]) => {
const aDay = parseInt(aKey);
const bDay = parseInt(bKey);
if (aDay > bDay) {
return 1;
} else if (aDay < bDay) {
return -1;
}
return 0;
})
.map(([_, events]) => events);
return groupedArray;
}

View File

@ -4,9 +4,10 @@ import { cn } from "@/lib/utils";
import Spinner from "@/components/spinner";
import { Event } from "nostr-tools";
import useEvents from "@/lib/hooks/useEvents";
import { type NDKFilter } from "@nostr-dev-kit/ndk";
import { NDKEvent, type NDKFilter } from "@nostr-dev-kit/ndk";
type FeedProps = {
filter?: NDKFilter;
secondaryFilter?: (event: NDKEvent) => Boolean;
className?: string;
loader?: () => JSX.Element;
empty?: () => JSX.Element;
@ -14,7 +15,7 @@ type FeedProps = {
export default function Feed({
filter,
className,
secondaryFilter,
loader: Loader,
empty: Empty,
}: FeedProps) {
@ -30,6 +31,16 @@ export default function Feed({
if (Empty && events.length === 0) {
return <Empty />;
}
if (secondaryFilter) {
return (
<>
{events.filter(secondaryFilter).map((e) => {
const event = e.rawEvent() as Event;
return <KindCard key={e.id} {...event} />;
})}
</>
);
}
return (
<>
{events.map((e) => {

View File

@ -7,7 +7,7 @@ import { useNDK } from "@/app/_providers/ndk";
import { nip19 } from "nostr-tools";
import useLists from "./useLists";
import useSubscriptions from "./useSubscriptions";
import { db } from "@nostr-dev-kit/ndk-cache-dexie";
import { type NDKKind } from "@nostr-dev-kit/ndk";
import { webln } from "@getalby/sdk";
const loadNWCUrl = "";
const nwc = new webln.NWC({ nostrWalletConnectUrl: loadNWCUrl });
@ -20,6 +20,8 @@ export default function useCurrentUser() {
follows,
setFollows,
addFollow,
calendars,
setCalendars,
} = currentUserStore();
const { loginWithNip07, loginWithNip46, getProfile, ndk, fetchEvents } =
useNDK();
@ -86,14 +88,24 @@ export default function useCurrentUser() {
useEffect(() => {
if (!currentUser) return;
console.log("fetching follows");
console.log("fetching follows & calendar");
(async () => {
const following = await currentUser.follows();
console.log("Follows", following);
setFollows(following);
await fetchCalendars();
})();
}, [currentUser]);
async function fetchCalendars() {
if (!ndk || !currentUser) return;
const calendars = await ndk.fetchEvents({
authors: [currentUser.pubkey],
kinds: [31924 as NDKKind],
});
setCalendars(new Set(calendars));
}
return {
currentUser,
isLoading: false,
@ -107,5 +119,7 @@ export default function useCurrentUser() {
mySubscription,
addFollow,
setFollows,
calendars,
fetchCalendars,
};
}

View File

@ -95,3 +95,73 @@ export default function useEvents({
},
};
}
export function useEvent({
filter,
enabled = true,
eventFilter = () => true,
}: UseEventsProps) {
const [isLoading, setIsLoading] = useState(true);
const [sub, setSub] = useState<NDKSubscription | undefined>(undefined);
const [event, setEvent] = useState<NDKEvent>();
const [eventId, setEventId] = useState<string>();
let onEventCallback: null | OnEventFunc = null;
let onSubscribeCallback: null | OnSubscribeFunc = null;
let onDoneCallback: null | OnDoneFunc = null;
const { ndk } = useNDK();
useEffect(() => {
if (!enabled || !ndk) return;
void init();
return () => {
console.log("STOPPING", sub);
if (sub) {
sub.stop();
}
};
}, [enabled, ndk]);
async function init() {
setIsLoading(true);
try {
const sub = ndk!.subscribe(
{ limit: 1, ...filter },
{ closeOnEose: false },
);
setSub(sub);
onSubscribeCallback?.(sub);
sub.on("event", (e, r) => {
if (eventId === e.id) {
return;
}
if (eventFilter(e)) {
setEvent(e);
setEventId(e.id);
}
});
} catch (err) {
log("error", `❌ nostr (${err})`);
} finally {
setIsLoading(false);
}
}
return {
isLoading,
event,
onEvent: (_onEventCallback: OnEventFunc) => {
if (_onEventCallback) {
onEventCallback = _onEventCallback;
}
},
onDone: (_onDoneCallback: OnDoneFunc) => {
if (_onDoneCallback) {
onDoneCallback = _onDoneCallback;
}
},
onSubscribe: (_onSubscribeCallback: OnSubscribeFunc) => {
if (_onSubscribeCallback) {
onSubscribeCallback = _onSubscribeCallback;
}
},
};
}

View File

@ -1,11 +1,13 @@
import { create } from "zustand";
import { type NDKUser } from "@nostr-dev-kit/ndk";
import { NDKEvent, type NDKUser } from "@nostr-dev-kit/ndk";
type Settings = {};
interface CurrentUserState {
currentUser: NDKUser | null;
follows: Set<NDKUser>;
calendars: Set<NDKEvent>;
setCalendars: (calendars: Set<NDKEvent>) => void;
settings: Settings;
setCurrentUser: (user: NDKUser | null) => void;
updateCurrentUser: (user: Partial<NDKUser>) => void;
@ -16,6 +18,7 @@ interface CurrentUserState {
const currentUserStore = create<CurrentUserState>()((set) => ({
currentUser: null,
follows: new Set(),
calendars: new Set(),
settings: {},
setCurrentUser: (user) => set((state) => ({ ...state, currentUser: user })),
updateCurrentUser: (user) =>
@ -24,6 +27,8 @@ const currentUserStore = create<CurrentUserState>()((set) => ({
currentUser: { ...state.currentUser, ...user } as NDKUser,
})),
setFollows: (follows) => set((state) => ({ ...state, follows: follows })),
setCalendars: (calendars) =>
set((state) => ({ ...state, calendars: calendars })),
addFollow: (follow) =>
set((state) => ({ ...state, follows: new Set(state.follows).add(follow) })),
}));