refactor(ark): rename widget to column

This commit is contained in:
2023-12-26 13:44:38 +07:00
parent 227c2ddefa
commit e1db873bd5
34 changed files with 800 additions and 892 deletions

View File

@@ -0,0 +1,6 @@
import { ReactNode } from "react";
import { Routes } from "react-router-dom";
export function ColumnContent({ children }: { children: ReactNode }) {
return <Routes>{children}</Routes>;
}

View File

@@ -0,0 +1,112 @@
import {
ArrowLeftIcon,
ArrowRightIcon,
HorizontalDotsIcon,
RefreshIcon,
ThreadIcon,
TrashIcon,
} from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useQueryClient } from "@tanstack/react-query";
import { ReactNode } from "react";
import { useWidget } from "../../hooks/useWidget";
export function ColumnHeader({
id,
title,
queryKey,
icon,
}: {
id: string;
title: string;
queryKey?: string[];
icon?: ReactNode;
}) {
const queryClient = useQueryClient();
const { removeWidget } = useWidget();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
const moveLeft = async () => {
removeWidget.mutate(id);
};
const moveRight = async () => {
removeWidget.mutate(id);
};
const deleteWidget = async () => {
removeWidget.mutate(id);
};
return (
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<div className="inline-flex items-center gap-4">
<div className="h-5 w-1 shrink-0 rounded-full bg-blue-500" />
<div className="text-neutral-800 dark:text-neutral-200 inline-flex items-center gap-2 flex-1">
{icon ? icon : <ThreadIcon className="size-4" />}
<div className="text-sm font-medium">{title}</div>
</div>
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center"
>
<HorizontalDotsIcon className="size-4" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] flex-col overflow-hidden rounded-xl border border-neutral-100 bg-white p-2 shadow-lg shadow-neutral-200/50 focus:outline-none dark:border-neutral-900 dark:bg-neutral-950 dark:shadow-neutral-900/50">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<RefreshIcon className="size-5" />
Refresh
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveLeft}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowLeftIcon className="size-5" />
Move left
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveRight}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowRightIcon className="size-5" />
Move right
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-100 dark:bg-neutral-900" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={deleteWidget}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-red-500 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="size-5" />
Delete
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Route } from "react-router-dom";
import { ColumnContent } from "./content";
import { ColumnHeader } from "./header";
import { ColumnLiveWidget } from "./live";
import { ColumnRoot } from "./root";
export const Column = {
Root: ColumnRoot,
Live: ColumnLiveWidget,
Header: ColumnHeader,
Content: ColumnContent,
Route: Route,
};

View File

@@ -0,0 +1,42 @@
import { ChevronUpIcon } from "@lume/icons";
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react";
import { useArk } from "../../provider";
export function ColumnLiveWidget({
filter,
onClick,
}: {
filter: NDKFilter;
onClick: () => void;
}) {
const ark = useArk();
const [events, setEvents] = useState<NDKEvent[]>([]);
useEffect(() => {
const sub = ark.subscribe({
filter,
closeOnEose: false,
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
});
return () => {
if (sub) sub.stop();
};
}, []);
if (!events.length) return null;
return (
<div className="absolute left-0 top-11 z-50 flex h-11 w-full items-center justify-center">
<button
type="button"
onClick={onClick}
className="inline-flex h-9 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-2.5 text-sm font-semibold text-white hover:bg-blue-600"
>
<ChevronUpIcon className="h-4 w-4" />
{events.length} {events.length === 1 ? "event" : "events"}
</button>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { Resizable } from "re-resizable";
import { ReactNode, useState } from "react";
import { MemoryRouter, UNSAFE_LocationContext } from "react-router-dom";
import { twMerge } from "tailwind-merge";
export function ColumnRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(420);
return (
<UNSAFE_LocationContext.Provider value={null}>
<Resizable
size={{ width, height: "100%" }}
onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width);
}}
minWidth={420}
maxWidth={600}
className={twMerge(
"relative flex flex-col border-r-2 border-neutral-50 hover:border-neutral-100 dark:border-neutral-950 dark:hover:border-neutral-900",
className,
)}
enable={{ right: true }}
>
<MemoryRouter future={{ v7_startTransition: true }}>
{children}
</MemoryRouter>
</Resizable>
</UNSAFE_LocationContext.Provider>
);
}

