link cards updates, profile page with feed, auth and current user stuff

This commit is contained in:
zmeyer44 2023-10-15 10:58:44 -04:00
parent 5bd643e672
commit 80b178306e
30 changed files with 1155 additions and 259 deletions

View File

@ -0,0 +1,20 @@
import Feed from "@/containers/Feed";
import Spinner from "@/components/spinner";
export default function ProfileFeed({ pubkey }: { pubkey: string }) {
return (
<div className="center w-full flex-col items-stretch space-y-6 text-primary">
<Feed
filter={{
authors: [pubkey],
kinds: [1],
}}
loader={() => (
<div className="center flex-col gap-y-4 pt-7 text-center">
<Spinner />
<p className="font-medium text-primary">Fetching notes...</p>
</div>
)}
/>
</div>
);
}

View File

@ -5,6 +5,10 @@ import { Button } from "@/components/ui/button";
import SubscriptionCard from "@/components/SubscriptionCard";
import { HiCheckBadge } from "react-icons/hi2";
import Tabs from "@/components/Tabs";
import useProfile from "@/lib/hooks/useProfile";
import { getTwoLetters, truncateText } from "@/lib/utils";
import ProfileFeed from "./_components/Feed";
import { nip19 } from "nostr-tools";
export default function ProfilePage({
params: { npub },
@ -14,6 +18,9 @@ export default function ProfilePage({
};
}) {
const [activeTab, setActiveTab] = useState("feed");
const pubkey = nip19.decode(npub).data.toString();
const { profile } = useProfile(pubkey);
const demo = [
{
id: "1",
@ -30,8 +37,9 @@ export default function ProfilePage({
<div className="relative -mx-5 @container ">
<div className="absolute top-0 h-[8rem] w-full" />
<div className="mx-auto max-w-5xl p-0">
<div className="m-0 @4xl:pt-8 @5xl:px-5">
<div className="m-0 @5xl:px-5 @5xl:pt-8">
<div className="relative w-full overflow-hidden bg-gradient-to-b from-primary pb-[29%] @5xl:rounded-[20px]">
{!!profile.banner && (
<Image
className="absolute inset-0 h-full w-full object-cover align-middle"
src={
@ -42,11 +50,13 @@ export default function ProfilePage({
alt="banner"
unoptimized
/>
)}
</div>
</div>
</div>
<div className="relative mx-auto mb-4 mt-[calc(-0.4375_*_4rem)] flex max-w-[800px] items-end justify-between gap-2 px-3 sm:mt-[calc(-0.4375_*_4.5rem)] sm:px-5 md:mt-[calc(-0.5625_*_5rem)] lg:mt-[calc(-0.5625_*_6rem)]">
<div className="z-1 ml-[calc(-1_*_3px)] overflow-hidden rounded-[0.5rem] bg-background p-[3px] sm:ml-[calc(-1_*_4px)] sm:p-[4px] lg:ml-[calc(-1_*_6px)] lg:rounded-[1rem] lg:p-[6px]">
{profile.image ? (
<Image
src={
"https://images.lumacdn.com/cdn-cgi/image/format=auto,fit=cover,dpr=2,background=white,quality=75,width=96,height=96/calendars/hw/70772773-6d97-4fbb-a076-fc4dee603080"
@ -57,6 +67,14 @@ export default function ProfilePage({
width={16}
height={16}
/>
) : (
<div className="center aspect-square w-[4rem] overflow-hidden rounded-[calc(0.5rem_-_3px)] bg-muted object-cover object-center text-primary @xl:text-2xl sm:w-[4.5rem] sm:rounded-[calc(0.5rem_-_4px)] md:w-[5rem] lg:w-[6rem] lg:rounded-[calc(1rem_-_6px)]">
{getTwoLetters({
npub,
profile,
})}
</div>
)}
</div>
<Button size={"sm"} className="rounded-sm px-5 sm:hidden">
Follow
@ -66,18 +84,27 @@ export default function ProfilePage({
<div className="mx-auto max-w-[800px] space-y-1 px-4">
<div className="flex items-center gap-x-1.5 lg:gap-x-2.5">
<h2 className="text-xl font-semibold sm:text-2xl lg:text-3xl">
Zach Meyer
{profile.displayName ?? profile.name ?? truncateText(npub)}
</h2>
{!!profile.nip05 && (
<HiCheckBadge className="h-5 w-5 text-primary lg:h-7 lg:w-7" />
)}
</div>
<div className="flex items-center text-xs text-muted-foreground/80 md:text-sm">
<p>@zach</p> <div className="inline-flex px-1">·</div>
<p>zach@ordstr.com</p>
{!!profile.name && <p>{profile.name}</p>}
{!!profile.name && !!profile.nip05 && (
<>
<div className="inline-flex px-1">·</div>
<p>{profile.nip05}</p>
</>
)}
</div>
<div className="pt-1 md:pt-2">
{!!profile.about && (
<p className="line-clamp-3 text-xs text-muted-foreground md:text-sm">
This is my bio. You should check it out now.
{profile.about}
</p>
)}
</div>
</div>
</div>
@ -107,6 +134,7 @@ export default function ProfilePage({
setActiveTab={(t) => setActiveTab(t.name)}
/>
</div>
{activeTab === "feed" ? <ProfileFeed pubkey={pubkey} /> : ""}
</div>
</div>
);

View File

@ -1,10 +1,7 @@
import { UserMenu } from "./components/UserMenu";
import { Search } from "./components/Search";
import { Notifications } from "./components/Notifications";
import { MobileMenu } from "./components/MobileMenu";
import { Relays } from "./components/Relays";
import AuthActions from "./components/AuthActions";
import Logo from "@/assets/Logo";
export default function Header() {
return (
<header className="flex h-[var(--header-height)] shrink-0 grow-0 ">
@ -21,9 +18,7 @@ export default function Header() {
<Search />
</div>
<div className="flex items-center gap-x-4">
<Notifications />
<Relays />
<UserMenu />
<AuthActions />
</div>
</div>
</div>

View File

@ -0,0 +1,209 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import useCurrentUser from "@/lib/hooks/useCurrentUser";
import { useModal } from "@/app/_providers/modal/provider";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { RiNotification4Line } from "react-icons/ri";
import { SiRelay } from "react-icons/si";
import { RELAYS } from "@/constants";
import StatusIndicator from "@/components/StatusIndicator";
import { type NDKUser } from "@nostr-dev-kit/ndk";
import { truncateText, getTwoLetters } from "@/lib/utils";
import { useNDK } from "@/app/_providers/ndk";
const LoginModal = dynamic(() => import("@/components/Modals/Login"), {
ssr: false,
});
export default function AuthActions() {
const modal = useModal();
const { currentUser, logout } = useCurrentUser();
if (currentUser) {
return (
<>
<Notifications user={currentUser} />
<Relays />
<UserMenu user={currentUser} logout={logout} />
</>
);
}
return (
<>
<Button
onClick={() => modal?.show(<LoginModal />)}
size={"sm"}
className="rounded-sm px-5"
>
Login
</Button>
</>
);
}
export function Notifications({ user }: { user: NDKUser }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="center relative h-8 w-8 rounded-full bg-muted text-foreground"
>
<RiNotification4Line className="h-[18px] w-[18px] text-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-header+ w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
{user.profile?.displayName || user.profile?.name ? (
<>
<p className="text-sm font-medium leading-none">
{user.profile?.displayName ?? user.profile.name}
</p>
<p className="text-xs leading-none text-muted-foreground">
m@example.com
</p>
</>
) : (
<p className="text-sm font-medium leading-none">
{truncateText(user.npub)}
</p>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Billing
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Settings
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
Log out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function Relays() {
const { ndk } = useNDK();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="center relative h-8 w-8 rounded-full bg-muted text-foreground"
>
<SiRelay className="h-[18px] w-[18px] text-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-header+ w-56" align="end" forceMount>
{ndk?.explicitRelayUrls?.map((r) => (
<DropdownMenuGroup key={r}>
<DropdownMenuItem className="flex items-center gap-x-2 overflow-hidden">
<StatusIndicator status="offline" />
<span className="w-full truncate">{r}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
))}
<DropdownMenuSeparator />
<DropdownMenuItem>
Manage Relays
<DropdownMenuShortcut>M</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function UserMenu({
user,
logout,
}: {
user: NDKUser;
logout: () => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={user.profile?.image} alt={user.npub} />
<AvatarFallback>{getTwoLetters(user)}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-header+ w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
{user.profile?.displayName || user.profile?.name ? (
<>
<p className="text-sm font-medium leading-none">
{user.profile?.displayName ?? user.profile.name}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.profile?.nip05 ?? truncateText(user.npub)}
</p>
</>
) : (
<p className="text-sm font-medium leading-none">
{truncateText(user.npub)}
</p>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
href={`/${user.npub}`}
className="flex w-full justify-between"
>
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</Link>
</DropdownMenuItem>
{/* <DropdownMenuItem>
<Link href="/" className="flex w-full justify-between">
Billing
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
Settings
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem> */}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="cursor-pointer">
Log out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,59 +0,0 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { RiNotification4Line } from "react-icons/ri";
export function Notifications() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="center relative h-8 w-8 rounded-full bg-muted text-foreground"
>
<RiNotification4Line className="h-[18px] w-[18px] text-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-header+ w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">shadcn</p>
<p className="text-xs leading-none text-muted-foreground">
m@example.com
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Billing
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Settings
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
Log out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,41 +0,0 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SiRelay } from "react-icons/si";
import { RELAYS } from "@/constants";
export function Relays() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="center relative h-8 w-8 rounded-full bg-muted text-foreground"
>
<SiRelay className="h-[18px] w-[18px] text-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-header+ w-56" align="end" forceMount>
{RELAYS.map((r) => (
<DropdownMenuGroup key={r}>
<DropdownMenuItem>{r}</DropdownMenuItem>
</DropdownMenuGroup>
))}
<DropdownMenuSeparator />
<DropdownMenuItem>
Manage Relays
<DropdownMenuShortcut>M</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,63 +0,0 @@
import Link from "next/link";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function UserMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src="/avatars/01.png" alt="@shadcn" />
<AvatarFallback>SC</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-header+ w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">shadcn</p>
<p className="text-xs leading-none text-muted-foreground">
m@example.com
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Link href="/npub" className="flex w-full justify-between">
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href="/" className="flex w-full justify-between">
Billing
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
Settings
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
Log out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -29,7 +29,7 @@ export default function Modal({
setShowModal(false);
}
},
[setShowModal]
[setShowModal],
);
useEffect(() => {
@ -86,7 +86,7 @@ export default function Modal({
</FocusTrap>
<motion.div
key="desktop-backdrop"
className="fixed inset-0 z-modal- bg-background bg-opacity-10 backdrop-blur"
className="z-overlay fixed inset-0 bg-background bg-opacity-10 backdrop-blur"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}

View File

@ -27,7 +27,7 @@ export default function Leaflet({
offset: { x: number; y: number };
point: { x: number; y: number };
velocity: { x: number; y: number };
}
},
) {
const offset = info.offset.y;
const velocity = info.velocity.y;
@ -45,7 +45,7 @@ export default function Leaflet({
<motion.div
ref={leafletRef}
key="leaflet"
className="group fixed inset-x-0 bottom-0 z-modal max-h-[95vh] w-screen cursor-grab rounded-t-lg bg-background pb-5 active:cursor-grabbing sm:hidden standalone:pb-8"
className="standalone-pb-8 group fixed inset-x-0 bottom-0 z-modal max-h-[95vh] w-screen cursor-grab rounded-t-lg bg-background pb-5 active:cursor-grabbing sm:hidden"
initial={{ y: "100%" }}
animate={controls}
exit={{ y: "100%" }}
@ -58,19 +58,19 @@ export default function Leaflet({
>
<div
className={cn(
"rounded-t-4xl -mb-1 flex h-7 w-full items-center justify-center"
"rounded-t-4xl z-modal -mb-1 flex h-7 w-full items-center justify-center",
)}
>
<div className="-mr-1 h-1 w-6 rounded-full bg-[#636363] transition-all group-active:rotate-12" />
<div className="h-1 w-6 rounded-full bg-[#636363] transition-all group-active:-rotate-12" />
<div className="-mr-1 h-1 w-6 rounded-full bg-muted transition-all group-active:rotate-12" />
<div className="h-1 w-6 rounded-full bg-muted transition-all group-active:-rotate-12" />
</div>
<div className="max-h-[calc(95vh_-_28px)] w-full overflow-y-auto scrollbar-track-background scrollbar-thumb-gray-900">
<div className="scrollbar-muted-foreground max-h-[calc(95vh_-_28px)] w-full overflow-y-auto scrollbar-track-background">
{children}
</div>
</motion.div>
<motion.div
key="leaflet-backdrop"
className="fixed inset-0 z-modal- bg-background bg-opacity-40 backdrop-blur"
className="z-overlay fixed inset-0 bg-background/40 backdrop-blur"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}

View File

@ -65,6 +65,9 @@
.bottom-tabs {
padding-bottom: 20px;
}
.standalone-pb-8 {
padding-bottom: 24px;
}
.standalone-hide {
display: none !important;
}

View File

@ -1,18 +1,23 @@
import Container from "./components/Container";
import { CardTitle, CardDescription } from "@/components/ui/card";
import { type Event } from "nostr-tools";
export default function Kind1({}: Event) {
import { RenderText } from "../TextRendering";
import { getTagsValues } from "@/lib/nostr/utils";
import LinkCard from "@/components/LinkCard";
export default function Kind1({ content, tags }: Event) {
const r = getTagsValues("r", tags);
return (
<Container>
<CardTitle className="mb-1.5 line-clamp-2 text-lg font-semibold">
The start of the Nostr revolution
</CardTitle>
<CardDescription className="line-clamp-4 text-sm">
This is the summary of this artilce. Let's hope that it is a good
article and that it will end up being worth reading. I don't want to
waste my time on some random other stuff.
<CardDescription className="text-base text-foreground">
<RenderText text={content} />
</CardDescription>
{!!r.length && (
<div className="mt-1.5 flex flex-wrap">
{r.map((url) => (
<LinkCard key={url} url={url} className="max-w-[250px]" />
))}
</div>
)}
</Container>
);
}

View File

@ -2,17 +2,15 @@
import { useState, useEffect } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { fetchMetadata } from "@/lib/fetchers/metadata";
import { HiOutlineCheckBadge } from "react-icons/hi2";
import { AspectRatio } from "@/components/ui/aspect-ratio";
type LinkCardProps = {
url: string;
@ -46,25 +44,29 @@ export default function LinkCard({
if (metadata) {
return (
<a href={url} target="_blank" rel="nonreferrer">
<Card className="group">
<Card className={cn("group", className)}>
{metadata.image && (
<div className="h-[150px] overflow-hidden rounded-t-md">
<div className="max-h-[100px] overflow-hidden rounded-t-md">
<AspectRatio ratio={16 / 9} className="bg-muted">
<Image
width={250}
height={150}
height={100}
src={metadata.image}
alt={metadata.title}
unoptimized
className={cn(
"w-auto object-cover object-center transition-all group-hover:scale-105",
"aspect-video w-auto object-cover object-center align-middle transition-all group-hover:scale-105",
)}
/>
</AspectRatio>
</div>
)}
<div className="">
<CardHeader className="">
<CardTitle className="line-clamp-2">{metadata.title}</CardTitle>
<CardDescription className="line-clamp-3">
<CardHeader className="space-y-0 p-2">
<CardTitle className="line-clamp-1 text-sm font-medium group-hover:underline">
{metadata.title}
</CardTitle>
<CardDescription className="line-clamp-2 text-[10px]">
{metadata.description}
</CardDescription>
</CardHeader>

View File

@ -0,0 +1,176 @@
"use client";
import { type ReactNode } from "react";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
FieldErrors,
useForm,
FieldValues,
DefaultValues,
Path,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import Template from "./Template";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
type FieldOptions =
| "toggle"
| "horizontal-tabs"
| "input"
| "text-area"
| "custom";
type DefaultFieldType<TSchema> = {
label: string;
slug: keyof z.infer<z.Schema<TSchema>> & string;
placeholder?: string;
description?: string;
lines?: number;
styles?: string;
value?: string | number | boolean;
custom?: ReactNode;
options?: { label: string; value: string; icon?: ReactNode }[];
};
type FieldType<TSchema> = DefaultFieldType<TSchema> &
(
| {
type: "select";
options: { label: string; value: string; icon?: ReactNode }[];
}
| {
type: FieldOptions;
}
);
type FormModalProps<TSchema> = {
title: string;
fields: FieldType<TSchema>[];
errors?: FieldErrors;
isSubmitting?: boolean;
cta: {
text: string;
};
defaultValues?: Partial<z.infer<z.Schema<TSchema>>>;
onSubmit: (props: z.infer<z.Schema<TSchema>>) => any;
formSchema: z.Schema<TSchema>;
};
export default function FormModal<TSchema extends FieldValues>({
title,
fields,
cta,
errors,
isSubmitting,
formSchema,
defaultValues,
onSubmit,
}: FormModalProps<TSchema>) {
type FormDataType = z.infer<typeof formSchema>;
const form = useForm<FormDataType>({
resolver: zodResolver(formSchema),
defaultValues: defaultValues as DefaultValues<TSchema>,
mode: "onChange",
});
return (
<Template title={title} className="md:max-w-[400px]">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{fields.map(
({
label,
slug,
type,
placeholder,
description,
...fieldProps
}) => (
<FormField
control={form.control}
name={slug as Path<TSchema>}
render={({ field }) => (
<FormItem
className={cn(
type === "toggle" &&
"flex items-center gap-x-3 space-y-0",
)}
>
<FormLabel>{label}</FormLabel>
{type === "input" ? (
<FormControl>
<Input placeholder={placeholder} {...field} />
</FormControl>
) : type === "text-area" ? (
<FormControl>
<Textarea
placeholder={placeholder}
{...field}
className="auto-sizing"
/>
</FormControl>
) : type === "select" ? (
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
</FormControl>
<SelectContent className="z-modal+">
{fieldProps.options?.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : type === "toggle" ? (
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
) : (
<FormControl>
<Input placeholder={placeholder} {...field} />
</FormControl>
)}
{!!description && (
<FormDescription>{description}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
),
)}
<Button type="submit" className="w-full" loading={isSubmitting}>
{cta.text}
</Button>
</form>
</Form>
</Template>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Template from "./Template";
import { Button } from "@/components/ui/button";
import { useModal } from "@/app/_providers/modal/provider";
import { nip19 } from "nostr-tools";
// import { useKeys } from "@/app/_providers/keysProvider";
import { useNDK } from "@/app/_providers/ndk";
import useCurrentUser from "@/lib/hooks/useCurrentUser";
export default function LoginModal() {
const { loginWithNip07 } = useNDK();
const { loginWithPubkey } = useCurrentUser();
const [isLoading, setIsLoading] = useState(false);
const modal = useModal();
useEffect(() => {
const shouldReconnect = localStorage.getItem("shouldReconnect");
const getConnected = async (shouldReconnect: string) => {
let enabled: boolean | void = false;
if (typeof window.nostr === "undefined") {
return;
}
if (shouldReconnect === "true") {
const user = await loginWithNip07();
if (!user) {
throw new Error("NO auth");
}
console.log("LOGIN", user);
await loginWithPubkey(nip19.decode(user.npub).data.toString());
// keys?.setKeys({
// privkey: "",
// pubkey: ,
// });
}
if (typeof window.webln === "undefined") {
return;
}
if (shouldReconnect === "true" && !window.webln.executing) {
try {
enabled = await window.webln.enable();
} catch (e: any) {
console.log(e.message);
}
}
return enabled;
};
if (shouldReconnect === "true") {
getConnected(shouldReconnect);
}
}, []);
async function handleLogin() {
setIsLoading(true);
if (typeof window.nostr !== "undefined") {
const user = await loginWithNip07();
if (!user) {
throw new Error("NO auth");
}
console.log("LOGIN", user);
await loginWithPubkey(nip19.decode(user.npub).data.toString());
localStorage.setItem("shouldReconnect", "true");
}
if (typeof window.webln !== "undefined") {
await window.webln.enable();
}
console.log("connected ");
setIsLoading(false);
modal?.hide();
}
return (
<Template title="Login" className="md:max-w-[400px]">
<div className="flex flex-col gap-y-5">
<Button onClick={() => void handleLogin()} loading={isLoading}>
Connect with extension
</Button>
</div>
</Template>
);
}

View File

@ -0,0 +1,41 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { HiX } from "react-icons/hi";
import { useModal } from "@/app/_providers/modal/provider";
type ModalProps = {
title: string;
} & React.HTMLAttributes<HTMLDivElement>;
export default function Template({ title, children, className }: ModalProps) {
const modal = useModal();
function handleClose() {
modal?.hide();
}
return (
<div
className={cn("relative w-full grow p-4 md:rounded-lg md:p-6", className)}
>
<div className="mb-4 flex items-center justify-between">
<h1
style={{
fontFamily: '"DM Sans", sans-serif',
}}
className="font-condensed text-xl font-semibold text-foreground"
>
{title}
</h1>
<button
onClick={handleClose}
className="hidden text-muted-foreground transition-all hover:text-primary md:flex"
>
<HiX className="h-5 w-5" />
</button>
</div>
{children}
</div>
);
}

View File

@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
const statuses = {
offline: "text-muted-foreground/20 bg-muted",
online: "text-green-400 bg-green-400/10",
error: "text-rose-400 bg-rose-400/10",
warning: "text-yellow-400 bg-yellow-400/10",
};
type StatusIndicatorProps = {
status: keyof typeof statuses;
};
export default function StatusIndicator({ status }: StatusIndicatorProps) {
return (
<div className={cn(statuses[status], "flex-none rounded-full p-1")}>
<div className="h-2 w-2 rounded-full bg-current" />
</div>
);
}

View File

@ -20,6 +20,7 @@ export default function Tabs<T extends { name: string; label: string }>({
>
{tabs.map((tab, idx) => (
<button
key={tab.name}
onClick={() => setActiveTab(tab)}
type="button"
role="tab"

View File

@ -0,0 +1,15 @@
"use client";
import Link from "next/link";
type EventMentionProps = {
mention: string;
};
export default function EventMention({ mention }: EventMentionProps) {
// const { user } = useProfile(mention);
return (
<Link href={`/${mention}`}>
<span className="text-primary-foreground hover:underline">{`*\$${mention}`}</span>
</Link>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import Link from "next/link";
import { nip19 } from "nostr-tools";
import { NOSTR_BECH32_REGEXP } from "@/lib/nostr/utils";
import { useNDK } from "@/app/_providers/ndk";
type ProfileMentionProps = {
mention: string;
};
function getPubkey(mention: string) {
try {
return NOSTR_BECH32_REGEXP.test(mention)
? nip19.decode(mention).data.toString()
: mention;
} catch (err) {
console.log("Error getting pubkey", err);
return "";
}
}
export default function ProfileMention({ mention }: ProfileMentionProps) {
const pubkey = getPubkey(mention);
// const { user } = useProfile(pubkey);
const { getProfile } = useNDK();
const profile = getProfile(mention);
return (
<Link href={`/${mention}`}>
<span className="text-primary-foreground hover:underline">{`@${
profile?.name ?? mention
}`}</span>
</Link>
);
}

View File

@ -0,0 +1,78 @@
import { cleanUrl } from "@/lib/utils";
import Link from "next/link";
import ProfileMention from "./ProfileMention";
import EventMention from "./EventMention";
const RenderText = ({ text }: { text?: string }) => {
if (!text) return null;
const Elements: JSX.Element[] = [];
const urlRegex =
/(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))/g;
const hashtagRegex = /#\b\w+\b/g;
const nostrPrefixRegex = /nostr:[a-z0-9]+/g;
// const usernameRegex = /(?:^|\s)\@(\w+)\b/g;
const combinedRegex = new RegExp(
`(${urlRegex.source}|${hashtagRegex.source}|${nostrPrefixRegex.source})`,
"g",
);
// Get Array of URLs
const specialValuesArray = text.match(combinedRegex);
const formattedText = text.replace(combinedRegex, "##[link]##");
const cleanTextArray = formattedText.split("##[link]##");
cleanTextArray.forEach((string, index) => {
const jsxElement = <span className="">{string}</span>;
Elements.push(jsxElement);
let specialElement;
if (specialValuesArray?.length && specialValuesArray.length > index) {
if (specialValuesArray[index]?.match(urlRegex)) {
specialElement = (
<a
className="text-primary-foreground hover:underline"
href={cleanUrl(specialValuesArray[index])}
target="_blank"
rel="noreferrer"
>
{cleanUrl(specialValuesArray[index])}
</a>
);
// specialElement = <ContentRendering url={specialValuesArray[index]} />;
// specialElement = <span>{cleanUrl(specialValuesArray[index])}</span>;
} else if (specialValuesArray[index]?.match(hashtagRegex)) {
specialElement = (
<Link href={`/?t=${specialValuesArray[index]?.substring(1)}`}>
<span className="break-words text-primary-foreground hover:underline">
{specialValuesArray[index]}
</span>
</Link>
);
// specialElement = <span className="">{specialValuesArray[index]}</span>;
} else if (specialValuesArray[index]?.match(nostrPrefixRegex)) {
const mention = specialValuesArray[index]?.split(":")[1];
if (mention.startsWith("nprofile") || mention.startsWith("npub")) {
specialElement = <ProfileMention mention={mention} />;
} else if (
mention.startsWith("nevent") ||
mention.startsWith("note") ||
mention.startsWith("naddr")
) {
specialElement = <EventMention mention={mention} />;
}
}
if (specialElement) {
Elements.push(specialElement);
}
}
});
return (
<>
{Elements.map((el, index) => (
<span key={index}>{el}</span>
))}
</>
);
};
export { RenderText };

View File

@ -1,11 +1,11 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import Spinner from "../spinner";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"relative inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
@ -31,27 +31,45 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
(
{ className, variant, size, asChild = false, loading = false, ...props },
ref,
) => {
const Comp = asChild ? Slot : "button";
if (loading) {
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
>
<div className="text-transparent">{props.children}</div>
<div className="center absolute inset-0">
<Spinner />
</div>
</Comp>
);
}
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants }
export { Button, buttonVariants };

32
containers/Feed/index.tsx Normal file
View File

@ -0,0 +1,32 @@
"use client";
import KindCard from "@/components/KindCard";
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";
type FeedProps = {
filter?: NDKFilter;
className?: string;
loader?: () => JSX.Element;
};
export default function Feed({ filter, className, loader: Loader }: FeedProps) {
const { events, isLoading } = useEvents({
filter: { ...filter },
});
if (isLoading) {
if (Loader) {
return <Loader />;
}
return <Spinner />;
}
return (
<>
{events.map((e) => {
const event = e.rawEvent() as Event;
return <KindCard key={e.id} {...event} />;
})}
</>
);
}

View File

@ -0,0 +1,77 @@
"use client";
import currentUserStore from "@/lib/stores/currentUser";
// import useEvents from "@/lib/hooks/useEvents";
import { UserSchema } from "@/types";
import { useNDK } from "@/app/_providers/ndk";
export default function useCurrentUser() {
const {
currentUser,
setCurrentUser,
setFollows,
updateCurrentUser,
follows,
} = currentUserStore();
const { loginWithNip07, getProfile, ndk } = useNDK();
// const {
// events: contactList,
// isLoading,
// onEvent,
// } = useEvents({
// filter: {
// kinds: [3],
// authors: [currentUser?.pubkey ?? ""],
// limit: 1,
// },
// enabled: !!currentUser,
// });
// onEvent((event) => {
// console.log("EVENT", event);
// const foundFollows = event.tags
// .filter(([key]) => key === "p")
// .map(([key, pubkey]) => pubkey);
// console.log("Found follows", foundFollows);
// if (follows.length !== foundFollows.length) {
// setFollows(follows);
// }
// });
function logout() {
localStorage.removeItem("shouldReconnect");
setCurrentUser(null);
window.location.reload();
}
function handleUpdateUser(userInfo: string) {
const parsedData = UserSchema.safeParse({
...currentUser,
...JSON.parse(userInfo),
});
if (parsedData.success) {
updateCurrentUser({
profile: {
...parsedData.data,
displayName: parsedData.data.display_name,
},
});
}
}
async function loginWithPubkey(pubkey: string) {
const user = ndk!.getUser({ hexpubkey: pubkey });
console.log("user", user);
await user.fetchProfile();
setCurrentUser(user);
}
return {
currentUser,
isLoading: false,
follows,
setCurrentUser,
logout,
updateUser: handleUpdateUser,
loginWithPubkey,
};
}

95
lib/hooks/useEvents.ts Normal file
View File

@ -0,0 +1,95 @@
import { useState, useEffect } from "react";
import { useNDK } from "@/app/_providers/ndk";
import {
NDKEvent,
type NDKFilter,
type NDKSubscription,
} from "@nostr-dev-kit/ndk";
import { log } from "@/lib/utils";
import { uniqBy } from "ramda";
import { Event as NostrEvent } from "nostr-tools";
const debug = true;
type OnEventFunc = (event: NostrEvent) => void;
type OnDoneFunc = () => void;
type OnSubscribeFunc = (sub: NDKSubscription) => void;
type UseEventsProps = {
filter: NDKFilter;
enabled?: boolean;
eventFilter?: (e: NDKEvent) => boolean;
};
export default function useEvents({
filter,
enabled = true,
eventFilter = () => true,
}: UseEventsProps) {
const [isLoading, setIsLoading] = useState(true);
const [sub, setSub] = useState<NDKSubscription | undefined>(undefined);
const [events, setEvents] = useState<NDKEvent[]>([]);
const [eventIds, setEventIds] = useState<Set<string>>(new Set());
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() {
console.log("Running init");
setIsLoading(true);
try {
const sub = ndk!.subscribe(
{ limit: 50, ...filter },
{ closeOnEose: false },
);
setSub(sub);
onSubscribeCallback?.(sub);
sub.on("event", (e, r) => {
if (eventIds.has(e.id)) {
return;
}
if (eventFilter(e)) {
setEvents((prevEvents) => {
const events = uniqBy((a) => a.id, [...prevEvents, e]);
return events.sort((a, b) => b.created_at - a.created_at);
});
setEventIds((prev) => prev.add(e.id));
}
});
} catch (err) {
log(debug, "error", `❌ nostr (${err})`);
} finally {
setIsLoading(false);
}
}
return {
isLoading,
events,
onEvent: (_onEventCallback: OnEventFunc) => {
if (_onEventCallback) {
onEventCallback = _onEventCallback;
}
},
onDone: (_onDoneCallback: OnDoneFunc) => {
if (_onDoneCallback) {
onDoneCallback = _onDoneCallback;
}
},
onSubscribe: (_onSubscribeCallback: OnSubscribeFunc) => {
if (_onSubscribeCallback) {
onSubscribeCallback = _onSubscribeCallback;
}
},
};
}

22
lib/hooks/useProfile.ts Normal file
View File

@ -0,0 +1,22 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { nip19 } from "nostr-tools";
import { NOSTR_BECH32_REGEXP } from "@/lib/nostr/utils";
import { useNDK } from "@/app/_providers/ndk";
import { type NDKUserProfile } from "@nostr-dev-kit/ndk";
export default function useProfile(key: string) {
const { ndk, getProfile } = useNDK();
useEffect(() => {
if (!ndk) return;
if (NOSTR_BECH32_REGEXP.test(key)) {
key = nip19.decode(key).data.toString();
}
return () => {
void ndk.getUser({ hexpubkey: key }).fetchProfile();
};
}, [key, ndk]);
return { profile: getProfile(key) };
}

28
lib/stores/currentUser.ts Normal file
View File

@ -0,0 +1,28 @@
import { create } from "zustand";
import { type NDKUser } from "@nostr-dev-kit/ndk";
type Settings = {};
interface CurrentUserState {
currentUser: NDKUser | null;
follows: string[];
settings: Settings;
setCurrentUser: (user: NDKUser | null) => void;
updateCurrentUser: (user: Partial<NDKUser>) => void;
setFollows: (follows: string[]) => void;
}
const currentUserStore = create<CurrentUserState>()((set) => ({
currentUser: null,
follows: [],
settings: {},
setCurrentUser: (user) => set((state) => ({ ...state, currentUser: user })),
updateCurrentUser: (user) =>
set((state) => ({
...state,
currentUser: { ...state.currentUser, ...user } as NDKUser,
})),
setFollows: (follows) => set((state) => ({ ...state, follows: follows })),
}));
export default currentUserStore;

View File

@ -1,9 +1,8 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
export function formatCount(count: number) {
@ -27,6 +26,31 @@ export function truncateText(text: string, size?: number) {
let length = size ?? 5;
return text.slice(0, length) + "..." + text.slice(-length);
}
export function getTwoLetters(user: {
npub: string;
profile?: {
displayName?: string;
name?: string;
};
}) {
if (user.profile) {
if (user.profile.displayName) {
const firstLetter = user.profile.displayName.at(0);
const secondLetter =
user.profile.displayName.split(" ")[1].at(0) ??
user.profile.displayName.at(1) ??
"";
return firstLetter + secondLetter;
}
if (user.profile.name) {
const firstLetter = user.profile.name.at(0);
const secondLetter =
user.profile.name.split(" ")[1].at(0) ?? user.profile.name.at(1) ?? "";
return firstLetter + secondLetter;
}
}
return (user.npub.at(5) ?? "") + (user.npub.at(6) ?? "");
}
export function removeDuplicates<T>(data: T[], key?: keyof T) {
if (key) {

View File

@ -104,9 +104,6 @@ module.exports = {
3: 3,
4: 4,
},
// screens: {
// standalone: { raw: "(display-mode: standalone)" },
// },
},
},
plugins: [

View File

@ -0,0 +1,8 @@
import { WebLNProvider } from "webln";
declare global {
interface Window {
webln?: WebLNProvider & {
executing?: boolean;
};
}
}

42
types/index.ts Normal file
View File

@ -0,0 +1,42 @@
import { z } from "zod";
type User = {
npub: string;
name?: string | undefined;
username?: string | undefined;
display_name?: string | undefined;
picture?: string | undefined;
banner?: string | undefined;
about?: string | undefined;
website?: string | undefined;
lud06?: string | undefined;
lud16?: string | undefined;
nip05?: string | undefined;
};
const UserSchema = z.object({
npub: z.string(),
name: z.string().optional(),
username: z.string().optional(),
display_name: z.string().optional(),
picture: z.string().optional(),
banner: z.string().optional(),
about: z.string().optional(),
website: z.string().optional(),
lud06: z.string().optional(),
lud16: z.string().optional(),
nip05: z.string().optional(),
});
const EventSchema = z.object({
id: z.string(),
content: z.string(),
pubkey: z.string(),
tags: z.string().array().array(),
kind: z.number(),
created_at: z.number(),
sig: z.string(),
});
export { UserSchema, EventSchema };
export type { User };