calendar improvments

This commit is contained in:
zmeyer44 2023-10-31 10:26:17 -04:00
parent 847fc2c190
commit 1b3f2b4d6e
15 changed files with 419 additions and 64 deletions

View File

@ -1,18 +1,19 @@
import { Button } from "@/components/ui/button";
import { useModal } from "@/app/_providers/modal/provider";
import RSVPModal from "@/components/Modals/RSVP";
import EditCalendarModal from "@/components/Modals/EditCalendar";
import { NostrEvent } from "@nostr-dev-kit/ndk";
type RSVPButtonProps = {
eventReference: string;
event: NostrEvent;
};
export default function RSVPButton({ eventReference }: RSVPButtonProps) {
export default function RSVPButton({ event }: RSVPButtonProps) {
const modal = useModal();
return (
<Button
variant={"outline"}
onClick={() => modal?.show(<RSVPModal eventReference={eventReference} />)}
onClick={() => modal?.show(<EditCalendarModal listEvent={event} />)}
>
Edit
</Button>

View File

@ -135,7 +135,7 @@ export default function Header({ event }: { event: NDKEvent }) {
{!!currentUser && currentUser.pubkey === pubkey && (
<>
<CreateEventButton eventReference={eventReference} />
<EditCalendarButton eventReference={eventReference} />
<EditCalendarButton event={event.rawEvent()} />
</>
)}
</div>

View File

@ -46,7 +46,6 @@ export default function EventPage({
}
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} />

View File

@ -0,0 +1,86 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { nip19 } from "nostr-tools";
import { getLettersPlain } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { HiMiniChevronRight, HiCalendarDays } from "react-icons/hi2";
import useEvents from "@/lib/hooks/useEvents";
import { getTagValues } from "@/lib/nostr/utils";
import ProfileInfo from "./ProfileInfo";
import { type NDKKind, type NDKEvent } from "@nostr-dev-kit/ndk";
import { useNDK } from "@/app/_providers/ndk";
type CalendarInfoProps = {
eventReference: string;
};
export default function CalendarInfo({ eventReference }: CalendarInfoProps) {
console.log("eventReference", eventReference);
const { type, data } = nip19.decode(eventReference);
if (type !== "naddr") {
throw new Error("Invalid list");
}
const { pubkey } = data;
const { ndk } = useNDK();
const [event, setEvent] = useState<NDKEvent>();
const [isFetching, setIsFetching] = useState(false);
useEffect(() => {
if (!ndk || isFetching || event) return;
handleFetchEvent();
}, [ndk, eventReference]);
async function handleFetchEvent() {
if (!ndk) return;
setIsFetching(true);
const calendarEvent = await ndk
.fetchEvent({
authors: [pubkey],
["#a"]: [eventReference],
kinds: [31924 as NDKKind],
})
.catch((err) => console.log("err"));
if (calendarEvent) {
console.log("Found calendar", calendarEvent);
setEvent(calendarEvent);
}
setIsFetching(false);
}
if (!event) {
return <ProfileInfo pubkey={pubkey} />;
}
const image = getTagValues("image", event.tags);
const name = getTagValues("name", event.tags);
return (
<Link
href={`/calendar/${eventReference}`}
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={image} alt={name ?? "event"} />
<AvatarFallback className="text-[8px]">
{getLettersPlain(name)}
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-1">
<span className="text-[14px] ">{name ?? "Calendar"}</span>
<HiCalendarDays className="h-3 w-3 text-primary" />
</div>
<HiMiniChevronRight className="h-4 w-4" />
</Link>
);
}
export function LoadingCalendarInfo() {
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,21 @@
import { Button } from "@/components/ui/button";
import { useModal } from "@/app/_providers/modal/provider";
import EditEventModal from "@/components/Modals/EditEvent";
import { NostrEvent } from "@nostr-dev-kit/ndk";
type EditEventProps = {
event: NostrEvent;
};
export default function EditEvent({ event }: EditEventProps) {
const modal = useModal();
return (
<Button
variant={"outline"}
onClick={() => modal?.show(<EditEventModal listEvent={event} />)}
>
Edit
</Button>
);
}

View File

@ -12,6 +12,7 @@ import {
getTagsValues,
} from "@/lib/nostr/utils";
import ProfileInfo from "./ProfileInfo";
import CalendarInfo from "./CalendarInfo";
import useCurrentUser from "@/lib/hooks/useCurrentUser";
import { useNDK } from "@/app/_providers/ndk";
import { toast } from "sonner";
@ -30,13 +31,8 @@ import LocationIcon from "@/components/EventIcons/LocationIcon";
const RSVPButton = dynamic(() => import("./RSVPButton"), {
ssr: false,
});
const CreateListEvent = dynamic(
() => import("@/components/Modals/ShortTextNoteOnList"),
{
ssr: false,
},
);
const ConfirmModal = dynamic(() => import("@/components/Modals/Confirm"), {
const EditEventButton = dynamic(() => import("./EditEventButton"), {
ssr: false,
});
@ -151,26 +147,14 @@ export default function Header({ event }: { event: NDKEvent }) {
{title}
</h2>
<div className="flex items-center">
<ProfileInfo pubkey={pubkey} />
<CalendarInfo eventReference={eventReference} />
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
{/* {!!currentUser && currentUser.pubkey === pubkey && (
<>
<Button onClick={() => modal?.show(<CreateListEvent />)}>
Invite Users
</Button>
<Button
variant="ghost"
onClick={() =>
modal?.show(<EditEventModal listEvent={rawEvent} />)
}
>
Edit
</Button>
</>
)} */}
<RSVPButton eventReference={eventReference} />
{!!currentUser && currentUser.pubkey === pubkey && (
<EditEventButton event={event.rawEvent()} />
)}
{!isMember && <RSVPButton eventReference={eventReference} />}
{/* {!isMember &&
(hasValidPayment ? (
<Button variant={"outline"}>Pending Sync</Button>

View File

@ -10,7 +10,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { cn, getLettersPlain, getTwoLetters } from "@/lib/utils";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { BANNER } from "@/constants/app";
import { getNameToShow } from "@/lib/utils";
@ -21,6 +21,7 @@ import { getTagAllValues, getTagValues } from "@/lib/nostr/utils";
import { useNDK } from "@/app/_providers/ndk";
import { Skeleton } from "@/components/ui/skeleton";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
type CalendarCardProps = {
calendar: NDKEvent;
@ -35,6 +36,9 @@ export default function CalendarCard({ calendar }: CalendarCardProps) {
const [upcomingEvents, setUpcomingEvents] = useState<NDKEvent[]>([]);
const [isFetching, setIsFetching] = useState(false);
const name = getTagValues("name", tags);
const image = getTagValues("image", tags);
const banner =
getTagValues("banner", tags) ?? profile?.image ?? profile?.banner ?? BANNER;
const description = content ?? getTagValues("about", tags);
const calendarEvents = getTagAllValues("a", tags);
const calendarEventIdentifiers = calendarEvents
@ -78,7 +82,7 @@ export default function CalendarCard({ calendar }: CalendarCardProps) {
<Card className="relative h-[350px] w-[250px] min-w-[250] overflow-hidden">
<Image
alt="background"
src={profile?.banner ?? BANNER}
src={banner}
className="absolute inset-0 object-cover"
fill
unoptimized
@ -95,18 +99,20 @@ export default function CalendarCard({ calendar }: CalendarCardProps) {
{description}
</CardDescription>
</CardHeader>
<Image
<Avatar className="absolute left-1/2 top-1/2 !aspect-square h-[100px] w-[100px] -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:h-[70px] group-hover:w-[70px]">
<AvatarImage src={image ?? BANNER} height={100} width={100} />
<AvatarFallback>
{getLettersPlain(name ?? profile?.displayName ?? profile?.name)}
</AvatarFallback>
</Avatar>
{/* <Image
alt="user"
src={
profile?.image ??
profile?.picture ??
`https://bitcoinfaces.xyz/api/get-image?name=${npub}&onchain=false`
}
src={image ?? BANNER}
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>

View File

@ -11,14 +11,8 @@ import { addMinutesToDate, toUnix, convertToTimezone } from "@/lib/utils/dates";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
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 SmallCalendarIcon from "@/components/EventIcons/DateIcon";
import LocationIcon from "@/components/EventIcons/LocationIcon";
import LocationSearchInput from "@/components/LocationSearch";
import Spinner from "../spinner";
import useAutosizeTextArea from "@/lib/hooks/useAutoSizeTextArea";
@ -40,6 +34,13 @@ export default function CreateCalendarEventModal() {
imageUrl,
status: imageStatus,
} = useImageUpload("event");
const {
ImageUploadButton: BannerImageUploadButton,
clear: clearBanner,
imagePreview: bannerImagePreview,
imageUrl: bannerImageUrl,
status: bannerImageStatus,
} = useImageUpload("event");
const [error, setError] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@ -73,6 +74,9 @@ export default function CreateCalendarEventModal() {
if (imageUrl) {
tags.push(["image", imageUrl]);
}
if (bannerImageUrl) {
tags.push(["banner", bannerImageUrl]);
}
const preEvent = {
content: description,
pubkey: currentUser.pubkey,
@ -134,7 +138,7 @@ export default function CreateCalendarEventModal() {
placeholder="Some into about this calendar..."
/>
</div>
<div className="flex justify-end">
<div className="flex justify-end gap-3">
{imagePreview ? (
<div className="relative overflow-hidden rounded-xl">
<div className="">
@ -178,13 +182,58 @@ export default function CreateCalendarEventModal() {
</Button>
</ImageUploadButton>
)}
{bannerImagePreview ? (
<div className="relative overflow-hidden rounded-xl">
<div className="">
<Image
alt="Image"
height="288"
width="288"
src={bannerImagePreview}
className={cn(
"bg-bckground h-full rounded-xl object-cover object-center max-sm:max-h-[100px]",
bannerImageStatus === "uploading" && "grayscale",
bannerImageStatus === "error" && "blur-xl",
)}
/>
</div>
{bannerImageStatus === "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>
)}
{bannerImageStatus === "success" && (
<button
onClick={clearBanner}
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={bannerImageStatus === "uploading"}
>
{bannerImageUrl ? "Uploaded!" : "Upload Banner Image"}
</Button>
</ImageUploadButton>
)}
</div>
<div className="flex">
<Button
onClick={handleSubmit}
loading={isLoading}
disabled={imageStatus === "uploading"}
disabled={
imageStatus === "uploading" || bannerImageStatus === "uploading"
}
className="w-full"
>
Create

View File

@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import FormModal from "./FormModal";
import { z } from "zod";
import useEvents from "@/lib/hooks/useEvents";
import { updateList } from "@/lib/actions/create";
import { unixTimeNowInSeconds } from "@/lib/nostr/dates";
import { useModal } from "@/app/_providers/modal/provider";
import { toast } from "sonner";
import { useNDK } from "@/app/_providers/ndk";
import { NostrEvent } from "@nostr-dev-kit/ndk";
import { getTagValues } from "@/lib/nostr/utils";
const EditCalendarSchema = z.object({
name: z.string(),
about: z.string().optional(),
image: z.string().optional(),
banner: z.string().optional(),
});
type EditListType = z.infer<typeof EditCalendarSchema>;
type EditListModalProps = {
listEvent: NostrEvent;
};
export default function EditListModal({ listEvent }: EditListModalProps) {
const modal = useModal();
const [isLoading, setIsLoading] = useState(false);
const [sent, setSent] = useState(false);
const { ndk } = useNDK();
const { events } = useEvents({
filter: {
kinds: [listEvent.kind as number],
authors: [listEvent.pubkey],
since: unixTimeNowInSeconds() - 10,
limit: 1,
},
enabled: sent,
});
useEffect(() => {
if (events.length) {
console.log("Done!");
setIsLoading(false);
toast.success("Calendar Updated!");
modal?.hide();
}
}, [events]);
async function handleSubmit(listData: EditListType) {
setIsLoading(true);
const newTags = Object.entries(listData);
setSent(true);
const result = await updateList(
ndk!,
{ ...listEvent, content: listData.about ?? "" },
newTags,
);
}
const defaultValues: Partial<EditListType> = {
name: getTagValues("name", listEvent.tags),
image:
getTagValues("image", listEvent.tags) ??
getTagValues("picture", listEvent.tags),
banner: getTagValues("banner", listEvent.tags),
about: listEvent.content,
};
return (
<FormModal
title="Edit Calendar"
fields={[
{
label: "Name",
type: "input",
slug: "name",
},
{
label: "About",
type: "text-area",
slug: "about",
},
{
label: "Image",
type: "upload",
slug: "image",
},
{
label: "Banner Image",
type: "upload",
slug: "banner",
},
]}
defaultValues={defaultValues ?? {}}
formSchema={EditCalendarSchema}
onSubmit={handleSubmit}
isSubmitting={isLoading}
cta={{
text: "Save Changes",
}}
/>
);
}

View File

@ -12,7 +12,7 @@ import { getTagValues } from "@/lib/nostr/utils";
const EditListSchema = z.object({
name: z.string(),
description: z.string().optional(),
about: z.string().optional(),
image: z.string().optional(),
});
@ -39,7 +39,7 @@ export default function EditListModal({ listEvent }: EditListModalProps) {
if (events.length) {
console.log("Done!");
setIsLoading(false);
toast.success("List Updated!");
toast.success("Event Updated!");
modal?.hide();
}
}, [events]);
@ -50,7 +50,7 @@ export default function EditListModal({ listEvent }: EditListModalProps) {
setSent(true);
const result = await updateList(
ndk!,
{ ...listEvent, content: listData.description ?? "" },
{ ...listEvent, content: listData.about ?? "" },
newTags,
);
}
@ -61,12 +61,12 @@ export default function EditListModal({ listEvent }: EditListModalProps) {
image:
getTagValues("image", listEvent.tags) ??
getTagValues("picture", listEvent.tags),
description: listEvent.content,
about: listEvent.content,
};
return (
<FormModal
title="Edit List"
title="Edit Event"
fields={[
{
label: "Name",
@ -74,14 +74,14 @@ export default function EditListModal({ listEvent }: EditListModalProps) {
slug: "name",
},
{
label: "Image",
type: "input",
slug: "image",
label: "About",
type: "text-area",
slug: "about",
},
{
label: "Description",
type: "text-area",
slug: "description",
label: "Image",
type: "upload",
slug: "image",
},
]}
defaultValues={defaultValues ?? {}}

View File

@ -1,9 +1,10 @@
"use client";
import { type ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { HiOutlineChevronDown } from "react-icons/hi2";
import { HiOutlineChevronDown, HiXMark } from "react-icons/hi2";
import Image from "next/image";
import {
FieldErrors,
useForm,
@ -47,6 +48,8 @@ import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import Spinner from "../spinner";
import useImageUpload from "@/lib/hooks/useImageUpload";
type FieldOptions =
| "toggle"
@ -54,6 +57,7 @@ type FieldOptions =
| "input"
| "number"
| "text-area"
| "upload"
| "custom";
type DefaultFieldType<TSchema> = {
@ -276,6 +280,13 @@ export default function FormModal<TSchema extends FieldValues>({
onCheckedChange={field.onChange}
/>
</FormControl>
) : type === "upload" ? (
<FormControl>
<ImageUpload
value={field.value}
onChange={field.onChange}
/>
</FormControl>
) : type === "number" ? (
<FormControl>
<Input
@ -316,3 +327,70 @@ export default function FormModal<TSchema extends FieldValues>({
</Template>
);
}
function ImageUpload({
value,
onChange,
}: {
value?: string;
onChange: (imageUrl: string) => void;
}) {
const { status, imageUrl, clear, ImageUploadButton } =
useImageUpload("event");
const handleClear = () => {
clear();
onChange("");
};
useEffect(() => {
if (imageUrl && value !== imageUrl) {
onChange(imageUrl);
}
}, [imageUrl]);
return (
<div className="">
{value || imageUrl ? (
<div className="relative overflow-hidden rounded-xl">
<div className="">
<Image
alt="Image"
height="288"
width="288"
src={(value || imageUrl) as string}
className={cn(
"bg-bckground h-full rounded-xl object-cover object-center max-sm:max-h-[100px]",
status === "uploading" && "grayscale",
status === "error" && "blur-xl",
)}
/>
</div>
{status === "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>
)}
{(status === "success" || value) && (
<button
onClick={handleClear}
className="center absolute left-1 top-1 rounded-full bg-foreground bg-opacity-70 p-1 hover:bg-opacity-100"
>
<HiXMark
className="block h-4 w-4 text-background"
aria-hidden="true"
/>
</button>
)}
</div>
) : (
<ImageUploadButton>
<Button
className=""
variant={"outline"}
loading={status === "uploading"}
>
{imageUrl ? "Uploaded!" : "Upload"}
</Button>
</ImageUploadButton>
)}
</div>
);
}

View File

@ -43,7 +43,15 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, variant, size, asChild = false, loading = false, ...props },
{
className,
variant,
size,
asChild = false,
type = "button",
loading = false,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
@ -52,6 +60,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
type={type}
{...props}
>
<div className="text-transparent">{props.children}</div>
@ -65,6 +74,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
type={type}
{...props}
/>
);

View File

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { getTagAllValues } from "@/lib/nostr/utils";
import { getTagsValues } from "@/lib/nostr/utils";
import { groupEventsByDay } from ".";
import { useNDK } from "@/app/_providers/ndk";
import { nip19 } from "nostr-tools";
@ -18,11 +18,13 @@ export default function EventsFromCalendar({
loader: Loader,
empty: Empty,
}: EventsFromCalendar) {
const calendarEvents = getTagAllValues("a", calendar.tags);
const calendarEvents = getTagsValues("a", calendar.tags);
const { ndk } = useNDK();
const [events, setEvents] = useState<NDKEvent[]>([]);
const [isFetching, setIsFetching] = useState(false);
const calendarEventIdentifiers = calendarEvents
.filter(Boolean)
.map((e) => nip19.decode(e))
.filter(({ type }) => type === "naddr")
.map((e) => e.data as nip19.AddressPointer);
@ -33,6 +35,7 @@ export default function EventsFromCalendar({
const events: NDKEvent[] = [];
const promiseArray = [];
for (const info of data) {
console.log("INFO", info);
const calendarEventPromise = ndk
.fetchEvent({
authors: [info.pubkey],

View File

@ -244,6 +244,7 @@ export async function createEventOnList(
return true;
}
const multipleTag = ["a", "p", "e"];
export async function updateList(
ndk: NDK,
list: NostrEvent,
@ -254,7 +255,13 @@ export async function updateList(
const index = tags.findIndex(([tK]) => tK === key);
if (index !== -1) {
// Replace old
if (multipleTag.includes(key)) {
if (value !== tags[index]?.[1]) {
tags.push([key, value]);
}
} else {
tags[index] = [key, value];
}
} else {
tags.push([key, value]);
}

View File

@ -37,6 +37,15 @@ export function getNameToShow(user: {
user.profile?.displayName ?? user.profile?.name ?? truncateText(user.npub)
);
}
export function getLettersPlain(text?: string) {
if (!text) return "";
const splitString = text
.split(" ")
.map((s) => s[0])
.filter(Boolean)
.join(" ");
return splitString;
}
export function getTwoLetters(user: {
npub: string;
profile?: {