refactor(widget): migrate widget component to ark lib

This commit is contained in:
reya 2023-12-18 13:39:03 +07:00
parent 344bdc0c66
commit 55298515af
36 changed files with 1153 additions and 1016 deletions

View File

@ -22,6 +22,7 @@
"@getalby/sdk": "^2.7.0",
"@nostr-dev-kit/ndk": "^2.3.0",
"@nostr-fetch/adapter-ndk": "^0.14.1",
"@preact/signals-react": "^1.3.8",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",

View File

@ -17,6 +17,9 @@ dependencies:
'@nostr-fetch/adapter-ndk':
specifier: ^0.14.1
version: 0.14.1(@nostr-dev-kit/ndk@2.3.0)(nostr-fetch@0.14.1)
'@preact/signals-react':
specifier: ^1.3.8
version: 1.3.8(react@18.2.0)
'@radix-ui/react-accordion':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.45)(react-dom@18.2.0)(react@18.2.0)
@ -876,6 +879,20 @@ packages:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
/@preact/signals-core@1.5.1:
resolution: {integrity: sha512-dE6f+WCX5ZUDwXzUIWNMhhglmuLpqJhuy3X3xHrhZYI0Hm2LyQwOu0l9mdPiWrVNsE+Q7txOnJPgtIqHCYoBVA==}
dev: false
/@preact/signals-react@1.3.8(react@18.2.0):
resolution: {integrity: sha512-i7mVZ/ZiD9WqNH79r+klpQsp8X+/dOd/5AtvDI0HNpgWuHyzyF9WXDViKl+1vXgB767n9VnH1W2azg+w1oyFMQ==}
peerDependencies:
react: ^16.14.0 || 17.x || 18.x
dependencies:
'@preact/signals-core': 1.5.1
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/@radix-ui/primitive@1.0.1:
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
dependencies:
@ -5981,6 +5998,14 @@ packages:
tslib: 2.6.2
dev: false
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/utf-8-validate@5.0.10:
resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
engines: {node: '>=6.14.2'}

View File

@ -124,144 +124,138 @@ export default function App() {
},
],
},
],
},
{
path: 'auth',
element: <AuthLayout platform={ark.platform} />,
errorElement: <ErrorScreen />,
children: [
{
path: 'auth',
element: <AuthLayout />,
errorElement: <ErrorScreen />,
children: [
{
path: 'welcome',
async lazy() {
const { WelcomeScreen } = await import('@app/auth/welcome');
return { Component: WelcomeScreen };
},
},
{
path: 'create',
async lazy() {
const { CreateAccountScreen } = await import('@app/auth/create');
return { Component: CreateAccountScreen };
},
},
{
path: 'import',
async lazy() {
const { ImportAccountScreen } = await import('@app/auth/import');
return { Component: ImportAccountScreen };
},
},
{
path: 'onboarding',
async lazy() {
const { OnboardingScreen } = await import('@app/auth/onboarding');
return { Component: OnboardingScreen };
},
},
{
path: 'follow',
async lazy() {
const { FollowScreen } = await import('@app/auth/follow');
return { Component: FollowScreen };
},
},
{
path: 'finish',
async lazy() {
const { FinishScreen } = await import('@app/auth/finish');
return { Component: FinishScreen };
},
},
{
path: 'tutorials/note',
async lazy() {
const { TutorialNoteScreen } = await import('@app/auth/tutorials/note');
return { Component: TutorialNoteScreen };
},
},
{
path: 'tutorials/widget',
async lazy() {
const { TutorialWidgetScreen } = await import(
'@app/auth/tutorials/widget'
);
return { Component: TutorialWidgetScreen };
},
},
{
path: 'tutorials/posting',
async lazy() {
const { TutorialPostingScreen } = await import(
'@app/auth/tutorials/posting'
);
return { Component: TutorialPostingScreen };
},
},
{
path: 'tutorials/finish',
async lazy() {
const { TutorialFinishScreen } = await import(
'@app/auth/tutorials/finish'
);
return { Component: TutorialFinishScreen };
},
},
],
path: 'welcome',
async lazy() {
const { WelcomeScreen } = await import('@app/auth/welcome');
return { Component: WelcomeScreen };
},
},
{
path: 'settings',
element: <SettingsLayout />,
errorElement: <ErrorScreen />,
children: [
{
index: true,
async lazy() {
const { UserSettingScreen } = await import('@app/settings');
return { Component: UserSettingScreen };
},
},
{
path: 'edit-profile',
async lazy() {
const { EditProfileScreen } = await import('@app/settings/editProfile');
return { Component: EditProfileScreen };
},
},
{
path: 'edit-contact',
async lazy() {
const { EditContactScreen } = await import('@app/settings/editContact');
return { Component: EditContactScreen };
},
},
{
path: 'general',
async lazy() {
const { GeneralSettingScreen } = await import('@app/settings/general');
return { Component: GeneralSettingScreen };
},
},
{
path: 'backup',
async lazy() {
const { BackupSettingScreen } = await import('@app/settings/backup');
return { Component: BackupSettingScreen };
},
},
{
path: 'advanced',
async lazy() {
const { AdvancedSettingScreen } = await import('@app/settings/advanced');
return { Component: AdvancedSettingScreen };
},
},
{
path: 'about',
async lazy() {
const { AboutScreen } = await import('@app/settings/about');
return { Component: AboutScreen };
},
},
],
path: 'create',
async lazy() {
const { CreateAccountScreen } = await import('@app/auth/create');
return { Component: CreateAccountScreen };
},
},
{
path: 'import',
async lazy() {
const { ImportAccountScreen } = await import('@app/auth/import');
return { Component: ImportAccountScreen };
},
},
{
path: 'onboarding',
async lazy() {
const { OnboardingScreen } = await import('@app/auth/onboarding');
return { Component: OnboardingScreen };
},
},
{
path: 'follow',
async lazy() {
const { FollowScreen } = await import('@app/auth/follow');
return { Component: FollowScreen };
},
},
{
path: 'finish',
async lazy() {
const { FinishScreen } = await import('@app/auth/finish');
return { Component: FinishScreen };
},
},
{
path: 'tutorials/note',
async lazy() {
const { TutorialNoteScreen } = await import('@app/auth/tutorials/note');
return { Component: TutorialNoteScreen };
},
},
{
path: 'tutorials/widget',
async lazy() {
const { TutorialWidgetScreen } = await import('@app/auth/tutorials/widget');
return { Component: TutorialWidgetScreen };
},
},
{
path: 'tutorials/posting',
async lazy() {
const { TutorialPostingScreen } = await import('@app/auth/tutorials/posting');
return { Component: TutorialPostingScreen };
},
},
{
path: 'tutorials/finish',
async lazy() {
const { TutorialFinishScreen } = await import('@app/auth/tutorials/finish');
return { Component: TutorialFinishScreen };
},
},
],
},
{
path: 'settings',
element: <SettingsLayout platform={ark.platform} />,
errorElement: <ErrorScreen />,
children: [
{
index: true,
async lazy() {
const { UserSettingScreen } = await import('@app/settings');
return { Component: UserSettingScreen };
},
},
{
path: 'edit-profile',
async lazy() {
const { EditProfileScreen } = await import('@app/settings/editProfile');
return { Component: EditProfileScreen };
},
},
{
path: 'edit-contact',
async lazy() {
const { EditContactScreen } = await import('@app/settings/editContact');
return { Component: EditContactScreen };
},
},
{
path: 'general',
async lazy() {
const { GeneralSettingScreen } = await import('@app/settings/general');
return { Component: GeneralSettingScreen };
},
},
{
path: 'backup',
async lazy() {
const { BackupSettingScreen } = await import('@app/settings/backup');
return { Component: BackupSettingScreen };
},
},
{
path: 'advanced',
async lazy() {
const { AdvancedSettingScreen } = await import('@app/settings/advanced');
return { Component: AdvancedSettingScreen };
},
},
{
path: 'about',
async lazy() {
const { AboutScreen } = await import('@app/settings/about');
return { Component: AboutScreen };
},
},
],
},

