mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-27 12:07:43 +02:00
add simple badges view
This commit is contained in:
11
src/app.tsx
11
src/app.tsx
@@ -51,6 +51,9 @@ import GoalDetailsView from "./views/goals/goal-details";
|
|||||||
import UserGoalsTab from "./views/user/goals";
|
import UserGoalsTab from "./views/user/goals";
|
||||||
import NetworkView from "./views/tools/network";
|
import NetworkView from "./views/tools/network";
|
||||||
import MutedByView from "./views/user/muted-by";
|
import MutedByView from "./views/user/muted-by";
|
||||||
|
import BadgesView from "./views/badges";
|
||||||
|
import BadgesBrowseView from "./views/badges/browse";
|
||||||
|
import BadgeDetailsView from "./views/badges/badge-details";
|
||||||
|
|
||||||
const StreamsView = React.lazy(() => import("./views/streams"));
|
const StreamsView = React.lazy(() => import("./views/streams"));
|
||||||
const StreamView = React.lazy(() => import("./views/streams/stream"));
|
const StreamView = React.lazy(() => import("./views/streams/stream"));
|
||||||
@@ -180,6 +183,14 @@ const router = createHashRouter([
|
|||||||
{ path: ":id", element: <GoalDetailsView /> },
|
{ path: ":id", element: <GoalDetailsView /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "badges",
|
||||||
|
children: [
|
||||||
|
{ path: "", element: <BadgesView /> },
|
||||||
|
{ path: "browse", element: <BadgesBrowseView /> },
|
||||||
|
{ path: ":naddr", element: <BadgeDetailsView /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "emojis",
|
path: "emojis",
|
||||||
children: [
|
children: [
|
||||||
|
@@ -379,3 +379,9 @@ export const GoalIcon = createIcon({
|
|||||||
d: "M5 3V19H21V21H3V3H5ZM20.2929 6.29289L21.7071 7.70711L16 13.4142L13 10.415L8.70711 14.7071L7.29289 13.2929L13 7.58579L16 10.585L20.2929 6.29289Z",
|
d: "M5 3V19H21V21H3V3H5ZM20.2929 6.29289L21.7071 7.70711L16 13.4142L13 10.415L8.70711 14.7071L7.29289 13.2929L13 7.58579L16 10.585L20.2929 6.29289Z",
|
||||||
defaultProps,
|
defaultProps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const BadgeIcon = createIcon({
|
||||||
|
displayName: "BadgeIcon",
|
||||||
|
d: "M17 15.2454V22.1169C17 22.393 16.7761 22.617 16.5 22.617C16.4094 22.617 16.3205 22.5923 16.2428 22.5457L12 20L7.75725 22.5457C7.52046 22.6877 7.21333 22.6109 7.07125 22.3742C7.02463 22.2964 7 22.2075 7 22.1169V15.2454C5.17107 13.7793 4 11.5264 4 9C4 4.58172 7.58172 1 12 1C16.4183 1 20 4.58172 20 9C20 11.5264 18.8289 13.7793 17 15.2454ZM9 16.4185V19.4676L12 17.6676L15 19.4676V16.4185C14.0736 16.7935 13.0609 17 12 17C10.9391 17 9.92643 16.7935 9 16.4185ZM12 15C15.3137 15 18 12.3137 18 9C18 5.68629 15.3137 3 12 3C8.68629 3 6 5.68629 6 9C6 12.3137 8.68629 15 12 15Z",
|
||||||
|
defaultProps,
|
||||||
|
});
|
||||||
|
@@ -34,7 +34,19 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
|||||||
<Avatar src="/apple-touch-icon.png" size="sm" />
|
<Avatar src="/apple-touch-icon.png" size="sm" />
|
||||||
<Heading size="md">noStrudel</Heading>
|
<Heading size="md">noStrudel</Heading>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Flex gap="2">
|
||||||
<ProfileLink />
|
<ProfileLink />
|
||||||
|
<IconButton
|
||||||
|
icon={<EditIcon />}
|
||||||
|
aria-label="New note"
|
||||||
|
title="New note"
|
||||||
|
w="3rem"
|
||||||
|
h="3rem"
|
||||||
|
fontSize="1.5rem"
|
||||||
|
colorScheme="brand"
|
||||||
|
onClick={() => openModal()}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
<AccountSwitcher />
|
<AccountSwitcher />
|
||||||
<NavItems />
|
<NavItems />
|
||||||
{account && (
|
{account && (
|
||||||
@@ -48,18 +60,6 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<ConnectedRelays />
|
<ConnectedRelays />
|
||||||
<Flex justifyContent="flex-end" py="8">
|
|
||||||
<IconButton
|
|
||||||
icon={<EditIcon />}
|
|
||||||
aria-label="New post"
|
|
||||||
w="4rem"
|
|
||||||
h="4rem"
|
|
||||||
fontSize="1.5rem"
|
|
||||||
borderRadius="50%"
|
|
||||||
colorScheme="brand"
|
|
||||||
onClick={() => openModal()}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
<PublishLog overflowY="auto" minH="15rem" />
|
<PublishLog overflowY="auto" minH="15rem" />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { AbsoluteCenter, Box, Button, Divider } from "@chakra-ui/react";
|
import { AbsoluteCenter, Box, Button, Divider } from "@chakra-ui/react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
BadgeIcon,
|
||||||
ChatIcon,
|
ChatIcon,
|
||||||
EmojiIcon,
|
EmojiIcon,
|
||||||
FeedIcon,
|
FeedIcon,
|
||||||
@@ -53,6 +54,9 @@ export default function NavItems({ isInDrawer = false }: { isInDrawer?: boolean
|
|||||||
<Button onClick={() => navigate("/goals")} leftIcon={<GoalIcon />} justifyContent="flex-start">
|
<Button onClick={() => navigate("/goals")} leftIcon={<GoalIcon />} justifyContent="flex-start">
|
||||||
Goals
|
Goals
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => navigate("/badges")} leftIcon={<BadgeIcon />} justifyContent="flex-start">
|
||||||
|
Badges
|
||||||
|
</Button>
|
||||||
<Button onClick={() => navigate("/emojis")} leftIcon={<EmojiIcon />} justifyContent="flex-start">
|
<Button onClick={() => navigate("/emojis")} leftIcon={<EmojiIcon />} justifyContent="flex-start">
|
||||||
Emojis
|
Emojis
|
||||||
</Button>
|
</Button>
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import { Box, Button, LinkBox, LinkOverlay } from "@chakra-ui/react";
|
import { Box, Button, ButtonProps, LinkBox, LinkOverlay } from "@chakra-ui/react";
|
||||||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||||
|
import { nip19 } from "nostr-tools";
|
||||||
|
|
||||||
import { UserAvatar } from "../user-avatar";
|
import { UserAvatar } from "../user-avatar";
|
||||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||||
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
|
|
||||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||||
import { nip19 } from "nostr-tools";
|
|
||||||
import { getUserDisplayName } from "../../helpers/user-metadata";
|
import { getUserDisplayName } from "../../helpers/user-metadata";
|
||||||
|
|
||||||
function ProfileButton() {
|
function ProfileButton() {
|
||||||
@@ -12,7 +12,7 @@ function ProfileButton() {
|
|||||||
const metadata = useUserMetadata(account.pubkey);
|
const metadata = useUserMetadata(account.pubkey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LinkBox borderRadius="lg" borderWidth={1} p="2" display="flex" gap="2" alignItems="center">
|
<LinkBox borderRadius="lg" borderWidth={1} p="2" display="flex" gap="2" alignItems="center" flexGrow={1}>
|
||||||
<UserAvatar pubkey={account.pubkey} noProxy size="sm" />
|
<UserAvatar pubkey={account.pubkey} noProxy size="sm" />
|
||||||
<Box>
|
<Box>
|
||||||
<LinkOverlay
|
<LinkOverlay
|
||||||
@@ -21,6 +21,7 @@ function ProfileButton() {
|
|||||||
whiteSpace="nowrap"
|
whiteSpace="nowrap"
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
fontSize="lg"
|
fontSize="lg"
|
||||||
|
title="View profile"
|
||||||
>
|
>
|
||||||
{getUserDisplayName(metadata, account.pubkey)}
|
{getUserDisplayName(metadata, account.pubkey)}
|
||||||
</LinkOverlay>
|
</LinkOverlay>
|
||||||
@@ -34,7 +35,7 @@ export default function ProfileLink() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
if (account) return <ProfileButton />;
|
if (account) return <ProfileButton />;
|
||||||
else
|
|
||||||
return (
|
return (
|
||||||
<Button as={RouterLink} to="/login" state={{ from: location.pathname }} colorScheme="brand">
|
<Button as={RouterLink} to="/login" state={{ from: location.pathname }} colorScheme="brand">
|
||||||
Login
|
Login
|
||||||
|
46
src/helpers/nostr/badges.ts
Normal file
46
src/helpers/nostr/badges.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NostrEvent, isATag, isPTag } from "../../types/nostr-event";
|
||||||
|
|
||||||
|
export const PROFILE_BADGES_IDENTIFIER = "profile_badges";
|
||||||
|
|
||||||
|
export function getBadgeName(event: NostrEvent) {
|
||||||
|
return event.tags.find((t) => t[0] === "name")?.[1];
|
||||||
|
}
|
||||||
|
export function getBadgeDescription(event: NostrEvent) {
|
||||||
|
return event.tags.find((t) => t[0] === "description")?.[1];
|
||||||
|
}
|
||||||
|
export function getBadgeImage(event: NostrEvent) {
|
||||||
|
const tag = event.tags.find((t) => t[0] === "image");
|
||||||
|
if (!tag) return;
|
||||||
|
return {
|
||||||
|
src: tag[1] as string,
|
||||||
|
size: tag[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function getBadgeThumbnails(event: NostrEvent) {
|
||||||
|
return event.tags
|
||||||
|
.filter((t) => t[0] === "thumb")
|
||||||
|
.map(
|
||||||
|
(tag) =>
|
||||||
|
tag && {
|
||||||
|
src: tag[1],
|
||||||
|
size: tag[2],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBadgeAwardPubkey(event: NostrEvent) {
|
||||||
|
const pubkey = event.tags.find(isPTag)?.[1];
|
||||||
|
if (!pubkey) throw new Error("Missing pubkey");
|
||||||
|
return pubkey;
|
||||||
|
}
|
||||||
|
export function getBadgeAwardBadge(event: NostrEvent) {
|
||||||
|
const badgeCord = event.tags.find(isATag)?.[1];
|
||||||
|
if (!badgeCord) throw new Error("Missing badge reference");
|
||||||
|
return badgeCord;
|
||||||
|
}
|
||||||
|
export function validateBadgeAwardEvent(event: NostrEvent) {
|
||||||
|
getBadgeAwardPubkey(event);
|
||||||
|
getBadgeAwardBadge(event);
|
||||||
|
return true;
|
||||||
|
}
|
117
src/views/badges/badge-details.tsx
Normal file
117
src/views/badges/badge-details.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Kind, nip19 } from "nostr-tools";
|
||||||
|
import { Box, Button, Divider, Flex, Heading, Image, SimpleGrid, Spacer, Spinner, Text } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import { ArrowLeftSIcon } from "../../components/icons";
|
||||||
|
import { useDeleteEventContext } from "../../providers/delete-event-provider";
|
||||||
|
import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||||
|
import { EventRelays } from "../../components/note/note-relays";
|
||||||
|
import { getBadgeDescription, getBadgeImage, getBadgeName } from "../../helpers/nostr/badges";
|
||||||
|
import BadgeMenu from "./components/badge-menu";
|
||||||
|
import BadgeAwardCard from "./components/award-card";
|
||||||
|
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||||
|
import { createCoordinate } from "../../services/replaceable-event-requester";
|
||||||
|
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||||
|
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||||
|
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||||
|
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||||
|
import useSubject from "../../hooks/use-subject";
|
||||||
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
|
import { getEventCoordinate } from "../../helpers/nostr/events";
|
||||||
|
import { UserAvatarLink } from "../../components/user-avatar-link";
|
||||||
|
import { UserLink } from "../../components/user-link";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { ErrorBoundary } from "../../components/error-boundary";
|
||||||
|
|
||||||
|
function BadgeDetailsPage({ badge }: { badge: NostrEvent }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { deleteEvent } = useDeleteEventContext();
|
||||||
|
const account = useCurrentAccount();
|
||||||
|
|
||||||
|
const image = getBadgeImage(badge);
|
||||||
|
const description = getBadgeDescription(badge);
|
||||||
|
|
||||||
|
const readRelays = useReadRelayUrls();
|
||||||
|
const coordinate = getEventCoordinate(badge);
|
||||||
|
const awardsTimeline = useTimelineLoader(`${coordinate}-awards`, readRelays, {
|
||||||
|
"#a": [coordinate],
|
||||||
|
kinds: [Kind.BadgeAward],
|
||||||
|
});
|
||||||
|
|
||||||
|
const awards = useSubject(awardsTimeline.timeline);
|
||||||
|
const callback = useTimelineCurserIntersectionCallback(awardsTimeline);
|
||||||
|
|
||||||
|
if (!badge) return <Spinner />;
|
||||||
|
|
||||||
|
const isAuthor = account?.pubkey === badge.pubkey;
|
||||||
|
return (
|
||||||
|
<IntersectionObserverProvider callback={callback}>
|
||||||
|
<Flex direction="column" px="2" pt="2" pb="8" overflow="hidden" h="full" gap="2">
|
||||||
|
<Flex gap="2" alignItems="center">
|
||||||
|
<Button onClick={() => navigate(-1)} leftIcon={<ArrowLeftSIcon />}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<UserAvatarLink pubkey={badge.pubkey} size="sm" />
|
||||||
|
<UserLink fontWeight="bold" pubkey={badge.pubkey} />
|
||||||
|
<Text>|</Text>
|
||||||
|
<Heading size="md">{getBadgeName(badge)}</Heading>
|
||||||
|
|
||||||
|
<Spacer />
|
||||||
|
|
||||||
|
<EventRelays event={badge} />
|
||||||
|
|
||||||
|
{isAuthor && (
|
||||||
|
<Button colorScheme="red" onClick={() => deleteEvent(badge).then(() => navigate("/lists"))}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<BadgeMenu aria-label="More options" badge={badge} />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex direction={{ base: "column", lg: "row" }} gap="2">
|
||||||
|
{image && <Image src={image.src} maxW="3in" mr="2" mb="2" mx={{ base: "auto", lg: "initial" }} />}
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
<Heading size="md">{getBadgeName(badge)}</Heading>
|
||||||
|
<Text>
|
||||||
|
Created by: <UserAvatarLink pubkey={badge.pubkey} size="xs" />{" "}
|
||||||
|
<UserLink fontWeight="bold" pubkey={badge.pubkey} />
|
||||||
|
</Text>
|
||||||
|
<Text>Last Updated: {dayjs.unix(badge.created_at).fromNow()}</Text>
|
||||||
|
{description && <Text pb="2">{description}</Text>}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{awards.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Heading size="md">Awarded to</Heading>
|
||||||
|
<Divider />
|
||||||
|
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
|
||||||
|
{awards.map((award) => (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BadgeAwardCard award={award} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</IntersectionObserverProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBadgeCoordinate() {
|
||||||
|
const { naddr } = useParams() as { naddr: string };
|
||||||
|
const parsed = nip19.decode(naddr);
|
||||||
|
if (parsed.type !== "naddr") throw new Error(`Unknown type ${parsed.type}`);
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BadgeDetailsView() {
|
||||||
|
const pointer = useBadgeCoordinate();
|
||||||
|
const badge = useReplaceableEvent(pointer);
|
||||||
|
|
||||||
|
if (!badge) return <Spinner />;
|
||||||
|
|
||||||
|
return <BadgeDetailsPage badge={badge} />;
|
||||||
|
}
|
51
src/views/badges/browse.tsx
Normal file
51
src/views/badges/browse.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Flex, SimpleGrid } from "@chakra-ui/react";
|
||||||
|
import { Kind } from "nostr-tools";
|
||||||
|
|
||||||
|
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
|
||||||
|
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
|
||||||
|
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||||
|
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||||
|
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||||
|
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||||
|
import useSubject from "../../hooks/use-subject";
|
||||||
|
import { getEventUID } from "../../helpers/nostr/events";
|
||||||
|
import BadgeCard from "./components/badge-card";
|
||||||
|
|
||||||
|
function BadgesBrowsePage() {
|
||||||
|
const { filter, listId } = usePeopleListContext();
|
||||||
|
|
||||||
|
const readRelays = useReadRelayUrls();
|
||||||
|
const timeline = useTimelineLoader(
|
||||||
|
`${listId}-badges`,
|
||||||
|
readRelays,
|
||||||
|
{ ...filter, kinds: [Kind.BadgeDefinition] },
|
||||||
|
{ enabled: !!filter },
|
||||||
|
);
|
||||||
|
|
||||||
|
const lists = useSubject(timeline.timeline);
|
||||||
|
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntersectionObserverProvider callback={callback}>
|
||||||
|
<Flex direction="column" gap="2" p="2">
|
||||||
|
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||||
|
<PeopleListSelection />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<SimpleGrid columns={{ base: 1, sm: 2, md: 2, lg: 3, xl: 4 }} spacing="2">
|
||||||
|
{lists.map((badge) => (
|
||||||
|
<BadgeCard key={getEventUID(badge)} badge={badge} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Flex>
|
||||||
|
</IntersectionObserverProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BadgesBrowseView() {
|
||||||
|
return (
|
||||||
|
<PeopleListProvider>
|
||||||
|
<BadgesBrowsePage />
|
||||||
|
</PeopleListProvider>
|
||||||
|
);
|
||||||
|
}
|
25
src/views/badges/components/award-card.tsx
Normal file
25
src/views/badges/components/award-card.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Card, CardBody, CardProps, Flex, Heading } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import { UserAvatar } from "../../../components/user-avatar";
|
||||||
|
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
|
||||||
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
|
import { getBadgeAwardPubkey } from "../../../helpers/nostr/badges";
|
||||||
|
import { UserLink } from "../../../components/user-link";
|
||||||
|
|
||||||
|
export default function BadgeAwardCard({ award, ...props }: Omit<CardProps, "children"> & { award: NostrEvent }) {
|
||||||
|
const pubkey = getBadgeAwardPubkey(award);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card {...props}>
|
||||||
|
<CardBody p="2" display="flex" alignItems="center" overflow="hidden" gap="2">
|
||||||
|
<UserAvatar pubkey={pubkey} />
|
||||||
|
<Flex direction="column" flex={1} overflow="hidden">
|
||||||
|
<Heading size="sm" whiteSpace="nowrap" isTruncated>
|
||||||
|
<UserLink pubkey={pubkey} />
|
||||||
|
</Heading>
|
||||||
|
<UserDnsIdentityIcon pubkey={pubkey} />
|
||||||
|
</Flex>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
49
src/views/badges/components/badge-card.tsx
Normal file
49
src/views/badges/components/badge-card.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { memo, useRef } from "react";
|
||||||
|
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||||
|
import { ButtonGroup, Card, CardBody, CardHeader, CardProps, Flex, Heading, Image, Link, Text } from "@chakra-ui/react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { UserAvatarLink } from "../../../components/user-avatar-link";
|
||||||
|
import { UserLink } from "../../../components/user-link";
|
||||||
|
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||||
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
|
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
||||||
|
import { getEventUID } from "../../../helpers/nostr/events";
|
||||||
|
import BadgeMenu from "./badge-menu";
|
||||||
|
import { getBadgeImage, getBadgeName } from "../../../helpers/nostr/badges";
|
||||||
|
|
||||||
|
function BadgeCard({ badge, ...props }: Omit<CardProps, "children"> & { badge: NostrEvent }) {
|
||||||
|
const naddr = getSharableEventAddress(badge);
|
||||||
|
const image = getBadgeImage(badge);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// if there is a parent intersection observer, register this card
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useRegisterIntersectionEntity(ref, getEventUID(badge));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card ref={ref} variant="outline" {...props}>
|
||||||
|
{image && <Image src={image.src} cursor="pointer" onClick={() => navigate(`/badges/${naddr}`)} />}
|
||||||
|
<CardHeader display="flex" alignItems="center" p="2" pb="0">
|
||||||
|
<Heading size="md">
|
||||||
|
<Link as={RouterLink} to={`/badges/${naddr}`}>
|
||||||
|
{getBadgeName(badge)}
|
||||||
|
</Link>
|
||||||
|
</Heading>
|
||||||
|
<ButtonGroup size="sm" ml="auto">
|
||||||
|
<BadgeMenu badge={badge} aria-label="badge menu" />
|
||||||
|
</ButtonGroup>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody p="2">
|
||||||
|
<Flex gap="2">
|
||||||
|
<Text>Created by:</Text>
|
||||||
|
<UserAvatarLink pubkey={badge.pubkey} size="xs" />
|
||||||
|
<UserLink pubkey={badge.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
|
||||||
|
</Flex>
|
||||||
|
<Text>Updated: {dayjs.unix(badge.created_at).fromNow()}</Text>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(BadgeCard);
|
51
src/views/badges/components/badge-menu.tsx
Normal file
51
src/views/badges/components/badge-menu.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { MenuItem, useDisclosure } from "@chakra-ui/react";
|
||||||
|
import { useCopyToClipboard } from "react-use";
|
||||||
|
|
||||||
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
|
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||||
|
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||||
|
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
|
||||||
|
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
|
||||||
|
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||||
|
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
|
||||||
|
import { useDeleteEventContext } from "../../../providers/delete-event-provider";
|
||||||
|
|
||||||
|
export default function BadgeMenu({ badge, ...props }: { badge: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
|
||||||
|
const account = useCurrentAccount();
|
||||||
|
const infoModal = useDisclosure();
|
||||||
|
|
||||||
|
const { deleteEvent } = useDeleteEventContext();
|
||||||
|
|
||||||
|
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
|
||||||
|
|
||||||
|
const naddr = getSharableEventAddress(badge);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuIconButton {...props}>
|
||||||
|
{naddr && (
|
||||||
|
<>
|
||||||
|
<MenuItem onClick={() => window.open(buildAppSelectUrl(naddr), "_blank")} icon={<ExternalLinkIcon />}>
|
||||||
|
View in app...
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => copyToClipboard("nostr:" + naddr)} icon={<RepostIcon />}>
|
||||||
|
Copy Share Link
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{account?.pubkey === badge.pubkey && (
|
||||||
|
<MenuItem icon={<TrashIcon />} color="red.500" onClick={() => deleteEvent(badge)}>
|
||||||
|
Delete Badge
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
|
||||||
|
View Raw
|
||||||
|
</MenuItem>
|
||||||
|
</MenuIconButton>
|
||||||
|
|
||||||
|
{infoModal.isOpen && (
|
||||||
|
<NoteDebugModal event={badge} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
52
src/views/badges/index.tsx
Normal file
52
src/views/badges/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Button, Flex, Heading, Image, Link, SimpleGrid, Spacer, useDisclosure } from "@chakra-ui/react";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||||
|
import { ExternalLinkIcon } from "../../components/icons";
|
||||||
|
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||||
|
import BadgeCard from "./components/badge-card";
|
||||||
|
import { getEventUID } from "../../helpers/nostr/events";
|
||||||
|
|
||||||
|
function BadgesPage() {
|
||||||
|
const account = useCurrentAccount()!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" pt="2" pb="10" gap="2" px={["2", "2", 0]}>
|
||||||
|
<Flex gap="2">
|
||||||
|
<Button as={RouterLink} to="/badges/browse">
|
||||||
|
Browse Badges
|
||||||
|
</Button>
|
||||||
|
<Spacer />
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
href="https://badges.page/"
|
||||||
|
isExternal
|
||||||
|
rightIcon={<ExternalLinkIcon />}
|
||||||
|
leftIcon={<Image src="https://badges.page/favicon.ico" w="1.2em" />}
|
||||||
|
>
|
||||||
|
Badges
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* {peopleLists.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Heading size="md">People lists</Heading>
|
||||||
|
<Divider />
|
||||||
|
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
|
||||||
|
{peopleLists.map((event) => (
|
||||||
|
<BadgeCard key={getEventUID(event)} badge={event} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
)} */}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BadgesView() {
|
||||||
|
return (
|
||||||
|
<RequireCurrentAccount>
|
||||||
|
<BadgesPage />
|
||||||
|
</RequireCurrentAccount>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,8 +1,8 @@
|
|||||||
import { Flex, Select, SimpleGrid, Switch, useDisclosure } from "@chakra-ui/react";
|
import { Flex, Select, SimpleGrid, Switch, useDisclosure } from "@chakra-ui/react";
|
||||||
import PeopleListProvider, { usePeopleListContext } from "../../../providers/people-list-provider";
|
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
|
||||||
import PeopleListSelection from "../../../components/people-list-selection/people-list-selection";
|
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
|
||||||
import useTimelineLoader from "../../../hooks/use-timeline-loader";
|
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||||
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
|
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||||
import {
|
import {
|
||||||
MUTE_LIST_KIND,
|
MUTE_LIST_KIND,
|
||||||
NOTE_LIST_KIND,
|
NOTE_LIST_KIND,
|
||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
getEventsFromList,
|
getEventsFromList,
|
||||||
getListName,
|
getListName,
|
||||||
getPubkeysFromList,
|
getPubkeysFromList,
|
||||||
} from "../../../helpers/nostr/lists";
|
} from "../../helpers/nostr/lists";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { NostrEvent } from "../../../types/nostr-event";
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
import IntersectionObserverProvider from "../../../providers/intersection-observer";
|
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||||
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
|
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||||
import useSubject from "../../../hooks/use-subject";
|
import useSubject from "../../hooks/use-subject";
|
||||||
import ListCard from "../components/list-card";
|
import ListCard from "./components/list-card";
|
||||||
import { getEventUID } from "../../../helpers/nostr/events";
|
import { getEventUID } from "../../helpers/nostr/events";
|
||||||
|
|
||||||
function BrowseListPage() {
|
function BrowseListPage() {
|
||||||
const { filter, listId } = usePeopleListContext();
|
const { filter, listId } = usePeopleListContext();
|
@@ -1,5 +1,5 @@
|
|||||||
import { Flex, Heading, SimpleGrid } from "@chakra-ui/react";
|
|
||||||
import { memo, useMemo, useRef } from "react";
|
import { memo, useMemo, useRef } from "react";
|
||||||
|
import { Flex, Heading, SimpleGrid } from "@chakra-ui/react";
|
||||||
|
|
||||||
import { UserAvatarLink } from "../../components/user-avatar-link";
|
import { UserAvatarLink } from "../../components/user-avatar-link";
|
||||||
import { UserLink } from "../../components/user-link";
|
import { UserLink } from "../../components/user-link";
|
||||||
|
Reference in New Issue
Block a user