View File

@@ -23,10 +23,10 @@ export function Reply({
className="h-14 px-3"
/>
<Note.TextContent content={event.content} className="min-w-0 px-3" />
<div className="-ml-1 flex items-center justify-between">
<div className="-ml-1 flex items-center justify-between h-14 px-3">
{event.replies?.length > 0 ? (
<Collapsible.Trigger asChild>
<div className="ml-4 inline-flex h-14 items-center gap-1 font-semibold text-blue-500">
<div className="ml-1 inline-flex h-14 items-center gap-1 font-semibold text-blue-500">
<NavArrowDownIcon
className={twMerge(
"h-3 w-3",
@@ -54,16 +54,13 @@ export function Reply({
<Note.User pubkey={event.pubkey} time={event.created_at} />
<Note.TextContent
content={event.content}
className="min-w-0 px-3"
className="min-w-0"
/>
<div className="-ml-1 flex h-14 items-center justify-between px-3">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
<div className="-ml-1 flex h-14 items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</Note.Root>
))}

View File

@@ -2,12 +2,15 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
import { Note } from "..";
import { useArk } from "../../../provider";
export function TextNote({ event }: { event: NDKEvent }) {
export function TextNote({
event,
className,
}: { event: NDKEvent; className?: string }) {
const ark = useArk();
const thread = ark.getEventThread({ tags: event.tags });
return (
<Note.Root>
<Note.Root className={className}>
<Note.User
pubkey={event.pubkey}
time={event.created_at}

View File

@@ -0,0 +1,55 @@
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { Note } from "..";
import { useEvent } from "../../../hooks/useEvent";
import { useArk } from "../../../provider";
export function ThreadNote({ eventId }: { eventId: string }) {
const ark = useArk();
const { isLoading, data } = useEvent(eventId);
const renderEventKind = (event: NDKEvent) => {
const thread = ark.getEventThread({ tags: data.tags });
switch (event.kind) {
case NDKKind.Text:
return (
<>
<Note.Thread thread={thread} className="mb-2" />
<Note.TextContent content={data.content} className="min-w-0 px-3" />
</>
);
case NDKKind.Article:
return <Note.ArticleContent eventId={event.id} tags={event.tags} />;
case 1063:
return <Note.MediaContent tags={event.tags} />;
default:
return (
<Note.TextContent content={data.content} className="min-w-0 px-3" />
);
}
};
if (isLoading) {
return <div>Loading...</div>;
}
return (
<Note.Root>
<Note.User
pubkey={data.pubkey}
time={data.created_at}
variant="thread"
className="h-16 px-3"
/>
{renderEventKind(data)}
<div className="flex h-14 items-center justify-between px-3">
<Note.Pin eventId={data.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={data.id} />
<Note.Reaction event={data} />
<Note.Repost event={data} />
<Note.Zap event={data} />
</div>
</div>
</Note.Root>
);
}

View File

@@ -8,7 +8,7 @@ import { NoteArticleContent } from "./kinds/article";
import { NoteMediaContent } from "./kinds/media";
import { NoteTextContent } from "./kinds/text";
import { NoteMenu } from "./menu";
import { NoteReplies } from "./reply";
import { NoteReplyList } from "./reply";
import { NoteRoot } from "./root";
import { NoteThread } from "./thread";
import { NoteUser } from "./user";
@@ -27,12 +27,13 @@ export const Note = {
TextContent: NoteTextContent,
MediaContent: NoteMediaContent,
ArticleContent: NoteArticleContent,
Replies: NoteReplies,
ReplyList: NoteReplyList,
};
export * from "./builds/text";
export * from "./builds/repost";
export * from "./builds/skeleton";
export * from "./builds/thread";
export * from "./preview/image";
export * from "./preview/link";
export * from "./preview/video";

View File

@@ -1,15 +1,13 @@
import { WIDGET_KIND } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { memo } from "react";
import { Link } from "react-router-dom";
import { Note } from "..";
import { useEvent } from "../../../hooks/useEvent";
import { useWidget } from "../../../hooks/useWidget";
export const MentionNote = memo(function MentionNote({
eventId,
}: { eventId: string }) {
const { isLoading, isError, data } = useEvent(eventId);
const { addWidget } = useWidget();
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
@@ -51,19 +49,12 @@ export const MentionNote = memo(function MentionNote({
</div>
<div className="mt-1 px-3 pb-3">
{renderKind(data)}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: "Thread",
content: data.id,
})
}
<Link
to={`/events/${data.id}`}
className="mt-2 text-blue-500 hover:text-blue-600"
>
Show more
</button>
</Link>
</div>
</Note.Root>
);

View File

@@ -1,67 +1,68 @@
import { LoaderIcon } from '@lume/icons';
import { NDKEventWithReplies } from '@lume/types';
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '../../provider';
import { Reply } from './builds/reply';
import { LoaderIcon } from "@lume/icons";
import { NDKEventWithReplies } from "@lume/types";
import { NDKSubscription } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useArk } from "../../provider";
import { Reply } from "./builds/reply";
export function NoteReplies({ eventId }: { eventId: string }) {
const ark = useArk();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
export function NoteReplyList({
eventId,
title,
className,
}: { eventId: string; title?: string; className?: string }) {
const ark = useArk();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
useEffect(() => {
let sub: NDKSubscription;
let isCancelled = false;
useEffect(() => {
let sub: NDKSubscription;
let isCancelled = false;
async function fetchRepliesAndSub() {
const events = await ark.getThreads({ id: eventId });
if (!isCancelled) {
setData(events);
}
// subscribe for new replies
sub = ark.subscribe({
filter: {
'#e': [eventId],
since: Math.floor(Date.now() / 1000),
},
closeOnEose: false,
cb: (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
});
}
async function fetchRepliesAndSub() {
const events = await ark.getThreads({ id: eventId });
if (!isCancelled) {
setData(events);
}
// subscribe for new replies
sub = ark.subscribe({
filter: {
"#e": [eventId],
since: Math.floor(Date.now() / 1000),
},
closeOnEose: false,
cb: (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
});
}
fetchRepliesAndSub();
fetchRepliesAndSub();
return () => {
isCancelled = true;
if (sub) sub.stop();
};
}, [eventId]);
return () => {
isCancelled = true;
if (sub) sub.stop();
};
}, [eventId]);
if (!data) {
return (
<div className="mt-3">
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
</div>
);
}
return (
<div className="mt-3 flex flex-col gap-5">
<h3 className="font-semibold">Replies</h3>
{data?.length === 0 ? (
<div className="mt-2 flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
Be the first to Reply!
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} rootEvent={eventId} />)
)}
</div>
);
return (
<div className={twMerge("flex flex-col gap-5", className)}>
<h3 className="font-semibold">{title}</h3>
{!data ? (
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : data.length === 0 ? (
<div className="flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
Be the first to Reply!
</p>
</div>
</div>
) : (
data.map((event) => (
<Reply key={event.id} event={event} rootEvent={eventId} />
))
)}
</div>
);
}

View File

@@ -1,21 +1,21 @@
import { ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
export function NoteRoot({
children,
className,
children,
className,
}: {
children: ReactNode;
className?: string;
children: ReactNode;
className?: string;
}) {
return (
<div
className={twMerge(
'mt-3 flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 px-3 dark:bg-neutral-950',
className
)}
>
{children}
</div>
);
return (
<div
className={twMerge(
"flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 dark:bg-neutral-950",
className,
)}
>
{children}
</div>
);
}

View File

@@ -1,38 +1,32 @@
import { WIDGET_KIND } from '@lume/utils';
import { twMerge } from 'tailwind-merge';
import { Note } from '.';
import { useWidget } from '../../hooks/useWidget';
import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { Note } from ".";
export function NoteThread({
thread,
className,
thread,
className,
}: {
thread: { rootEventId: string; replyEventId: string };
className?: string;
thread: { rootEventId: string; replyEventId: string };
className?: string;
}) {
const { addWidget } = useWidget();
if (!thread) return null;
if (!thread) return null;
return (
<div className={twMerge('w-full px-3', className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? <Note.Child eventId={thread.rootEventId} isRoot /> : null}
{thread.replyEventId ? <Note.Child eventId={thread.replyEventId} /> : null}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</button>
</div>
</div>
);
return (
<div className={twMerge("w-full px-3", className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? (
<Note.Child eventId={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? (
<Note.Child eventId={thread.replyEventId} />
) : null}
<Link
to={`/events/${thread?.rootEventId || thread?.replyEventId}`}
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</Link>
</div>
</div>
);
}

View File

@@ -1,173 +1,236 @@
import { RepostIcon } from '@lume/icons';
import { displayNpub, formatCreatedAt } from '@lume/utils';
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { twMerge } from 'tailwind-merge';
import { useProfile } from '../../hooks/useProfile';
import { RepostIcon } from "@lume/icons";
import { displayNpub, formatCreatedAt } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import { useMemo } from "react";
import { twMerge } from "tailwind-merge";
import { useProfile } from "../../hooks/useProfile";
export function NoteUser({
pubkey,
time,
variant = 'text',
className,
pubkey,
time,
variant = "text",
className,
}: {
pubkey: string;
time: number;
variant?: 'text' | 'repost' | 'mention';
className?: string;
pubkey: string;
time: number;
variant?: "text" | "repost" | "mention" | "thread";
className?: string;
}) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() => `data:image/svg+xml;utf8,${encodeURIComponent(minidenticon(pubkey, 90, 50))}`,
[pubkey]
);
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(pubkey, 90, 50),
)}`,
[pubkey],
);
const { isLoading, user } = useProfile(pubkey);
const { isLoading, user } = useProfile(pubkey);
if (variant === 'mention') {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
if (variant === "mention") {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">
{createdAt}
</span>
</div>
</div>
);
}
return (
<div className="flex h-6 items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
return (
<div className="flex h-6 items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">
{createdAt}
</span>
</div>
</div>
);
}
if (variant === 'repost') {
if (isLoading) {
return (
<div className={twMerge('flex gap-3', className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<div className="h-6 w-6 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
if (variant === "repost") {
if (isLoading) {
return (
<div className={twMerge("flex gap-3", className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<div className="h-6 w-6 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className={twMerge('flex gap-2', className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-blue-500">reposted</span>
</div>
</div>
</div>
);
}
return (
<div className={twMerge("flex gap-2", className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</h5>
<span className="text-blue-500">reposted</span>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className={twMerge('flex items-center gap-3', className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Root>
<div className="h-6 flex-1">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{fallbackName}
</div>
</div>
</div>
);
}
if (variant === "thread") {
if (isLoading) {
return (
<div className="flex h-16 items-center gap-3 px-3">
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-4 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className={twMerge('flex items-center gap-3', className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-9 w-9 rounded-lg bg-white object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex h-6 flex-1 items-start justify-between gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
);
return (
<div className="flex h-16 items-center gap-3 px-3">
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 flex-col">
<h5 className="max-w-[15rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || "Anon"}
</h5>
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<span>{createdAt}</span>
<span>·</span>
<span>{fallbackName}</span>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className={twMerge("flex items-center gap-3", className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Root>
<div className="h-6 flex-1">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{fallbackName}
</div>
</div>
</div>
);
}
return (
<div className={twMerge("flex items-center gap-3", className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-9 w-9 rounded-lg bg-white object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex h-6 flex-1 items-start justify-between gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</div>
<div className="text-neutral-500 dark:text-neutral-400">
{createdAt}
</div>
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
import { ReactNode } from 'react';
export function WidgetContent({ children }: { children: ReactNode }) {
return <div className="h-full w-full">{children}</div>;
}

View File

@@ -1,112 +0,0 @@
import {
ArrowLeftIcon,
ArrowRightIcon,
HorizontalDotsIcon,
RefreshIcon,
ThreadIcon,
TrashIcon,
} from '@lume/icons';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useQueryClient } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useWidget } from '../../hooks/useWidget';
export function WidgetHeader({
id,
title,
queryKey,
icon,
}: {
id: string;
title: string;
queryKey?: string[];
icon?: ReactNode;
}) {
const queryClient = useQueryClient();
const { removeWidget } = useWidget();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
const moveLeft = async () => {
removeWidget.mutate(id);
};
const moveRight = async () => {
removeWidget.mutate(id);
};
const deleteWidget = async () => {
removeWidget.mutate(id);
};
return (
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<div className="inline-flex items-center gap-4">
<div className="h-5 w-1 rounded-full bg-blue-500" />
<div className="inline-flex items-center gap-2">
{icon ? icon : <ThreadIcon className="h-5 w-5" />}
<div className="text-sm font-medium">{title}</div>
</div>
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center"
>
<HorizontalDotsIcon className="h-4 w-4" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] flex-col overflow-hidden rounded-xl border border-neutral-100 bg-white p-2 shadow-lg shadow-neutral-200/50 focus:outline-none dark:border-neutral-900 dark:bg-neutral-950 dark:shadow-neutral-900/50">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<RefreshIcon className="h-5 w-5" />
Refresh
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveLeft}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowLeftIcon className="h-5 w-5" />
Move left
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveRight}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowRightIcon className="h-5 w-5" />
Move right
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-100 dark:bg-neutral-900" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={deleteWidget}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-red-500 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="h-5 w-5" />
Delete
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
import { WidgetContent } from "./content";
import { WidgetHeader } from "./header";
import { WidgetLive } from "./live";
import { WidgetRoot } from "./root";
export const Widget = {
Root: WidgetRoot,
Live: WidgetLive,
Header: WidgetHeader,
Content: WidgetContent,
};

View File

@@ -1,42 +0,0 @@
import { ChevronUpIcon } from '@lume/icons';
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '../../provider';
export function WidgetLive({
filter,
onClick,
}: {
filter: NDKFilter;
onClick: () => void;
}) {
const ark = useArk();
const [events, setEvents] = useState<NDKEvent[]>([]);
useEffect(() => {
const sub = ark.subscribe({
filter,
closeOnEose: false,
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
});
return () => {
if (sub) sub.stop();
};
}, []);
if (!events.length) return null;
return (
<div className="absolute left-0 top-11 z-50 flex h-11 w-full items-center justify-center">
<button
type="button"
onClick={onClick}
className="inline-flex h-9 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-2.5 text-sm font-semibold text-white hover:bg-blue-600"
>
<ChevronUpIcon className="h-4 w-4" />
{events.length} {events.length === 1 ? 'event' : 'events'}
</button>
</div>
);
}

View File

@@ -1,32 +0,0 @@
import { Resizable } from "re-resizable";
import { ReactNode, useState } from "react";
import { twMerge } from "tailwind-merge";
export function WidgetRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(420);
return (
<Resizable
size={{ width, height: "100%" }}
onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width);
}}
minWidth={420}
maxWidth={600}
className={twMerge(
"relative flex flex-col border-r-2 border-neutral-50 hover:border-neutral-100 dark:border-neutral-950 dark:hover:border-neutral-900",
className,
)}
enable={{ right: true }}
>
{children}
</Resizable>
);
}

View File

@@ -1,6 +1,6 @@
export * from "./ark";
export * from "./provider";
export * from "./components/widget";
export * from "./components/column";
export * from "./components/note";
export * from "./hooks/useWidget";
export * from "./hooks/useRichContent";