final design (#184)

* feat: redesign

* feat: update other columns to new design

* chore: small fixes

* fix: better manage external webview

* feat: redesign note

* feat: update ui

* chore: update

* chore: update

* chore: polish ui

* chore: update auth ui

* feat: finalize note design

* chore: small fixes

* feat: add window management in rust

* chore: format

* feat: update ui for event screen

* feat: update event screen

* feat: final
This commit is contained in:
雨宮蓮
2024-05-03 15:15:48 +07:00
committed by GitHub
parent 61d1f095d4
commit a4aef25adb
250 changed files with 9360 additions and 9235 deletions

View File

@@ -1,61 +1,61 @@
{ {
"name": "@lume/desktop2", "name": "@lume/desktop2",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@lume/ark": "workspace:^", "@lume/ark": "workspace:^",
"@lume/icons": "workspace:^", "@lume/icons": "workspace:^",
"@lume/ui": "workspace:^", "@lume/ui": "workspace:^",
"@lume/utils": "workspace:^", "@lume/utils": "workspace:^",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/query-sync-storage-persister": "^5.31.0", "@tanstack/query-sync-storage-persister": "^5.32.0",
"@tanstack/react-query": "^5.31.0", "@tanstack/react-query": "^5.32.0",
"@tanstack/react-query-persist-client": "^5.31.0", "@tanstack/react-query-persist-client": "^5.32.0",
"@tanstack/react-router": "^1.29.2", "@tanstack/react-router": "1.29.2",
"i18next": "^23.11.2", "i18next": "^23.11.3",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1", "minidenticons": "^4.2.1",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nostr-tools": "^2.5.0", "nostr-tools": "^2.5.1",
"react": "^18.2.0", "react": "^18.3.1",
"react-currency-input-field": "^3.8.0", "react-currency-input-field": "^3.8.0",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"react-hook-form": "^7.51.3", "react-hook-form": "^7.51.3",
"react-hotkeys-hook": "^4.5.0", "react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.1",
"slate": "^0.102.0", "slate": "^0.102.0",
"slate-react": "^0.102.0", "slate-react": "^0.102.0",
"sonner": "^1.4.41", "sonner": "^1.4.41",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.0",
"virtua": "^0.30.2" "virtua": "^0.30.2"
}, },
"devDependencies": { "devDependencies": {
"@lume/tailwindcss": "workspace:^", "@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^", "@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^", "@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.29.2", "@tanstack/router-devtools": "^1.31.3",
"@tanstack/router-vite-plugin": "^1.30.0", "@tanstack/router-vite-plugin": "^1.30.0",
"@types/react": "^18.2.79", "@types/react": "^18.3.1",
"@types/react-dom": "^18.2.25", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.6.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.10", "vite": "^5.2.10",
"vite-plugin-top-level-await": "^1.4.1", "vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"
} }
} }

View File

@@ -1,6 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
}; };

View File

@@ -1,69 +1,72 @@
import { Ark } from "@lume/ark";
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { RouterProvider, createRouter } from "@tanstack/react-router"; import { RouterProvider, createRouter } from "@tanstack/react-router";
import { platform } from "@tauri-apps/plugin-os";
import React, { StrictMode } from "react"; import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { Toaster } from "sonner";
import "./app.css"; import "./app.css";
import i18n from "./locale"; import i18n from "./locale";
import { Toaster } from "sonner";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { routeTree } from "./router.gen"; // auto generated file import { routeTree } from "./router.gen"; // auto generated file
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
import { Ark } from "@lume/ark";
const ark = new Ark(); const ark = new Ark();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const platformName = await platform();
const persister = createSyncStoragePersister({ const persister = createSyncStoragePersister({
storage: window.localStorage, storage: window.localStorage,
}); });
// Set up a Router instance // Set up a Router instance
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
context: { context: {
ark, ark,
queryClient, queryClient,
}, platform: platformName,
},
}); });
// Register things for typesafety // Register things for typesafety
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: typeof router; router: typeof router;
} }
} }
function App() { function App() {
return <RouterProvider router={router} />; return <RouterProvider router={router} />;
} }
// biome-ignore lint/style/noNonNullAssertion: idk // biome-ignore lint/style/noNonNullAssertion: idk
const rootElement = document.getElementById("root")!; const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) { if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement); const root = ReactDOM.createRoot(rootElement);
root.render( root.render(
<I18nextProvider i18n={i18n} defaultNS={"translation"}> <I18nextProvider i18n={i18n} defaultNS={"translation"}>
<PersistQueryClientProvider <PersistQueryClientProvider
client={queryClient} client={queryClient}
persistOptions={{ persister }} persistOptions={{ persister }}
> >
<StrictMode> <StrictMode>
<Toaster <Toaster
position="bottom-right" position="bottom-right"
icons={{ icons={{
success: <CheckCircleIcon className="size-5" />, success: <CheckCircleIcon className="size-5" />,
info: <InfoCircleIcon className="size-5" />, info: <InfoCircleIcon className="size-5" />,
error: <CancelCircleIcon className="size-5" />, error: <CancelCircleIcon className="size-5" />,
}} }}
closeButton closeButton
theme="system" theme="system"
/> />
<App /> <App />
</StrictMode> </StrictMode>
</PersistQueryClientProvider> </PersistQueryClientProvider>
</I18nextProvider>, </I18nextProvider>,
); );
} }

View File

@@ -1,69 +0,0 @@
import { Account } from "@lume/types";
import { User } from "@lume/ui";
import {
useNavigate,
useParams,
useRouteContext,
} from "@tanstack/react-router";
import { useEffect, useState } from "react";
export function Accounts() {
const { ark } = useRouteContext({ strict: false });
const params = useParams({ strict: false });
const [accounts, setAccounts] = useState<Account[]>(null);
useEffect(() => {
async function getAllAccounts() {
const data = await ark.get_all_accounts();
if (data) setAccounts(data);
}
getAllAccounts();
}, []);
return (
<div data-tauri-drag-region className="flex items-center gap-3">
{accounts
? accounts.map((account) =>
// @ts-ignore, useless
account.npub === params.account ? (
<Active key={account.npub} pubkey={account.npub} />
) : (
<Inactive key={account.npub} pubkey={account.npub} />
),
)
: null}
</div>
);
}
function Inactive({ pubkey }: { pubkey: string }) {
const { ark } = useRouteContext({ strict: false });
const navigate = useNavigate();
const changeAccount = async (npub: string) => {
const select = await ark.load_selected_account(npub);
if (select) navigate({ to: "/$account/home", params: { account: npub } });
};
return (
<button type="button" onClick={() => changeAccount(pubkey)}>
<User.Provider pubkey={pubkey}>
<User.Root className="rounded-full">
<User.Avatar className="aspect-square h-auto w-8 rounded-full object-cover" />
</User.Root>
</User.Provider>
</button>
);
}
function Active({ pubkey }: { pubkey: string }) {
return (
<User.Provider pubkey={pubkey}>
<User.Root className="rounded-full ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950">
<User.Avatar className="aspect-square h-auto w-7 rounded-full object-cover" />
</User.Root>
</User.Provider>
);
}

View File

@@ -1,42 +1,47 @@
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";
import { Dispatch, ReactNode, SetStateAction, useState } from "react"; import {
type Dispatch,
type ReactNode,
type SetStateAction,
useState,
} from "react";
import { toast } from "sonner"; import { toast } from "sonner";
export function AvatarUploader({ export function AvatarUploader({
setPicture, setPicture,
children, children,
className, className,
}: { }: {
setPicture: Dispatch<SetStateAction<string>>; setPicture: Dispatch<SetStateAction<string>>;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false }); const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadAvatar = async () => { const uploadAvatar = async () => {
// start loading // start loading
setLoading(true); setLoading(true);
try { try {
const image = await ark.upload(); const image = await ark.upload();
setPicture(image); setPicture(image);
} catch (e) { } catch (e) {
toast.error(String(e)); toast.error(String(e));
} }
// stop loading // stop loading
setLoading(false); setLoading(false);
}; };
return ( return (
<button <button
type="button" type="button"
onClick={() => uploadAvatar()} onClick={() => uploadAvatar()}
className={cn("size-4", className)} className={cn("size-4", className)}
> >
{loading ? <Spinner className="size-4" /> : children} {loading ? <Spinner className="size-4" /> : children}
</button> </button>
); );
} }

View File

@@ -4,39 +4,39 @@ import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
export function Balance({ account }: { account: string }) { export function Balance({ account }: { account: string }) {
const { ark } = useRouteContext({ strict: false }); const { ark } = useRouteContext({ strict: false });
const [balance, setBalance] = useState(0); const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]); const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => { useEffect(() => {
async function getBalance() { async function getBalance() {
const val = await ark.get_balance(); const val = await ark.get_balance();
setBalance(val); setBalance(val);
} }
getBalance(); getBalance();
}, []); }, []);
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex h-16 items-center justify-end px-3" className="flex h-16 items-center justify-end px-3"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-end"> <div className="text-end">
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300"> <div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Your balance Your balance
</div> </div>
<div className="font-medium leading-tight"> <div className="font-medium leading-tight">
{value.bitcoinFormatted} {value.bitcoinFormatted}
</div> </div>
</div> </div>
<User.Provider pubkey={account}> <User.Provider pubkey={account}>
<User.Root> <User.Root>
<User.Avatar className="size-9 rounded-full" /> <User.Avatar className="size-9 rounded-full" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,83 +1,160 @@
import { useEffect, useRef, useState } from "react"; import { CancelIcon, CheckIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types"; import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef, useState } from "react";
export function Col({ export function Col({
column, column,
account, account,
isScroll, isScroll,
isResize, isResize,
}: { }: {
column: LumeColumn; column: LumeColumn;
account: string; account: string;
isScroll: boolean; isScroll: boolean;
isResize: boolean; isResize: boolean;
}) { }) {
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
const [webview, setWebview] = useState<string | undefined>(undefined); const [webview, setWebview] = useState<string | undefined>(undefined);
const repositionWebview = async () => { const repositionWebview = async () => {
if (webview && webview.length > 1) { if (webview && webview.length > 1) {
const newRect = container.current.getBoundingClientRect(); const newRect = container.current.getBoundingClientRect();
await invoke("reposition_column", { await invoke("reposition_column", {
label: webview, label: webview,
x: newRect.x, x: newRect.x,
y: newRect.y, y: newRect.y,
}); });
} }
}; };
const resizeWebview = async () => { const resizeWebview = async () => {
if (webview && webview.length > 1) { if (webview && webview.length > 1) {
const newRect = container.current.getBoundingClientRect(); const newRect = container.current.getBoundingClientRect();
await invoke("resize_column", { await invoke("resize_column", {
label: webview, label: webview,
width: newRect.width, width: newRect.width,
height: newRect.height, height: newRect.height,
}); });
} }
}; };
useEffect(() => { useEffect(() => {
resizeWebview(); resizeWebview();
}, [isResize]); }, [isResize]);
useEffect(() => { useEffect(() => {
if (isScroll) repositionWebview(); if (isScroll) repositionWebview();
}, [isScroll]); }, [isScroll]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (webview && webview.length > 1) return; if (webview && webview.length > 1) return;
const rect = container.current.getBoundingClientRect(); const rect = container.current.getBoundingClientRect();
const windowLabel = `column-${column.label}`; const windowLabel = `column-${column.label}`;
const url = const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
column.content +
`?account=${account}&label=${column.label}&name=${column.name}`;
// create new webview // create new webview
const label: string = await invoke("create_column", { const label: string = await invoke("create_column", {
label: windowLabel, label: windowLabel,
x: rect.x, x: rect.x,
y: rect.y, y: rect.y,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
url, url,
}); });
setWebview(label); setWebview(label);
})(); })();
// close webview when unmounted // close webview when unmounted
return () => { return () => {
if (webview && webview.length > 1) { if (webview && webview.length > 1) {
invoke("close_column", { invoke("close_column", {
label: webview, label: webview,
}); });
} }
}; };
}, [webview]); }, [webview]);
return <div ref={container} className="h-full w-[440px] shrink-0 p-2" />; return (
<div className="h-full w-[440px] shrink-0 p-2">
<div
className={cn(
"flex flex-col w-full h-full rounded-xl",
column.label !== "open"
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm"
: "",
)}
>
{column.label !== "open" ? (
<Header label={column.label} name={column.name} />
) : null}
<div ref={container} className="flex-1 w-full h-full" />
</div>
</div>
);
}
function Header({ label, name }: { label: string; name: string }) {
const [title, setTitle] = useState(name);
const [isChanged, setIsChanged] = useState(false);
const saveNewTitle = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "set_title", label, title });
// update search params
// @ts-ignore, hahaha
search.name = title;
// reset state
setIsChanged(false);
};
const close = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "remove", label });
};
useEffect(() => {
if (title.length !== name.length) setIsChanged(true);
}, [title]);
return (
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
<div className="size-7" />
<div className="shrink-0 h-9 flex items-center justify-center">
<div className="relative flex gap-2 items-center">
<div
contentEditable
suppressContentEditableWarning={true}
onBlur={(e) => setTitle(e.currentTarget.textContent)}
className="text-sm font-medium focus:outline-none"
>
{name}
</div>
{isChanged ? (
<button
type="button"
onClick={() => saveNewTitle()}
className="text-teal-500 hover:text-teal-600"
>
<CheckIcon className="size-4" />
</button>
) : null}
</div>
</div>
<button
type="button"
onClick={() => close()}
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
>
<CancelIcon className="size-4" />
</button>
</div>
);
} }

View File

@@ -0,0 +1,55 @@
import { ThreadIcon } from "@lume/icons";
import type { Event } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
export function Conversation({
event,
className,
}: {
event: Event;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const thread = ark.parse_event_thread({
content: event.content,
tags: event.tags,
});
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="flex flex-col gap-3">
{thread?.rootEventId ? (
<Note.Child eventId={thread?.rootEventId} isRoot />
) : null}
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<ThreadIcon className="size-4" />
Thread
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
{thread?.replyEventId ? (
<Note.Child eventId={thread?.replyEventId} />
) : null}
<div>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
</div>
<Note.Content className="px-3" />
</div>
</div>
<div className="flex items-center h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -0,0 +1,47 @@
import { QuoteIcon } from "@lume/icons";
import type { Event } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
export function Quote({
event,
className,
}: {
event: Event;
className?: string;
}) {
const quoteEventId = event.tags.find(
(tag) => tag[0] === "q" || tag[3] === "mention",
)?.[1];
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="flex flex-col gap-3">
<Note.Child eventId={quoteEventId} isRoot />
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<QuoteIcon className="size-4" />
Quote
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
<div>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
</div>
<Note.Content className="px-3" quote={false} clean />
</div>
</div>
<div className="flex items-center h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,91 +1,83 @@
import { RepostIcon } from "@lume/icons"; import type { Event } from "@lume/types";
import { Event } from "@lume/types"; import { Note, Spinner, User } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Note, Spinner, User } from "@lume/ui";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";
export function RepostNote({ export function RepostNote({
event, event,
className, className,
}: { }: {
event: Event; event: Event;
className?: string; className?: string;
}) { }) {
const { ark, settings } = useRouteContext({ strict: false }); const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation(); const {
const { isLoading,
isLoading, isError,
isError, data: repostEvent,
data: repostEvent, } = useQuery({
} = useQuery({ queryKey: ["repost", event.id],
queryKey: ["repost", event.id], queryFn: async () => {
queryFn: async () => { try {
try { if (event.content.length > 50) {
if (event.content.length > 50) { const embed: Event = JSON.parse(event.content);
const embed: Event = JSON.parse(event.content); return embed;
return embed; }
}
const id = event.tags.find((el) => el[0] === "e")?.[1];
if (id) return await ark.get_event(id);
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
});
return ( const id = event.tags.find((el) => el[0] === "e")?.[1];
<Note.Root const repostEvent = await ark.get_event(id);
className={cn(
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900", return repostEvent;
className, } catch (e) {
)} throw new Error(e);
> }
<User.Provider pubkey={event.pubkey}> },
<User.Root className="flex gap-3"> refetchOnWindowFocus: false,
<div className="inline-flex w-11 shrink-0 items-center justify-center"> refetchOnMount: false,
<RepostIcon className="h-5 w-5 text-blue-500" /> });
</div>
<div className="inline-flex items-center gap-2"> return (
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" /> <Note.Root
<div className="inline-flex items-baseline gap-1"> className={cn(
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" /> "bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
<span className="text-blue-500">{t("note.reposted")}</span> className,
</div> )}
</div> >
</User.Root> <User.Provider pubkey={event.pubkey}>
</User.Provider> <User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
{isLoading ? ( <div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
<Spinner /> Reposted by
) : isError ? ( </div>
<div className="w-full h-16 flex items-center px-3 border border-neutral-100 dark:border-neutral-900"> <User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
<p>Event not found</p> </User.Root>
</div> </User.Provider>
) : ( {isLoading ? (
<Note.Provider event={repostEvent}> <div className="flex h-20 items-center justify-center gap-2">
<div className="flex flex-col gap-2"> <Spinner />
<Note.User /> Loading event...
<div className="flex gap-3"> </div>
<div className="size-11 shrink-0" /> ) : isError || !repostEvent ? (
<div className="min-w-0 flex-1"> <div className="flex h-20 items-center justify-center">
<Note.Content /> Event not found within your current relay set
<div className="mt-4 flex items-center justify-between"> </div>
<div className="-ml-1 inline-flex items-center gap-4"> ) : (
<Note.Reply /> <Note.Provider event={repostEvent}>
<Note.Repost /> <Note.Root>
<Note.Pin /> <div className="px-3 h-14 flex items-center justify-between">
{settings.zap ? <Note.Zap /> : null} <Note.User />
</div> <Note.Menu />
<Note.Menu /> </div>
</div> <Note.Content className="px-3" />
</div> <div className="mt-3 flex items-center gap-4 h-14 px-3">
</div> <Note.Open />
</div> <Note.Reply />
</Note.Provider> <Note.Repost />
)} <Note.Zap />
</Note.Root> </div>
); </Note.Root>
</Note.Provider>
)}
</Note.Root>
);
} }

View File

@@ -1,42 +1,34 @@
import { Event } from "@lume/types"; import type { Event } from "@lume/types";
import { Note } from "@lume/ui"; import { Note } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
export function TextNote({ export function TextNote({
event, event,
className, className,
}: { }: {
event: Event; event: Event;
className?: string; className?: string;
}) { }) {
const { settings } = useRouteContext({ strict: false }); return (
<Note.Provider event={event}>
return ( <Note.Root
<Note.Provider event={event}> className={cn(
<Note.Root "bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className={cn( className,
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900", )}
className, >
)} <div className="px-3 h-14 flex items-center justify-between">
> <Note.User />
<Note.User /> <Note.Menu />
<div className="flex gap-3"> </div>
<div className="size-11 shrink-0" /> <Note.Content className="px-3" />
<div className="min-w-0 flex-1"> <div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Content className="mb-2" /> <Note.Open />
<Note.Thread /> <Note.Reply />
<div className="mt-4 flex items-center justify-between"> <Note.Repost />
<div className="-ml-1 inline-flex items-center gap-4"> <Note.Zap />
<Note.Reply /> </div>
<Note.Repost /> </Note.Root>
{settings.zap ? <Note.Zap /> : null} </Note.Provider>
</div> );
<Note.Menu />
</div>
</div>
</div>
</Note.Root>
</Note.Provider>
);
} }

View File

@@ -1,15 +1,15 @@
import { ReactNode } from "@tanstack/react-router"; import type { ReactNode } from "@tanstack/react-router";
import { useLayoutEffect, useState } from "react"; import { useLayoutEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
export function Toolbar({ children }: { children: ReactNode }) { export function Toolbar({ children }: { children: ReactNode }) {
const [domReady, setDomReady] = useState(false); const [domReady, setDomReady] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
setDomReady(true); setDomReady(true);
}, []); }, []);
return domReady return domReady
? createPortal(children, document.getElementById("toolbar")) ? createPortal(children, document.getElementById("toolbar"))
: null; : null;
} }

View File

@@ -2,7 +2,6 @@ import { Col } from "@/components/col";
import { Toolbar } from "@/components/toolbar"; import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import type { EventColumns, LumeColumn } from "@lume/types"; import type { EventColumns, LumeColumn } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { resolveResource } from "@tauri-apps/api/path"; import { resolveResource } from "@tauri-apps/api/path";
@@ -14,180 +13,173 @@ import { useDebouncedCallback } from "use-debounce";
import { VList, type VListHandle } from "virtua"; import { VList, type VListHandle } from "virtua";
export const Route = createFileRoute("/$account/home")({ export const Route = createFileRoute("/$account/home")({
component: Screen, beforeLoad: async ({ context }) => {
pendingComponent: Pending, const ark = context.ark;
beforeLoad: async ({ context }) => { const resourcePath = await resolveResource("resources/system_columns.json");
const ark = context.ark; const systemColumns: LumeColumn[] = JSON.parse(
const resourcePath = await resolveResource("resources/system_columns.json"); await readTextFile(resourcePath),
const systemColumns: LumeColumn[] = JSON.parse( );
await readTextFile(resourcePath), const userColumns = await ark.get_columns();
);
const userColumns = await ark.get_columns();
return { return {
storedColumns: !userColumns.length ? systemColumns : userColumns, storedColumns: !userColumns.length ? systemColumns : userColumns,
}; };
}, },
component: Screen,
}); });
function Screen() { function Screen() {
const vlistRef = useRef<VListHandle>(null); const vlistRef = useRef<VListHandle>(null);
const { account } = Route.useParams(); const { account } = Route.useParams();
const { ark, storedColumns } = Route.useRouteContext(); const { ark, storedColumns } = Route.useRouteContext();
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const [columns, setColumns] = useState(storedColumns); const [columns, setColumns] = useState(storedColumns);
const [isScroll, setIsScroll] = useState(false); const [isScroll, setIsScroll] = useState(false);
const [isResize, setIsResize] = useState(false); const [isResize, setIsResize] = useState(false);
const goLeft = () => { const goLeft = () => {
const prevIndex = Math.max(selectedIndex - 1, 0); const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex); setSelectedIndex(prevIndex);
vlistRef.current.scrollToIndex(prevIndex, { vlistRef.current.scrollToIndex(prevIndex, {
align: "center", align: "center",
}); });
}; };
const goRight = () => { const goRight = () => {
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1); const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
setSelectedIndex(nextIndex); setSelectedIndex(nextIndex);
vlistRef.current.scrollToIndex(nextIndex, { vlistRef.current.scrollToIndex(nextIndex, {
align: "center", align: "center",
}); });
}; };
const add = useDebouncedCallback((column: LumeColumn) => { const add = useDebouncedCallback((column: LumeColumn) => {
// update col label // update col label
column.label = `${column.label}-${nanoid()}`; column.label = `${column.label}-${nanoid()}`;
// create new cols // create new cols
const cols = [...columns]; const cols = [...columns];
const openColIndex = cols.findIndex((col) => col.label === "open"); const openColIndex = cols.findIndex((col) => col.label === "open");
const newCols = [ const newCols = [
...cols.slice(0, openColIndex), ...cols.slice(0, openColIndex),
column, column,
...cols.slice(openColIndex), ...cols.slice(openColIndex),
]; ];
setColumns(newCols); setColumns(newCols);
setSelectedIndex(cols.length - 1); setSelectedIndex(newCols.length);
setIsScroll(true);
// scroll to the newest column // scroll to the newest column
vlistRef.current.scrollToIndex(cols.length - 1, { vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "end", align: "end",
}); });
}, 150); }, 150);
const remove = useDebouncedCallback((label: string) => { const remove = useDebouncedCallback((label: string) => {
setColumns((state) => state.filter((t) => t.label !== label)); const newCols = columns.filter((t) => t.label !== label);
setSelectedIndex(columns.length - 1);
// scroll to the first column setColumns(newCols);
vlistRef.current.scrollToIndex(0, { setSelectedIndex(newCols.length);
align: "start", setIsScroll(true);
});
}, 150);
const updateName = useDebouncedCallback((label: string, title: string) => { // scroll to the first column
const currentColIndex = columns.findIndex((col) => col.label === label); vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "start",
});
}, 150);
const updatedCol = Object.assign({}, columns[currentColIndex]); const updateName = useDebouncedCallback((label: string, title: string) => {
updatedCol.name = title; const currentColIndex = columns.findIndex((col) => col.label === label);
const newCols = columns.slice(); const updatedCol = Object.assign({}, columns[currentColIndex]);
newCols[currentColIndex] = updatedCol; updatedCol.name = title;
setColumns(newCols); const newCols = columns.slice();
}, 150); newCols[currentColIndex] = updatedCol;
const startResize = useDebouncedCallback( setColumns(newCols);
() => setIsResize((prev) => !prev), }, 150);
150,
);
useEffect(() => { const startResize = useDebouncedCallback(
// save state () => setIsResize((prev) => !prev),
ark.set_columns(columns); 150,
}, [columns]); );
useEffect(() => { useEffect(() => {
let unlistenColEvent: Awaited<ReturnType<typeof listen>> | undefined = // save state
undefined; ark.set_columns(columns);
let unlistenWindowResize: Awaited<ReturnType<typeof listen>> | undefined = }, [columns]);
undefined;
(async () => { useEffect(() => {
if (unlistenColEvent && unlistenWindowResize) return; let unlistenColEvent: Awaited<ReturnType<typeof listen>> | undefined =
undefined;
let unlistenWindowResize: Awaited<ReturnType<typeof listen>> | undefined =
undefined;
unlistenColEvent = await listen<EventColumns>("columns", (data) => { (async () => {
if (data.payload.type === "add") add(data.payload.column); if (unlistenColEvent && unlistenWindowResize) return;
if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title);
});
unlistenWindowResize = await getCurrent().listen("tauri://resize", () => { unlistenColEvent = await listen<EventColumns>("columns", (data) => {
startResize(); if (data.payload.type === "add") add(data.payload.column);
}); if (data.payload.type === "remove") remove(data.payload.label);
})(); if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title);
});
return () => { unlistenWindowResize = await getCurrent().listen("tauri://resize", () => {
if (unlistenColEvent) unlistenColEvent(); startResize();
if (unlistenWindowResize) unlistenWindowResize(); });
}; })();
}, []);
return ( return () => {
<div className="h-full w-full"> if (unlistenColEvent) unlistenColEvent();
<VList if (unlistenWindowResize) unlistenWindowResize();
ref={vlistRef} };
horizontal }, []);
tabIndex={-1}
itemSize={440} return (
overscan={3} <div className="h-full w-full">
onScroll={() => setIsScroll(true)} <VList
onScrollEnd={() => setIsScroll(false)} ref={vlistRef}
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none" horizontal
> tabIndex={-1}
{columns.map((column) => ( itemSize={440}
<Col overscan={3}
key={column.label} onScroll={() => setIsScroll(true)}
column={column} onScrollEnd={() => setIsScroll(false)}
account={account} className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
isScroll={isScroll} >
isResize={isResize} {columns.map((column) => (
/> <Col
))} key={column.label}
</VList> column={column}
<Toolbar> account={account}
<div className="flex items-center gap-1"> isScroll={isScroll}
<button isResize={isResize}
type="button" />
onClick={() => goLeft()} ))}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800" </VList>
> <Toolbar>
<ArrowLeftIcon className="size-5" /> <div className="flex items-center gap-1">
</button> <button
<button type="button"
type="button" onClick={() => goLeft()}
onClick={() => goRight()} className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800" >
> <ArrowLeftIcon className="size-5" />
<ArrowRightIcon className="size-5" /> </button>
</button> <button
</div> type="button"
</Toolbar> onClick={() => goRight()}
</div> className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
); >
} <ArrowRightIcon className="size-5" />
</button>
function Pending() { </div>
return ( </Toolbar>
<div className="flex h-full w-full items-center justify-center"> </div>
<button type="button" className="size-5" disabled> );
<Spinner className="size-5" />
</button>
</div>
);
} }