View File

@ -1,5 +1,6 @@
import { useSignal } from '@preact/signals-react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useRef, useState } from 'react';
import { useRef } from 'react';
import { VList, VListHandle } from 'virtua';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
@ -22,27 +23,27 @@ import { WIDGET_KIND } from '@utils/constants';
import { Widget } from '@utils/types';
export function HomeScreen() {
const ref = useRef<VListHandle>(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const ark = useArk();
const { status, data } = useQuery({
const ref = useRef<VListHandle>(null);
const index = useSignal(-1);
const { isLoading, data } = useQuery({
queryKey: ['widgets'],
queryFn: async () => {
const dbWidgets = await ark.getWidgets();
const defaultWidgets = [
{
id: '9999',
title: 'Newsfeed',
content: '',
kind: WIDGET_KIND.newsfeed,
},
{
id: '9998',
title: 'Notification',
content: '',
kind: WIDGET_KIND.notification,
},
{
id: '9999',
title: 'Newsfeed',
content: '',
kind: WIDGET_KIND.newsfeed,
},
];
return [...defaultWidgets, ...dbWidgets];
@ -53,7 +54,7 @@ export function HomeScreen() {
staleTime: Infinity,
});
const renderItem = useCallback((widget: Widget) => {
const renderItem = (widget: Widget) => {
switch (widget.kind) {
case WIDGET_KIND.notification:
return <NotificationWidget key={widget.id} />;
@ -80,13 +81,13 @@ export function HomeScreen() {
case WIDGET_KIND.list:
return <WidgetList key={widget.id} widget={widget} />;
default:
return null;
return <NewsfeedWidget key={widget.id} />;
}
}, []);
};
if (status === 'pending') {
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center bg-white dark:bg-black">
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
);
@ -106,8 +107,8 @@ export function HomeScreen() {
case 'ArrowUp':
case 'ArrowLeft': {
e.preventDefault();
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
const prevIndex = Math.max(index.peek() - 1, 0);
index.value = prevIndex;
ref.current.scrollToIndex(prevIndex, {
align: 'center',
smooth: true,
@ -117,8 +118,8 @@ export function HomeScreen() {
case 'ArrowDown':
case 'ArrowRight': {
e.preventDefault();
const nextIndex = Math.min(selectedIndex + 1, data.length - 1);
setSelectedIndex(nextIndex);
const nextIndex = Math.min(index.peek() + 1, data.length - 1);
index.value = nextIndex;
ref.current.scrollToIndex(nextIndex, {
align: 'center',
smooth: true,

View File

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

View File

@ -0,0 +1,112 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useQueryClient } from '@tanstack/react-query';
import { ReactNode } from 'react';
import {
ArrowLeftIcon,
ArrowRightIcon,
HomeIcon,
HorizontalDotsIcon,
RefreshIcon,
TrashIcon,
} from '@shared/icons';
import { useWidget } from '@utils/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: [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 : <HomeIcon 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

@ -0,0 +1,9 @@
import { WidgetContent } from './content';
import { WidgetHeader } from './header';
import { WidgetRoot } from './root';
export const Widget = {
Root: WidgetRoot,
Header: WidgetHeader,
Content: WidgetContent,
};

View File

@ -1,22 +1,23 @@
import { useSignal } from '@preact/signals-react';
import { Resizable } from 're-resizable';
import { ReactNode, useState } from 'react';
import { ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export function WidgetWrapper({
export function WidgetRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(420);
const width = useSignal(420);
return (
<Resizable
size={{ width: width, height: '100%' }}
size={{ width: width.value, height: '100%' }}
onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width);
width.value = width.peek() + d.width;
}}
minWidth={420}
maxWidth={600}

View File

@ -1,2 +1,6 @@
export * from './ark';
export * from './provider';
export * from './components/widget';
export * from './components/widget/content';
export * from './components/widget/header';
export * from './components/widget/root';

View File

@ -38,7 +38,7 @@ const ArkProvider = ({ children }: PropsWithChildren<object>) => {
// start depot
if (_ark.settings.depot) {
await ark.launchDepot();
await _ark.launchDepot();
await delay(2000);
}

View File

@ -0,0 +1,18 @@
export function AnnouncementIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M16.36 3.014A27.429 27.429 0 0 1 8.143 8.04l-4.67 1.825a5.126 5.126 0 0 0 1.7 6.34l1.631-.25m9.556-12.94c-.875.234-.824 3.262.114 6.764.938 3.501 2.408 6.15 3.283 5.915M16.36 3.014c.875-.234 2.345 2.414 3.284 5.915.938 3.502.989 6.53.113 6.765m0 0a27.428 27.428 0 0 0-8.595-.382m0 0L13.295 22H8.92l-2.116-6.044m4.358-.644c-.345.04-.69.085-1.034.138l-3.324.506" />
</svg>
);
}

View File

@ -1,15 +1,18 @@
import { SVGProps } from 'react';
export function ArrowLeftIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function ArrowLeftIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M10 18.25L3.75 12M3.75 12L10 5.75M3.75 12H20.25"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M8.83 6a30.23 30.23 0 0 0-5.62 5.406A.949.949 0 0 0 3 12m5.83 6a30.233 30.233 0 0 1-5.62-5.406A.949.949 0 0 1 3 12m0 0h18" />
</svg>
);
}

View File

@ -1,15 +1,18 @@
import { SVGProps } from 'react';
export function ArrowRightIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function ArrowRightIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M14 5.75L20.25 12M20.25 12L14 18.25M20.25 12H3.75"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M15.17 6a30.23 30.23 0 0 1 5.62 5.406c.14.174.21.384.21.594m-5.83 6a30.232 30.232 0 0 0 5.62-5.406A.949.949 0 0 0 21 12m0 0H3" />
</svg>
);
}

View File

@ -1,21 +1,18 @@
import { SVGProps } from 'react';
export function HomeIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function HomeIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M10.108 1.999a3 3 0 013.784 0l6 4.875A3 3 0 0121 9.202V18a3 3 0 01-3 3H6a3 3 0 01-3-3V9.202a3 3 0 011.108-2.328l6-4.875zM8 15a1 1 0 100 2h8a1 1 0 100-2H8z"
clipRule="evenodd"
></path>
<path d="M9 17h6M7.606 5.65l-2.6 2.456c-.74.698-1.11 1.047-1.374 1.46a4 4 0 0 0-.513 1.191C3 11.233 3 11.742 3 12.76V14.6c0 2.24 0 3.36.436 4.216a4 4 0 0 0 1.748 1.748C6.04 21 7.16 21 9.4 21h5.2c2.24 0 3.36 0 4.216-.436a4 4 0 0 0 1.748-1.748C21 17.96 21 16.84 21 14.6v-1.841c0-1.017 0-1.526-.119-2.002a4 4 0 0 0-.513-1.19c-.265-.414-.634-.763-1.374-1.461l-2.6-2.456c-1.546-1.46-2.32-2.19-3.201-2.466a4 4 0 0 0-2.386 0c-.882.275-1.655 1.006-3.201 2.466Z" />
</svg>
);
}

View File

@ -84,3 +84,4 @@ export * from './info';
export * from './light';
export * from './dark';
export * from './system';
export * from './announcement';

View File

@ -1,14 +1,18 @@
import { SVGProps } from 'react';
export function RefreshIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function RefreshIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.979 4.5C7.83687 4.5 4.479 7.85786 4.479 12C4.479 16.1421 7.83687 19.5 11.979 19.5C15.2434 19.5 18.0225 17.4141 19.0524 14.5001C19.1905 14.1095 19.619 13.9048 20.0095 14.0429C20.4 14.1809 20.6047 14.6094 20.4667 14.9999C19.2315 18.4945 15.8988 21 11.979 21C7.00844 21 2.979 16.9706 2.979 12C2.979 7.02944 7.00844 3 11.979 3C13.709 3 15.1419 3.42256 16.4191 4.20651C17.1663 4.6651 17.8487 5.24046 18.5 5.90708V4C18.5 3.58579 18.8358 3.25 19.25 3.25C19.6642 3.25 20 3.58579 20 4V8C20 8.41421 19.6642 8.75 19.25 8.75H15.25C14.8358 8.75 14.5 8.41421 14.5 8C14.5 7.58579 14.8358 7.25 15.25 7.25H17.7068C17.0285 6.51595 16.3546 5.92693 15.6345 5.4849C14.6015 4.85088 13.4417 4.5 11.979 4.5Z"
fill="currentColor"
/>
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M17.5 2.474c.51 1.192.861 2.444 1.049 3.726a.479.479 0 0 1-.298.515l-.181.07M6.5 21.527A15 15 0 0 1 5.451 17.8a.48.48 0 0 1 .298-.515l.181-.07M14.5 7.67a15 15 0 0 0 3.57-.884m0 0a8 8 0 0 0-13.912 6.797m15.75-2.79A8 8 0 0 1 5.93 17.215m3.571-.885a15.002 15.002 0 0 0-3.57.884" />
</svg>
);
}

View File

@ -1,22 +1,18 @@
import { SVGProps } from 'react';
export function TimeLineIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function TimelineIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.5"
d="M22.25 15C17.215 15 15 17.215 15 22.25 15 17.215 12.785 15 7.75 15 12.785 15 15 12.785 15 7.75c0 5.035 2.215 7.25 7.25 7.25zM11.25 6.5c-3.299 0-4.75 1.451-4.75 4.75 0-3.299-1.451-4.75-4.75-4.75 3.299 0 4.75-1.451 4.75-4.75 0 3.299 1.451 4.75 4.75 4.75z"
clipRule="evenodd"
></path>
<path d="M15 21v-5a3 3 0 1 0-6 0v5M7.606 5.65l-2.6 2.456c-.74.698-1.11 1.047-1.374 1.46a4 4 0 0 0-.513 1.191C3 11.233 3 11.742 3 12.76V14.6c0 2.24 0 3.36.436 4.216a4 4 0 0 0 1.748 1.748C6.04 21 7.16 21 9.4 21h5.2c2.24 0 3.36 0 4.216-.436a4 4 0 0 0 1.748-1.748C21 17.96 21 16.84 21 14.6v-1.841c0-1.017 0-1.526-.119-2.002a4 4 0 0 0-.513-1.19c-.265-.414-.634-.763-1.374-1.461l-2.6-2.456c-1.546-1.46-2.32-2.19-3.201-2.466a4 4 0 0 0-2.386 0c-.882.275-1.655 1.006-3.201 2.466Z" />
</svg>
);
}

View File

@ -1,19 +1,18 @@
import { SVGProps } from 'react';
export function TrashIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function TrashIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path
d="M5.75 21.25L5.00156 21.2983C5.02702 21.6929 5.35453 22 5.75 22V21.25ZM18.25 21.25V22C18.6455 22 18.973 21.6929 18.9984 21.2983L18.25 21.25ZM2.75 5C2.33579 5 2 5.33579 2 5.75C2 6.16421 2.33579 6.5 2.75 6.5V5ZM21.25 6.5C21.6642 6.5 22 6.16421 22 5.75C22 5.33579 21.6642 5 21.25 5V6.5ZM10.5 10.75C10.5 10.3358 10.1642 10 9.75 10C9.33579 10 9 10.3358 9 10.75H10.5ZM9 16.25C9 16.6642 9.33579 17 9.75 17C10.1642 17 10.5 16.6642 10.5 16.25H9ZM15 10.75C15 10.3358 14.6642 10 14.25 10C13.8358 10 13.5 10.3358 13.5 10.75H15ZM13.5 16.25C13.5 16.6642 13.8358 17 14.25 17C14.6642 17 15 16.6642 15 16.25H13.5ZM15.1477 5.93694C15.2509 6.33808 15.6598 6.57957 16.0609 6.47633C16.4621 6.37308 16.7036 5.9642 16.6003 5.56306L15.1477 5.93694ZM4.00156 5.79829L5.00156 21.2983L6.49844 21.2017L5.49844 5.70171L4.00156 5.79829ZM5.75 22H18.25V20.5H5.75V22ZM18.9984 21.2983L19.9984 5.79829L18.5016 5.70171L17.5016 21.2017L18.9984 21.2983ZM19.25 5H4.75V6.5H19.25V5ZM2.75 6.5H4.75V5H2.75V6.5ZM19.25 6.5H21.25V5H19.25V6.5ZM9 10.75V16.25H10.5V10.75H9ZM13.5 10.75V16.25H15V10.75H13.5ZM12 3.5C13.5134 3.5 14.7868 4.53504 15.1477 5.93694L16.6003 5.56306C16.0731 3.51451 14.2144 2 12 2V3.5ZM8.85237 5.93694C9.21319 4.53504 10.4867 3.5 12 3.5V2C9.78568 2 7.92697 3.51451 7.39971 5.56306L8.85237 5.93694Z"
fill="currentColor"
/>
<path d="m16 6-1.106-2.211a3.236 3.236 0 0 0-5.788 0L8 6M4 6h16m-10 5v5m4-5v5M6 6h12v9c0 1.864 0 2.796-.305 3.53a4 4 0 0 1-2.164 2.165C14.796 21 13.864 21 12 21s-2.796 0-3.53-.305a4 4 0 0 1-2.166-2.164C6 17.796 6 16.864 6 15V6Z" />
</svg>
);
}

View File

@ -1,10 +1,18 @@
import { Outlet } from 'react-router-dom';
import { type Platform } from '@tauri-apps/plugin-os';
import { Outlet, ScrollRestoration } from 'react-router-dom';
import { WindowTitleBar } from '@shared/titlebar';
export function AuthLayout() {
export function AuthLayout({ platform }: { platform: Platform }) {
return (
<div className="h-full w-full px-2.5 pb-2.5 pt-1">
<div className="flex h-full min-h-0 w-full rounded-lg bg-white p-3 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
{platform !== 'macos' ? (
<WindowTitleBar platform={platform} />
) : (
<div data-tauri-drag-region className="h-9 shrink-0" />
)}
<div className="h-full w-full">
<Outlet />
<ScrollRestoration />
</div>
</div>
);

View File

@ -1,3 +1,4 @@
import { Platform } from '@tauri-apps/plugin-os';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import {
@ -8,98 +9,106 @@ import {
SettingsIcon,
UserIcon,
} from '@shared/icons';
import { WindowTitleBar } from '@shared/titlebar';
export function SettingsLayout() {
export function SettingsLayout({ platform }: { platform: Platform }) {
const navigate = useNavigate();
return (
<div className="flex h-full min-h-0 w-full flex-col gap-8 overflow-y-auto">
<div className="flex h-20 w-full items-center justify-between border-b border-neutral-200 px-2 pb-2 dark:border-neutral-900">
<div>
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-12 w-12 items-center justify-center rounded-xl"
>
<ArrowLeftIcon className="h-5 w-5" />
</button>
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
{platform !== 'macos' ? (
<WindowTitleBar platform={platform} />
) : (
<div data-tauri-drag-region className="h-9 shrink-0" />
)}
<div className="flex h-full min-h-0 w-full flex-col gap-8 overflow-y-auto">
<div className="flex h-20 w-full items-center justify-between border-b border-neutral-200 px-2 pb-2 dark:border-neutral-900">
<div>
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-12 w-12 items-center justify-center rounded-xl"
>
<ArrowLeftIcon className="h-5 w-5" />
</button>
</div>
<div className="flex items-center gap-0.5">
<NavLink
to="/settings/"
end
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<UserIcon className="h-6 w-6" />
<p className="text-sm font-medium">User</p>
</NavLink>
<NavLink
to="/settings/general"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<SettingsIcon className="h-6 w-6" />
<p className="text-sm font-medium">General</p>
</NavLink>
<NavLink
to="/settings/backup"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<SecureIcon className="h-6 w-6" />
<p className="text-sm font-medium">Backup</p>
</NavLink>
<NavLink
to="/settings/advanced"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<AdvancedSettingsIcon className="h-6 w-6" />
<p className="text-sm font-medium">Advanced</p>
</NavLink>
<NavLink
to="/settings/about"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<InfoIcon className="h-6 w-6" />
<p className="text-sm font-medium">About</p>
</NavLink>
</div>
<div />
</div>
<div className="flex items-center gap-0.5">
<NavLink
to="/settings/"
end
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<UserIcon className="h-6 w-6" />
<p className="text-sm font-medium">User</p>
</NavLink>
<NavLink
to="/settings/general"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<SettingsIcon className="h-6 w-6" />
<p className="text-sm font-medium">General</p>
</NavLink>
<NavLink
to="/settings/backup"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<SecureIcon className="h-6 w-6" />
<p className="text-sm font-medium">Backup</p>
</NavLink>
<NavLink
to="/settings/advanced"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<AdvancedSettingsIcon className="h-6 w-6" />
<p className="text-sm font-medium">Advanced</p>
</NavLink>
<NavLink
to="/settings/about"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<InfoIcon className="h-6 w-6" />
<p className="text-sm font-medium">About</p>
</NavLink>
</div>
<div />
<Outlet />
</div>
<Outlet />
</div>
);
}

View File

@ -3,18 +3,18 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import { FetchFilter } from 'nostr-fetch';
import { useMemo } from 'react';
import { VList } from 'virtua';
import { useArk } from '@libs/ark';
import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedArticleNote } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
import { WidgetProps } from '@utils/types';
export function ArticleWidget({ widget }: { widget: Widget }) {
export function ArticleWidget({ props }: { props: WidgetProps }) {
const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['article', widget.id],
queryKey: ['article', props.id],
initialPageParam: 0,
queryFn: async ({
signal,
@ -24,7 +24,7 @@ export function ArticleWidget({ widget }: { widget: Widget }) {
pageParam: number;
}) => {
let filter: FetchFilter;
const content = JSON.parse(widget.content);
const content = JSON.parse(props.content);
if (content.global) {
filter = {
@ -62,50 +62,52 @@ export function ArticleWidget({ widget }: { widget: Widget }) {
);
return (
<WidgetWrapper>
<TitleBar id={widget.id} title={widget.title} />
<VList className="flex-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : allEvents.length === 0 ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
Oops, it looks like there are no articles.
</h3>
<p className="text-neutral-500 dark:text-neutral-400">
You can close this widget
</p>
<Widget.Root>
<Widget.Header id={props.id} title={props.title} />
<Widget.Content>
<VList className="flex-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : allEvents.length === 0 ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
Oops, it looks like there are no articles.
</h3>
<p className="text-neutral-500 dark:text-neutral-400">
You can close this widget
</p>
</div>
</div>
</div>
) : (
allEvents.map((item) => <MemoizedArticleNote key={item.id} event={item} />)
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
) : (
allEvents.map((item) => <MemoizedArticleNote key={item.id} event={item} />)
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -2,18 +2,18 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import { FetchFilter } from 'nostr-fetch';
import { useMemo } from 'react';
import { VList } from 'virtua';
import { useArk } from '@libs/ark';
import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedFileNote } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
import { WidgetProps } from '@utils/types';
export function FileWidget({ widget }: { widget: Widget }) {
export function FileWidget({ props }: { props: WidgetProps }) {
const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['media', widget.id],
queryKey: ['media', props.id],
initialPageParam: 0,
queryFn: async ({
signal,
@ -23,7 +23,7 @@ export function FileWidget({ widget }: { widget: Widget }) {
pageParam: number;
}) => {
let filter: FetchFilter;
const content = JSON.parse(widget.content);
const content = JSON.parse(props.content);
if (content.global) {
filter = {
@ -61,50 +61,52 @@ export function FileWidget({ widget }: { widget: Widget }) {
);
return (
<WidgetWrapper>
<TitleBar id={widget.id} title={widget.title} />
<VList className="flex-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : allEvents.length === 0 ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
Oops, it looks like there are no files.
</h3>
<p className="text-neutral-500 dark:text-neutral-400">
You can close this widget
</p>
<Widget.Root>
<Widget.Header id={props.id} title={props.title} />
<Widget.Content>
<VList className="flex-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : allEvents.length === 0 ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
Oops, it looks like there are no files.
</h3>
<p className="text-neutral-500 dark:text-neutral-400">
You can close this widget
</p>
</div>
</div>
</div>
) : (
allEvents.map((item) => <MemoizedFileNote key={item.id} event={item} />)
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
) : (
allEvents.map((item) => <MemoizedFileNote key={item.id} event={item} />)
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,8 +1,8 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { VList } from 'virtua';
import { useArk } from '@libs/ark';
import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
MemoizedRepost,
@ -10,15 +10,14 @@ import {
NoteSkeleton,
UnknownNote,
} from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
import { WidgetProps } from '@utils/types';
export function GroupWidget({ widget }: { widget: Widget }) {
export function GroupWidget({ props }: { props: WidgetProps }) {
const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['groupfeeds', widget.id],
queryKey: ['groupfeeds', props.id],
initialPageParam: 0,
queryFn: async ({
signal,
@ -27,7 +26,7 @@ export function GroupWidget({ widget }: { widget: Widget }) {
signal: AbortSignal;
pageParam: number;
}) => {
const authors = JSON.parse(widget.content);
const authors = JSON.parse(props.content);
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
@ -55,53 +54,52 @@ export function GroupWidget({ widget }: { widget: Widget }) {
[data]
);
const renderItem = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <MemoizedRepost key={event.id} event={event} />;
default:
return <UnknownNote key={event.id} event={event} />;
}
},
[data]
);
const renderItem = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <MemoizedRepost key={event.id} event={event} />;
default:
return <UnknownNote key={event.id} event={event} />;
}
};
return (
<WidgetWrapper>
<TitleBar id={widget.id} title={widget.title} />
<VList className="flex-1">
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
<Widget.Root>
<Widget.Header id={props.id} title={props.title} />
<Widget.Content>
<VList className="flex-1">
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,19 +1,18 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { VList } from 'virtua';
import { useArk } from '@libs/ark';
import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
import { WidgetProps } from '@utils/types';
export function HashtagWidget({ widget }: { widget: Widget }) {
export function HashtagWidget({ props }: { props: WidgetProps }) {
const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['hashtag', widget.id],
queryKey: ['hashtag', props.id],
initialPageParam: 0,
queryFn: async ({
signal,
@ -25,7 +24,7 @@ export function HashtagWidget({ widget }: { widget: Widget }) {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
'#t': [widget.content],
'#t': [props.content],
},
limit: FETCH_LIMIT,
pageParam,
@ -50,65 +49,64 @@ export function HashtagWidget({ widget }: { widget: Widget }) {
);
// render event match event kind
const renderItem = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <MemoizedRepost key={event.id} event={event} />;
default:
return <UnknownNote key={event.id} event={event} />;
}
},
[data]
);
const renderItem = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <MemoizedRepost key={event.id} event={event} />;
default:
return <UnknownNote key={event.id} event={event} />;
}
};
return (
<WidgetWrapper>
<TitleBar id={widget.id} title={widget.title} />
<VList className="flex-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : allEvents.length === 0 ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
Oops, it looks like there are no events related to {widget.content}.
</h3>
<p className="text-neutral-500 dark:text-neutral-400">
You can close this widget
</p>
<Widget.Root>
<Widget.Header id={props.id} title={props.title} />
<Widget.Content>
<VList className="flex-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : allEvents.length === 0 ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
Oops, it looks like there are no events related to {props.content}.
</h3>
<p className="text-neutral-500 dark:text-neutral-400">
You can close this widget
</p>
</div>
</div>
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -7,10 +7,8 @@ export * from './file';
export * from './hashtag';
export * from './thread';
export * from './group';
export * from './titleBar';
export * from './nostrBand/trendingAccounts';
export * from './nostrBand/trendingNotes';
export * from './other/wrapper';
export * from './other/liveUpdater';
export * from './other/toggleWidgetList';
export * from './other/widgetList';

View File

@ -2,15 +2,14 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo, useRef } from 'react';
import { VList, VListHandle } from 'virtua';
import { useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } from '@shared/icons';
import {
MemoizedRepost,
MemoizedTextNote,
NoteSkeleton,
UnknownNote,
} from '@shared/notes';
import { LiveUpdater, TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants';
export function NewsfeedWidget() {
@ -67,39 +66,44 @@ export function NewsfeedWidget() {
};
return (
<WidgetWrapper>
<TitleBar id="9999" isLive />
<LiveUpdater status={status} />
<VList ref={ref} overscan={2} className="flex-1">
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
<Widget.Root>
<Widget.Header
id="9999"
title="Timeline"
icon={<TimelineIcon className="h-5 w-5" />}
/>
<Widget.Content>
<VList ref={ref} overscan={2} className="flex-1">
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 py-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,19 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { VList } from 'virtua';
import { Widget } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import {
NostrBandUserProfile,
type Profile,
TitleBar,
WidgetWrapper,
} from '@shared/widgets';
import { Widget } from '@utils/types';
import { NostrBandUserProfile, type Profile } from '@shared/widgets';
import { WidgetProps } from '@utils/types';
interface Response {
profiles: Array<{ pubkey: string }>;
}
export function TrendingAccountsWidget({ widget }: { widget: Widget }) {
export function TrendingAccountsWidget({ props }: { props: WidgetProps }) {
const { status, data } = useQuery({
queryKey: ['trending-users'],
queryFn: async () => {
@ -32,38 +28,40 @@ export function TrendingAccountsWidget({ widget }: { widget: Widget }) {
});
return (
<WidgetWrapper>
<TitleBar id={widget.id} title="Trending Accounts" />
<div className="flex-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center ">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-300">
Loading trending accounts...
</p>
</div>
</div>
) : status === 'error' ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-500 dark:text-neutral-300">
Sorry, an unexpected error has occurred.
</h3>
<Widget.Root>
<Widget.Header id={props.id} title="Trending Accounts" />
<Widget.Content>
<div className="flex-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center ">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-300">
Loading trending accounts...
</p>
</div>
</div>
</div>
) : (
<VList className="h-full">
{data.map((item: Profile) => (
<NostrBandUserProfile key={item.pubkey} data={item} />
))}
<div className="h-16" />
</VList>
)}
</div>
</WidgetWrapper>
) : status === 'error' ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-500 dark:text-neutral-300">
Sorry, an unexpected error has occurred.
</h3>
</div>
</div>
</div>
) : (
<VList className="h-full">
{data.map((item: Profile) => (
<NostrBandUserProfile key={item.pubkey} data={item} />
))}
<div className="h-16" />
</VList>
)}
</div>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,16 +1,16 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { VList } from 'virtua';
import { Widget } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { MemoizedTextNote } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { Widget } from '@utils/types';
import { WidgetProps } from '@utils/types';
interface Response {
notes: Array<{ event: NDKEvent }>;
}
export function TrendingNotesWidget({ widget }: { widget: Widget }) {
export function TrendingNotesWidget({ props }: { props: WidgetProps }) {
const { status, data } = useQuery({
queryKey: ['trending-posts'],
queryFn: async () => {
@ -29,33 +29,37 @@ export function TrendingNotesWidget({ widget }: { widget: Widget }) {
});
return (
<WidgetWrapper>
<TitleBar id={widget.id} title="Trending Notes" />
<VList className="flex-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center ">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-300">
Loading trending posts...
</p>
</div>
</div>
) : status === 'error' ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-500 dark:text-neutral-300">
Sorry, an unexpected error has occurred.
</h3>
<Widget.Root>
<Widget.Header id={props.id} title="Trending Notes" />
<Widget.Content>
<VList className="flex-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center ">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-300">
Loading trending posts...
</p>
</div>
</div>
</div>
) : (
data.map((item) => <MemoizedTextNote key={item.event.id} event={item.event} />)
)}
</VList>
</WidgetWrapper>
) : status === 'error' ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-500 dark:text-neutral-300">
Sorry, an unexpected error has occurred.
</h3>
</div>
</div>
</div>
) : (
data.map((item) => (
<MemoizedTextNote key={item.event.id} event={item.event} />
))
)}
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,18 +1,17 @@
import { NDKEvent, NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { VList } from 'virtua';
import { useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { Widget, useArk } from '@libs/ark';
import { AnnouncementIcon, ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedNotifyNote, NoteSkeleton } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants';
import { sendNativeNotification } from '@utils/notification';
export function NotificationWidget() {
const ark = useArk();
const queryClient = useQueryClient();
const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['notification'],
@ -52,10 +51,10 @@ export function NotificationWidget() {
[data]
);
const renderEvent = useCallback((event: NDKEvent) => {
const renderEvent = (event: NDKEvent) => {
if (event.pubkey === ark.account.pubkey) return null;
return <MemoizedNotifyNote key={event.id} event={event} />;
}, []);
};
useEffect(() => {
let sub: NDKSubscription = undefined;
@ -124,45 +123,51 @@ export function NotificationWidget() {
}, [status]);
return (
<WidgetWrapper>
<TitleBar id="9998" title="Notification" isLive />
<VList className="flex-1" overscan={2}>
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
<Widget.Root>
<Widget.Header
id="9998"
title="Notification"
icon={<AnnouncementIcon className="h-5 w-5" />}
/>
<Widget.Content>
<VList className="flex-1" overscan={2}>
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div>
) : allEvents.length < 1 ? (
<div className="flex h-[400px] w-full flex-col items-center justify-center">
<p className="mb-2 text-4xl">🎉</p>
<p className="text-center font-medium text-neutral-900 dark:text-neutral-100">
Hmm! Nothing new yet.
</p>
</div>
) : (
allEvents.map((event) => renderEvent(event))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
) : allEvents.length < 1 ? (
<div className="flex h-[400px] w-full flex-col items-center justify-center">
<p className="mb-2 text-4xl">🎉</p>
<p className="text-center font-medium text-neutral-900 dark:text-neutral-100">
Hmm! Nothing new yet.
</p>
</div>
) : (
allEvents.map((event) => renderEvent(event))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,5 +1,5 @@
import { Widget } from '@libs/ark';
import { PlusIcon } from '@shared/icons';
import { WidgetWrapper } from '@shared/widgets';
import { WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget';
@ -7,18 +7,20 @@ export function ToggleWidgetList() {
const { addWidget } = useWidget();
return (
<WidgetWrapper>
<div className="relative flex h-full w-full flex-col items-center justify-center">
<button
type="button"
onClick={() =>
addWidget.mutate({ kind: WIDGET_KIND.list, title: '', content: '' })
}
className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-100 text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</WidgetWrapper>
<Widget.Root>
<Widget.Content>
<div className="relative flex h-full w-full flex-col items-center justify-center">
<button
type="button"
onClick={() =>
addWidget.mutate({ kind: WIDGET_KIND.list, title: '', content: '' })
}
className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-100 text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,121 +1,124 @@
import { Widget } from '@libs/ark';
import { ArticleIcon, MediaIcon, PlusIcon } from '@shared/icons';
import { AddGroupFeeds, AddHashtagFeeds, TitleBar, WidgetWrapper } from '@shared/widgets';
import { AddGroupFeeds, AddHashtagFeeds } from '@shared/widgets';
import { TOPICS, WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget';
import { Widget } from '@utils/types';
import { WidgetProps } from '@utils/types';
export function WidgetList({ widget }: { widget: Widget }) {
export function WidgetList({ props }: { props: WidgetProps }) {
const { replaceWidget } = useWidget();
return (
<WidgetWrapper>
<TitleBar id={widget.id} title="Add widgets" />
<div className="flex-1 overflow-y-auto pb-10 scrollbar-none">
<div className="flex flex-col gap-6 px-3">
<div className="rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<h3 className="mb-2.5 text-sm font-semibold uppercase text-neutral-700 dark:text-neutral-300">
Topics
</h3>
<div className="flex flex-col gap-3">
{TOPICS.sort((a, b) => a.title.localeCompare(b.title)).map(
(topic, index) => (
<div
key={index}
className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 ring-1 ring-transparent hover:ring-neutral-200 dark:bg-neutral-950 dark:hover:ring-neutral-800"
>
<div className="inline-flex items-center gap-2.5">
<div className="h-9 w-9 shrink-0 rounded-md">
<img
src={`/${topic.title.toLowerCase()}.jpg`}
alt={topic.title}
className="h-9 w-9 rounded-md"
/>
</div>
<p className="font-medium">{topic.title}</p>
</div>
<button
type="button"
onClick={() =>
replaceWidget.mutate({
currentId: widget.id,
widget: {
kind: WIDGET_KIND.topic,
title: topic.title,
content: JSON.stringify(topic.content),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
<Widget.Root>
<Widget.Header id={props.id} title="Add widgets" />
<Widget.Content>
<div className="flex-1 overflow-y-auto pb-10 scrollbar-none">
<div className="flex flex-col gap-6 px-3">
<div className="rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<h3 className="mb-2.5 text-sm font-semibold uppercase text-neutral-700 dark:text-neutral-300">
Topics
</h3>
<div className="flex flex-col gap-3">
{TOPICS.sort((a, b) => a.title.localeCompare(b.title)).map(
(topic, index) => (
<div
key={index}
className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 ring-1 ring-transparent hover:ring-neutral-200 dark:bg-neutral-950 dark:hover:ring-neutral-800"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
</div>
)
)}
</div>
</div>
<div className="rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<h3 className="mb-2.5 text-sm font-semibold uppercase text-neutral-700 dark:text-neutral-300">
Newsfeed
</h3>
<div className="flex flex-col gap-3">
<AddGroupFeeds currentWidgetId={widget.id} />
<AddHashtagFeeds currentWidgetId={widget.id} />
<div className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 hover:shadow-md hover:shadow-neutral-200/50 dark:bg-neutral-950 dark:hover:shadow-neutral-800/50">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<ArticleIcon className="h-4 w-4" />
</div>
<p className="font-medium">Articles</p>
</div>
<button
type="button"
onClick={() =>
replaceWidget.mutate({
currentId: widget.id,
widget: {
kind: WIDGET_KIND.article,
title: 'Articles',
content: JSON.stringify({ global: true }),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
<div className="inline-flex items-center gap-2.5">
<div className="h-9 w-9 shrink-0 rounded-md">
<img
src={`/${topic.title.toLowerCase()}.jpg`}
alt={topic.title}
className="h-9 w-9 rounded-md"
/>
</div>
<p className="font-medium">{topic.title}</p>
</div>
<button
type="button"
onClick={() =>
replaceWidget.mutate({
currentId: props.id,
widget: {
kind: WIDGET_KIND.topic,
title: topic.title,
content: JSON.stringify(topic.content),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
</div>
)
)}
</div>
<div className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 hover:shadow-md hover:shadow-neutral-200/50 dark:bg-neutral-950 dark:hover:shadow-neutral-800/50">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<MediaIcon className="h-4 w-4" />
</div>
<div className="rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<h3 className="mb-2.5 text-sm font-semibold uppercase text-neutral-700 dark:text-neutral-300">
Newsfeed
</h3>
<div className="flex flex-col gap-3">
<AddGroupFeeds currentWidgetId={props.id} />
<AddHashtagFeeds currentWidgetId={props.id} />
<div className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 hover:shadow-md hover:shadow-neutral-200/50 dark:bg-neutral-950 dark:hover:shadow-neutral-800/50">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<ArticleIcon className="h-4 w-4" />
</div>
<p className="font-medium">Articles</p>
</div>
<p className="font-medium">Media</p>
<button
type="button"
onClick={() =>
replaceWidget.mutate({
currentId: props.id,
widget: {
kind: WIDGET_KIND.article,
title: 'Articles',
content: JSON.stringify({ global: true }),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
</div>
<div className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 hover:shadow-md hover:shadow-neutral-200/50 dark:bg-neutral-950 dark:hover:shadow-neutral-800/50">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<MediaIcon className="h-4 w-4" />
</div>
<p className="font-medium">Media</p>
</div>
<button
type="button"
onClick={() =>
replaceWidget.mutate({
currentId: props.id,
widget: {
kind: WIDGET_KIND.file,
title: 'Media',
content: JSON.stringify({ global: true }),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
</div>
<button
type="button"
onClick={() =>
replaceWidget.mutate({
currentId: widget.id,
widget: {
kind: WIDGET_KIND.file,
title: 'Media',
content: JSON.stringify({ global: true }),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
</div>
</div>
</div>
</div>
</div>
</WidgetWrapper>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,7 +1,6 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useCallback } from 'react';
import { WVList } from 'virtua';
import { useArk } from '@libs/ark';
import { Widget, useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import {
ChildNote,
@ -13,71 +12,69 @@ import {
} from '@shared/notes';
import { ReplyList } from '@shared/notes/replies/list';
import { User } from '@shared/user';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { useEvent } from '@utils/hooks/useEvent';
import { Widget } from '@utils/types';
import { type WidgetProps } from '@utils/types';
export function ThreadWidget({ widget }: { widget: Widget }) {
const { isFetching, isError, data } = useEvent(widget.content);
export function ThreadWidget({ props }: { props: WidgetProps }) {
const ark = useArk();
const { isFetching, isError, data } = useEvent(props.content);
const renderKind = useCallback(
(event: NDKEvent) => {
const thread = ark.getEventThread({ tags: event.tags });
switch (event.kind) {
case NDKKind.Text:
return (
<>
{thread ? (
<div className="mb-2 w-full px-3">
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? (
<ChildNote id={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? <ChildNote id={thread.replyEventId} /> : null}
</div>
const renderKind = (event: NDKEvent) => {
const thread = ark.getEventThread({ tags: event.tags });
switch (event.kind) {
case NDKKind.Text:
return (
<>
{thread ? (
<div className="mb-2 w-full px-3">
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? (
<ChildNote id={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? <ChildNote id={thread.replyEventId} /> : null}
</div>
) : null}
<MemoizedTextKind content={event.content} />
</>
);
case NDKKind.Article:
return <MemoizedArticleKind id={event.id} tags={event.tags} />;
case 1063:
return <MemoizedFileKind tags={event.tags} />;
default:
return null;
}
},
[data]
);
</div>
) : null}
<MemoizedTextKind content={event.content} />
</>
);
case NDKKind.Article:
return <MemoizedArticleKind id={event.id} tags={event.tags} />;
case 1063:
return <MemoizedFileKind tags={event.tags} />;
default:
return null;
}
};
return (
<WidgetWrapper>
<TitleBar id={widget.id} title={widget.title} />
<WVList className="flex-1 overflow-y-auto px-3 pb-5">
{isFetching ? (
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 px-3 py-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : (
<>
<div className="flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
{isError ? (
<div>Failed to fetch event</div>
) : (
<>
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
{renderKind(data)}
<NoteActions event={data} />
</>
)}
<Widget.Root>
<Widget.Header id={props.id} title={props.title} />
<Widget.Content>
<WVList className="flex-1 overflow-y-auto px-3 pb-5">
{isFetching ? (
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 px-3 py-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
<NoteReplyForm rootEvent={data} />
<ReplyList eventId={data.id} />
</>
)}
</WVList>
</WidgetWrapper>
) : (
<>
<div className="flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
{isError ? (
<div>Failed to fetch event</div>
) : (
<>
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
{renderKind(data)}
<NoteActions event={data} />
</>
)}
</div>
<NoteReplyForm rootEvent={data} />
<ReplyList eventId={data.id} />
</>
)}
</WVList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -1,64 +0,0 @@
import { useArk } from '@libs/ark';
import { CancelIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useWidget } from '@utils/hooks/useWidget';
export function TitleBar({
id,
title,
isLive,
}: {
id?: string;
title?: string;
isLive?: boolean;
}) {
const ark = useArk();
const { removeWidget } = useWidget();
return (
<div className="grid h-11 w-full shrink-0 grid-cols-3 items-center px-3">
<div className="col-span-1 flex justify-start">
{isLive ? (
<div className="flex items-center gap-1.5">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-teal-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500"></span>
</span>
<p className="text-xs font-medium text-teal-500">Live</p>
</div>
) : null}
</div>
<div className="col-span-1 flex justify-center">
{id === '9999' ? (
<div className="isolate flex -space-x-2">
{ark.account.contacts
?.slice(0, 8)
.map((item) => <User key={item} pubkey={item} variant="ministacked" />)}
{ark.account.contacts?.length > 8 ? (
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
<span className="text-[8px] font-medium">
+{ark.account.contacts?.length - 8}
</span>
</div>
) : null}
</div>
) : (
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h3>
)}
</div>
<div className="col-span-1 flex justify-end">
{id !== '9999' && id !== '9998' ? (
<button
type="button"
onClick={() => removeWidget.mutate(id)}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-neutral-900 backdrop-blur-xl hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-900"
>
<CancelIcon className="h-3 w-3" />
</button>
) : null}
</div>
</div>
);
}

View File

@ -1,8 +1,8 @@
import { NDKEvent, NDKFilter, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { VList } from 'virtua';
import { useArk } from '@libs/ark';
import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
MemoizedRepost,
@ -10,15 +10,14 @@ import {
NoteSkeleton,
UnknownNote,
} from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
import { type WidgetProps } from '@utils/types';
export function TopicWidget({ widget }: { widget: Widget }) {
export function TopicWidget({ props }: { props: WidgetProps }) {
const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['topic', widget.id],
queryKey: ['topic', props.id],
initialPageParam: 0,
queryFn: async ({
signal,
@ -27,7 +26,7 @@ export function TopicWidget({ widget }: { widget: Widget }) {
signal: AbortSignal;
pageParam: number;
}) => {
const hashtags: string[] = JSON.parse(widget.content as string);
const hashtags: string[] = JSON.parse(props.content as string);
const filter: NDKFilter = {
kinds: [NDKKind.Text, NDKKind.Repost],
'#t': hashtags.map((tag) => tag.replace('#', '')),
@ -55,53 +54,52 @@ export function TopicWidget({ widget }: { widget: Widget }) {
[data]
);
const renderItem = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <MemoizedRepost key={event.id} event={event} />;
default:
return <UnknownNote key={event.id} event={event} />;
}
},
[data]
);
const renderItem = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <MemoizedRepost key={event.id} event={event} />;
default:
return <UnknownNote key={event.id} event={event} />;
}
};
return (
<WidgetWrapper>
<TitleBar id={widget.id} title={widget.title} />
<VList className="flex-1" overscan={2}>
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
<Widget.Root>
<Widget.Header id={props.id} title={props.title} />
<Widget.Content>
<VList className="flex-1" overscan={2}>
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -2,7 +2,7 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { WVList } from 'virtua';
import { useArk } from '@libs/ark';
import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
MemoizedRepost,
@ -10,15 +10,15 @@ import {
NoteSkeleton,
UnknownNote,
} from '@shared/notes';
import { TitleBar, UserProfile, WidgetWrapper } from '@shared/widgets';
import { UserProfile } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
import { type WidgetProps } from '@utils/types';
export function UserWidget({ widget }: { widget: Widget }) {
export function UserWidget({ props }: { props: WidgetProps }) {
const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['user-posts', widget.content],
queryKey: ['user-posts', props.content],
initialPageParam: 0,
queryFn: async ({
signal,
@ -30,7 +30,7 @@ export function UserWidget({ widget }: { widget: Widget }) {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: [widget.content],
authors: [props.content],
},
limit: FETCH_LIMIT,
pageParam,
@ -68,48 +68,50 @@ export function UserWidget({ widget }: { widget: Widget }) {
);
return (
<WidgetWrapper>
<TitleBar id={widget.id} title={widget.title} />
<WVList className="flex-1 overflow-y-auto">
<div className="px-3 pt-1.5">
<UserProfile pubkey={widget.content} />
</div>
<div>
<h3 className="mb-3 mt-4 px-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Latest posts
</h3>
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
<Widget.Root>
<Widget.Header id={props.id} title={props.title} />
<Widget.Content>
<WVList className="flex-1 overflow-y-auto">
<div className="px-3 pt-1.5">
<UserProfile pubkey={props.content} />
</div>
<div>
<h3 className="mb-3 mt-4 px-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Latest posts
</h3>
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</div>
</div>
</WVList>
</WidgetWrapper>
</WVList>
</Widget.Content>
</Widget.Root>
);
}

View File

@ -30,7 +30,7 @@ export interface WidgetGroupItem {
icon?: string;
}
export interface Widget {
export interface WidgetProps {
id?: string;
account_id?: number;
kind: number;