link cards updates, profile page with feed, auth and current user stuff
This commit is contained in:
parent
5bd643e672
commit
80b178306e
20
app/(app)/(profile)/[npub]/_components/Feed.tsx
Normal file
20
app/(app)/(profile)/[npub]/_components/Feed.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
209
app/(app)/_layout/components/AuthActions.tsx
Normal file
209
app/(app)/_layout/components/AuthActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 }}
|
||||
|
@ -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 }}
|
||||
|
@ -65,6 +65,9 @@
|
||||
.bottom-tabs {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.standalone-pb-8 {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.standalone-hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
176
components/Modals/FormModal.tsx
Normal file
176
components/Modals/FormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
89
components/Modals/Login.tsx
Normal file
89
components/Modals/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
41
components/Modals/Template.tsx
Normal file
41
components/Modals/Template.tsx
Normal 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>
|
||||
);
|
||||
}
|
19
components/StatusIndicator/index.tsx
Normal file
19
components/StatusIndicator/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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"
|
||||
|
15
components/TextRendering/EventMention.tsx
Normal file
15
components/TextRendering/EventMention.tsx
Normal 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>
|
||||
);
|
||||
}
|
35
components/TextRendering/ProfileMention.tsx
Normal file
35
components/TextRendering/ProfileMention.tsx
Normal 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>
|
||||
);
|
||||
}
|
78
components/TextRendering/index.tsx
Normal file
78
components/TextRendering/index.tsx
Normal 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 };
|
@ -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
32
containers/Feed/index.tsx
Normal 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} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
77
lib/hooks/useCurrentUser.ts
Normal file
77
lib/hooks/useCurrentUser.ts
Normal 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
95
lib/hooks/useEvents.ts
Normal 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
22
lib/hooks/useProfile.ts
Normal 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
28
lib/stores/currentUser.ts
Normal 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;
|
@ -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) {
|
||||
|
@ -104,9 +104,6 @@ module.exports = {
|
||||
3: 3,
|
||||
4: 4,
|
||||
},
|
||||
// screens: {
|
||||
// standalone: { raw: "(display-mode: standalone)" },
|
||||
// },
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
@ -0,0 +1,8 @@
|
||||
import { WebLNProvider } from "webln";
|
||||
declare global {
|
||||
interface Window {
|
||||
webln?: WebLNProvider & {
|
||||
executing?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
42
types/index.ts
Normal file
42
types/index.ts
Normal 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 };
|
Loading…
x
Reference in New Issue
Block a user