View File

@@ -1,62 +1,113 @@
import { ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons"; import { ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; import type { Account } from "@lume/types";
import { User } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Accounts } from "@/components/accounts"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { platform } from "@tauri-apps/plugin-os"; import { useEffect, useState } from "react";
export const Route = createFileRoute("/$account")({ export const Route = createFileRoute("/$account")({
component: App, component: Screen,
beforeLoad: async () => {
const platformName = await platform();
return { platform: platformName };
},
}); });
function App() { function Screen() {
const navigate = useNavigate(); const navigate = Route.useNavigate();
const { ark, platform } = Route.useRouteContext(); const { ark, platform } = Route.useRouteContext();
return ( return (
<div className="flex h-screen w-screen flex-col"> <div className="flex h-screen w-screen flex-col">
<div <div
data-tauri-drag-region data-tauri-drag-region
className={cn( className={cn(
"flex h-11 shrink-0 items-center justify-between pr-2", "flex h-11 shrink-0 items-center justify-between pr-2",
platform === "macos" ? "ml-2 pl-20" : "pl-4", platform === "macos" ? "ml-2 pl-20" : "pl-4",
)} )}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Accounts /> <Accounts />
<button <button
type="button" type="button"
onClick={() => navigate({ to: "/landing" })} onClick={() => navigate({ to: "/landing" })}
className="inline-flex size-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-800 hover:bg-neutral-400 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-600" className="inline-flex size-8 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
> >
<PlusIcon className="size-5" /> <PlusIcon className="size-5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => ark.open_search()} onClick={() => ark.open_search()}
className="inline-flex size-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-800 hover:bg-neutral-400 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-600" className="inline-flex size-8 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
> >
<SearchIcon className="size-5" /> <SearchIcon className="size-4" />
</button> </button>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
onClick={() => ark.open_editor()} onClick={() => ark.open_editor()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600" className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
> >
<ComposeFilledIcon className="size-4" /> <ComposeFilledIcon className="size-4" />
New Post New Post
</button> </button>
<div id="toolbar" /> <div id="toolbar" />
</div> </div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
); );
}
export function Accounts() {
const navigate = Route.useNavigate();
const { ark } = Route.useRouteContext();
const { account } = Route.useParams();
const [accounts, setAccounts] = useState<Account[]>([]);
const changeAccount = async (npub: string) => {
if (npub === account) return;
const select = await ark.load_selected_account(npub);
if (select)
return navigate({ to: "/$account/home", params: { account: npub } });
};
useEffect(() => {
async function getAllAccounts() {
const data = await ark.get_all_accounts();
if (data) setAccounts(data);
}
getAllAccounts();
}, []);
return (
<div data-tauri-drag-region className="flex items-center gap-3">
{accounts.map((user) => (
<button
key={user.npub}
type="button"
onClick={() => changeAccount(user.npub)}
>
<User.Provider pubkey={user.npub}>
<User.Root
className={cn(
"rounded-full",
user.npub === account
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
: "",
)}
>
<User.Avatar
className={cn(
"aspect-square h-auto rounded-full object-cover",
user.npub === account ? "w-7" : "w-8",
)}
/>
</User.Root>
</User.Provider>
</button>
))}
</div>
);
} }

View File

@@ -1,48 +1,44 @@
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; import type { Ark } from "@lume/ark";
import { type Ark } from "@lume/ark"; import type { Account, Interests, Metadata, Settings } from "@lume/types";
import { type QueryClient } from "@tanstack/react-query";
import { type Platform } from "@tauri-apps/plugin-os";
import type {
Account,
Contact,
Interests,
Metadata,
Settings,
} from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { type Descendant } from "slate"; import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { Platform } from "@tauri-apps/plugin-os";
import type { Descendant } from "slate";
type EditorElement = { type EditorElement = {
type: string; type: string;
children: Descendant[]; children: Descendant[];
eventId?: string; eventId?: string;
}; };
interface RouterContext { interface RouterContext {
ark: Ark; // System
queryClient: QueryClient; ark: Ark;
platform?: Platform; queryClient: QueryClient;
locale?: string; // App info
settings?: Settings; platform?: Platform;
interests?: Interests; locale?: string;
accounts?: Account[]; // Settings
initialValue?: EditorElement[]; settings?: Settings;
profile?: Metadata; interests?: Interests;
contacts?: Contact[]; // Profile
accounts?: Account[];
profile?: Metadata;
// Editor
initialValue?: EditorElement[];
} }
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />, component: () => <Outlet />,
pendingComponent: Pending, pendingComponent: Pending,
wrapInSuspense: true, wrapInSuspense: true,
}); });
function Pending() { function Pending() {
return ( return (
<div className="flex h-screen w-screen flex-col items-center justify-center"> <div className="flex h-screen w-screen flex-col items-center justify-center">
<button type="button" className="size-5" disabled> <Spinner className="size-5" />
<Spinner className="size-5" /> </div>
</button> );
</div>
);
} }

View File

@@ -1,119 +0,0 @@
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
import { Column, Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/antenas")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
export function Screen() {
const { label, name, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: [name, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(20, pageParam);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<Column.Root>
<Column.Header label={label} name={name} />
<Column.Content>
{isLoading ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<Spinner className="size-5" />
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
<div className="flex h-20 items-center justify-center">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
) : null}
</div>
</Column.Content>
</Column.Root>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -2,15 +2,15 @@ import { Box, Container } from "@lume/ui";
import { Outlet, createLazyFileRoute } from "@tanstack/react-router"; import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/auth")({ export const Route = createLazyFileRoute("/auth")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
return ( return (
<Container withDrag> <Container withDrag>
<Box className="px-3 pt-3"> <Box className="px-3 pt-3">
<Outlet /> <Outlet />
</Box> </Box>
</Container> </Container>
); );
} }

View File

@@ -1,191 +1,191 @@
import { CheckIcon } from "@lume/icons";
import type { AppRouteSearch } from "@lume/types";
import { displayNsec } from "@lume/utils"; import { displayNsec } from "@lume/utils";
import * as Checkbox from "@radix-ui/react-checkbox";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import * as Checkbox from "@radix-ui/react-checkbox";
import { CheckIcon } from "@lume/icons";
import { AppRouteSearch } from "@lume/types";
export const Route = createFileRoute("/auth/new/backup")({ export const Route = createFileRoute("/auth/new/backup")({
validateSearch: (search: Record<string, string>): AppRouteSearch => { validateSearch: (search: Record<string, string>): AppRouteSearch => {
return { return {
account: search.account, account: search.account,
}; };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { account } = Route.useSearch(); const { account } = Route.useSearch();
const { t } = useTranslation(); const { t } = useTranslation();
const [key, setKey] = useState(null); const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState(""); const [passphase, setPassphase] = useState("");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false }); const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
const navigate = useNavigate(); const navigate = useNavigate();
const submit = async () => { const submit = async () => {
try { try {
if (key) { if (key) {
if (!confirm.c1 || !confirm.c2 || !confirm.c3) { if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
return toast.warning("You need to confirm before continue"); return toast.warning("You need to confirm before continue");
} else { }
return navigate({
to: "/auth/settings",
search: { account },
});
}
}
const encrypted: string = await invoke("get_encrypted_key", { return navigate({
npub: account, to: "/auth/settings",
password: passphase, search: { account },
}); });
}
setKey(encrypted); const encrypted: string = await invoke("get_encrypted_key", {
} catch (e) { npub: account,
toast.error(String(e)); password: passphase,
} });
};
const copyKey = async () => { setKey(encrypted);
try { } catch (e) {
await writeText(key); toast.error(String(e));
setCopied(true); }
} catch (e) { };
toast.error(e);
}
};
return ( const copyKey = async () => {
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl"> try {
<div className="flex flex-col text-center"> await writeText(key);
<h3 className="text-xl font-semibold">Backup your sign in keys</h3> setCopied(true);
<p className="text-neutral-700 dark:text-neutral-300"> } catch (e) {
It's use for login to Lume or other Nostr clients. You will lost toast.error(e);
access to your account if you lose this key. }
</p> };
</div>
<div className="flex w-full flex-col gap-5"> return (
<div className="flex flex-col gap-2"> <div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<label htmlFor="passphase" className="font-medium"> <div className="flex flex-col text-center">
Set a passphase to secure your key <h3 className="text-xl font-semibold">Backup your sign in keys</h3>
</label> <p className="text-neutral-700 dark:text-neutral-300">
<div className="relative"> It's use for login to Lume or other Nostr clients. You will lost
<input access to your account if you lose this key.
name="passphase" </p>
type="password" </div>
value={passphase} <div className="flex w-full flex-col gap-5">
onChange={(e) => setPassphase(e.target.value)} <div className="flex flex-col gap-2">
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900" <label htmlFor="passphase" className="font-medium">
/> Set a passphase to secure your key
</div> </label>
</div> <div className="relative">
{key ? ( <input
<> name="passphase"
<div className="flex flex-col gap-2"> type="password"
<label htmlFor="nsec" className="font-medium"> value={passphase}
Copy this key and keep it in safe place onChange={(e) => setPassphase(e.target.value)}
</label> className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
<div className="flex items-center gap-2"> />
<input </div>
name="nsec" </div>
type="text" {key ? (
value={displayNsec(key, 36)} <>
readOnly <div className="flex flex-col gap-2">
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900" <label htmlFor="nsec" className="font-medium">
/> Copy this key and keep it in safe place
<button </label>
type="button" <div className="flex items-center gap-2">
onClick={copyKey} <input
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700" name="nsec"
> type="text"
{copied ? "Copied" : "Copy"} value={displayNsec(key, 36)}
</button> readOnly
</div> className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
</div> />
<div className="flex flex-col gap-2"> <button
<div className="font-medium">Before you continue:</div> type="button"
<div className="flex flex-col gap-2"> onClick={() => copyKey()}
<div className="flex items-center gap-2"> className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
<Checkbox.Root >
checked={confirm.c1} {copied ? "Copied" : "Copy"}
onCheckedChange={() => </button>
setConfirm((state) => ({ ...state, c1: !state.c1 })) </div>
} </div>
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900" <div className="flex flex-col gap-2">
id="confirm1" <div className="font-medium">Before you continue:</div>
> <div className="flex flex-col gap-2">
<Checkbox.Indicator className="text-blue-500"> <div className="flex items-center gap-2">
<CheckIcon className="size-4" /> <Checkbox.Root
</Checkbox.Indicator> checked={confirm.c1}
</Checkbox.Root> onCheckedChange={() =>
<label setConfirm((state) => ({ ...state, c1: !state.c1 }))
className="text-sm leading-none text-neutral-800 dark:text-neutral-200" }
htmlFor="confirm1" className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
> id="confirm1"
{t("backup.confirm1")} >
</label> <Checkbox.Indicator className="text-blue-500">
</div> <CheckIcon className="size-4" />
<div className="flex items-center gap-2"> </Checkbox.Indicator>
<Checkbox.Root </Checkbox.Root>
checked={confirm.c2} <label
onCheckedChange={() => className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
setConfirm((state) => ({ ...state, c2: !state.c2 })) htmlFor="confirm1"
} >
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900" {t("backup.confirm1")}
id="confirm2" </label>
> </div>
<Checkbox.Indicator className="text-blue-500"> <div className="flex items-center gap-2">
<CheckIcon className="size-4" /> <Checkbox.Root
</Checkbox.Indicator> checked={confirm.c2}
</Checkbox.Root> onCheckedChange={() =>
<label setConfirm((state) => ({ ...state, c2: !state.c2 }))
className="text-sm leading-none text-neutral-800 dark:text-neutral-200" }
htmlFor="confirm2" className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
> id="confirm2"
{t("backup.confirm2")} >
</label> <Checkbox.Indicator className="text-blue-500">
</div> <CheckIcon className="size-4" />
<div className="flex items-center gap-2"> </Checkbox.Indicator>
<Checkbox.Root </Checkbox.Root>
checked={confirm.c3} <label
onCheckedChange={() => className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
setConfirm((state) => ({ ...state, c3: !state.c3 })) htmlFor="confirm2"
} >
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900" {t("backup.confirm2")}
id="confirm3" </label>
> </div>
<Checkbox.Indicator className="text-blue-500"> <div className="flex items-center gap-2">
<CheckIcon className="size-4" /> <Checkbox.Root
</Checkbox.Indicator> checked={confirm.c3}
</Checkbox.Root> onCheckedChange={() =>
<label setConfirm((state) => ({ ...state, c3: !state.c3 }))
className="text-sm leading-none text-neutral-800 dark:text-neutral-200" }
htmlFor="confirm3" className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
> id="confirm3"
{t("backup.confirm3")} >
</label> <Checkbox.Indicator className="text-blue-500">
</div> <CheckIcon className="size-4" />
</div> </Checkbox.Indicator>
</div> </Checkbox.Root>
</> <label
) : null} className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
<div> htmlFor="confirm3"
<button >
type="button" {t("backup.confirm3")}
onClick={submit} </label>
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50" </div>
> </div>
{t("global.continue")} </div>
</button> </>
</div> ) : null}
</div> <div>
</div> <button
); type="button"
onClick={() => submit()}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</button>
</div>
</div>
</div>
);
} }

View File

@@ -1,6 +1,6 @@
import { AvatarUploader } from "@/components/avatarUploader"; import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons"; import { PlusIcon } from "@lume/icons";
import { Metadata } from "@lume/types"; import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
@@ -9,135 +9,135 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/profile")({ export const Route = createFileRoute("/auth/new/profile")({
component: Screen, component: Screen,
loader: ({ context }) => { loader: ({ context }) => {
return context.ark.create_keys(); return context.ark.create_keys();
}, },
}); });
function Screen() { function Screen() {
const keys = Route.useLoaderData(); const keys = Route.useLoaderData();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const { register, handleSubmit } = useForm(); const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>(""); const [picture, setPicture] = useState<string>("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const onSubmit = async (data: { const onSubmit = async (data: {
name: string; name: string;
about: string; about: string;
website: string; website: string;
}) => { }) => {
setLoading(true); setLoading(true);
try { try {
// Save account keys // Save account keys
const save = await ark.save_account(keys.nsec); const save = await ark.save_account(keys.nsec);
// Then create profile // Then create profile
if (save) { if (save) {
const profile: Metadata = { ...data, picture }; const profile: Metadata = { ...data, picture };
const eventId = await ark.create_profile(profile); const eventId = await ark.create_profile(profile);
if (eventId) { if (eventId) {
navigate({ navigate({
to: "/auth/new/backup", to: "/auth/new/backup",
search: { account: keys.npub }, search: { account: keys.npub },
replace: true, replace: true,
}); });
} }
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); toast.error(String(e));
} }
}; };
return ( return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl"> <div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="text-center"> <div className="text-center">
<h3 className="text-xl font-semibold">Let's set up your profile.</h3> <h3 className="text-xl font-semibold">Let's set up your profile.</h3>
</div> </div>
<div> <div>
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200"> <div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{picture ? ( {picture ? (
<img <img
src={picture} src={picture}
alt="avatar" alt="avatar"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover" className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
/> />
) : null} ) : null}
<AvatarUploader <AvatarUploader
setPicture={setPicture} setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full dark:text-black bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
> >
<PlusIcon className="size-8" /> <PlusIcon className="size-8" />
</AvatarUploader> </AvatarUploader>
</div> </div>
</div> </div>
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex w-full flex-col gap-3" className="flex w-full flex-col gap-3"
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium"> <label htmlFor="display_name" className="font-medium">
{t("user.displayName")} * {t("user.displayName")} *
</label> </label>
<input <input
type={"text"} type={"text"}
{...register("display_name", { required: true, minLength: 1 })} {...register("display_name", { required: true, minLength: 1 })}
placeholder="e.g. Alice in Nostrland" placeholder="e.g. Alice in Nostrland"
spellCheck={false} spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium"> <label htmlFor="name" className="font-medium">
{t("user.name")} {t("user.name")}
</label> </label>
<input <input
type={"text"} type={"text"}
{...register("name")} {...register("name")}
placeholder="e.g. alice" placeholder="e.g. alice"
spellCheck={false} spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium"> <label htmlFor="about" className="font-medium">
{t("user.bio")} {t("user.bio")}
</label> </label>
<textarea <textarea
{...register("about")} {...register("about")}
placeholder="e.g. Artist, anime-lover, and k-pop fan" placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false} spellCheck={false}
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium"> <label htmlFor="website" className="font-medium">
{t("user.website")} {t("user.website")}
</label> </label>
<input <input
type="url" type="url"
{...register("website")} {...register("website")}
placeholder="e.g. https://alice.me" placeholder="e.g. https://alice.me"
spellCheck={false} spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<button <button
type="submit" type="submit"
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50" className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner /> : t("global.continue")} {loading ? <Spinner /> : t("global.continue")}
</button> </button>
</form> </form>
</div> </div>
); );
} }

View File

@@ -4,87 +4,87 @@ import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/privkey")({ export const Route = createLazyFileRoute("/auth/privkey")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!key.startsWith("nsec1")) if (!key.startsWith("nsec1"))
return toast.warning( return toast.warning(
"You need to enter a valid private key starts with nsec or ncryptsec", "You need to enter a valid private key starts with nsec or ncryptsec",
); );
try { try {
setLoading(true); setLoading(true);
const npub = await ark.save_account(key, password); const npub = await ark.save_account(key, password);
if (npub) { if (npub) {
navigate({ navigate({
to: "/auth/settings", to: "/auth/settings",
search: { account: npub }, search: { account: npub },
replace: true, replace: true,
}); });
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(e); toast.error(e);
} }
}; };
return ( return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl"> <div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="text-center"> <div className="text-center">
<h3 className="text-xl font-semibold">Continue with Private Key</h3> <h3 className="text-xl font-semibold">Continue with Private Key</h3>
</div> </div>
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label
htmlFor="key" htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100" className="font-medium text-neutral-900 dark:text-neutral-100"
> >
Private Key Private Key
</label> </label>
<input <input
name="key" name="key"
type="text" type="text"
placeholder="nsec or ncryptsec..." placeholder="nsec or ncryptsec..."
value={key} value={key}
onChange={(e) => setKey(e.target.value)} onChange={(e) => setKey(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label
htmlFor="password" htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100" className="font-medium text-neutral-900 dark:text-neutral-100"
> >
Password (Optional) Password (Optional)
</label> </label>
<input <input
name="password" name="password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={submit} onClick={() => submit()}
disabled={loading} disabled={loading}
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50" className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner /> : "Login"} {loading ? <Spinner /> : "Login"}
</button> </button>
</div> </div>
</div> </div>
); );
} }

View File

@@ -4,71 +4,71 @@ import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/remote")({ export const Route = createLazyFileRoute("/auth/remote")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const [uri, setUri] = useState(""); const [uri, setUri] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!uri.startsWith("bunker://")) if (!uri.startsWith("bunker://"))
return toast.warning( return toast.warning(
"You need to enter a valid Connect URI starts with bunker://", "You need to enter a valid Connect URI starts with bunker://",
); );
try { try {
setLoading(true); setLoading(true);
const npub = await ark.nostr_connect(uri); const npub = await ark.nostr_connect(uri);
if (npub) { if (npub) {
navigate({ navigate({
to: "/auth/settings", to: "/auth/settings",
search: { account: npub }, search: { account: npub },
replace: true, replace: true,
}); });
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(e); toast.error(e);
} }
}; };
return ( return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl"> <div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="text-center"> <div className="text-center">
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3> <h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
</div> </div>
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label
htmlFor="uri" htmlFor="uri"
className="font-medium text-neutral-900 dark:text-neutral-100" className="font-medium text-neutral-900 dark:text-neutral-100"
> >
Connect URI Connect URI
</label> </label>
<input <input
name="uri" name="uri"
type="text" type="text"
placeholder="bunker://..." placeholder="bunker://..."
value={uri} value={uri}
onChange={(e) => setUri(e.target.value)} onChange={(e) => setUri(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={submit} onClick={() => submit()}
disabled={loading} disabled={loading}
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50" className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner /> : "Login"} {loading ? <Spinner /> : "Login"}
</button> </button>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,215 +1,215 @@
import { LaurelIcon } from "@lume/icons"; import { LaurelIcon } from "@lume/icons";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import type { AppRouteSearch, Settings } from "@lume/types";
import { useTranslation } from "react-i18next";
import * as Switch from "@radix-ui/react-switch";
import { useState } from "react";
import { AppRouteSearch, Settings } from "@lume/types";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { toast } from "sonner";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/settings")({ export const Route = createFileRoute("/auth/settings")({
validateSearch: (search: Record<string, string>): AppRouteSearch => { validateSearch: (search: Record<string, string>): AppRouteSearch => {
return { return {
account: search.account, account: search.account,
}; };
}, },
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
const permissionGranted = await isPermissionGranted(); // get notification permission const permissionGranted = await isPermissionGranted(); // get notification permission
const ark = context.ark; const ark = context.ark;
const settings = await ark.get_settings(); const settings = await ark.get_settings();
return { return {
settings: { ...settings, notification: permissionGranted }, settings: { ...settings, notification: permissionGranted },
}; };
}, },
component: Screen, component: Screen,
pendingComponent: Pending, pendingComponent: Pending,
}); });
function Screen() { function Screen() {
const navigate = useNavigate(); const navigate = useNavigate();
const { account } = Route.useSearch(); const { account } = Route.useSearch();
const { t } = useTranslation(); const { t } = useTranslation();
const { ark, settings } = Route.useRouteContext(); const { ark, settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings); const [newSettings, setNewSettings] = useState<Settings>(settings);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const toggleNofitication = async () => { const toggleNofitication = async () => {
await requestPermission(); await requestPermission();
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
notification: !newSettings.notification, notification: !newSettings.notification,
})); }));
}; };
const toggleAutoUpdate = () => { const toggleAutoUpdate = () => {
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
autoUpdate: !newSettings.autoUpdate, autoUpdate: !newSettings.autoUpdate,
})); }));
}; };
const toggleEnhancedPrivacy = () => { const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
enhancedPrivacy: !newSettings.enhancedPrivacy, enhancedPrivacy: !newSettings.enhancedPrivacy,
})); }));
}; };
const toggleZap = () => { const toggleZap = () => {
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
zap: !newSettings.zap, zap: !newSettings.zap,
})); }));
}; };
const toggleNsfw = () => { const toggleNsfw = () => {
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
nsfw: !newSettings.nsfw, nsfw: !newSettings.nsfw,
})); }));
}; };
const submit = async () => { const submit = async () => {
try { try {
// start loading // start loading
setLoading(true); setLoading(true);
// publish settings // publish settings
const eventId = await ark.set_settings(newSettings); const eventId = await ark.set_settings(newSettings);
if (eventId) { if (eventId) {
console.log("event_id: ", eventId); console.log("event_id: ", eventId);
navigate({ to: "/$account/home", params: { account }, replace: true }); navigate({ to: "/$account/home", params: { account }, replace: true });
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(e); toast.error(e);
} }
}; };
return ( return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl"> <div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center gap-5 text-center"> <div className="flex flex-col items-center gap-5 text-center">
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 text-teal-500"> <div className="flex size-20 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-950 text-teal-500">
<LaurelIcon className="size-8" /> <LaurelIcon className="size-8" />
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{t("onboardingSettings.title")} {t("onboardingSettings.title")}
</h1> </h1>
<p className="leading-snug text-neutral-600 dark:text-neutral-400"> <p className="leading-snug text-neutral-600 dark:text-neutral-400">
{t("onboardingSettings.subtitle")} {t("onboardingSettings.subtitle")}
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<Switch.Root <div className="flex-1">
checked={newSettings.notification} <h3 className="font-semibold">Push Notification</h3>
onClick={() => toggleNofitication()} <p className="text-sm text-neutral-700 dark:text-neutral-300">
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" Enabling push notifications will allow you to receive
> notifications from Lume.
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> </p>
</Switch.Root> </div>
<div className="flex-1"> <Switch.Root
<h3 className="font-semibold">Push Notification</h3> checked={newSettings.notification}
<p className="text-sm text-neutral-700 dark:text-neutral-300"> onClick={() => toggleNofitication()}
Enabling push notifications will allow you to receive className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
notifications from Lume. >
</p> <Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</div> </Switch.Root>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<Switch.Root <div className="flex-1">
checked={newSettings.enhancedPrivacy} <h3 className="font-semibold">Enhanced Privacy</h3>
onClick={() => toggleEnhancedPrivacy()} <p className="text-sm text-neutral-700 dark:text-neutral-300">
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" Lume will display external resources like image, video or link
> preview as plain text.
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> </p>
</Switch.Root> </div>
<div className="flex-1"> <Switch.Root
<h3 className="font-semibold">Enhanced Privacy</h3> checked={newSettings.enhancedPrivacy}
<p className="text-sm text-neutral-700 dark:text-neutral-300"> onClick={() => toggleEnhancedPrivacy()}
Lume will display external resources like image, video or link className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
preview as plain text. >
</p> <Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</div> </Switch.Root>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<Switch.Root <div className="flex-1">
checked={newSettings.autoUpdate} <h3 className="font-semibold">Auto Update</h3>
onClick={() => toggleAutoUpdate()} <p className="text-sm text-neutral-700 dark:text-neutral-300">
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" Automatically download and install new version.
> </p>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> </div>
</Switch.Root> <Switch.Root
<div className="flex-1"> checked={newSettings.autoUpdate}
<h3 className="font-semibold">Auto Update</h3> onClick={() => toggleAutoUpdate()}
<p className="text-sm text-neutral-700 dark:text-neutral-300"> className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
Automatically download and install new version. >
</p> <Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</div> </Switch.Root>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<Switch.Root <div className="flex-1">
checked={newSettings.zap} <h3 className="font-semibold">Zap</h3>
onClick={() => toggleZap()} <p className="text-sm text-neutral-700 dark:text-neutral-300">
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" Show the Zap button in each note and user's profile screen, use
> for send Bitcoin tip to other users.
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> </p>
</Switch.Root> </div>
<div className="flex-1"> <Switch.Root
<h3 className="font-semibold">Zap</h3> checked={newSettings.zap}
<p className="text-sm text-neutral-700 dark:text-neutral-300"> onClick={() => toggleZap()}
Show the Zap button in each note and user's profile screen, use className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
for send Bitcoin tip to other users. >
</p> <Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</div> </Switch.Root>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<Switch.Root <div className="flex-1">
checked={newSettings.nsfw} <h3 className="font-semibold">Filter sensitive content</h3>
onClick={() => toggleNsfw()} <p className="text-sm text-neutral-700 dark:text-neutral-300">
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" By default, Lume will display all content which have Content
> Warning tag, it's may include NSFW content.
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> </p>
</Switch.Root> </div>
<div className="flex-1"> <Switch.Root
<h3 className="font-semibold">Filter sensitive content</h3> checked={newSettings.nsfw}
<p className="text-sm text-neutral-700 dark:text-neutral-300"> onClick={() => toggleNsfw()}
By default, Lume will display all content which have Content className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
Warning tag, it's may include NSFW content. >
</p> <Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</div> </Switch.Root>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={submit} onClick={() => submit()}
disabled={loading} disabled={loading}
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50" className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
> >
{t("global.continue")} {t("global.continue")}
</button> </button>
</div> </div>
</div> </div>
); );
} }
function Pending() { function Pending() {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<button type="button" className="size-5" disabled> <button type="button" className="size-5" disabled>
<Spinner className="size-5" /> <Spinner className="size-5" />
</button> </button>
</div> </div>
); );
} }

View File

@@ -1,127 +1,126 @@
import { CheckCircleIcon } from "@lume/icons"; import { CheckCircleIcon } from "@lume/icons";
import { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Column, Spinner, User } from "@lume/ui"; import { Spinner, User } from "@lume/ui";
import { createFileRoute, useRouter } from "@tanstack/react-router"; import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createFileRoute("/create-group")({ export const Route = createFileRoute("/create-group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
account: search.account, account: search.account,
label: search.label, label: search.label,
name: search.name, name: search.name,
}; };
}, },
loader: async ({ context }) => { loader: async ({ context }) => {
const ark = context.ark; const ark = context.ark;
const contacts = await ark.get_contact_list(); const contacts = await ark.get_contact_list();
return contacts; return contacts;
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const contacts = Route.useLoaderData(); const contacts = Route.useLoaderData();
const router = useRouter(); const router = useRouter();
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const { label, name, redirect } = Route.useSearch(); const { label, redirect } = Route.useSearch();
const [title, setTitle] = useState<string>("Just a new group"); const [title, setTitle] = useState<string>("Just a new group");
const [users, setUsers] = useState<Array<string>>([]); const [users, setUsers] = useState<Array<string>>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isDone, setIsDone] = useState(false); const [isDone, setIsDone] = useState(false);
const toggleUser = (pubkey: string) => { const toggleUser = (pubkey: string) => {
const arr = users.includes(pubkey) const arr = users.includes(pubkey)
? users.filter((i) => i !== pubkey) ? users.filter((i) => i !== pubkey)
: [...users, pubkey]; : [...users, pubkey];
setUsers(arr); setUsers(arr);
}; };
const submit = async () => { const submit = async () => {
try { try {
if (isDone) return router.history.push(redirect); if (isDone) return router.history.push(redirect);
// start loading // start loading
setLoading(true); setLoading(true);
const groups = await ark.set_nstore( const groups = await ark.set_nstore(
`lume_group_${label}`, `lume_group_${label}`,
JSON.stringify(users), JSON.stringify(users),
); );
if (groups) { if (groups) {
toast.success("Group has been created successfully."); toast.success("Group has been created successfully.");
// start loading // start loading
setIsDone(true); setIsDone(true);
setLoading(false); setLoading(false);
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(e); toast.error(e);
} }
}; };
return ( return (
<Column.Root> <div className="h-full overflow-y-auto scrollbar-none">
<Column.Header label={label} name={name} /> <div className="flex flex-col gap-5 p-3">
<Column.Content> <div className="flex flex-col gap-1">
<div className="flex flex-col gap-5 p-3"> <label htmlFor="name" className="font-medium">
<div className="flex flex-col gap-1"> Name
<label htmlFor="name" className="font-medium"> </label>
Name <input
</label> name="name"
<input value={title}
name="name" onChange={(e) => setTitle(e.target.value)}
value={title} placeholder="Nostrichs..."
onChange={(e) => setTitle(e.target.value)} className="h-10 rounded-lg bg-transparent border border-neutral-300 dark:border-neutral-700 px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
placeholder="Nostrichs..." />
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" </div>
/> <div className="flex flex-col gap-1">
</div> <div className="inline-flex items-center justify-between">
<div className="flex flex-col gap-1"> <span className="font-medium">Pick user</span>
<div className="inline-flex items-center justify-between"> <span className="text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
<span className="font-medium">Pick user</span> </div>
<span className="text-xs text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span> <div className="flex flex-col gap-2">
</div> {contacts.map((item: string) => (
<div className="flex flex-col gap-2"> <button
{contacts.map((item: string) => ( key={item}
<button type="button"
key={item} onClick={() => toggleUser(item)}
type="button" className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
onClick={() => toggleUser(item)} >
className="inline-flex items-center justify-between px-3 py-2 rounded-xl bg-neutral-50 dark:bg-neutral-950 hover:bg-neutral-100 dark:hover:bg-neutral-900" <User.Provider pubkey={item}>
> <User.Root className="flex items-center gap-2.5">
<User.Provider pubkey={item}> <User.Avatar className="size-10 rounded-full object-cover" />
<User.Root className="flex items-center gap-2.5"> <div className="flex items-center gap-1">
<User.Avatar className="size-10 rounded-full object-cover" /> <User.Name className="font-medium" />
<div className="flex flex-col items-start"> <User.NIP05 />
<User.Name className="font-medium" /> </div>
<User.NIP05 className="text-neutral-700 dark:text-neutral-300" /> </User.Root>
</div> </User.Provider>
</User.Root> {users.includes(item) ? (
</User.Provider> <CheckCircleIcon className="size-5 text-teal-500" />
{users.includes(item) ? ( ) : null}
<CheckCircleIcon className="size-5 text-teal-500" /> </button>
) : null} ))}
</button> </div>
))} </div>
</div> </div>
</div> <div className="fixed z-10 flex items-center justify-center w-full bottom-6">
</div> {users.length >= 1 ? (
<div className="fixed z-10 flex items-center justify-center w-full bottom-6"> <button
<button type="button"
type="button" onClick={() => submit()}
onClick={submit} disabled={users.length < 1}
disabled={users.length < 1} className="inline-flex items-center justify-center px-4 font-medium text-white transform bg-blue-500 rounded-full active:translate-y-1 w-32 h-10 hover:bg-blue-600 focus:outline-none"
className="inline-flex items-center justify-center px-4 font-medium text-white transform bg-blue-500 rounded-full active:translate-y-1 w-26 h-9 hover:bg-blue-600 focus:outline-none disabled:cursor-not-allowed" >
> {isDone ? "Back" : loading ? <Spinner /> : "Update"}
{isDone ? "Back" : loading ? <Spinner /> : "Update"} </button>
</button> ) : null}
</div> </div>
</Column.Content> </div>
</Column.Root> );
);
} }

View File

@@ -1,91 +1,91 @@
import { AddMediaIcon } from "@lume/icons"; import { AddMediaIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { cn, insertImage, isImagePath } from "@lume/utils"; import { cn, insertImage, isImagePath } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSlateStatic } from "slate-react"; import { useSlateStatic } from "slate-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getCurrent } from "@tauri-apps/api/window";
import { UnlistenFn } from "@tauri-apps/api/event";
import { useRouteContext } from "@tanstack/react-router";
import { Spinner } from "@lume/ui";
import * as Tooltip from "@radix-ui/react-tooltip";
export function MediaButton({ className }: { className?: string }) { export function MediaButton({ className }: { className?: string }) {
const { ark } = useRouteContext({ strict: false }); const { ark } = useRouteContext({ strict: false });
const editor = useSlateStatic(); const editor = useSlateStatic();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => { const uploadToNostrBuild = async () => {
try { try {
// start loading // start loading
setLoading(true); setLoading(true);
const image = await ark.upload(); const image = await ark.upload();
insertImage(editor, image); insertImage(editor, image);
// reset loading // reset loading
setLoading(false); setLoading(false);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(`Upload failed, error: ${e}`); toast.error(`Upload failed, error: ${e}`);
} }
}; };
useEffect(() => { useEffect(() => {
let unlisten: UnlistenFn = undefined; let unlisten: UnlistenFn = undefined;
async function listenFileDrop() { async function listenFileDrop() {
const window = getCurrent(); const window = getCurrent();
if (!unlisten) { if (!unlisten) {
unlisten = await window.listen("tauri://file-drop", async (event) => { unlisten = await window.listen("tauri://file-drop", async (event) => {
// @ts-ignore, lfg !!! // @ts-ignore, lfg !!!
const items: string[] = event.payload.paths; const items: string[] = event.payload.paths;
// start loading // start loading
setLoading(true); setLoading(true);
// upload all images // upload all images
for (const item of items) { for (const item of items) {
if (isImagePath(item)) { if (isImagePath(item)) {
const image = await ark.upload(item); const image = await ark.upload(item);
insertImage(editor, image); insertImage(editor, image);
} }
} }
// stop loading // stop loading
setLoading(false); setLoading(false);
}); });
} }
} }
listenFileDrop(); listenFileDrop();
return () => { return () => {
if (unlisten) unlisten(); if (unlisten) unlisten();
}; };
}, []); }, []);
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root delayDuration={150}> <Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => uploadToNostrBuild()} onClick={() => uploadToNostrBuild()}
disabled={loading} disabled={loading}
className={cn("inline-flex items-center justify-center", className)} className={cn("inline-flex items-center justify-center", className)}
> >
{loading ? ( {loading ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
) : ( ) : (
<AddMediaIcon className="size-4" /> <AddMediaIcon className="size-4" />
)} )}
</button> </button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950"> <Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Upload media Upload media
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
); );
} }

View File

@@ -1,40 +1,40 @@
import { NsfwIcon } from "@lume/icons"; import { NsfwIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
export function NsfwToggle({ export function NsfwToggle({
nsfw, nsfw,
setNsfw, setNsfw,
className, className,
}: { }: {
nsfw: boolean; nsfw: boolean;
setNsfw: Dispatch<SetStateAction<boolean>>; setNsfw: Dispatch<SetStateAction<boolean>>;
className?: string; className?: string;
}) { }) {
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root delayDuration={150}> <Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => setNsfw((prev) => !prev)} onClick={() => setNsfw((prev) => !prev)}
className={cn( className={cn(
"inline-flex items-center justify-center", "inline-flex items-center justify-center",
className, className,
nsfw ? "bg-blue-500 text-white" : "", nsfw ? "bg-blue-500 text-white" : "",
)} )}
> >
<NsfwIcon className="size-4" /> <NsfwIcon className="size-4" />
</button> </button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950"> <Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mark as sensitive content Mark as sensitive content
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
); );
} }

View File

@@ -1,441 +1,437 @@
import { ComposeFilledIcon, TrashIcon } from "@lume/icons"; import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
import { import { Spinner, User } from "@lume/ui";
Portal,
cn,
insertImage,
insertMention,
insertNostrEvent,
isImageUrl,
sendNativeNotification,
} from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { MediaButton } from "./-components/media";
import { MentionNote } from "@lume/ui/src/note/mentions/note"; import { MentionNote } from "@lume/ui/src/note/mentions/note";
import { import {
Descendant, Portal,
Editor, cn,
Node, insertImage,
Range, insertMention,
Transforms, insertNostrEvent,
createEditor, isImageUrl,
sendNativeNotification,
} from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
type Descendant,
Editor,
Node,
Range,
Transforms,
createEditor,
} from "slate"; } from "slate";
import { import {
ReactEditor, Editable,
useSlateStatic, ReactEditor,
useSelected, Slate,
useFocused, useFocused,
withReact, useSelected,
Slate, useSlateStatic,
Editable, withReact,
} from "slate-react"; } from "slate-react";
import { Contact } from "@lume/types"; import { MediaButton } from "./-components/media";
import { Spinner, User } from "@lume/ui";
import { nip19 } from "nostr-tools";
import { invoke } from "@tauri-apps/api/core";
import { NsfwToggle } from "./-components/nsfw"; import { NsfwToggle } from "./-components/nsfw";
type EditorSearch = { type EditorSearch = {
reply_to: string; reply_to: string;
quote: boolean; quote: boolean;
}; };
export const Route = createFileRoute("/editor/")({ export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => { validateSearch: (search: Record<string, string>): EditorSearch => {
return { return {
reply_to: search.reply_to, reply_to: search.reply_to,
quote: search.quote === "true" ?? false, quote: search.quote === "true" ?? false,
}; };
}, },
beforeLoad: async ({ search }) => { beforeLoad: async ({ search }) => {
const contacts: Contact[] = await invoke("get_contact_metadata"); return {
initialValue: search.quote
return { ? [
contacts, {
initialValue: search.quote type: "paragraph",
? [ children: [{ text: "" }],
{ },
type: "paragraph", {
children: [{ text: "" }], type: "event",
}, eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
{ children: [{ text: "" }],
type: "event", },
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`, {
children: [{ text: "" }], type: "paragraph",
}, children: [{ text: "" }],
{ },
type: "paragraph", ]
children: [{ text: "" }], : [
}, {
] type: "paragraph",
: [ children: [{ text: "" }],
{ },
type: "paragraph", ],
children: [{ text: "" }], };
}, },
], component: Screen,
}; pendingComponent: Pending,
},
component: Screen,
pendingComponent: Pending,
}); });
function Screen() { function Screen() {
const ref = useRef<HTMLDivElement | null>(); const ref = useRef<HTMLDivElement | null>();
const { reply_to, quote } = Route.useSearch(); const { reply_to, quote } = Route.useSearch();
const { ark, initialValue, contacts } = Route.useRouteContext(); const { ark, initialValue, contacts } = Route.useRouteContext();
const [t] = useTranslation(); const [t] = useTranslation();
const [editorValue, setEditorValue] = useState(initialValue); const [editorValue, setEditorValue] = useState(initialValue);
const [target, setTarget] = useState<Range | undefined>(); const [target, setTarget] = useState<Range | undefined>();
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [nsfw, setNsfw] = useState(false); const [nsfw, setNsfw] = useState(false);
const [editor] = useState(() => const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))), withMentions(withNostrEvent(withImages(withReact(createEditor())))),
); );
const filters = contacts const filters =
?.filter((c) => contacts
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()), ?.filter((c) =>
) c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),
?.slice(0, 5); )
?.slice(0, 5) ?? [];
const reset = () => { const reset = () => {
// @ts-expect-error, backlog // @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }]; editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]); setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
}; };
const serialize = (nodes: Descendant[]) => { const serialize = (nodes: Descendant[]) => {
return nodes return nodes
.map((n) => { .map((n) => {
// @ts-expect-error, backlog // @ts-expect-error, backlog
if (n.type === "image") return n.url; if (n.type === "image") return n.url;
// @ts-expect-error, backlog // @ts-expect-error, backlog
if (n.type === "event") return n.eventId; if (n.type === "event") return n.eventId;
// @ts-expect-error, backlog // @ts-expect-error, backlog
if (n.children.length) { if (n.children.length) {
// @ts-expect-error, backlog // @ts-expect-error, backlog
return n.children return n.children
.map((n) => { .map((n) => {
if (n.type === "mention") return n.npub; if (n.type === "mention") return n.npub;
return Node.string(n).trim(); return Node.string(n).trim();
}) })
.join(" "); .join(" ");
} }
return Node.string(n); return Node.string(n);
}) })
.join("\n"); .join("\n");
}; };
const publish = async () => { const publish = async () => {
try { try {
// start loading // start loading
setLoading(true); setLoading(true);
const content = serialize(editor.children); const content = serialize(editor.children);
const eventId = await ark.publish(content, reply_to, quote); const eventId = await ark.publish(content, reply_to, quote);
if (eventId) { if (eventId) {
await sendNativeNotification("You've publish new post successfully."); await sendNativeNotification("You've publish new post successfully.");
} }
// stop loading // stop loading
setLoading(false); setLoading(false);
// reset form // reset form
reset(); reset();
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
await sendNativeNotification(String(e)); await sendNativeNotification(String(e));
} }
}; };
useEffect(() => { useEffect(() => {
if (target && filters.length > 0) { if (target && filters.length > 0) {
const el = ref.current; const el = ref.current;
const domRange = ReactEditor.toDOMRange(editor, target); const domRange = ReactEditor.toDOMRange(editor, target);
const rect = domRange.getBoundingClientRect(); const rect = domRange.getBoundingClientRect();
el.style.top = `${rect.top + window.scrollY + 24}px`; el.style.top = `${rect.top + window.scrollY + 24}px`;
el.style.left = `${rect.left + window.scrollX}px`; el.style.left = `${rect.left + window.scrollX}px`;
} }
}, [filters.length, editor, index, search, target]); }, [filters.length, editor, index, search, target]);
return ( return (
<div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900"> <div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900">
<Slate <Slate
editor={editor} editor={editor}
initialValue={editorValue} initialValue={editorValue}
onChange={() => { onChange={() => {
const { selection } = editor; const { selection } = editor;
if (selection && Range.isCollapsed(selection)) { if (selection && Range.isCollapsed(selection)) {
const [start] = Range.edges(selection); const [start] = Range.edges(selection);
const wordBefore = Editor.before(editor, start, { unit: "word" }); const wordBefore = Editor.before(editor, start, { unit: "word" });
const before = wordBefore && Editor.before(editor, wordBefore); const before = wordBefore && Editor.before(editor, wordBefore);
const beforeRange = before && Editor.range(editor, before, start); const beforeRange = before && Editor.range(editor, before, start);
const beforeText = const beforeText =
beforeRange && Editor.string(editor, beforeRange); beforeRange && Editor.string(editor, beforeRange);
const beforeMatch = beforeText?.match(/^@(\w+)$/); const beforeMatch = beforeText?.match(/^@(\w+)$/);
const after = Editor.after(editor, start); const after = Editor.after(editor, start);
const afterRange = Editor.range(editor, start, after); const afterRange = Editor.range(editor, start, after);
const afterText = Editor.string(editor, afterRange); const afterText = Editor.string(editor, afterRange);
const afterMatch = afterText.match(/^(\s|$)/); const afterMatch = afterText.match(/^(\s|$)/);
if (beforeMatch && afterMatch) { if (beforeMatch && afterMatch) {
setTarget(beforeRange); setTarget(beforeRange);
setSearch(beforeMatch[1]); setSearch(beforeMatch[1]);
setIndex(0); setIndex(0);
return; return;
} }
} }
setTarget(null); setTarget(null);
}} }}
> >
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2" className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2"
> >
<NsfwToggle <NsfwToggle
nsfw={nsfw} nsfw={nsfw}
setNsfw={setNsfw} setNsfw={setNsfw}
className="size-8 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" className="size-8 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
/> />
<MediaButton className="size-8 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" /> <MediaButton className="size-8 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" />
<button <button
type="button" type="button"
onClick={publish} onClick={() => publish()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600" className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
> >
{loading ? ( {loading ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
) : ( ) : (
<ComposeFilledIcon className="size-4" /> <ComposeFilledIcon className="size-4" />
)} )}
{t("global.post")} {t("global.post")}
</button> </button>
</div> </div>
<div className="flex h-full min-h-0 w-full"> <div className="flex h-full min-h-0 w-full">
<div className="flex h-full w-full flex-1 flex-col gap-2 px-2 pb-2"> <div className="flex h-full w-full flex-1 flex-col gap-2 px-2 pb-2">
{reply_to && !quote ? <MentionNote eventId={reply_to} /> : null} {reply_to && !quote ? <MentionNote eventId={reply_to} /> : null}
<div className="h-full w-full flex-1 overflow-hidden overflow-y-auto rounded-xl bg-white p-5 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-none dark:ring-1 dark:ring-white/5"> <div className="h-full w-full flex-1 overflow-hidden overflow-y-auto rounded-xl bg-white p-5 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-none dark:ring-1 dark:ring-white/5">
<Editable <Editable
key={JSON.stringify(editorValue)} key={JSON.stringify(editorValue)}
autoFocus={true} autoFocus={true}
autoCapitalize="none" autoCapitalize="none"
autoCorrect="none" autoCorrect="none"
spellCheck={false} spellCheck={false}
renderElement={(props) => <Element {...props} />} renderElement={(props) => <Element {...props} />}
placeholder={ placeholder={
reply_to ? "Type your reply..." : t("editor.placeholder") reply_to ? "Type your reply..." : t("editor.placeholder")
} }
className="focus:outline-none" className="focus:outline-none"
/> />
{target && filters.length > 0 && ( {target && filters.length > 0 && (
<Portal> <Portal>
<div <div
ref={ref} ref={ref}
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-xl border border-neutral-50 bg-white p-2 shadow-lg dark:border-neutral-900 dark:bg-neutral-950" className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-xl border border-neutral-50 bg-white p-2 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
> >
{filters.map((contact) => ( {filters.map((contact) => (
<button <button
key={contact.pubkey} key={contact.pubkey}
type="button" type="button"
onClick={() => { onClick={() => {
Transforms.select(editor, target); Transforms.select(editor, target);
insertMention(editor, contact); insertMention(editor, contact);
setTarget(null); setTarget(null);
}} }}
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900" className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
> >
<User.Provider pubkey={contact.pubkey}> <User.Provider pubkey={contact.pubkey}>
<User.Root className="flex w-full items-center gap-2"> <User.Root className="flex w-full items-center gap-2">
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" /> <User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
<div className="flex w-full flex-col items-start"> <div className="flex w-full flex-col items-start">
<User.Name className="max-w-[8rem] truncate text-sm font-medium" /> <User.Name className="max-w-[8rem] truncate text-sm font-medium" />
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</button> </button>
))} ))}
</div> </div>
</Portal> </Portal>
)} )}
</div> </div>
</div> </div>
</div> </div>
</Slate> </Slate>
</div> </div>
); );
} }
function Pending() { function Pending() {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex h-full w-full items-center justify-center gap-2.5" className="flex h-full w-full items-center justify-center gap-2.5"
> >
<button type="button" disabled> <button type="button" disabled>
<Spinner className="size-5" /> <Spinner className="size-5" />
</button> </button>
<p>Loading cache...</p> <p>Loading cache...</p>
</div> </div>
); );
} }
const withNostrEvent = (editor: ReactEditor) => { const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor; const { insertData, isVoid } = editor;
editor.isVoid = (element) => { editor.isVoid = (element) => {
// @ts-expect-error, wtf // @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element); return element.type === "event" ? true : isVoid(element);
}; };
editor.insertData = (data) => { editor.insertData = (data) => {
const text = data.getData("text/plain"); const text = data.getData("text/plain");
if (text.startsWith("nevent1") || text.startsWith("note1")) { if (text.startsWith("nevent1") || text.startsWith("note1")) {
insertNostrEvent(editor, text); insertNostrEvent(editor, text);
} else { } else {
insertData(data); insertData(data);
} }
}; };
return editor; return editor;
}; };
const withMentions = (editor: ReactEditor) => { const withMentions = (editor: ReactEditor) => {
const { isInline, isVoid, markableVoid } = editor; const { isInline, isVoid, markableVoid } = editor;
editor.isInline = (element) => { editor.isInline = (element) => {
// @ts-expect-error, wtf // @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element); return element.type === "mention" ? true : isInline(element);
}; };
editor.isVoid = (element) => { editor.isVoid = (element) => {
// @ts-expect-error, wtf // @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element); return element.type === "mention" ? true : isVoid(element);
}; };
editor.markableVoid = (element) => { editor.markableVoid = (element) => {
// @ts-expect-error, wtf // @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element); return element.type === "mention" || markableVoid(element);
}; };
return editor; return editor;
}; };
const withImages = (editor: ReactEditor) => { const withImages = (editor: ReactEditor) => {
const { insertData, isVoid } = editor; const { insertData, isVoid } = editor;
editor.isVoid = (element) => { editor.isVoid = (element) => {
// @ts-expect-error, wtf // @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element); return element.type === "image" ? true : isVoid(element);
}; };
editor.insertData = (data) => { editor.insertData = (data) => {
const text = data.getData("text/plain"); const text = data.getData("text/plain");
if (isImageUrl(text)) { if (isImageUrl(text)) {
insertImage(editor, text); insertImage(editor, text);
} else { } else {
insertData(data); insertData(data);
} }
}; };
return editor; return editor;
}; };
const Image = ({ attributes, children, element }) => { const Image = ({ attributes, children, element }) => {
const editor = useSlateStatic(); const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element); const path = ReactEditor.findPath(editor as ReactEditor, element);
const selected = useSelected(); const selected = useSelected();
const focused = useFocused(); const focused = useFocused();
return ( return (
<div {...attributes}> <div {...attributes}>
{children} {children}
<div contentEditable={false} className="relative my-2"> <div contentEditable={false} className="relative my-2">
<img <img
src={element.url} src={element.url}
alt={element.url} alt={element.url}
className={cn( className={cn(
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900", "h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
selected && focused ? "ring-blue-500" : "ring-transparent", selected && focused ? "ring-blue-500" : "ring-transparent",
)} )}
contentEditable={false} contentEditable={false}
/> />
<button <button
type="button" type="button"
contentEditable={false} contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })} onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600" className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
> >
<TrashIcon className="size-4" /> <TrashIcon className="size-4" />
</button> </button>
</div> </div>
</div> </div>
); );
}; };
const Mention = ({ attributes, element }) => { const Mention = ({ attributes, element }) => {
const editor = useSlateStatic(); const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element); const path = ReactEditor.findPath(editor as ReactEditor, element);
return ( return (
<span <span
{...attributes} {...attributes}
type="button" type="button"
contentEditable={false} contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })} onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block align-baseline text-blue-500 hover:text-blue-600" className="inline-block align-baseline text-blue-500 hover:text-blue-600"
>{`@${element.name}`}</span> >{`@${element.name}`}</span>
); );
}; };
const Event = ({ attributes, element, children }) => { const Event = ({ attributes, element, children }) => {
const editor = useSlateStatic(); const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element); const path = ReactEditor.findPath(editor as ReactEditor, element);
return ( return (
<div {...attributes}> <div {...attributes}>
{children} {children}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */} {/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
<div <div
contentEditable={false} contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })} onClick={() => Transforms.removeNodes(editor, { at: path })}
className="user-select-none relative my-2" className="user-select-none relative my-2"
> >
<MentionNote <MentionNote
eventId={element.eventId.replace("nostr:", "")} eventId={element.eventId.replace("nostr:", "")}
openable={false} openable={false}
/> />
</div> </div>
</div> </div>
); );
}; };
const Element = (props) => { const Element = (props) => {
const { attributes, children, element } = props; const { attributes, children, element } = props;
switch (element.type) { switch (element.type) {
case "image": case "image":
return <Image {...props} />; return <Image {...props} />;
case "mention": case "mention":
return <Mention {...props} />; return <Mention {...props} />;
case "event": case "event":
return <Event {...props} />; return <Event {...props} />;
default: default:
return ( return (
<p {...attributes} className="text-lg"> <p {...attributes} className="text-lg">
{children} {children}
</p> </p>
); );
} }
}; };

View File

@@ -1,71 +0,0 @@
import { useEvent } from "@lume/ark";
import { Box, Container, Note, Spinner, User } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { ReplyList } from "./-components/replyList";
import { WindowVirtualizer } from "virtua";
import { type Event } from "@lume/types";
export const Route = createLazyFileRoute("/events/$eventId")({
component: Event,
});
function Event() {
const { eventId } = Route.useParams();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5 animate-spin" />
</div>
);
}
if (isError) {
<div className="flex h-full w-full items-center justify-center">
<p>Not found.</p>
</div>;
}
return (
<Container withDrag>
<Box className="px-3 pt-3 scrollbar-none">
<WindowVirtualizer>
<MainNote data={data} />
{data ? <ReplyList eventId={eventId} /> : null}
</WindowVirtualizer>
</Box>
</Container>
);
}
function MainNote({ data }: { data: Event }) {
return (
<Note.Provider event={data}>
<Note.Root className="flex flex-col pb-3">
<User.Provider pubkey={data.pubkey}>
<User.Root className="mb-3 flex flex-1 items-center gap-3">
<User.Avatar className="size-11 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
<div className="flex flex-1 flex-col">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<User.Time time={data.created_at} />
<span>·</span>
<User.NIP05 />
</div>
</div>
</User.Root>
</User.Provider>
<Note.Thread className="mb-2" />
<Note.Content className="min-w-0" />
<div className="mt-4 flex items-center justify-between">
<div className="-ml-1 inline-flex items-center gap-4">
<Note.Repost />
<Note.Zap />
</div>
<Note.Menu />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -0,0 +1,71 @@
import { useEvent } from "@lume/ark";
import type { Event } from "@lume/types";
import { Box, Container, Note, Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList";
export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
return { settings };
},
component: Screen,
});
function Screen() {
const { eventId } = Route.useParams();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
);
}
if (isError) {
<div className="flex h-full w-full items-center justify-center">
<p>Not found.</p>
</div>;
}
return (
<Container withDrag>
<Box className="scrollbar-none">
<WindowVirtualizer>
<MainNote data={data} />
{data ? (
<ReplyList eventId={eventId} />
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
)}
</WindowVirtualizer>
</Box>
</Container>
);
}
function MainNote({ data }: { data: Event }) {
return (
<Note.Provider event={data}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-4 h-11 gap-2 flex items-center justify-end px-3">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,47 +1,36 @@
import { EventWithReplies } from "@lume/types"; import type { EventWithReplies } from "@lume/types";
import { cn } from "@lume/utils";
import { Note, User } from "@lume/ui"; import { Note, User } from "@lume/ui";
import { cn } from "@lume/utils";
import { SubReply } from "./subReply"; import { SubReply } from "./subReply";
export function Reply({ event }: { event: EventWithReplies }) { export function Reply({ event }: { event: EventWithReplies }) {
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root className="border-t border-neutral-100 pt-3 dark:border-neutral-900"> <Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
<User.Provider pubkey={event.pubkey}> <div className="px-3 h-14 flex items-center justify-between">
<User.Root className="mb-2 flex items-center justify-between"> <Note.User />
<div className="inline-flex gap-2"> <Note.Menu />
<User.Avatar className="size-6 rounded-full" /> </div>
<div className="inline-flex items-center gap-2"> <Note.ContentLarge className="px-3" />
<User.Name className="font-semibold" /> <div className="mt-3 flex items-center gap-4 px-3 h-14">
<User.NIP05 className="text-base lowercase text-neutral-600 dark:text-neutral-400" /> <Note.Reply />
</div> <Note.Repost />
</div> <Note.Zap />
<User.Time time={event.created_at} /> </div>
</User.Root> <div
</User.Provider> className={cn(
<Note.Content /> event.replies?.length > 0
<div className="mt-4 flex items-center justify-between"> ? "py-2 pl-3 flex flex-col gap-3 divide-y divide-neutral-100 bg-neutral-50 dark:bg-white/5 border-l-2 border-blue-500 dark:divide-neutral-900"
<div className="-ml-1 inline-flex items-center gap-4"> : "",
<Note.Reply /> )}
<Note.Repost /> >
<Note.Zap /> {event.replies?.length > 0
</div> ? event.replies?.map((childEvent) => (
<Note.Menu /> <SubReply key={childEvent.id} event={childEvent} />
</div> ))
<div : null}
className={cn( </div>
event.replies?.length > 0 </Note.Root>
? "my-3 mt-6 flex flex-col gap-3 divide-y divide-neutral-100 border-l-2 border-neutral-100 pl-6 dark:divide-neutral-900 dark:border-neutral-900" </Note.Provider>
: "", );
)}
>
{event.replies?.length > 0
? event.replies?.map((childEvent) => (
<SubReply key={childEvent.id} event={childEvent} />
))
: null}
</div>
</Note.Root>
</Note.Provider>
);
} }

View File

@@ -1,48 +1,51 @@
import type { EventWithReplies } from "@lume/types";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { EventWithReplies } from "@lume/types";
import { Reply } from "./reply"; import { Reply } from "./reply";
import { useRouteContext } from "@tanstack/react-router";
import { Spinner } from "@lume/ui";
export function ReplyList({ export function ReplyList({
eventId, eventId,
className, className,
}: { }: {
eventId: string; eventId: string;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false }); const { ark } = useRouteContext({ strict: false });
const [t] = useTranslation(); const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null); const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => { useEffect(() => {
async function getReplies() { async function getReplies() {
const events = await ark.get_event_thread(eventId); const events = await ark.get_event_thread(eventId);
setData(events); setData(events);
} }
getReplies(); getReplies();
}, [eventId]); }, [eventId]);
return ( return (
<div className={cn("flex flex-col gap-3", className)}> <div className={cn("flex flex-col", className)}>
{!data ? ( <div className="h-11 flex px-3 items-center text-sm font-semibold text-neutral-700 dark:text-neutral-300 border-t border-neutral-100 dark:border-neutral-900">
<div className="mt-4 flex h-16 items-center justify-center p-3"> Replies ({data?.length ?? 0})
<Spinner className="size-5" /> </div>
</div> {!data ? (
) : data.length === 0 ? ( <div className="flex h-16 items-center justify-center p-3">
<div className="mt-4 flex w-full items-center justify-center"> <Spinner className="size-5" />
<div className="flex flex-col items-center justify-center gap-2 py-6"> </div>
<h3 className="text-3xl">👋</h3> ) : data.length === 0 ? (
<p className="leading-none text-neutral-600 dark:text-neutral-400"> <div className="flex w-full items-center justify-center">
{t("note.reply.empty")} <div className="flex flex-col items-center justify-center gap-2 py-6">
</p> <h3 className="text-3xl">👋</h3>
</div> <p className="leading-none text-neutral-600 dark:text-neutral-400">
</div> {t("note.reply.empty")}
) : ( </p>
data.map((event) => <Reply key={event.id} event={event} />) </div>
)} </div>
</div> ) : (
); data.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
);
} }

View File

@@ -1,32 +1,21 @@
import { Event } from "@lume/types"; import type { Event } from "@lume/types";
import { Note, User } from "@lume/ui"; import { Note } from "@lume/ui";
export function SubReply({ event }: { event: Event; rootEventId?: string }) { export function SubReply({ event }: { event: Event; rootEventId?: string }) {
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root className="pt-3"> <Note.Root>
<User.Provider pubkey={event.pubkey}> <div className="px-3 h-14 flex items-center justify-between">
<User.Root className="mb-2 flex items-center justify-between"> <Note.User />
<div className="inline-flex gap-2"> <Note.Menu />
<User.Avatar className="size-6 rounded-full" /> </div>
<div className="inline-flex items-center gap-2"> <Note.ContentLarge className="px-3" />
<User.Name className="font-semibold" /> <div className="mt-3 flex items-center gap-4 px-3">
<User.NIP05 className="text-base lowercase text-neutral-600 dark:text-neutral-400" /> <Note.Reply />
</div> <Note.Repost />
</div> <Note.Zap />
<User.Time time={event.created_at} /> </div>
</User.Root> </Note.Root>
</User.Provider> </Note.Provider>
<Note.Content /> );
<div className="mt-4 flex items-center justify-between">
<div className="-ml-1 inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
<Note.Menu />
</div>
</Note.Root>
</Note.Provider>
);
} }

View File

@@ -1,145 +1,155 @@
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { ColumnRouteSearch, Event, Kind } from "@lume/types"; import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Column, Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router"; import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/foryou")({ export const Route = createFileRoute("/foryou")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
account: search.account, account: search.account,
label: search.label, label: search.label,
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ search, context }) => { beforeLoad: async ({ search, context }) => {
const ark = context.ark; const ark = context.ark;
const interests = await ark.get_interest(); const interests = await ark.get_interest();
const settings = await ark.get_settings(); const settings = await ark.get_settings();
if (!interests) { if (!interests) {
throw redirect({ throw redirect({
to: "/interests", to: "/interests",
search: { search: {
...search, ...search,
redirect: "/foryou", redirect: "/foryou",
}, },
}); });
} }
return { return {
interests, interests,
settings, settings,
}; };
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { label, name, account } = Route.useSearch(); const { name, account } = Route.useSearch();
const { ark, interests } = Route.useRouteContext(); const { ark, interests } = Route.useRouteContext();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = const {
useInfiniteQuery({ data,
queryKey: [name, account], isLoading,
initialPageParam: 0, isFetching,
queryFn: async ({ pageParam }: { pageParam: number }) => { isFetchingNextPage,
const events = await ark.get_events_from_interests( hasNextPage,
interests.hashtags, fetchNextPage,
20, } = useInfiniteQuery({
pageParam, queryKey: [name, account],
); initialPageParam: 0,
return events; queryFn: async ({ pageParam }: { pageParam: number }) => {
}, const events = await ark.get_events_from_interests(
getNextPageParam: (lastPage) => { interests.hashtags,
const lastEvent = lastPage?.at(-1); 20,
return lastEvent ? lastEvent.created_at - 1 : null; pageParam,
}, );
select: (data) => data?.pages.flatMap((page) => page), return events;
refetchOnWindowFocus: false, },
}); getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => { const renderItem = (event: Event) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return <RepostNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} />;
default: default:
return <TextNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
} }
}; };
return ( return (
<Column.Root> <div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
<Column.Header label={label} name={name} /> {isFetching && !isLoading && !isFetchingNextPage ? (
<Column.Content> <div className="w-full h-11 flex items-center justify-center">
{isLoading ? ( <div className="flex items-center justify-center gap-2">
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <Spinner className="size-5" />
<button type="button" className="size-5" disabled> <span className="text-sm font-medium">Fetching new notes...</span>
<Spinner className="size-5" /> </div>
</button> </div>
</div> ) : null}
) : !data.length ? ( {isLoading ? (
<Empty /> <div className="flex h-16 w-full items-center justify-center gap-2">
) : ( <Spinner className="size-5" />
<Virtualizer overscan={3}> <span className="text-sm font-medium">Loading...</span>
{data.map((item) => renderItem(item))} </div>
</Virtualizer> ) : !data.length ? (
)} <Empty />
{data?.length && hasNextPage ? ( ) : (
<div className="flex h-20 items-center justify-center"> <Virtualizer overscan={3}>
<button {data.map((item) => renderItem(item))}
type="button" </Virtualizer>
onClick={() => fetchNextPage()} )}
disabled={isFetchingNextPage} {data?.length && hasNextPage ? (
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800" <div>
> <button
{isFetchingNextPage ? ( type="button"
<Spinner className="size-5" /> onClick={() => fetchNextPage()}
) : ( disabled={isFetchingNextPage || isLoading}
<> className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
<ArrowRightCircleIcon className="size-5" /> >
Load more {isFetchingNextPage ? (
</> <Spinner className="size-5" />
)} ) : (
</button> <>
</div> <ArrowRightCircleIcon className="size-5" />
) : null} Load more
</Column.Content> </>
</Column.Root> )}
); </button>
</div>
) : null}
</div>
);
} }
function Empty() { function Empty() {
return ( return (
<div className="flex flex-col py-10 gap-10"> <div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center"> <div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8"> <div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" /> <div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div> </div>
<p className="text-lg font-medium">Your newsfeed is empty</p> <p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started. Here are few suggestions to get started.
</p> </p>
</div> </div>
<div className="flex flex-col px-3 gap-2"> <div className="flex flex-col px-3 gap-2">
<Link <Link
to="/trending/notes" to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
> >
<ArrowRightIcon className="size-5" /> <ArrowRightIcon className="size-5" />
Show trending notes Show trending notes
</Link> </Link>
<Link <Link
to="/trending/users" to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
> >
<ArrowRightIcon className="size-5" /> <ArrowRightIcon className="size-5" />
Discover trending users Discover trending users
</Link> </Link>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,127 +1,153 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { ColumnRouteSearch, Event, Kind } from "@lume/types"; import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Column, Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/global")({ export const Route = createFileRoute("/global")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
account: search.account, account: search.account,
label: search.label, label: search.label,
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
const ark = context.ark; const ark = context.ark;
const settings = await ark.get_settings(); const settings = await ark.get_settings();
return { settings }; return { settings };
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { label, name, account } = Route.useSearch(); const { account } = Route.useSearch();
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = const {
useInfiniteQuery({ data,
queryKey: ["global", account], isLoading,
initialPageParam: 0, isFetching,
queryFn: async ({ pageParam }: { pageParam: number }) => { isFetchingNextPage,
const events = await ark.get_events(20, pageParam, undefined, true); hasNextPage,
return events; fetchNextPage,
}, } = useInfiniteQuery({
getNextPageParam: (lastPage) => { queryKey: ["global", account],
const lastEvent = lastPage?.at(-1); initialPageParam: 0,
return lastEvent ? lastEvent.created_at - 1 : null; queryFn: async ({ pageParam }: { pageParam: number }) => {
}, const events = await ark.get_events(20, pageParam, undefined, true);
select: (data) => data?.pages.flatMap((page) => page), return events;
refetchOnWindowFocus: false, },
}); getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => { const renderItem = (event: Event) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return <RepostNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} />;
default: default: {
return <TextNote key={event.id} event={event} />; const isConversation =
} event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
}; .length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
return ( if (isConversation) {
<Column.Root> return <Conversation key={event.id} event={event} className="mb-3" />;
<Column.Header label={label} name={name} /> }
<Column.Content>
{isLoading ? ( if (isQuote) {
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> return <Quote key={event.id} event={event} className="mb-3" />;
<button type="button" className="size-5" disabled> }
<Spinner className="size-5" />
</button> return <TextNote key={event.id} event={event} className="mb-3" />;
</div> }
) : !data.length ? ( }
<Empty /> };
) : (
<Virtualizer overscan={3}> return (
{data.map((item) => renderItem(item))} <div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
</Virtualizer> {isFetching && !isLoading && !isFetchingNextPage ? (
)} <div className="w-full h-11 flex items-center justify-center">
{data?.length && hasNextPage ? ( <div className="flex items-center justify-center gap-2">
<div className="flex h-20 items-center justify-center"> <Spinner className="size-5" />
<button <span className="text-sm font-medium">Fetching new notes...</span>
type="button" </div>
onClick={() => fetchNextPage()} </div>
disabled={isFetchingNextPage || isLoading} ) : null}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800" {isLoading ? (
> <div className="flex h-16 w-full items-center justify-center gap-2">
{isFetchingNextPage ? ( <Spinner className="size-5" />
<Spinner className="size-5" /> <span className="text-sm font-medium">Loading...</span>
) : ( </div>
<> ) : !data.length ? (
<ArrowRightCircleIcon className="size-5" /> <Empty />
Load more ) : (
</> <Virtualizer overscan={3}>
)} {data.map((item) => renderItem(item))}
</button> </Virtualizer>
</div> )}
) : null} {data?.length && hasNextPage ? (
</Column.Content> <div>
</Column.Root> <button
); type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
} }
function Empty() { function Empty() {
return ( return (
<div className="flex flex-col py-10 gap-10"> <div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center"> <div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8"> <div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" /> <div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div> </div>
<p className="text-lg font-medium">Your newsfeed is empty</p> <p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started. Here are few suggestions to get started.
</p> </p>
</div> </div>
<div className="flex flex-col px-3 gap-2"> <div className="flex flex-col px-3 gap-2">
<Link <Link
to="/trending/notes" to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
> >
<ArrowRightIcon className="size-5" /> <ArrowRightIcon className="size-5" />
Show trending notes Show trending notes
</Link> </Link>
<Link <Link
to="/trending/users" to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
> >
<ArrowRightIcon className="size-5" /> <ArrowRightIcon className="size-5" />
Discover trending users Discover trending users
</Link> </Link>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,141 +1,154 @@
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { ColumnRouteSearch, Event, Kind } from "@lume/types"; import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Column, Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router"; import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/group")({ export const Route = createFileRoute("/group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
account: search.account, account: search.account,
label: search.label, label: search.label,
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ search, context }) => { beforeLoad: async ({ search, context }) => {
const ark = context.ark; const ark = context.ark;
const groups = (await ark.get_nstore( const groups = (await ark.get_nstore(
`lume_group_${search.label}`, `lume_group_${search.label}`,
)) as string[]; )) as string[];
const settings = await ark.get_settings(); const settings = await ark.get_settings();
if (!groups) { if (!groups) {
throw redirect({ throw redirect({
to: "/create-group", to: "/create-group",
search: { search: {
...search, ...search,
redirect: "/group", redirect: "/group",
}, },
}); });
} }
return { return {
groups, groups,
settings, settings,
}; };
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { label, name, account } = Route.useSearch(); const { name, account } = Route.useSearch();
const { ark, groups } = Route.useRouteContext(); const { ark, groups } = Route.useRouteContext();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = const {
useInfiniteQuery({ data,
queryKey: [name, account], isLoading,
initialPageParam: 0, isFetching,
queryFn: async ({ pageParam }: { pageParam: number }) => { isFetchingNextPage,
const events = await ark.get_events(20, pageParam, groups); hasNextPage,
return events; fetchNextPage,
}, } = useInfiniteQuery({
getNextPageParam: (lastPage) => { queryKey: [name, account],
const lastEvent = lastPage?.at(-1); initialPageParam: 0,
return lastEvent ? lastEvent.created_at - 1 : null; queryFn: async ({ pageParam }: { pageParam: number }) => {
}, const events = await ark.get_events(20, pageParam, groups);
select: (data) => data?.pages.flatMap((page) => page), return events;
refetchOnWindowFocus: false, },
}); getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) =>
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => { const renderItem = (event: Event) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return <RepostNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} />;
default: default:
return <TextNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
} }
}; };
return ( return (
<Column.Root> <div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
<Column.Header label={label} name={name} /> {isFetching && !isLoading && !isFetchingNextPage ? (
<Column.Content> <div className="w-full h-11 flex items-center justify-center">
{isLoading ? ( <div className="flex items-center justify-center gap-2">
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <Spinner className="size-5" />
<Spinner className="size-5" /> <span className="text-sm font-medium">Fetching new notes...</span>
</div> </div>
) : !data.length ? ( </div>
<Empty /> ) : null}
) : ( {isLoading ? (
<Virtualizer overscan={3}> <div className="flex h-16 w-full items-center justify-center gap-2">
{data.map((item) => renderItem(item))} <Spinner className="size-5" />
</Virtualizer> <span className="text-sm font-medium">Loading...</span>
)} </div>
<div className="flex h-20 items-center justify-center"> ) : !data.length ? (
{hasNextPage ? ( <Empty />
<button ) : (
type="button" <Virtualizer overscan={3}>
onClick={() => fetchNextPage()} {data.map((item) => renderItem(item))}
disabled={!hasNextPage || isFetchingNextPage} </Virtualizer>
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800" )}
> {data?.length && hasNextPage ? (
{isFetchingNextPage ? ( <div>
<Spinner className="size-5" /> <button
) : ( type="button"
<> onClick={() => fetchNextPage()}
<ArrowRightCircleIcon className="size-5" /> disabled={isFetchingNextPage || isLoading}
Load more className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
</> >
)} {isFetchingNextPage ? (
</button> <Spinner className="size-5" />
) : null} ) : (
</div> <>
</Column.Content> <ArrowRightCircleIcon className="size-5" />
</Column.Root> Load more
); </>
)}
</button>
</div>
) : null}
</div>
);
} }
function Empty() { function Empty() {
return ( return (
<div className="flex flex-col py-10 gap-10"> <div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center"> <div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8"> <div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" /> <div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div> </div>
<p className="text-lg font-medium">Your newsfeed is empty</p> <p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started. Here are few suggestions to get started.
</p> </p>
</div> </div>
<div className="flex flex-col px-3 gap-2"> <div className="flex flex-col px-3 gap-2">
<Link <Link
to="/trending/notes" to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
> >
<ArrowRightIcon className="size-5" /> <ArrowRightIcon className="size-5" />
Show trending notes Show trending notes
</Link> </Link>
<Link <Link
to="/trending/users" to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
> >
<ArrowRightIcon className="size-5" /> <ArrowRightIcon className="size-5" />
Discover trending users Discover trending users
</Link> </Link>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,124 +1,128 @@
import { PlusIcon } from "@lume/icons"; import { PlusIcon } from "@lume/icons";
import { Spinner, User } from "@lume/ui"; import { Spinner, User } from "@lume/ui";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
const ark = context.ark; const ark = context.ark;
const accounts = await ark.get_all_accounts(); const accounts = await ark.get_all_accounts();
switch (accounts.length) { switch (accounts.length) {
// Guest account // Guest account
case 0: case 0:
throw redirect({ throw redirect({
to: "/landing", to: "/landing",
replace: true, replace: true,
}); });
// Only 1 account, skip account selection screen // Only 1 account, skip account selection screen
case 1: case 1: {
const account = accounts[0].npub; const account = accounts[0].npub;
const loadedAccount = await ark.load_selected_account(account); const loadedAccount = await ark.load_selected_account(account);
if (loadedAccount) { if (loadedAccount) {
throw redirect({ throw redirect({
to: "/$account/home", to: "/$account/home",
params: { account }, params: { account },
replace: true, replace: true,
}); });
} }
// Account selection
default: break;
return { accounts }; }
} // Account selection
}, default:
component: Screen, return { accounts };
}
},
component: Screen,
}); });
function Screen() { function Screen() {
const navigate = useNavigate(); const navigate = Route.useNavigate();
const context = Route.useRouteContext(); const context = Route.useRouteContext();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const select = async (npub: string) => { const select = async (npub: string) => {
setLoading(true); setLoading(true);
const ark = context.ark; const ark = context.ark;
const loadAccount = await ark.load_selected_account(npub); const loadAccount = await ark.load_selected_account(npub);
if (loadAccount) { if (loadAccount) {
return navigate({ return navigate({
to: "/$account/home", to: "/$account/home",
params: { account: npub }, params: { account: npub },
replace: true, replace: true,
}); });
} }
}; };
const currentDate = new Date().toLocaleString("default", { const currentDate = new Date().toLocaleString("default", {
weekday: "long", weekday: "long",
month: "long", month: "long",
day: "numeric", day: "numeric",
}); });
return ( return (
<div className="relative flex h-full w-full items-center justify-center"> <div className="relative flex h-full w-full items-center justify-center">
<div className="relative z-20 flex flex-col items-center gap-16"> <div className="relative z-20 flex flex-col items-center gap-16">
<div className="text-center text-white"> <div className="text-center text-white">
<h2 className="mb-1 text-2xl">{currentDate}</h2> <h2 className="mb-1 text-2xl">{currentDate}</h2>
<h2 className="text-2xl font-semibold">Welcome back!</h2> <h2 className="text-2xl font-semibold">Welcome back!</h2>
</div> </div>
<div className="flex items-center justify-center gap-6"> <div className="flex items-center justify-center gap-6">
{loading ? ( {loading ? (
<div className="inline-flex size-6 items-center justify-center"> <div className="inline-flex size-6 items-center justify-center">
<Spinner className="size-6" /> <Spinner className="size-6" />
</div> </div>
) : ( ) : (
<> <>
{context.accounts.map((account) => ( {context.accounts.map((account) => (
<button <button
type="button" type="button"
key={account.npub} key={account.npub}
onClick={() => select(account.npub)} onClick={() => select(account.npub)}
> >
<User.Provider pubkey={account.npub}> <User.Provider pubkey={account.npub}>
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10"> <User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
<User.Avatar className="size-20 rounded-full object-cover" /> <User.Avatar className="size-20 rounded-full object-cover" />
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" /> <User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</button> </button>
))} ))}
<Link to="/landing"> <Link to="/landing">
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10"> <div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20"> <div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
<PlusIcon className="size-5" /> <PlusIcon className="size-5" />
</div> </div>
<p className="text-lg font-medium leading-tight">Add</p> <p className="text-lg font-medium leading-tight">Add</p>
</div> </div>
</Link> </Link>
</> </>
)} )}
</div> </div>
</div> </div>
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" /> <div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
<div className="absolute inset-0 h-full w-full"> <div className="absolute inset-0 h-full w-full">
<img <img
src="/lock-screen.jpg" src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x" srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background" alt="Lock Screen Background"
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
<a <a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw" href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank" target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white dark:bg-black/20" className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white dark:bg-black/20"
> rel="noreferrer"
Design by NoGood >
</a> Design by NoGood
</div> </a>
</div> </div>
); </div>
);
} }

View File

@@ -1,5 +1,4 @@
import { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Column } from "@lume/ui";
import { TOPICS, cn } from "@lume/utils"; import { TOPICS, cn } from "@lume/utils";
import { createFileRoute, useRouter } from "@tanstack/react-router"; import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
@@ -7,116 +6,114 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createFileRoute("/interests")({ export const Route = createFileRoute("/interests")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
account: search.account, account: search.account,
label: search.label, label: search.label,
name: search.name, name: search.name,
}; };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { t } = useTranslation(); const { t } = useTranslation();
const { label, name, redirect } = Route.useSearch(); const { label, name, redirect } = Route.useSearch();
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const [hashtags, setHashtags] = useState<string[]>([]); const [hashtags, setHashtags] = useState<string[]>([]);
const [isDone, setIsDone] = useState(false); const [isDone, setIsDone] = useState(false);
const router = useRouter(); const router = useRouter();
const toggleHashtag = (item: string) => { const toggleHashtag = (item: string) => {
const arr = hashtags.includes(item) const arr = hashtags.includes(item)
? hashtags.filter((i) => i !== item) ? hashtags.filter((i) => i !== item)
: [...hashtags, item]; : [...hashtags, item];
setHashtags(arr); setHashtags(arr);
}; };
const toggleAll = (item: string[]) => { const toggleAll = (item: string[]) => {
const sets = new Set([...hashtags, ...item]); const sets = new Set([...hashtags, ...item]);
setHashtags([...sets]); setHashtags([...sets]);
}; };
const submit = async () => { const submit = async () => {
try { try {
if (isDone) { if (isDone) {
return router.history.push(redirect); return router.history.push(redirect);
} }
const eventId = await ark.set_interest(undefined, undefined, hashtags); const eventId = await ark.set_interest(undefined, undefined, hashtags);
if (eventId) { if (eventId) {
setIsDone(true); setIsDone(true);
toast.success("Interest has been updated successfully."); toast.success("Interest has been updated successfully.");
} }
} catch (e) { } catch (e) {
toast.error(String(e)); toast.error(String(e));
} }
}; };
return ( return (
<Column.Root> <div className="h-full flex flex-col px-2">
<Column.Header label={label} name={name} /> <div className="shrink-0 flex h-16 items-center justify-between">
<Column.Content> <div className="flex flex-1 flex-col">
<div className="sticky left-0 top-0 flex h-16 w-full items-center justify-between border-b border-neutral-100 bg-white px-3 dark:border-neutral-900 dark:bg-black"> <h3 className="font-semibold">Interests</h3>
<div className="flex flex-1 flex-col"> <p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
<h3 className="font-semibold">Interests</h3> Pick things you'd like to see.
<p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300"> </p>
Pick things you'd like to see. </div>
</p> <button
</div> type="button"
<button onClick={submit}
type="button" className="inline-flex h-8 w-20 items-center justify-center rounded-full bg-blue-500 px-2 text-sm font-medium text-white hover:bg-blue-600 disabled:opacity-50"
onClick={submit} >
className="inline-flex h-8 w-20 items-center justify-center rounded-full bg-blue-500 px-2 text-sm font-medium text-white hover:bg-blue-600 disabled:opacity-50" {isDone ? t("global.back") : t("global.update")}
> </button>
{isDone ? t("global.back") : t("global.update")} </div>
</button> <div className="flex-1 flex flex-col gap-3 pb-2 scrollbar-none overflow-y-auto">
</div> {TOPICS.map((topic) => (
<div className="flex w-full flex-col p-3"> <div
<div className="flex flex-col gap-8"> key={topic.title}
{TOPICS.map((topic) => ( className="flex flex-col gap-4 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
<div key={topic.title} className="flex flex-col gap-4"> >
<div className="flex w-full items-center justify-between"> <div className="px-3 flex w-full items-center justify-between h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900">
<div className="inline-flex items-center gap-2.5"> <div className="inline-flex items-center gap-2.5">
<img <img
src={topic.icon} src={topic.icon}
alt={topic.title} alt={topic.title}
className="size-8 rounded-lg object-cover" className="size-8 rounded-lg object-cover"
/> />
<h3 className="text-lg font-semibold">{topic.title}</h3> <h3 className="text-lg font-semibold">{topic.title}</h3>
</div> </div>
<button <button
type="button" type="button"
onClick={() => toggleAll(topic.content)} onClick={() => toggleAll(topic.content)}
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
{t("interests.followAll")} {t("interests.followAll")}
</button> </button>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="px-3 pb-3 flex flex-wrap items-center gap-3">
{topic.content.map((hashtag) => ( {topic.content.map((hashtag) => (
<button <button
key={hashtag} key={hashtag}
type="button" type="button"
onClick={() => toggleHashtag(hashtag)} onClick={() => toggleHashtag(hashtag)}
className={cn( className={cn(
"inline-flex items-center rounded-full border border-transparent bg-neutral-100 px-2 py-1 text-sm font-medium dark:bg-neutral-900", "inline-flex items-center rounded-full border border-transparent bg-neutral-100 px-2 py-1 text-sm font-medium dark:bg-neutral-900",
hashtags.includes(hashtag) hashtags.includes(hashtag)
? "border-blue-500 text-blue-500" ? "border-blue-500 text-blue-500"
: "", : "",
)} )}
> >
{hashtag} {hashtag}
</button> </button>
))} ))}
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</Column.Content> );
</Column.Root>
);
} }

View File

@@ -3,82 +3,83 @@ import { Link, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/landing/")({ export const Route = createFileRoute("/landing/")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="relative flex h-screen w-screen bg-black"> <div className="relative flex h-screen w-screen bg-black">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="absolute left-0 top-0 z-50 h-16 w-full" className="absolute left-0 top-0 z-50 h-16 w-full"
/> />
<div className="z-20 flex h-full w-full flex-col items-center justify-between"> <div className="z-20 flex h-full w-full flex-col items-center justify-between">
<div /> <div />
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-10"> <div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-10">
<div className="flex flex-col items-center text-center"> <div className="flex flex-col items-center text-center">
<img <img
src={`/heading-en.png`} src="/heading-en.png"
srcSet={`/heading-en@2x.png 2x`} srcSet="/heading-en@2x.png 2x"
alt="lume" alt="lume"
className="xl:w-2/3" className="xl:w-2/3"
/> />
<p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70"> <p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70">
{t("welcome.title")} {t("welcome.title")}
</p> </p>
</div> </div>
<div className="mx-auto flex w-full max-w-sm flex-col gap-4"> <div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<Link <Link
to="/auth/new/profile" to="/auth/new/profile"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90" className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
> >
{t("welcome.signup")} {t("welcome.signup")}
</Link> </Link>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-px flex-1 bg-white/20" /> <div className="h-px flex-1 bg-white/20" />
<div className="text-white/70">{t("login.or")}</div> <div className="text-white/70">{t("login.or")}</div>
<div className="h-px flex-1 bg-white/20" /> <div className="h-px flex-1 bg-white/20" />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Link <Link
to="/auth/remote" to="/auth/remote"
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40" className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
> >
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" /> <RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Nostr Connect Nostr Connect
<div className="size-5" /> <div className="size-5" />
</Link> </Link>
<Link <Link
to="/auth/privkey" to="/auth/privkey"
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40" className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
> >
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" /> <KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Private Key Private Key
<div className="size-5" /> <div className="size-5" />
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="flex h-11 items-center justify-center"></div> <div className="flex h-11 items-center justify-center" />
</div> </div>
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" /> <div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
<div className="absolute inset-0 h-full w-full"> <div className="absolute inset-0 h-full w-full">
<img <img
src="/lock-screen.jpg" src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x" srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background" alt="Lock Screen Background"
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
<a <a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw" href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank" target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white backdrop-blur-md dark:bg-black/20" className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white backdrop-blur-md dark:bg-black/20"
> rel="noreferrer"
Design by NoGood >
</a> Design by NoGood
</div> </a>
</div> </div>
); </div>
);
} }

View File

@@ -1,152 +1,165 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { ColumnRouteSearch, Event, Kind } from "@lume/types"; import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Column, Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/newsfeed")({ export const Route = createFileRoute("/newsfeed")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
account: search.account, account: search.account,
label: search.label, label: search.label,
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
const ark = context.ark; const ark = context.ark;
const settings = await ark.get_settings(); const settings = await ark.get_settings();
return { settings }; return { settings };
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { label, name, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
isFetching, isFetching,
isFetchingNextPage, isFetchingNextPage,
hasNextPage, hasNextPage,
fetchNextPage, fetchNextPage,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(20, pageParam); const events = await ark.get_events(20, pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1); const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null; return lastEvent ? lastEvent.created_at - 1 : null;
}, },
select: (data) => data?.pages.flatMap((page) => page), select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const renderItem = (event: Event) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return <RepostNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} />;
default: default: {
return <TextNote key={event.id} event={event} />; const isConversation =
} event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
}; .length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
return ( if (isConversation) {
<Column.Root> return <Conversation key={event.id} event={event} className="mb-3" />;
<Column.Header label={label} name={name} /> }
<Column.Content>
{isFetching && !isLoading && !isFetchingNextPage ? ( if (isQuote) {
<div className="w-full h-16 flex items-center justify-center border-b border-neutral-100 dark:border-neutral-900"> return <Quote key={event.id} event={event} className="mb-3" />;
<div className="flex items-center justify-center gap-2"> }
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span> return <TextNote key={event.id} event={event} className="mb-3" />;
</div> }
</div> }
) : null} };
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2"> return (
<Spinner className="size-5" /> <div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
<span className="text-sm font-medium">Loading...</span> {isFetching && !isLoading && !isFetchingNextPage ? (
</div> <div className="w-full h-11 flex items-center justify-center">
) : !data.length ? ( <div className="flex items-center justify-center gap-2">
<Empty /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Fetching new notes...</span>
<Virtualizer overscan={3}> </div>
{data.map((item) => renderItem(item))} </div>
</Virtualizer> ) : null}
)} {isLoading ? (
{data?.length && hasNextPage ? ( <div className="flex h-16 w-full items-center justify-center gap-2">
<div className="flex h-20 items-center justify-center"> <Spinner className="size-5" />
<button <span className="text-sm font-medium">Loading...</span>
type="button" </div>
onClick={() => fetchNextPage()} ) : !data.length ? (
disabled={isFetchingNextPage || isLoading} <Empty />
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800" ) : (
> <Virtualizer overscan={3}>
{isFetchingNextPage ? ( {data.map((item) => renderItem(item))}
<Spinner className="size-5" /> </Virtualizer>
) : ( )}
<> {data?.length && hasNextPage ? (
<ArrowRightCircleIcon className="size-5" /> <div>
Load more <button
</> type="button"
)} onClick={() => fetchNextPage()}
</button> disabled={isFetchingNextPage || isLoading}
</div> className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
) : null} >
</Column.Content> {isFetchingNextPage ? (
</Column.Root> <Spinner className="size-5" />
); ) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
} }
function Empty() { function Empty() {
const search = Route.useSearch(); const search = Route.useSearch();
return ( return (
<div className="flex flex-col py-10 gap-10"> <div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center"> <div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8"> <div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" /> <div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div> </div>
<p className="text-lg font-medium">Your newsfeed is empty</p> <p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started. Here are few suggestions to get started.
</p> </p>
</div> </div>
<div className="flex flex-col px-3 gap-2"> <div className="flex flex-col px-3 gap-2">
<Link <Link
to="/global" to="/global"
search={search} search={search}
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" className="h-11 w-full flex items-center justify-between bg-black/10 hover:bg-black/20 text-sm font-medium dark:bg-white/10 dark:hover:bg-white/20 gap-2 rounded-lg px-3"
> >
<ArrowRightIcon className="size-5" /> Show global newsfeed
Show global newsfeed <ArrowRightIcon className="size-5" />
</Link> </Link>
<Link <Link
to="/trending/notes" to="/trending/notes"
search={search} search={search}
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" className="h-11 w-full flex items-center justify-between bg-black/10 hover:bg-black/20 text-sm font-medium dark:bg-white/10 dark:hover:bg-white/20 gap-2 rounded-lg px-3"
> >
<ArrowRightIcon className="size-5" /> Show trending notes
Show trending notes <ArrowRightIcon className="size-5" />
</Link> </Link>
<Link <Link
to="/trending/users" to="/trending/users"
search={search} search={search}
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" className="h-11 w-full flex items-center justify-between bg-black/10 hover:bg-black/20 text-sm font-medium dark:bg-white/10 dark:hover:bg-white/20 gap-2 rounded-lg px-3"
> >
<ArrowRightIcon className="size-5" /> Discover trending users
Discover trending users <ArrowRightIcon className="size-5" />
</Link> </Link>
</div> </div>
</div> </div>
); );
} }

View File

@@ -4,59 +4,59 @@ import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
export const Route = createLazyFileRoute("/nwc")({ export const Route = createLazyFileRoute("/nwc")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const [uri, setUri] = useState(""); const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false); const [isDone, setIsDone] = useState(false);
const save = async () => { const save = async () => {
const nwc = await ark.set_nwc(uri); const nwc = await ark.set_nwc(uri);
setIsDone(nwc); setIsDone(nwc);
}; };
return ( return (
<Container withDrag withNavigate={false}> <Container withDrag withNavigate={false}>
<div className="h-full w-full flex-1 px-5"> <div className="h-full w-full flex-1 px-5">
{!isDone ? ( {!isDone ? (
<> <>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md"> <div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
<ZapIcon className="size-5" /> <ZapIcon className="size-5" />
</div> </div>
<div> <div>
<h3 className="text-2xl font-light"> <h3 className="text-2xl font-light">
Connect <span className="font-semibold">bitcoin wallet</span>{" "} Connect <span className="font-semibold">bitcoin wallet</span>{" "}
to start zapping to your favorite content and creator. to start zapping to your favorite content and creator.
</h3> </h3>
</div> </div>
</div> </div>
<div className="mt-10 flex flex-col gap-2"> <div className="mt-10 flex flex-col gap-2">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label>Paste a Nostr Wallet Connect connection string</label> <label>Paste a Nostr Wallet Connect connection string</label>
<textarea <textarea
value={uri} value={uri}
onChange={(e) => setUri(e.target.value)} onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://" placeholder="nostrconnect://"
className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={save} onClick={save}
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600" className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
> >
Save & Connect Save & Connect
</button> </button>
</div> </div>
</> </>
) : ( ) : (
<div>Done</div> <div>Done</div>
)} )}
</div> </div>
</Container> </Container>
); );
} }

View File

@@ -1,51 +1,48 @@
import { PlusIcon } from "@lume/icons"; import { PlusIcon } from "@lume/icons";
import { LumeColumn } from "@lume/types"; import type { LumeColumn } from "@lume/types";
import { Column } from "@lume/ui";
import { createLazyRoute } from "@tanstack/react-router"; import { createLazyRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
export const Route = createLazyRoute("/open")({ export const Route = createLazyRoute("/open")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const install = async (column: LumeColumn) => { const install = async (column: LumeColumn) => {
const mainWindow = getCurrent(); const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column }); await mainWindow.emit("columns", { type: "add", column });
}; };
return ( return (
<Column.Root shadow={false} background={false}> <div className="relative flex h-full w-full items-center justify-center">
<Column.Content className="relative flex h-full w-full items-center justify-center"> <div className="group absolute left-0 top-0 z-10 h-full w-12">
<div className="group absolute left-0 top-0 z-10 h-full w-12"> <button
<button type="button"
type="button" onClick={() =>
onClick={() => install({
install({ label: "store",
label: "store", name: "Store",
name: "Store", content: "/store/official",
content: "/store/official", })
}) }
} className="flex h-full w-full items-center justify-center rounded-xl bg-transparent transition-colors duration-100 ease-in-out group-hover:bg-black/5 dark:group-hover:bg-white/5"
className="flex h-full w-full items-center justify-center rounded-xl bg-transparent transition-colors duration-100 ease-in-out group-hover:bg-black/5 dark:group-hover:bg-white/5" >
> <PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" />
<PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" /> </button>
</button> </div>
</div> <button
<button type="button"
type="button" onClick={() =>
onClick={() => install({
install({ label: "store",
label: "store", name: "Store",
name: "Store", content: "/store/official",
content: "/store/official", })
}) }
} className="inline-flex size-14 items-center justify-center rounded-full bg-black/10 backdrop-blur-lg hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
className="inline-flex size-14 items-center justify-center rounded-full bg-black/10 backdrop-blur-lg hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" >
> <PlusIcon className="size-8" />
<PlusIcon className="size-8" /> </button>
</button> </div>
</Column.Content> );
</Column.Root>
);
} }

View File

@@ -1,148 +1,153 @@
import { SearchIcon } from "@lume/icons"; import { SearchIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types"; import { type Event, Kind } from "@lume/types";
import { Note, Spinner, User } from "@lume/ui"; import { Note, Spinner, User } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
export const Route = createFileRoute("/search")({ export const Route = createFileRoute("/search")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [value] = useDebounce(search, 500); const [value] = useDebounce(search, 500);
const searchEvents = async () => { const searchEvents = async () => {
if (!value.length) return; if (!value.length) return;
// start loading // start loading
setLoading(true); setLoading(true);
const data = await ark.search(value, 100); const data = await ark.search(value, 100);
// update state // update state
setLoading(false); setLoading(false);
setEvents(data); setEvents(data);
}; };
useEffect(() => { useEffect(() => {
searchEvents(); searchEvents();
}, [value]); }, [value]);
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex flex-col w-full h-full bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900" className="flex flex-col w-full h-full bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900"
> >
<div <div
data-tauri-drag-region data-tauri-drag-region
className="h-24 shrink-0 flex items-end border-neutral-300 border dark:border-neutral-700" className="relative h-24 shrink-0 flex items-end border-neutral-300 border-b dark:border-neutral-700"
> >
<input <div
value={search} data-tauri-drag-region
onChange={(e) => setSearch(e.target.value)} className="absolute top-0 left-0 w-full h-4"
onKeyDown={(e) => { />
if (e.key === "Enter") searchEvents(); <input
}} value={search}
placeholder="Search anything..." onChange={(e) => setSearch(e.target.value)}
className="w-full h-20 pt-10 px-6 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600" onKeyDown={(e) => {
/> if (e.key === "Enter") searchEvents();
</div> }}
<div className="flex-1 p-3 overflow-y-auto scrollbar-none"> placeholder="Search anything..."
{loading ? ( className="z-10 w-full h-20 pt-10 px-6 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
<div className="w-full h-full flex items-center justify-center"> />
<Spinner /> </div>
</div> <div className="flex-1 p-3 overflow-y-auto scrollbar-none">
) : !events.length ? ( {loading ? (
<div className="flex items-center justify-center h-full text-sm"> <div className="w-full h-full flex items-center justify-center">
Empty <Spinner />
</div> </div>
) : ( ) : !events.length ? (
<div className="flex flex-col gap-5"> <div className="flex items-center justify-center h-full text-sm">
<div className="flex flex-col gap-1.5"> Empty
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0"> </div>
Users ) : (
</div> <div className="flex flex-col gap-5">
<div className="flex-1 flex flex-col gap-3"> <div className="flex flex-col gap-1.5">
{events <div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
.filter((ev) => ev.kind === Kind["Metadata"]) Users
.map((event) => ( </div>
<SearchUser event={event} /> <div className="flex-1 flex flex-col gap-3">
))} {events
</div> .filter((ev) => ev.kind === Kind.Metadata)
</div> .map((event) => (
<div className="flex flex-col gap-1.5"> <SearchUser key={event.id} event={event} />
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0"> ))}
Notes </div>
</div> </div>
<div className="flex-1 flex flex-col gap-3"> <div className="flex flex-col gap-1.5">
{events <div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
.filter((ev) => ev.kind === Kind["Text"]) Notes
.map((event) => ( </div>
<SearchNote event={event} /> <div className="flex-1 flex flex-col gap-3">
))} {events
</div> .filter((ev) => ev.kind === Kind.Text)
</div> .map((event) => (
</div> <SearchNote key={event.id} event={event} />
)} ))}
{!loading && !events.length ? ( </div>
<div className="h-full flex items-center justify-center flex-col gap-3"> </div>
<div className="size-16 bg-blue-100 dark:bg-blue-900 rounded-full inline-flex items-center justify-center text-blue-500"> </div>
<SearchIcon className="size-6" /> )}
</div> {!loading && !events.length ? (
Try searching for people, notes, or keywords <div className="h-full flex items-center justify-center flex-col gap-3">
</div> <div className="size-16 bg-blue-100 dark:bg-blue-900 rounded-full inline-flex items-center justify-center text-blue-500">
) : null} <SearchIcon className="size-6" />
</div> </div>
</div> Try searching for people, notes, or keywords
); </div>
) : null}
</div>
</div>
);
} }
function SearchUser({ event }: { event: Event }) { function SearchUser({ event }: { event: Event }) {
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
return ( return (
<button <button
key={event.id} key={event.id}
type="button" type="button"
onClick={() => ark.open_profile(event.pubkey)} onClick={() => ark.open_profile(event.pubkey)}
className="p-3 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg" className="p-3 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
> >
<User.Provider pubkey={event.pubkey} embedProfile={event.content}> <User.Provider pubkey={event.pubkey} embedProfile={event.content}>
<User.Root className="flex items-center gap-2"> <User.Root className="flex items-center gap-2">
<User.Avatar className="size-11 rounded-full shrink-0" /> <User.Avatar className="size-11 rounded-full shrink-0" />
<div> <div>
<User.Name className="font-semibold" /> <User.Name className="font-semibold" />
<User.NIP05 /> <User.NIP05 />
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</button> </button>
); );
} }
function SearchNote({ event }: { event: Event }) { function SearchNote({ event }: { event: Event }) {
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
return ( return (
<div <div
key={event.id} key={event.id}
onClick={() => ark.open_thread(event.id)} onClick={() => ark.open_event(event)}
className="p-3 bg-white rounded-lg dark:bg-black" onKeyDown={() => ark.open_event(event)}
> className="p-3 bg-white rounded-lg dark:bg-black"
<Note.Provider event={event}> >
<Note.Root> <Note.Provider event={event}>
<Note.User /> <Note.Root>
<div className="select-text mt-2.5 leading-normal line-clamp-5 text-balance"> <Note.User />
{event.content} <div className="select-text mt-2.5 leading-normal line-clamp-5 text-balance">
</div> {event.content}
</Note.Root> </div>
</Note.Provider> </Note.Root>
</div> </Note.Provider>
); </div>
);
} }

View File

@@ -1,104 +1,104 @@
import { SettingsIcon, UserIcon, ZapIcon, SecureIcon } from "@lume/icons"; import { SecureIcon, SettingsIcon, UserIcon, ZapIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/settings")({ export const Route = createFileRoute("/settings")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex h-full w-full flex-col bg-neutral-100 dark:bg-neutral-950"> <div className="flex h-full w-full flex-col bg-neutral-100 dark:bg-neutral-950">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-neutral-200 dark:border-neutral-800" className="flex h-20 w-full shrink-0 items-center justify-center border-b border-neutral-200 dark:border-neutral-800"
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link to="/settings/general"> <Link to="/settings/general">
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
className={cn( className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2", "flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive isActive
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700" ? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800", : "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
)} )}
> >
<SettingsIcon className="size-5 shrink-0" /> <SettingsIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{t("settings.general.title")} {t("settings.general.title")}
</p> </p>
</div> </div>
); );
}} }}
</Link> </Link>
<Link to="/settings/user"> <Link to="/settings/user">
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
className={cn( className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2", "flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive isActive
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700" ? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800", : "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
)} )}
> >
<UserIcon className="size-5 shrink-0" /> <UserIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{t("settings.user.title")} {t("settings.user.title")}
</p> </p>
</div> </div>
); );
}} }}
</Link> </Link>
<Link to="/settings/zap"> <Link to="/settings/zap">
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
className={cn( className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2", "flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive isActive
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700" ? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800", : "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
)} )}
> >
<ZapIcon className="size-5 shrink-0" /> <ZapIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{t("settings.zap.title")} {t("settings.zap.title")}
</p> </p>
</div> </div>
); );
}} }}
</Link> </Link>
<Link to="/settings/backup"> <Link to="/settings/backup">
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
className={cn( className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2", "flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive isActive
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700" ? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800", : "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
)} )}
> >
<SecureIcon className="size-5 shrink-0" /> <SecureIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{t("settings.backup.title")} {t("settings.backup.title")}
</p> </p>
</div> </div>
); );
}} }}
</Link> </Link>
</div> </div>
</div> </div>
<div className="w-full flex-1 overflow-y-auto px-5 py-4"> <div className="w-full flex-1 overflow-y-auto px-5 py-4">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { type Account } from "@lume/types"; import type { Account } from "@lume/types";
import { User } from "@lume/ui"; import { User } from "@lume/ui";
import { displayNsec } from "@lume/utils"; import { displayNsec } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
@@ -8,121 +8,121 @@ import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createFileRoute("/settings/backup")({ export const Route = createFileRoute("/settings/backup")({
component: Screen, component: Screen,
loader: async ({ context }) => { loader: async ({ context }) => {
const ark = context.ark; const ark = context.ark;
const npubs = await ark.get_all_accounts(); const npubs = await ark.get_all_accounts();
let accounts: Account[] = []; const accounts: Account[] = [];
for (const account of npubs) { for (const account of npubs) {
const nsec: string = await invoke("get_stored_nsec", { const nsec: string = await invoke("get_stored_nsec", {
npub: account.npub, npub: account.npub,
}); });
accounts.push({ ...account, nsec }); accounts.push({ ...account, nsec });
} }
return accounts; return accounts;
}, },
}); });
function Screen() { function Screen() {
const accounts = Route.useLoaderData(); const accounts = Route.useLoaderData();
return ( return (
<div className="mx-auto w-full max-w-xl"> <div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700"> <div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
{accounts.map((account) => ( {accounts.map((account) => (
<Account account={account} /> <NostrAccount key={account.npub} account={account} />
))} ))}
</div> </div>
</div> </div>
); );
} }
function Account({ account }: { account: Account }) { function NostrAccount({ account }: { account: Account }) {
const [key, setKey] = useState(account.nsec); const [key, setKey] = useState(account.nsec);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [passphase, setPassphase] = useState(""); const [passphase, setPassphase] = useState("");
const encrypt = async () => { const encrypt = async () => {
const encrypted: string = await invoke("get_encrypted_key", { const encrypted: string = await invoke("get_encrypted_key", {
npub: account.npub, npub: account.npub,
password: passphase, password: passphase,
}); });
setKey(encrypted); setKey(encrypted);
}; };
const copyKey = async () => { const copyKey = async () => {
try { try {
await writeText(key); await writeText(key);
setCopied(true); setCopied(true);
} catch (e) { } catch (e) {
toast.error(e); toast.error(e);
} }
}; };
return ( return (
<div className="flex flex-1 flex-col gap-2 py-3"> <div className="flex flex-1 flex-col gap-2 py-3">
<User.Provider pubkey={account.npub}> <User.Provider pubkey={account.npub}>
<User.Root className="flex items-center gap-2"> <User.Root className="flex items-center gap-2">
<User.Avatar className="size-8 rounded-full object-cover" /> <User.Avatar className="size-8 rounded-full object-cover" />
<div className="flex flex-col"> <div className="flex flex-col">
<User.Name className="text-sm leading-tight" /> <User.Name className="text-sm leading-tight" />
<User.NIP05 className="text-sm leading-tight text-neutral-700 dark:text-neutral-300" /> <User.NIP05 />
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="nsec" htmlFor="nsec"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
Private Key Private Key
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
readOnly readOnly
name="nsec" name="nsec"
type="text" type="text"
value={displayNsec(key, 36)} value={displayNsec(key, 36)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
<button <button
type="button" type="button"
onClick={copyKey} onClick={() => copyKey()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700" className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
> >
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy"}
</button> </button>
</div> </div>
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="passphase" htmlFor="passphase"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
Set a passphase to secure your key Set a passphase to secure your key
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
name="passphase" name="passphase"
type="password" type="password"
value={passphase} value={passphase}
onChange={(e) => setPassphase(e.target.value)} onChange={(e) => setPassphase(e.target.value)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
<button <button
type="button" type="button"
onClick={encrypt} onClick={() => encrypt()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700" className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
> >
Update Update
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,159 +1,159 @@
import { Settings } from "@lume/types"; import type { Settings } from "@lume/types";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { import {
isPermissionGranted, isPermissionGranted,
requestPermission, requestPermission,
} from "@tauri-apps/plugin-notification"; } from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import * as Switch from "@radix-ui/react-switch";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
export const Route = createFileRoute("/settings/general")({ export const Route = createFileRoute("/settings/general")({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
const permissionGranted = await isPermissionGranted(); // get notification permission const permissionGranted = await isPermissionGranted(); // get notification permission
const ark = context.ark; const ark = context.ark;
const settings = await ark.get_settings(); const settings = await ark.get_settings();
return { return {
settings: { ...settings, notification: permissionGranted }, settings: { ...settings, notification: permissionGranted },
}; };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, settings } = Route.useRouteContext(); const { ark, settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings); const [newSettings, setNewSettings] = useState<Settings>(settings);
const toggleNofitication = async () => { const toggleNofitication = async () => {
await requestPermission(); await requestPermission();
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
notification: !newSettings.notification, notification: !newSettings.notification,
})); }));
}; };
const toggleAutoUpdate = () => { const toggleAutoUpdate = () => {
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
autoUpdate: !newSettings.autoUpdate, autoUpdate: !newSettings.autoUpdate,
})); }));
}; };
const toggleEnhancedPrivacy = () => { const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
enhancedPrivacy: !newSettings.enhancedPrivacy, enhancedPrivacy: !newSettings.enhancedPrivacy,
})); }));
}; };
const toggleZap = () => { const toggleZap = () => {
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
zap: !newSettings.zap, zap: !newSettings.zap,
})); }));
}; };
const toggleNsfw = () => { const toggleNsfw = () => {
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
nsfw: !newSettings.nsfw, nsfw: !newSettings.nsfw,
})); }));
}; };
const updateSettings = useDebouncedCallback(() => { const updateSettings = useDebouncedCallback(() => {
ark.set_settings(newSettings); ark.set_settings(newSettings);
}, 200); }, 200);
useEffect(() => { useEffect(() => {
updateSettings(); updateSettings();
}, [newSettings]); }, [newSettings]);
return ( return (
<div className="mx-auto w-full max-w-xl"> <div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700"> <div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root <Switch.Root
checked={newSettings.notification} checked={newSettings.notification}
onClick={() => toggleNofitication()} onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
> >
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> <Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root> </Switch.Root>
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold">Push Notification</h3> <h3 className="font-semibold">Push Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300"> <p className="text-sm text-neutral-700 dark:text-neutral-300">
Enabling push notifications will allow you to receive Enabling push notifications will allow you to receive
notifications from Lume. notifications from Lume.
</p> </p>
</div> </div>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root <Switch.Root
checked={newSettings.enhancedPrivacy} checked={newSettings.enhancedPrivacy}
onClick={() => toggleEnhancedPrivacy()} onClick={() => toggleEnhancedPrivacy()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
> >
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> <Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root> </Switch.Root>
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold">Enhanced Privacy</h3> <h3 className="font-semibold">Enhanced Privacy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300"> <p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume will display external resources like image, video or link Lume will display external resources like image, video or link
preview as plain text. preview as plain text.
</p> </p>
</div> </div>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root <Switch.Root
checked={newSettings.autoUpdate} checked={newSettings.autoUpdate}
onClick={() => toggleAutoUpdate()} onClick={() => toggleAutoUpdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
> >
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> <Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root> </Switch.Root>
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold">Auto Update</h3> <h3 className="font-semibold">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300"> <p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version. Automatically download and install new version.
</p> </p>
</div> </div>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root <Switch.Root
checked={newSettings.zap} checked={newSettings.zap}
onClick={() => toggleZap()} onClick={() => toggleZap()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
> >
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> <Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root> </Switch.Root>
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold">Zap</h3> <h3 className="font-semibold">Zap</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300"> <p className="text-sm text-neutral-700 dark:text-neutral-300">
Show the Zap button in each note and user's profile screen, use Show the Zap button in each note and user's profile screen, use
for send Bitcoin tip to other users. for send Bitcoin tip to other users.
</p> </p>
</div> </div>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root <Switch.Root
checked={newSettings.nsfw} checked={newSettings.nsfw}
onClick={() => toggleNsfw()} onClick={() => toggleNsfw()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
> >
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> <Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root> </Switch.Root>
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold">Filter sensitive content</h3> <h3 className="font-semibold">Filter sensitive content</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300"> <p className="text-sm text-neutral-700 dark:text-neutral-300">
By default, Lume will display all content which have Content By default, Lume will display all content which have Content
Warning tag, it's may include NSFW content. Warning tag, it's may include NSFW content.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
import { AvatarUploader } from "@/components/avatarUploader"; import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons"; import { PlusIcon } from "@lume/icons";
import { Metadata } from "@lume/types"; import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
@@ -9,178 +9,178 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createFileRoute("/settings/user")({ export const Route = createFileRoute("/settings/user")({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
const ark = context.ark; const ark = context.ark;
const profile = await ark.get_current_user_profile(); const profile = await ark.get_current_user_profile();
return { profile }; return { profile };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, profile } = Route.useRouteContext(); const { ark, profile } = Route.useRouteContext();
const { register, handleSubmit } = useForm(); const { register, handleSubmit } = useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState<string>(""); const [picture, setPicture] = useState<string>("");
const onSubmit = async (data: Metadata) => { const onSubmit = async (data: Metadata) => {
try { try {
setLoading(true); setLoading(true);
const profile = { ...data }; const profile = { ...data };
await ark.create_profile(profile); await ark.create_profile(profile);
setLoading(false); setLoading(false);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); toast.error(String(e));
} }
}; };
return ( return (
<div className="flex w-full h-full"> <div className="flex w-full h-full">
<div className="flex-1 h-full flex items-center flex-col justify-center gap-3"> <div className="flex-1 h-full flex items-center flex-col justify-center gap-3">
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200"> <div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{profile.picture ? ( {profile.picture ? (
<img <img
src={profile.picture} src={profile.picture}
alt="avatar" alt="avatar"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover" className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
/> />
) : null} ) : null}
<AvatarUploader <AvatarUploader
setPicture={setPicture} setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
> >
<PlusIcon className="size-8" /> <PlusIcon className="size-8" />
</AvatarUploader> </AvatarUploader>
</div> </div>
<div className="text-center flex flex-col items-center"> <div className="text-center flex flex-col items-center">
<div className="text-lg font-semibold">{profile.display_name}</div> <div className="text-lg font-semibold">{profile.display_name}</div>
<div className="text-neutral-800 dark:text-neutral-200"> <div className="text-neutral-800 dark:text-neutral-200">
{profile.nip05} {profile.nip05}
</div> </div>
<div className="mt-4"> <div className="mt-4">
<Link <Link
to="/settings/backup" to="/settings/backup"
className="px-5 h-9 border border-blue-300 text-sm font-medium hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-full bg-blue-100 text-blue-500 inline-flex items-center justify-center" className="px-5 h-9 border border-blue-300 text-sm font-medium hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-full bg-blue-100 text-blue-500 inline-flex items-center justify-center"
> >
Backup Account Backup Account
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="flex-1 h-full"> <div className="flex-1 h-full">
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3 mb-0" className="flex flex-col gap-3 mb-0"
> >
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="display_name" htmlFor="display_name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
Display Name Display Name
</label> </label>
<input <input
name="display_name" name="display_name"
{...register("display_name", { required: true, minLength: 1 })} {...register("display_name", { required: true, minLength: 1 })}
value={profile.display_name} value={profile.display_name}
spellCheck={false} spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="name" htmlFor="name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
Name Name
</label> </label>
<input <input
name="name" name="name"
{...register("name")} {...register("name")}
spellCheck={false} spellCheck={false}
value={profile.name} value={profile.name}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="website" htmlFor="website"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
Website Website
</label> </label>
<input <input
name="website" name="website"
type="url" type="url"
{...register("website")} {...register("website")}
spellCheck={false} spellCheck={false}
value={profile.website} value={profile.website}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="banner" htmlFor="banner"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
Cover Cover
</label> </label>
<input <input
name="banner" name="banner"
type="url" type="url"
{...register("banner")} {...register("banner")}
spellCheck={false} spellCheck={false}
value={profile.banner} value={profile.banner}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="nip05" htmlFor="nip05"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
NIP-05 NIP-05
</label> </label>
<input <input
name="nip05" name="nip05"
type="email" type="email"
{...register("nip05")} {...register("nip05")}
spellCheck={false} spellCheck={false}
value={profile.nip05} value={profile.nip05}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="lnaddress" htmlFor="lnaddress"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
Lightning Address Lightning Address
</label> </label>
<input <input
name="lnaddress" name="lnaddress"
type="email" type="email"
{...register("lud16")} {...register("lud16")}
value={profile.lud16} value={profile.lud16}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<button <button
type="submit" type="submit"
className="inline-flex h-9 w-32 px-2 items-center justify-center rounded-lg bg-blue-500 font-medium text-sm text-white hover:bg-blue-600 disabled:opacity-50" className="inline-flex h-9 w-32 px-2 items-center justify-center rounded-lg bg-blue-500 font-medium text-sm text-white hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner className="size-4" /> : "Update Profile"} {loading ? <Spinner className="size-4" /> : "Update Profile"}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
); );
} }

View File

@@ -4,99 +4,99 @@ import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createLazyFileRoute("/settings/zap")({ export const Route = createLazyFileRoute("/settings/zap")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
return ( return (
<div className="mx-auto w-full max-w-xl"> <div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700"> <div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
<div className="flex flex-col gap-6 py-3"> <div className="flex flex-col gap-6 py-3">
<Connection /> <Connection />
<DefaultAmount /> <DefaultAmount />
</div> </div>
</div> </div>
</div> </div>
); );
} }
function Connection() { function Connection() {
const [uri, setUri] = useState(""); const [uri, setUri] = useState("");
const connect = async () => { const connect = async () => {
try { try {
await invoke("set_nwc", { uri }); await invoke("set_nwc", { uri });
} catch (e) { } catch (e) {
toast.error(String(e)); toast.error(String(e));
} }
}; };
return ( return (
<div className="flex items-start gap-6"> <div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium text-sm"> <div className="w-36 shrink-0 text-end font-medium text-sm">
Connection Connection
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="nwc" htmlFor="nwc"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
Nostr Wallet Connect Nostr Wallet Connect
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
name="nwc" name="nwc"
type="text" type="text"
value={uri} value={uri}
onChange={(e) => setUri(e.target.value)} onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://" placeholder="nostrconnect://"
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
<button <button
type="button" type="button"
onClick={connect} onClick={() => connect()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700" className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
> >
Connect Connect
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
function DefaultAmount() { function DefaultAmount() {
return ( return (
<div className="flex items-start gap-6"> <div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium text-sm"> <div className="w-36 shrink-0 text-end font-medium text-sm">
Default amount Default amount
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<label <label
htmlFor="amount" htmlFor="amount"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
> >
Set default amount for quick zapping Set default amount for quick zapping
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
name="amount" name="amount"
type="number" type="number"
value={21} value={21}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
<button <button
type="button" type="button"
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700" className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
> >
Update Update
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,21 +1,21 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store/community")({ export const Route = createFileRoute("/store/community")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-3"> <div className="flex h-full flex-col items-center justify-center gap-3 p-3">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full"> <div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" /> <div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div> </div>
<div className="text-center"> <div className="text-center">
<h1 className="font-semibold text-lg">Coming Soon</h1> <h1 className="font-semibold text-lg">Coming Soon</h1>
<p className="text-sm text-neutral-700 dark:text-neutral-300 leading-tight"> <p className="text-sm text-neutral-700 dark:text-neutral-300 leading-tight">
Enhance your experience <br /> by adding column shared by community. Enhance your experience <br /> by adding column shared by community.
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,69 +1,69 @@
import { LumeColumn } from "@lume/types"; import type { LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { resolveResource } from "@tauri-apps/api/path"; import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs"; import { readTextFile } from "@tauri-apps/plugin-fs";
export const Route = createFileRoute("/store/official")({ export const Route = createFileRoute("/store/official")({
component: Screen, component: Screen,
beforeLoad: async () => { beforeLoad: async () => {
const resourcePath = await resolveResource( const resourcePath = await resolveResource(
"resources/official_columns.json", "resources/official_columns.json",
); );
const officialColumns: LumeColumn[] = JSON.parse( const officialColumns: LumeColumn[] = JSON.parse(
await readTextFile(resourcePath), await readTextFile(resourcePath),
); );
return { return {
officialColumns, officialColumns,
}; };
}, },
}); });
function Screen() { function Screen() {
const { officialColumns } = Route.useRouteContext(); const { officialColumns } = Route.useRouteContext();
const install = async (column: LumeColumn) => { const install = async (column: LumeColumn) => {
const mainWindow = getCurrent(); const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column }); await mainWindow.emit("columns", { type: "add", column });
}; };
return ( return (
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
{officialColumns.map((column) => ( {officialColumns.map((column) => (
<div <div
key={column.label} key={column.label}
className="relative h-[200px] w-full overflow-hidden rounded-xl bg-gradient-to-tr from-orange-100 to-blue-200 px-3 pt-3" className="relative h-[200px] w-full overflow-hidden rounded-xl bg-gradient-to-tr from-orange-100 to-blue-200 px-3 pt-3"
> >
{column.cover ? ( {column.cover ? (
<img <img
src={column.cover} src={column.cover}
srcSet={column.coverRetina} srcSet={column.coverRetina}
alt={column.name} alt={column.name}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="absolute left-0 top-0 z-10 h-full w-full object-cover" className="absolute left-0 top-0 z-10 h-full w-full object-cover"
/> />
) : null} ) : null}
<div className="absolute bottom-0 left-0 z-20 h-16 w-full bg-black/40 px-3 backdrop-blur-xl"> <div className="absolute bottom-0 left-0 z-20 h-16 w-full bg-black/40 px-3 backdrop-blur-xl">
<div className="flex h-full items-center justify-between"> <div className="flex h-full items-center justify-between">
<div> <div>
<h1 className="font-semibold text-white">{column.name}</h1> <h1 className="font-semibold text-white">{column.name}</h1>
<p className="max-w-[24rem] truncate text-sm text-white/80"> <p className="max-w-[24rem] truncate text-sm text-white/80">
{column.description} {column.description}
</p> </p>
</div> </div>
<button <button
type="button" type="button"
onClick={() => install(column)} onClick={() => install(column)}
className="inline-flex h-8 w-16 shrink-0 items-center justify-center rounded-full bg-white/20 text-sm font-medium text-white hover:bg-white hover:text-blue-500" className="inline-flex h-8 w-16 shrink-0 items-center justify-center rounded-full bg-white/20 text-sm font-medium text-white hover:bg-white hover:text-blue-500"
> >
Add Add
</button> </button>
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
); );
} }

View File

@@ -1,63 +1,56 @@
import { GlobalIcon, LaurelIcon } from "@lume/icons"; import { GlobalIcon, LaurelIcon } from "@lume/icons";
import { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Column } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store")({ export const Route = createFileRoute("/store")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
account: search.account, account: search.account,
label: search.label, label: search.label,
name: search.name, name: search.name,
}; };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { label, name } = Route.useSearch(); return (
<div className="flex flex-col h-full">
return ( <div className="px-3 my-2">
<Column.Root> <div className="p-1 shrink-0 inline-flex w-full rounded-lg items-center gap-1 bg-black/10 dark:bg-white/10">
<Column.Header label={label} name={name}> <Link to="/store/official" className="flex-1">
<div className="inline-flex h-full w-full items-center gap-1"> {({ isActive }) => (
<Link to="/store/official"> <div
{({ isActive }) => ( className={cn(
<div "inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
className={cn( isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium", )}
isActive >
? "bg-neutral-100 dark:bg-neutral-900" <LaurelIcon className="size-4" />
: "opacity-50", Official
)} </div>
> )}
<LaurelIcon className="size-4" /> </Link>
Official <Link to="/store/community" className="flex-1">
</div> {({ isActive }) => (
)} <div
</Link> className={cn(
<Link to="/store/community"> "inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
{({ isActive }) => ( isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
<div )}
className={cn( >
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium", <GlobalIcon className="size-4" />
isActive Community
? "bg-neutral-100 dark:bg-neutral-900" </div>
: "opacity-50", )}
)} </Link>
> </div>
<GlobalIcon className="size-4" /> </div>
Community <div className="flex-1 overflow-y-auto scrollbar-none">
</div> <Outlet />
)} </div>
</Link> </div>
</div> );
</Column.Header>
<Column.Content>
<Outlet />
</Column.Content>
</Column.Root>
);
} }

View File

@@ -1,66 +1,60 @@
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { Event, Kind } from "@lume/types"; import { type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Await, createFileRoute } from "@tanstack/react-router"; import { Await, createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
import { defer } from "@tanstack/react-router"; import { defer } from "@tanstack/react-router";
import { Suspense } from "react"; import { Suspense } from "react";
import { Spinner } from "@lume/ui"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/trending/notes")({ export const Route = createFileRoute("/trending/notes")({
loader: async ({ abortController }) => { loader: async ({ abortController }) => {
try { try {
return { return {
data: defer( data: defer(
fetch("https://api.nostr.band/v0/trending/notes", { fetch("https://api.nostr.band/v0/trending/notes", {
signal: abortController.signal, signal: abortController.signal,
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((res) => res.notes.map((item) => item.event) as Event[]), .then((res) => res.notes.map((item) => item.event) as Event[]),
), ),
}; };
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));
} }
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { data } = Route.useLoaderData(); const { data } = Route.useLoaderData();
const renderItem = (event: Event) => { return (
if (!event) return; <div className="w-full h-full">
switch (event.kind) { <Virtualizer overscan={3}>
case Kind.Repost: <Suspense
return <RepostNote key={event.id} event={event} />; fallback={
default: <div className="flex h-20 w-full flex-col items-center justify-center gap-1">
return <TextNote key={event.id} event={event} />; <button
} type="button"
}; className="inline-flex items-center gap-2 text-sm font-medium"
disabled
return ( >
<div className="w-full h-full"> <Spinner className="size-5" />
<Virtualizer overscan={3}> Loading...
<Suspense </button>
fallback={ </div>
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> }
<button >
type="button" <Await promise={data}>
className="inline-flex items-center gap-2 text-sm font-medium" {(notes) =>
disabled notes.map((event) => (
> <TextNote key={event.id} event={event} className="mb-3" />
<Spinner className="size-5" /> ))
Loading... }
</button> </Await>
</div> </Suspense>
} </Virtualizer>
> </div>
<Await promise={data}> );
{(notes) => notes.map((event) => renderItem(event))}
</Await>
</Suspense>
</Virtualizer>
</div>
);
} }

View File

@@ -1,69 +1,62 @@
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons"; import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
import { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Column } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router"; import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/trending")({ export const Route = createFileRoute("/trending")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
account: search.account, account: search.account,
label: search.label, label: search.label,
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
const ark = context.ark; const ark = context.ark;
const settings = await ark.get_settings(); const settings = await ark.get_settings();
return { settings }; return { settings };
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { label, name } = Route.useSearch(); return (
<div className="flex flex-col h-full">
return ( <div className="h-11 shrink-0 inline-flex w-full items-center gap-1 px-3">
<Column.Root> <div className="inline-flex h-full w-full items-center gap-1">
<Column.Header label={label} name={name}> <Link to="/trending/notes">
<div className="inline-flex h-full w-full items-center gap-1"> {({ isActive }) => (
<Link to="/trending/notes"> <div
{({ isActive }) => ( className={cn(
<div "inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
className={cn( isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium", )}
isActive >
? "bg-neutral-100 dark:bg-neutral-900" <ArticleIcon className="size-4" />
: "opacity-50", Notes
)} </div>
> )}
<ArticleIcon className="size-4" /> </Link>
Notes <Link to="/trending/users">
</div> {({ isActive }) => (
)} <div
</Link> className={cn(
<Link to="/trending/users"> "inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
{({ isActive }) => ( isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
<div )}
className={cn( >
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium", <GroupFeedsIcon className="size-4" />
isActive Users
? "bg-neutral-100 dark:bg-neutral-900" </div>
: "opacity-50", )}
)} </Link>
> </div>
<GroupFeedsIcon className="size-4" /> </div>
Users <div className="p-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
</div> <Outlet />
)} </div>
</Link> </div>
</div> );
</Column.Header>
<Column.Content>
<Outlet />
</Column.Content>
</Column.Root>
);
} }

View File

@@ -4,67 +4,67 @@ import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react"; import { Suspense } from "react";
export const Route = createFileRoute("/trending/users")({ export const Route = createFileRoute("/trending/users")({
loader: async ({ abortController }) => { loader: async ({ abortController }) => {
try { try {
return { return {
data: defer( data: defer(
fetch("https://api.nostr.band/v0/trending/profiles", { fetch("https://api.nostr.band/v0/trending/profiles", {
signal: abortController.signal, signal: abortController.signal,
}).then((res) => res.json()), }).then((res) => res.json()),
), ),
}; };
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));
} }
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { data } = Route.useLoaderData(); const { data } = Route.useLoaderData();
return ( return (
<div className="w-full h-full px-3"> <div className="w-full h-full">
<Suspense <Suspense
fallback={ fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button <button
type="button" type="button"
className="inline-flex items-center gap-2 text-sm font-medium" className="inline-flex items-center gap-2 text-sm font-medium"
disabled disabled
> >
<Spinner className="size-5" /> <Spinner className="size-5" />
Loading... Loading...
</button> </button>
</div> </div>
} }
> >
<Await promise={data}> <Await promise={data}>
{(users) => {(users) =>
users.profiles.map((item) => ( users.profiles.map((item) => (
<div <div
key={item.pubkey} key={item.pubkey}
className="h-max w-full overflow-hidden py-5 border-b border-neutral-100 dark:border-neutral-900" className="h-max w-full overflow-hidden mb-3 p-2 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"
> >
<User.Provider pubkey={item.pubkey}> <User.Provider pubkey={item.pubkey}>
<User.Root> <User.Root>
<div className="flex h-full w-full flex-col gap-2"> <div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" /> <User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" /> <User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
</div> </div>
<User.Button className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800" /> <User.Button className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
</div> </div>
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" /> <User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</div> </div>
)) ))
} }
</Await> </Await>
</Suspense> </Suspense>
</div> </div>
); );
} }

View File

@@ -1,43 +1,43 @@
import { Box, Container, User } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua"; import { WindowVirtualizer } from "virtua";
import { Box, Container, User } from "@lume/ui";
import { EventList } from "./-components/eventList"; import { EventList } from "./-components/eventList";
export const Route = createLazyFileRoute("/users/$pubkey")({ export const Route = createLazyFileRoute("/users/$pubkey")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { pubkey } = Route.useParams(); const { pubkey } = Route.useParams();
return ( return (
<Container withDrag> <Container withDrag>
<Box className="px-0 scrollbar-none"> <Box className="px-0 scrollbar-none">
<WindowVirtualizer> <WindowVirtualizer>
<User.Provider pubkey={pubkey}> <User.Provider pubkey={pubkey}>
<User.Root> <User.Root>
<User.Cover className="h-44 w-full object-cover" /> <User.Cover className="h-44 w-full object-cover" />
<div className="relative -mt-8 flex flex-col gap-4 px-3"> <div className="relative -mt-8 flex flex-col gap-4 px-3">
<User.Avatar className="size-14 rounded-full" /> <User.Avatar className="size-14 rounded-full" />
<div className="inline-flex items-start justify-between"> <div className="inline-flex items-start justify-between">
<div> <div>
<User.Name className="font-semibold leading-tight" /> <User.Name className="font-semibold leading-tight" />
<User.NIP05 className="text-sm leading-tight text-neutral-600 dark:text-neutral-400" /> <User.NIP05 className="text-sm leading-tight text-neutral-600 dark:text-neutral-400" />
</div> </div>
<User.Button className="h-9 w-24 rounded-full bg-black text-sm font-medium text-white hover:bg-neutral-900 dark:bg-neutral-900" /> <User.Button className="h-9 w-24 rounded-full bg-black text-sm font-medium text-white hover:bg-neutral-900 dark:bg-neutral-900" />
</div> </div>
<User.About /> <User.About />
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
<div className="mt-4"> <div className="mt-4">
<div className="px-3"> <div className="px-3">
<h3 className="text-lg font-semibold">Latest notes</h3> <h3 className="text-lg font-semibold">Latest notes</h3>
</div> </div>
<EventList id={pubkey} /> <EventList id={pubkey} />
</div> </div>
</WindowVirtualizer> </WindowVirtualizer>
</Box> </Box>
</Container> </Container>
); );
} }

View File

@@ -1,74 +1,74 @@
import { TextNote } from "@/components/text";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, InfoIcon } from "@lume/icons"; import { ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types"; import { type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { FETCH_LIMIT } from "@lume/utils"; import { FETCH_LIMIT } from "@lume/utils";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";
import { Spinner } from "@lume/ui";
export function EventList({ id }: { id: string }) { export function EventList({ id }: { id: string }) {
const { ark } = useRouteContext({ strict: false }); const { ark } = useRouteContext({ strict: false });
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } = const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ["events", id], queryKey: ["events", id],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events_from(id, FETCH_LIMIT, pageParam); const events = await ark.get_events_from(id, FETCH_LIMIT, pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1); const lastEvent = lastPage?.at(-1);
if (!lastEvent) return; if (!lastEvent) return;
return lastEvent.created_at - 1; return lastEvent.created_at - 1;
}, },
select: (data) => data?.pages.flatMap((page) => page), select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const renderItem = (event: Event) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return <RepostNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} />;
default: default:
return <TextNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
} }
}; };
return ( return (
<div> <div>
{isLoading ? ( {isLoading ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<Spinner className="size-5" /> <Spinner className="size-5" />
</div> </div>
) : !data.length ? ( ) : !data.length ? (
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
<InfoIcon className="size-6" /> <InfoIcon className="size-6" />
<p>Empty newsfeed.</p> <p>Empty newsfeed.</p>
</div> </div>
) : ( ) : (
data.map((item) => renderItem(item)) data.map((item) => renderItem(item))
)} )}
<div className="flex h-20 items-center justify-center"> <div className="flex h-20 items-center justify-center">
{hasNextPage ? ( {hasNextPage ? (
<button <button
type="button" type="button"
onClick={() => fetchNextPage()} onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage} disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800" className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
> >
{isFetchingNextPage ? ( {isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( ) : (
<> <>
<ArrowRightCircleIcon className="size-5" /> <ArrowRightCircleIcon className="size-5" />
Load more Load more
</> </>
)} )}
</button> </button>
) : null} ) : null}
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,122 +1,123 @@
import { Balance } from "@/components/balance"; import { Balance } from "@/components/balance";
import { Box, Container, User } from "@lume/ui"; import { Box, Container, User } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { getCurrent } from "@tauri-apps/api/webviewWindow"; import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { toast } from "sonner"; import { useState } from "react";
import CurrencyInput from "react-currency-input-field"; import CurrencyInput from "react-currency-input-field";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
const DEFAULT_VALUES = [69, 100, 200, 500]; const DEFAULT_VALUES = [69, 100, 200, 500];
export const Route = createLazyFileRoute("/zap/$id")({ export const Route = createLazyFileRoute("/zap/$id")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { t } = useTranslation(); const { t } = useTranslation();
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const { id } = Route.useParams(); const { id } = Route.useParams();
// @ts-ignore, magic !!! // @ts-ignore, magic !!!
const { pubkey, account } = Route.useSearch(); const { pubkey, account } = Route.useSearch();
const [amount, setAmount] = useState(21); const [amount, setAmount] = useState(21);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [isCompleted, setIsCompleted] = useState(false); const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const submit = async () => { const submit = async () => {
try { try {
// start loading // start loading
setIsLoading(true); setIsLoading(true);
const val = await ark.zap_event(id, amount, message); const val = await ark.zap_event(id, amount, message);
if (val) { if (val) {
setIsCompleted(true); setIsCompleted(true);
const window = getCurrent(); const window = getCurrent();
// close current window // close current window
window.close(); window.close();
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(e); toast.error(e);
} }
}; };
return ( return (
<Container> <Container>
<Balance account={account} /> <Balance account={account} />
<Box className="flex flex-col gap-3"> <Box className="flex flex-col gap-3">
<div className="flex h-full flex-col justify-between py-5"> <div className="flex h-full flex-col justify-between py-5">
<div className="flex h-11 shrink-0 items-center justify-center gap-2"> <div className="flex h-11 shrink-0 items-center justify-center gap-2">
{t("note.zap.modalTitle")}{" "} {t("note.zap.modalTitle")}{" "}
<User.Provider pubkey={pubkey}> <User.Provider pubkey={pubkey}>
<User.Root className="inline-flex items-center gap-2 rounded-full bg-neutral-100 p-1 dark:bg-neutral-900"> <User.Root className="inline-flex items-center gap-2 rounded-full bg-neutral-100 p-1 dark:bg-neutral-900">
<User.Avatar className="size-6 rounded-full" /> <User.Avatar className="size-6 rounded-full" />
<User.Name className="pr-2 text-sm font-medium" /> <User.Name className="pr-2 text-sm font-medium" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</div> </div>
<div className="flex flex-1 flex-col justify-between px-5"> <div className="flex flex-1 flex-col justify-between px-5">
<div className="relative flex flex-1 flex-col pb-8"> <div className="relative flex flex-1 flex-col pb-8">
<div className="inline-flex h-full flex-1 items-center justify-center gap-1"> <div className="inline-flex h-full flex-1 items-center justify-center gap-1">
<CurrencyInput <CurrencyInput
placeholder="0" placeholder="0"
defaultValue={21} defaultValue={21}
value={amount} value={amount}
decimalsLimit={2} decimalsLimit={2}
min={0} // 0 sats min={0} // 0 sats
max={10000} // 1M sats max={10000} // 1M sats
maxLength={10000} // 1M sats maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(Number(value))} onValueChange={(value) => setAmount(Number(value))}
className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400" className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
/> />
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-500 dark:text-neutral-400"> <span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-500 dark:text-neutral-400">
sats sats
</span> </span>
</div> </div>
<div className="inline-flex items-center justify-center gap-2"> <div className="inline-flex items-center justify-center gap-2">
{DEFAULT_VALUES.map((value) => ( {DEFAULT_VALUES.map((value) => (
<button <button
type="button" key={value}
onClick={() => setAmount(value)} type="button"
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800" onClick={() => setAmount(value)}
> className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
{value} sats >
</button> {value} sats
))} </button>
</div> ))}
</div> </div>
<div className="flex w-full flex-col gap-2"> </div>
<input <div className="flex w-full flex-col gap-2">
name="message" <input
value={message} name="message"
onChange={(e) => setMessage(e.target.value)} value={message}
spellCheck={false} onChange={(e) => setMessage(e.target.value)}
autoComplete="off" spellCheck={false}
autoCorrect="off" autoComplete="off"
autoCapitalize="off" autoCorrect="off"
placeholder={t("note.zap.messagePlaceholder")} autoCapitalize="off"
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:text-neutral-400" placeholder={t("note.zap.messagePlaceholder")}
/> className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:text-neutral-400"
<div className="flex flex-col gap-2"> />
<button <div className="flex flex-col gap-2">
type="button" <button
onClick={() => submit()} type="button"
className="inline-flex h-9 w-full items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800" onClick={() => submit()}
> className="inline-flex h-9 w-full items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
{isCompleted >
? t("note.zap.buttonFinish") {isCompleted
: isLoading ? t("note.zap.buttonFinish")
? t("note.zap.buttonLoading") : isLoading
: t("note.zap.zap")} ? t("note.zap.buttonLoading")
</button> : t("note.zap.zap")}
</div> </button>
</div> </div>
</div> </div>
</div> </div>
</Box> </div>
</Container> </Box>
); </Container>
);
} }

View File

@@ -3,14 +3,14 @@
import preset from "@lume/tailwindcss"; import preset from "@lume/tailwindcss";
const config = { const config = {
content: [ content: [
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",
"../../packages/@columns/**/*{.js,.ts,.jsx,.tsx}", "../../packages/@columns/**/*{.js,.ts,.jsx,.tsx}",
"../../packages/ark/**/*{.js,.ts,.jsx,.tsx}", "../../packages/ark/**/*{.js,.ts,.jsx,.tsx}",
"../../packages/ui/**/*{.js,.ts,.jsx,.tsx}", "../../packages/ui/**/*{.js,.ts,.jsx,.tsx}",
"index.html", "index.html",
], ],
presets: [preset], presets: [preset],
}; };
export default config; export default config;

View File

@@ -1,12 +1,12 @@
{ {
"extends": "@lume/tsconfig/base.json", "extends": "@lume/tsconfig/base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"], "include": ["src"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,8 +1,8 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind"; import tailwind from "@astrojs/tailwind";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
integrations: [tailwind()] integrations: [tailwind()],
}); });

View File

@@ -13,14 +13,14 @@
"@astrojs/check": "^0.5.10", "@astrojs/check": "^0.5.10",
"@astrojs/tailwind": "^5.1.0", "@astrojs/tailwind": "^5.1.0",
"@fontsource/geist-mono": "^5.0.3", "@fontsource/geist-mono": "^5.0.3",
"astro": "^4.6.3", "astro": "^4.7.0",
"astro-seo-meta": "^4.1.0", "astro-seo-meta": "^4.1.1",
"astro-seo-schema": "^4.0.0", "astro-seo-schema": "^4.0.2",
"schema-dts": "^1.1.2", "schema-dts": "^1.1.2",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.4.5" "typescript": "^5.4.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.12" "@tailwindcss/typography": "^0.5.13"
} }
} }

View File

@@ -1,3 +1,3 @@
{ {
"extends": "astro/tsconfigs/strict" "extends": "astro/tsconfigs/strict"
} }

View File

@@ -3,6 +3,9 @@
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true
}, },
"files": {
"ignore": ["apps/desktop2/src/router.gen.ts"]
},
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {

View File

@@ -13,7 +13,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.7.1", "@biomejs/biome": "^1.7.1",
"@tauri-apps/cli": "2.0.0-beta.12", "@tauri-apps/cli": "2.0.0-beta.12",
"turbo": "^1.13.2" "turbo": "^1.13.3"
}, },
"packageManager": "pnpm@8.9.0", "packageManager": "pnpm@8.9.0",
"engines": { "engines": {

View File

@@ -5,13 +5,13 @@
"main": "./src/index.ts", "main": "./src/index.ts",
"dependencies": { "dependencies": {
"@lume/utils": "workspace:^", "@lume/utils": "workspace:^",
"@tanstack/react-query": "^5.31.0", "@tanstack/react-query": "^5.32.0",
"react": "^18.2.0" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@lume/tsconfig": "workspace:^", "@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^", "@lume/types": "workspace:^",
"@types/react": "^18.2.79", "@types/react": "^18.3.1",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
} }

View File

@@ -1,4 +1,3 @@
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import type { import type {
Account, Account,
Contact, Contact,
@@ -10,10 +9,11 @@ import type {
Metadata, Metadata,
Settings, Settings,
} from "@lume/types"; } from "@lume/types";
import { generateContentTags } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs"; import { readFile } from "@tauri-apps/plugin-fs";
import { generateContentTags } from "@lume/utils";
enum NSTORE_KEYS { enum NSTORE_KEYS {
settings = "lume_user_settings", settings = "lume_user_settings",
@@ -86,7 +86,7 @@ export class Ark {
} }
} }
public async save_account(nsec: string, password: string = "") { public async save_account(nsec: string, password = "") {
try { try {
const cmd: string = await invoke("save_key", { const cmd: string = await invoke("save_key", {
nsec, nsec,
@@ -118,6 +118,7 @@ export class Ark {
const event: Event = JSON.parse(cmd); const event: Event = JSON.parse(cmd);
return event; return event;
} catch (e) { } catch (e) {
console.error(id, String(e));
throw new Error(String(e)); throw new Error(String(e));
} }
} }
@@ -163,7 +164,7 @@ export class Ark {
) { ) {
try { try {
let until: string = undefined; let until: string = undefined;
let isGlobal = global ?? false; const isGlobal = global ?? false;
if (asOf && asOf > 0) until = asOf.toString(); if (asOf && asOf > 0) until = asOf.toString();
@@ -178,17 +179,17 @@ export class Ark {
}); });
for (const event of nostrEvents) { for (const event of nostrEvents) {
const tags = event.tags const eventIds = event.tags
.filter((el) => el[0] === "e") .filter((el) => el[3] === "root" || el[3] === "reply")
?.map((item) => item[1]); ?.map((item) => item[1]);
if (tags.length) { if (eventIds.length) {
for (const tag of tags) { for (const id of eventIds) {
if (seenIds.has(tag)) { if (seenIds.has(id)) {
dedupQueue.add(event.id); dedupQueue.add(event.id);
break; break;
} }
seenIds.add(tag); seenIds.add(id);
} }
} }
} }
@@ -380,14 +381,13 @@ export class Ark {
public parse_event_thread({ public parse_event_thread({
content, content,
tags, tags,
}: { content: string; tags: string[][] }) { }: {
content: string;
tags: string[][];
}) {
let rootEventId: string = null; let rootEventId: string = null;
let replyEventId: string = null; let replyEventId: string = null;
// Ignore quote repost
if (content.includes("nostr:note1") || content.includes("nostr:nevent1"))
return null;
// Get all event references from tags, ignore mention // Get all event references from tags, ignore mention
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention"); const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
@@ -420,7 +420,8 @@ export class Ark {
const cmd: Metadata = await invoke("get_profile", { id }); const cmd: Metadata = await invoke("get_profile", { id });
return cmd; return cmd;
} catch { } catch (e) {
console.error(pubkey, String(e));
return null; return null;
} }
} }
@@ -561,36 +562,37 @@ export class Ark {
} }
public async upload(filePath?: string) { public async upload(filePath?: string) {
const allowExts = [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
];
const selected =
filePath ||
(
await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
})
).path;
// User cancelled action
if (!selected) return null;
try { try {
const allowExts = [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
];
const selected =
filePath ||
(
await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
})
).path;
if (!selected) return null;
const file = await readFile(selected); const file = await readFile(selected);
const blob = new Blob([file]); const blob = new Blob([file]);
@@ -640,11 +642,15 @@ export class Ark {
public async get_settings() { public async get_settings() {
try { try {
if (this.settings) return this.settings;
const cmd: string = await invoke("get_nstore", { const cmd: string = await invoke("get_nstore", {
key: NSTORE_KEYS.settings, key: NSTORE_KEYS.settings,
}); });
const settings: Settings = cmd ? JSON.parse(cmd) : null; const settings: Settings = cmd ? JSON.parse(cmd) : null;
this.settings = settings; this.settings = settings;
return settings; return settings;
} catch { } catch {
const defaultSettings: Settings = { const defaultSettings: Settings = {
@@ -729,44 +735,69 @@ export class Ark {
} }
} }
public open_thread(id: string) { public async open_event_id(id: string) {
try { try {
const window = new WebviewWindow(`event-${id}`, { const label = `event-${id}`;
const url = `/events/${id}`;
await invoke("open_window", {
label,
title: "Thread", title: "Thread",
url: `/events/${id}`, url,
minWidth: 500,
minHeight: 800,
width: 500, width: 500,
height: 800, height: 800,
titleBarStyle: "overlay",
center: false,
}); });
this.windows.push(window);
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));
} }
} }
public open_profile(pubkey: string) { public async open_event(event: Event) {
try { try {
const window = new WebviewWindow(`user-${pubkey}`, { let root: string = undefined;
let reply: string = undefined;
const eTags = event.tags.filter(
(tag) => tag[0] === "e" || tag[0] === "q",
);
root = eTags.find((el) => el[3] === "root")?.[1];
reply = eTags.find((el) => el[3] === "reply")?.[1];
if (!root) root = eTags[0]?.[1];
if (!reply) reply = eTags[1]?.[1];
const label = `event-${event.id}`;
const url = `/events/${root ?? reply ?? event.id}`;
await invoke("open_window", {
label,
title: "Thread",
url,
width: 500,
height: 800,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_profile(pubkey: string) {
try {
const label = `user-${pubkey}`;
await invoke("open_window", {
label,
title: "Profile", title: "Profile",
url: `/users/${pubkey}`, url: `/users/${pubkey}`,
minWidth: 500,
minHeight: 800,
width: 500, width: 500,
height: 800, height: 800,
titleBarStyle: "overlay",
}); });
this.windows.push(window);
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));
} }
} }
public open_editor(reply_to?: string, quote: boolean = false) { public async open_editor(reply_to?: string, quote = false) {
try { try {
let url: string; let url: string;
@@ -776,91 +807,75 @@ export class Ark {
url = "/editor"; url = "/editor";
} }
const window = new WebviewWindow(`editor-${reply_to ? reply_to : 0}`, { const label = `editor-${reply_to ? reply_to : 0}`;
await invoke("open_window", {
label,
title: "Editor", title: "Editor",
url, url,
minWidth: 500, width: 500,
minHeight: 400, height: 360,
width: 600,
height: 400,
hiddenTitle: true,
titleBarStyle: "overlay",
}); });
this.windows.push(window);
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));
} }
} }
public open_nwc() { public async open_nwc() {
try { try {
const window = new WebviewWindow("nwc", { const label = "nwc";
await invoke("open_window", {
label,
title: "Nostr Wallet Connect", title: "Nostr Wallet Connect",
url: "/nwc", url: "/nwc",
minWidth: 400,
minHeight: 600,
width: 400, width: 400,
height: 600, height: 600,
hiddenTitle: true,
titleBarStyle: "overlay",
}); });
this.windows.push(window);
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));
} }
} }
public open_zap(id: string, pubkey: string, account: string) { public async open_zap(id: string, pubkey: string, account: string) {
try { try {
const window = new WebviewWindow(`zap-${id}`, { const label = `zap-${id}`;
await invoke("open_window", {
label,
title: "Zap", title: "Zap",
url: `/zap/${id}?pubkey=${pubkey}&account=${account}`, url: `/zap/${id}?pubkey=${pubkey}&account=${account}`,
minWidth: 400,
minHeight: 500,
width: 400, width: 400,
height: 500, height: 500,
titleBarStyle: "overlay",
}); });
this.windows.push(window);
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));
} }
} }
public open_settings() { public async open_settings() {
try { try {
const window = new WebviewWindow("settings", { const label = "settings";
await invoke("open_window", {
label,
title: "Settings", title: "Settings",
url: "/settings", url: "/settings",
minWidth: 600,
minHeight: 500,
width: 800, width: 800,
height: 500, height: 500,
titleBarStyle: "overlay",
}); });
this.windows.push(window);
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));
} }
} }
public open_search() { public async open_search() {
try { try {
const window = new WebviewWindow("search", { const label = "search";
await invoke("open_window", {
label,
title: "Search", title: "Search",
url: "/search", url: "/search",
width: 750, width: 750,
height: 470, height: 470,
minimizable: false,
resizable: false,
titleBarStyle: "overlay",
}); });
this.windows.push(window);
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));
} }

View File

@@ -1,4 +1,4 @@
import { Event } from "@lume/types"; import type { Event } from "@lume/types";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
@@ -21,7 +21,7 @@ export function useEvent(id: string) {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnMount: false, refetchOnMount: false,
refetchOnReconnect: false, refetchOnReconnect: false,
staleTime: Infinity, staleTime: Number.POSITIVE_INFINITY,
retry: 2, retry: 2,
}); });

View File

@@ -16,7 +16,7 @@ export function usePreview(url: string) {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnMount: false, refetchOnMount: false,
refetchOnReconnect: false, refetchOnReconnect: false,
staleTime: Infinity, staleTime: Number.POSITIVE_INFINITY,
retry: 2, retry: 2,
}); });

View File

@@ -1,5 +1,5 @@
import type { Metadata } from "@lume/types";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Metadata } from "@lume/types";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
export function useProfile(pubkey: string, embed?: string) { export function useProfile(pubkey: string, embed?: string) {
@@ -27,7 +27,7 @@ export function useProfile(pubkey: string, embed?: string) {
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
staleTime: Infinity, staleTime: Number.POSITIVE_INFINITY,
retry: 2, retry: 2,
}); });

View File

@@ -124,3 +124,4 @@ export * from "./src/quote";
export * from "./src/key"; export * from "./src/key";
export * from "./src/remote"; export * from "./src/remote";
export * from "./src/nsfw"; export * from "./src/nsfw";
export * from "./src/visit";

View File

@@ -1,14 +1,14 @@
{ {
"name": "@lume/icons", "name": "@lume/icons",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"main": "./index.ts", "main": "./index.ts",
"dependencies": { "dependencies": {
"react": "^18.2.0" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@lume/tsconfig": "workspace:*", "@lume/tsconfig": "workspace:*",
"@types/react": "^18.2.79", "@types/react": "^18.3.1",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
} }

View File

@@ -1,13 +1,13 @@
export function AddMediaIcon(props: JSX.IntrinsicElements["svg"]) { export function AddMediaIcon(props: JSX.IntrinsicElements["svg"]) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M15.25 8.75v-4a2 2 0 0 0-2-2h-8.5a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h4M3.1 11.9l1.794-1.176a2 2 0 0 1 2.206.01l1.279.852M6 6.25h.5m8 8.75h.5M6.75 6.25a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm7 6.95v3.6l2.8-1.8-2.8-1.8Zm5.5 8.05h-8.5a2 2 0 0 1-2-2v-8.5a2 2 0 0 1 2-2h8.5a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2Z" d="M15.25 8.75v-4a2 2 0 0 0-2-2h-8.5a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h4M3.1 11.9l1.794-1.176a2 2 0 0 1 2.206.01l1.279.852M6 6.25h.5m8 8.75h.5M6.75 6.25a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm7 6.95v3.6l2.8-1.8-2.8-1.8Zm5.5 8.05h-8.5a2 2 0 0 1-2-2v-8.5a2 2 0 0 1 2-2h8.5a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2Z"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,22 +1,24 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function AddWidgetIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) { export function AddWidgetIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg ) {
xmlns="http://www.w3.org/2000/svg" return (
width="24" <svg
height="24" xmlns="http://www.w3.org/2000/svg"
fill="none" width="24"
viewBox="0 0 24 24" height="24"
{...props} fill="none"
> viewBox="0 0 24 24"
<path {...props}
stroke="currentColor" >
strokeLinecap="round" <path
strokeLinejoin="round" stroke="currentColor"
strokeWidth="1.5" strokeLinecap="round"
d="M12.25 21.25h-6.5a1 1 0 01-1-1V3.75a1 1 0 011-1h12.5a1 1 0 011 1v8.5m-1 3v3m0 0v3m0-3h-3m3 0h3" strokeLinejoin="round"
/> strokeWidth="1.5"
</svg> d="M12.25 21.25h-6.5a1 1 0 01-1-1V3.75a1 1 0 011-1h12.5a1 1 0 011 1v8.5m-1 3v3m0 0v3m0-3h-3m3 0h3"
); />
</svg>
);
} }

View File

@@ -1,24 +1,24 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function AdvancedSettingsIcon( export function AdvancedSettingsIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement> props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
{...props} {...props}
> >
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M13.75 7h-10m10 0a3.25 3.25 0 116.5 0 3.25 3.25 0 11-6.5 0zm6.5 10h-8m0 0a3.25 3.25 0 11-6.5 0m6.5 0a3.25 3.25 0 10-6.5 0m0 0h-2" d="M13.75 7h-10m10 0a3.25 3.25 0 116.5 0 3.25 3.25 0 11-6.5 0zm6.5 10h-8m0 0a3.25 3.25 0 11-6.5 0m6.5 0a3.25 3.25 0 10-6.5 0m0 0h-2"
></path> ></path>
</svg> </svg>
); );
} }

View File

@@ -1,74 +1,76 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function AlbyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) { export function AlbyIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg ) {
xmlns="http://www.w3.org/2000/svg" return (
width="400" <svg
height="578" xmlns="http://www.w3.org/2000/svg"
fill="none" width="400"
viewBox="0 0 400 578" height="578"
{...props} fill="none"
> viewBox="0 0 400 578"
<path {...props}
fill="#000" >
d="M201.283 577.511c54.122 0 97.998-8.1 97.998-18.092 0-9.992-43.876-18.092-97.998-18.092-54.123 0-97.998 8.1-97.998 18.092 0 9.992 43.875 18.092 97.998 18.092z" <path
opacity="0.1" fill="#000"
></path> d="M201.283 577.511c54.122 0 97.998-8.1 97.998-18.092 0-9.992-43.876-18.092-97.998-18.092-54.123 0-97.998 8.1-97.998 18.092 0 9.992 43.875 18.092 97.998 18.092z"
<path opacity="0.1"
fill="#fff" ></path>
stroke="#000" <path
strokeWidth="15.077" fill="#fff"
d="M295.75 471.344c50.627 0 73.67-112.102 73.67-154.608 0-33.13-22.86-53.208-52.913-53.208-29.866 0-54.113 12.843-54.414 28.747-.001 41.971-7.388 179.069 33.657 179.069zM110.837 471.344c-50.627 0-73.67-112.102-73.67-154.608 0-33.13 22.86-53.208 52.913-53.208 29.866 0 54.113 12.843 54.414 28.747.001 41.971 7.388 179.069-33.657 179.069z" stroke="#000"
></path> strokeWidth="15.077"
<path d="M295.75 471.344c50.627 0 73.67-112.102 73.67-154.608 0-33.13-22.86-53.208-52.913-53.208-29.866 0-54.113 12.843-54.414 28.747-.001 41.971-7.388 179.069 33.657 179.069zM110.837 471.344c-50.627 0-73.67-112.102-73.67-154.608 0-33.13 22.86-53.208 52.913-53.208 29.866 0 54.113 12.843 54.414 28.747.001 41.971 7.388 179.069-33.657 179.069z"
fill="#FFDF6F" ></path>
stroke="#000" <path
strokeWidth="15" fill="#FFDF6F"
d="M68.83 303.262v-.002c-.054-.519.052-.82.16-1.016.127-.232.368-.508.773-.738.84-.477 2.014-.563 3.108.076 37.603 22.042 80.976 34.678 128.13 34.678 47.163 0 91.339-12.881 129.184-35.307 1.087-.645 2.26-.565 3.102-.091.407.229.65.504.779.737.109.197.216.499.163 1.019-5.854 58.014-37.322 105.977-79.618 128.054-13.969 7.293-23.576 19.962-32.013 31.089l-.452.597-.002.002c-6.857 9.046-13.063 17.147-20.648 23.116-7.584-5.969-13.791-14.07-20.648-23.116l-.001-.002-.452-.597c-8.437-11.127-18.043-23.796-32.013-31.089-42.135-21.992-73.523-69.677-79.551-127.41z" stroke="#000"
></path> strokeWidth="15"
<path d="M68.83 303.262v-.002c-.054-.519.052-.82.16-1.016.127-.232.368-.508.773-.738.84-.477 2.014-.563 3.108.076 37.603 22.042 80.976 34.678 128.13 34.678 47.163 0 91.339-12.881 129.184-35.307 1.087-.645 2.26-.565 3.102-.091.407.229.65.504.779.737.109.197.216.499.163 1.019-5.854 58.014-37.322 105.977-79.618 128.054-13.969 7.293-23.576 19.962-32.013 31.089l-.452.597-.002.002c-6.857 9.046-13.063 17.147-20.648 23.116-7.584-5.969-13.791-14.07-20.648-23.116l-.001-.002-.452-.597c-8.437-11.127-18.043-23.796-32.013-31.089-42.135-21.992-73.523-69.677-79.551-127.41z"
fill="#000" ></path>
stroke="#000" <path
strokeWidth="15.077" fill="#000"
d="M201.786 346.338c73.274 0 132.674-19.8 132.674-44.225s-59.4-44.225-132.674-44.225-132.674 19.8-132.674 44.225 59.4 44.225 132.674 44.225z" stroke="#000"
></path> strokeWidth="15.077"
<path d="M201.786 346.338c73.274 0 132.674-19.8 132.674-44.225s-59.4-44.225-132.674-44.225-132.674 19.8-132.674 44.225 59.4 44.225 132.674 44.225z"
stroke="#000" ></path>
strokeLinecap="round" <path
strokeWidth="15.077" stroke="#000"
d="M95.245 376.491s65.44 22.112 107.546 22.112c42.105 0 107.546-22.112 107.546-22.112" strokeLinecap="round"
></path> strokeWidth="15.077"
<path d="M95.245 376.491s65.44 22.112 107.546 22.112c42.105 0 107.546-22.112 107.546-22.112"
fill="#000" ></path>
d="M77 143c-16.569 0-30-13.431-30-30 0-16.569 13.431-30 30-30 16.569 0 30 13.431 30 30 0 16.569-13.431 30-30 30z" <path
></path> fill="#000"
<path stroke="#000" strokeWidth="15" d="M72 108.5l56 56"></path> d="M77 143c-16.569 0-30-13.431-30-30 0-16.569 13.431-30 30-30 16.569 0 30 13.431 30 30 0 16.569-13.431 30-30 30z"
<path ></path>
fill="#000" <path stroke="#000" strokeWidth="15" d="M72 108.5l56 56"></path>
d="M322 143c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30-16.569 0-30 13.431-30 30 0 16.569 13.431 30 30 30z" <path
></path> fill="#000"
<path stroke="#000" strokeWidth="15" d="M327.5 108.5l-56 56"></path> d="M322 143c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30-16.569 0-30 13.431-30 30 0 16.569 13.431 30 30 30z"
<path ></path>
fill="#FFDF6F" <path stroke="#000" strokeWidth="15" d="M327.5 108.5l-56 56"></path>
fillRule="evenodd" <path
d="M85.516 292.019c-16.17-7.698-25.58-24.983-22.427-42.612C76.618 173.747 133 117 200.5 117c67.663 0 124.155 57.023 137.509 132.958 3.106 17.66-6.381 34.937-22.605 42.572C280.687 308.868 241.91 318 201 318c-41.335 0-80.493-9.323-115.484-25.981z" fill="#FFDF6F"
clipRule="evenodd" fillRule="evenodd"
></path> d="M85.516 292.019c-16.17-7.698-25.58-24.983-22.427-42.612C76.618 173.747 133 117 200.5 117c67.663 0 124.155 57.023 137.509 132.958 3.106 17.66-6.381 34.937-22.605 42.572C280.687 308.868 241.91 318 201 318c-41.335 0-80.493-9.323-115.484-25.981z"
<path clipRule="evenodd"
fill="#000" ></path>
d="M70.472 250.728C83.544 177.62 137.582 124.5 200.5 124.5v-15c-72.082 0-130.809 60.375-144.794 138.587l14.766 2.641zM200.5 124.5c63.069 0 117.218 53.379 130.122 126.757l14.774-2.598C331.592 170.166 272.758 109.5 200.5 109.5v15zm111.71 161.244C278.472 301.621 240.783 310.5 201 310.5v15c42.037 0 81.902-9.386 117.597-26.183l-6.387-13.573zM201 310.5c-40.196 0-78.255-9.064-112.26-25.253l-6.448 13.544C118.269 315.918 158.526 325.5 201 325.5v-15zm129.622-59.243c2.49 14.159-5.091 28.219-18.412 34.487l6.387 13.573c19.128-9.002 30.52-29.497 26.799-50.658l-14.774 2.598zm-274.916-3.17c-3.778 21.124 7.524 41.629 26.586 50.704l6.447-13.544c-13.276-6.32-20.795-20.387-18.267-34.519l-14.766-2.641z" <path
></path> fill="#000"
<path d="M70.472 250.728C83.544 177.62 137.582 124.5 200.5 124.5v-15c-72.082 0-130.809 60.375-144.794 138.587l14.766 2.641zM200.5 124.5c63.069 0 117.218 53.379 130.122 126.757l14.774-2.598C331.592 170.166 272.758 109.5 200.5 109.5v15zm111.71 161.244C278.472 301.621 240.783 310.5 201 310.5v15c42.037 0 81.902-9.386 117.597-26.183l-6.387-13.573zM201 310.5c-40.196 0-78.255-9.064-112.26-25.253l-6.448 13.544C118.269 315.918 158.526 325.5 201 325.5v-15zm129.622-59.243c2.49 14.159-5.091 28.219-18.412 34.487l6.387 13.573c19.128-9.002 30.52-29.497 26.799-50.658l-14.774 2.598zm-274.916-3.17c-3.778 21.124 7.524 41.629 26.586 50.704l6.447-13.544c-13.276-6.32-20.795-20.387-18.267-34.519l-14.766-2.641z"
fill="#000" ></path>
fillRule="evenodd" <path
d="M114.365 273.209c-13.015-5.301-20.736-19.149-16.226-32.459C112.047 199.704 152.618 170 200.5 170c47.882 0 88.453 29.704 102.361 70.75 4.51 13.31-3.211 27.158-16.226 32.459C260.053 284.035 230.973 290 200.5 290c-30.473 0-59.553-5.965-86.135-16.791z" fill="#000"
clipRule="evenodd" fillRule="evenodd"
></path> d="M114.365 273.209c-13.015-5.301-20.736-19.149-16.226-32.459C112.047 199.704 152.618 170 200.5 170c47.882 0 88.453 29.704 102.361 70.75 4.51 13.31-3.211 27.158-16.226 32.459C260.053 284.035 230.973 290 200.5 290c-30.473 0-59.553-5.965-86.135-16.791z"
<path clipRule="evenodd"
fill="#fff" ></path>
d="M235 254c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20zM163.432 254.012c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20z" <path
></path> fill="#fff"
</svg> d="M235 254c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20zM163.432 254.012c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20z"
); ></path>
</svg>
);
} }

View File

@@ -1,18 +1,18 @@
export function AnnouncementIcon(props: JSX.IntrinsicElements['svg']) { export function AnnouncementIcon(props: JSX.IntrinsicElements["svg"]) {
return ( return (
<svg <svg
{...props} {...props}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
width="24" width="24"
height="24" height="24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" 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" /> <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> </svg>
); );
} }

View File

@@ -1,24 +1,24 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function ArrowDownIcon( export function ArrowDownIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
{...props} {...props}
> >
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
d="M6.5 14.17a30.23 30.23 0 005.406 5.62c.174.14.384.21.594.21m6-5.83a30.232 30.232 0 01-5.406 5.62.949.949 0 01-.594.21m0 0V4" d="M6.5 14.17a30.23 30.23 0 005.406 5.62c.174.14.384.21.594.21m6-5.83a30.232 30.232 0 01-5.406 5.62.949.949 0 01-.594.21m0 0V4"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,13 +1,13 @@
export function ArrowLeftIcon(props: JSX.IntrinsicElements["svg"]) { export function ArrowLeftIcon(props: JSX.IntrinsicElements["svg"]) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M10 5.75 3.75 12 10 18.25M4.5 12h15.75" d="M10 5.75 3.75 12 10 18.25M4.5 12h15.75"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,13 +1,13 @@
export function ArrowRightIcon(props: JSX.IntrinsicElements["svg"]) { export function ArrowRightIcon(props: JSX.IntrinsicElements["svg"]) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M14 5.75 20.25 12 14 18.25M19.5 12H3.75" d="M14 5.75 20.25 12 14 18.25M19.5 12H3.75"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,24 +1,24 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function ArrowRightCircleIcon( export function ArrowRightCircleIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement> props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
{...props} {...props}
> >
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M7.75 12h8M13 8.75l2.896 2.896a.5.5 0 010 .708L13 15.25M21.25 12a9.25 9.25 0 11-18.5 0 9.25 9.25 0 0118.5 0z" d="M7.75 12h8M13 8.75l2.896 2.896a.5.5 0 010 .708L13 15.25M21.25 12a9.25 9.25 0 11-18.5 0 9.25 9.25 0 0118.5 0z"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,4 +1,4 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function ArrowUpIcon( export function ArrowUpIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,

View File

@@ -1,4 +1,4 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function ArrowUpSquareIcon( export function ArrowUpSquareIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,

View File

@@ -1,17 +1,17 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function ArticleIcon( export function ArticleIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M20.248 15.25H17.25a2 2 0 0 0-2 2v2.998m4.998-4.998c.002-.026.002-.052.002-.078V5.75a2 2 0 0 0-2-2H5.75a2 2 0 0 0-2 2v12.5a2 2 0 0 0 2 2h9.422c.026 0 .052 0 .078-.002m4.998-4.998a2 2 0 0 1-.584 1.336l-3.078 3.078a2 2 0 0 1-1.336.584M8.75 8.75h6.5m-6.5 4h2.5" d="M20.248 15.25H17.25a2 2 0 0 0-2 2v2.998m4.998-4.998c.002-.026.002-.052.002-.078V5.75a2 2 0 0 0-2-2H5.75a2 2 0 0 0-2 2v12.5a2 2 0 0 0 2 2h9.422c.026 0 .052 0 .078-.002m4.998-4.998a2 2 0 0 1-.584 1.336l-3.078 3.078a2 2 0 0 1-1.336.584M8.75 8.75h6.5m-6.5 4h2.5"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,16 +1,16 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function BellIcon( export function BellIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
d="M16 18c-.673 1.766-2.21 3-4 3s-3.327-1.234-4-3m-1.716 0h11.432a2 2 0 0 0 1.982-2.264l-.905-6.789a6.853 6.853 0 0 0-13.586 0l-.905 6.789A2 2 0 0 0 6.284 18Z" d="M16 18c-.673 1.766-2.21 3-4 3s-3.327-1.234-4-3m-1.716 0h11.432a2 2 0 0 0 1.982-2.264l-.905-6.789a6.853 6.853 0 0 0-13.586 0l-.905 6.789A2 2 0 0 0 6.284 18Z"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,16 +1,16 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function BellFilledIcon( export function BellFilledIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
fill="currentColor" fill="currentColor"
fillRule="evenodd" fillRule="evenodd"
d="M12 2a7.853 7.853 0 0 0-7.784 6.815l-.905 6.789A3 3 0 0 0 6.284 19h1.07c.904 1.748 2.607 3 4.646 3 2.039 0 3.742-1.252 4.646-3h1.07a3 3 0 0 0 2.973-3.396l-.905-6.789A7.853 7.853 0 0 0 12 2Zm2.222 17H9.778c.61.637 1.399 1 2.222 1s1.613-.363 2.222-1Z" d="M12 2a7.853 7.853 0 0 0-7.784 6.815l-.905 6.789A3 3 0 0 0 6.284 19h1.07c.904 1.748 2.607 3 4.646 3 2.039 0 3.742-1.252 4.646-3h1.07a3 3 0 0 0 2.973-3.396l-.905-6.789A7.853 7.853 0 0 0 12 2Zm2.222 17H9.778c.61.637 1.399 1 2.222 1s1.613-.363 2.222-1Z"
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,22 +1,24 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function BoldIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) { export function BoldIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg ) {
xmlns="http://www.w3.org/2000/svg" return (
width="24" <svg
height="24" xmlns="http://www.w3.org/2000/svg"
fill="none" width="24"
viewBox="0 0 24 24" height="24"
{...props} fill="none"
> viewBox="0 0 24 24"
<path {...props}
stroke="currentColor" >
strokeLinecap="square" <path
strokeLinejoin="round" stroke="currentColor"
strokeWidth="2" strokeLinecap="square"
d="M13 12H6m7 0a4 4 0 000-8H8a2 2 0 00-2 2v6m7 0h1a4 4 0 010 8H8a2 2 0 01-2-2v-6" strokeLinejoin="round"
></path> strokeWidth="2"
</svg> d="M13 12H6m7 0a4 4 0 000-8H8a2 2 0 00-2 2v6m7 0h1a4 4 0 010 8H8a2 2 0 01-2-2v-6"
); ></path>
</svg>
);
} }

View File

@@ -1,12 +1,12 @@
export function CancelIcon(props: JSX.IntrinsicElements["svg"]) { export function CancelIcon(props: JSX.IntrinsicElements["svg"]) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeWidth="1.5" strokeWidth="1.5"
d="m4.75 4.75 14.5 14.5m0-14.5-14.5 14.5" d="m4.75 4.75 14.5 14.5m0-14.5-14.5 14.5"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,12 +1,12 @@
export function CancelCircleIcon(props: JSX.IntrinsicElements["svg"]) { export function CancelCircleIcon(props: JSX.IntrinsicElements["svg"]) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
fill="currentColor" fill="currentColor"
fillRule="evenodd" fillRule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm7.707-3.707a1 1 0 0 0-1.414 1.414L10.586 12l-2.293 2.293a1 1 0 1 0 1.414 1.414L12 13.414l2.293 2.293a1 1 0 0 0 1.414-1.414L13.414 12l2.293-2.293a1 1 0 0 0-1.414-1.414L12 10.586 9.707 8.293Z" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm7.707-3.707a1 1 0 0 0-1.414 1.414L10.586 12l-2.293 2.293a1 1 0 1 0 1.414 1.414L12 13.414l2.293 2.293a1 1 0 0 0 1.414-1.414L13.414 12l2.293-2.293a1 1 0 0 0-1.414-1.414L12 10.586 9.707 8.293Z"
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,4 +1,4 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function ChatsIcon( export function ChatsIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,

View File

@@ -1,16 +1,16 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function CheckIcon( export function CheckIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M4.75 12.777 10 19.25l9.25-14.5" d="M4.75 12.777 10 19.25l9.25-14.5"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,16 +1,16 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function CheckCircleIcon( export function CheckCircleIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
fill="currentColor" fill="currentColor"
fillRule="evenodd" fillRule="evenodd"
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm3.774 8.133a1 1 0 0 0-1.548-1.266l-3.8 4.645-1.219-1.22a1 1 0 0 0-1.414 1.415l2 2a1 1 0 0 0 1.481-.074l4.5-5.5Z" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm3.774 8.133a1 1 0 0 0-1.548-1.266l-3.8 4.645-1.219-1.22a1 1 0 0 0-1.414 1.415l2 2a1 1 0 0 0 1.481-.074l4.5-5.5Z"
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,17 +1,17 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function ChevronDownIcon( export function ChevronDownIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="m20 9-6.586 6.586a2 2 0 0 1-2.828 0L4 9" d="m20 9-6.586 6.586a2 2 0 0 1-2.828 0L4 9"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,24 +1,24 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function ChevronRightIcon( export function ChevronRightIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement> props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg <svg
width={24} width={24}
height={24} height={24}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
{...props} {...props}
> >
<path <path
d="M10 16L14 12L10 8" d="M10 16L14 12L10 8"
stroke="currentColor" stroke="currentColor"
strokeWidth={1.5} strokeWidth={1.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,4 +1,4 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function ChevronUpIcon( export function ChevronUpIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,

View File

@@ -1,21 +1,23 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function CommandIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) { export function CommandIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg ) {
xmlns="http://www.w3.org/2000/svg" return (
width="24" <svg
height="24" xmlns="http://www.w3.org/2000/svg"
fill="none" width="24"
viewBox="0 0 24 24" height="24"
{...props} fill="none"
> viewBox="0 0 24 24"
<path {...props}
stroke="currentColor" >
strokeLinecap="square" <path
strokeWidth="1.5" stroke="currentColor"
d="M9.25 9.25V6.5A2.75 2.75 0 106.5 9.25h2.75zm0 0h5.5m-5.5 0v5.5m5.5-5.5V6.5a2.75 2.75 0 112.75 2.75h-2.75zm0 0v5.5m0 0h-5.5m5.5 0v2.75a2.75 2.75 0 102.75-2.75h-2.75zm-5.5 0v2.75a2.75 2.75 0 11-2.75-2.75h2.75z" strokeLinecap="square"
/> strokeWidth="1.5"
</svg> d="M9.25 9.25V6.5A2.75 2.75 0 106.5 9.25h2.75zm0 0h5.5m-5.5 0v5.5m5.5-5.5V6.5a2.75 2.75 0 112.75 2.75h-2.75zm0 0v5.5m0 0h-5.5m5.5 0v2.75a2.75 2.75 0 102.75-2.75h-2.75zm-5.5 0v2.75a2.75 2.75 0 11-2.75-2.75h2.75z"
); />
</svg>
);
} }

View File

@@ -1,19 +1,21 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function CommunityIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) { export function CommunityIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg ) {
xmlns="http://www.w3.org/2000/svg" return (
width="24" <svg
height="24" xmlns="http://www.w3.org/2000/svg"
fill="none" width="24"
viewBox="0 0 24 24" height="24"
{...props} fill="none"
> viewBox="0 0 24 24"
<path {...props}
fill="currentColor" >
d="M14.606 17.613C13.593 13.981 10.87 12 8 12s-5.594 1.98-6.608 5.613C.861 19.513 2.481 21 4.145 21h7.708c1.663 0 3.283-1.487 2.753-3.387zM3.999 7a4 4 0 118 0 4 4 0 01-8 0zM13.498 7.5a3.5 3.5 0 117 0 3.5 3.5 0 01-7 0zM14.194 12.773c1.046 1.136 1.86 2.59 2.339 4.303A4.501 4.501 0 0116.387 20h3.918c1.497 0 2.983-1.344 2.497-3.084-.883-3.168-3.268-4.916-5.8-4.916-.985 0-1.947.264-2.808.773z" <path
></path> fill="currentColor"
</svg> d="M14.606 17.613C13.593 13.981 10.87 12 8 12s-5.594 1.98-6.608 5.613C.861 19.513 2.481 21 4.145 21h7.708c1.663 0 3.283-1.487 2.753-3.387zM3.999 7a4 4 0 118 0 4 4 0 01-8 0zM13.498 7.5a3.5 3.5 0 117 0 3.5 3.5 0 01-7 0zM14.194 12.773c1.046 1.136 1.86 2.59 2.339 4.303A4.501 4.501 0 0116.387 20h3.918c1.497 0 2.983-1.344 2.497-3.084-.883-3.168-3.268-4.916-5.8-4.916-.985 0-1.947.264-2.808.773z"
); ></path>
</svg>
);
} }

View File

@@ -1,4 +1,4 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function ComposeIcon( export function ComposeIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,

View File

@@ -1,16 +1,16 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function ComposeFilledIcon( export function ComposeFilledIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
fill="currentColor" fill="currentColor"
fillRule="evenodd" fillRule="evenodd"
d="M19.367 2.814c.565.603.814 1.49.489 2.391-.614 1.705-1.793 3.098-2.765 4.04-.266.256-.52.484-.752.68.894.77 1.345 2.1.652 3.301-1.22 2.116-4.304 5.716-10.928 5.756A32 32 0 0 0 6 21a1 1 0 1 1-2 0c0-4.329.793-8.748 2.831-12.259 2.063-3.553 5.386-6.14 10.288-6.721a2.645 2.645 0 0 1 2.248.794Z" d="M19.367 2.814c.565.603.814 1.49.489 2.391-.614 1.705-1.793 3.098-2.765 4.04-.266.256-.52.484-.752.68.894.77 1.345 2.1.652 3.301-1.22 2.116-4.304 5.716-10.928 5.756A32 32 0 0 0 6 21a1 1 0 1 1-2 0c0-4.329.793-8.748 2.831-12.259 2.063-3.553 5.386-6.14 10.288-6.721a2.645 2.645 0 0 1 2.248.794Z"
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,22 +1,24 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function CopyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) { export function CopyIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg ) {
width={24} return (
height={24} <svg
viewBox="0 0 24 24" width={24}
fill="none" height={24}
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
{...props} fill="none"
> xmlns="http://www.w3.org/2000/svg"
<path {...props}
d="M15.25 15.25V21.25H2.75V8.75H8.75M8.75 15.25H21.25V2.75H8.75V15.25Z" >
stroke="currentColor" <path
strokeWidth={1.5} d="M15.25 15.25V21.25H2.75V8.75H8.75M8.75 15.25H21.25V2.75H8.75V15.25Z"
strokeLinecap="round" stroke="currentColor"
strokeLinejoin="round" strokeWidth={1.5}
/> strokeLinecap="round"
</svg> strokeLinejoin="round"
); />
</svg>
);
} }

View File

@@ -1,22 +1,24 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function DarkIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) { export function DarkIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg ) {
xmlns="http://www.w3.org/2000/svg" return (
width="24" <svg
height="24" xmlns="http://www.w3.org/2000/svg"
fill="none" width="24"
viewBox="0 0 24 24" height="24"
{...props} fill="none"
> viewBox="0 0 24 24"
<path {...props}
stroke="currentColor" >
strokeLinecap="round" <path
strokeLinejoin="round" stroke="currentColor"
strokeWidth="1.5" strokeLinecap="round"
d="M21.248 11.811a6.5 6.5 0 01-9.06-9.06 9.25 9.25 0 109.06 9.06z" strokeLinejoin="round"
></path> strokeWidth="1.5"
</svg> d="M21.248 11.811a6.5 6.5 0 01-9.06-9.06 9.25 9.25 0 109.06 9.06z"
); ></path>
</svg>
);
} }

View File

@@ -1,20 +1,28 @@
import { SVGProps } from 'react'; import type { SVGProps } from "react";
export function DotsPattern(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) { export function DotsPattern(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg {...props}> ) {
<pattern return (
id="pattern-circles" <svg {...props}>
width="30" <pattern
height="30" id="pattern-circles"
x="0" width="30"
y="0" height="30"
patternContentUnits="userSpaceOnUse" x="0"
patternUnits="userSpaceOnUse" y="0"
> patternContentUnits="userSpaceOnUse"
<circle cx="2" cy="2" r="1.626" fill="currentColor"></circle> patternUnits="userSpaceOnUse"
</pattern> >
<rect width="100%" height="100%" x="0" y="0" fill="url(#pattern-circles)"></rect> <circle cx="2" cy="2" r="1.626" fill="currentColor"></circle>
</svg> </pattern>
); <rect
width="100%"
height="100%"
x="0"
y="0"
fill="url(#pattern-circles)"
></rect>
</svg>
);
} }

View File

@@ -1,17 +1,17 @@
import { SVGProps } from "react"; import type { SVGProps } from "react";
export function DownloadIcon( export function DownloadIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M20.25 14.75v3.5a2 2 0 0 1-2 2H5.75a2 2 0 0 1-2-2v-3.5M12 15V3.75M12 15l-3.5-3.5M12 15l3.5-3.5" d="M20.25 14.75v3.5a2 2 0 0 1-2 2H5.75a2 2 0 0 1-2-2v-3.5M12 15V3.75M12 15l-3.5-3.5M12 15l3.5-3.5"
/> />
</svg> </svg>
); );
} }

Some files were not shown because too many files have changed in this diff Show More