Compare commits
37 Commits
v4.0.0-alp
...
v4.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| 89f577fbef | |||
| a14aeaeb55 | |||
|
|
35cf0abda4 | ||
| 8a7b246315 | |||
|
|
d98c6d0709 | ||
| bda20e8fe8 | |||
| c342daecc8 | |||
| 5e6692cd6d | |||
| 420be77b5c | |||
| 999073f84c | |||
| 174b28f1a7 | |||
| 763cb10e85 | |||
| 89bb8d88f6 | |||
| 09aa2ecafc | |||
| 7271e9ea87 | |||
| a02e496b29 | |||
| cbbf5eaf50 | |||
| d3fa59d2b1 | |||
| aa23e39334 | |||
| c8e2018fd0 | |||
| 6e489f1c49 | |||
| a49b88ab35 | |||
| 31839531ea | |||
| ec0f3fabc0 | |||
| dd7155a3a6 | |||
| cb565ff35b | |||
| 5d59040224 | |||
| 7fabf949c6 | |||
| ea5120e2f0 | |||
| 14f07dfea8 | |||
|
|
1de48cc640 | ||
|
|
f72eb456e8 | ||
| 05b52564e0 | |||
| c8e014f33e | |||
| 46cc01e0ee | |||
| 16e6d234e5 | |||
| 3005d27403 |
1
.gitignore
vendored
@@ -35,3 +35,4 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
.vscode/
|
.vscode/
|
||||||
|
ndb/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>Lume Desktop</title>
|
<title>Lume Desktop</title>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="relative h-screen w-screen cursor-default select-none overflow-hidden bg-white font-sans text-black antialiased dark:bg-black dark:text-white"
|
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
|
||||||
>
|
>
|
||||||
<div id="root" class="h-full w-full"></div>
|
<div id="root" class="h-full w-full"></div>
|
||||||
<script type="module" src="/src/app.tsx"></script>
|
<script type="module" src="/src/app.tsx"></script>
|
||||||
|
|||||||
@@ -18,37 +18,43 @@
|
|||||||
"@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",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.24.1",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@tanstack/react-query": "^5.24.1",
|
"@tanstack/query-sync-storage-persister": "^5.29.0",
|
||||||
"@tanstack/react-query-persist-client": "^5.24.1",
|
"@tanstack/react-query": "^5.29.0",
|
||||||
"@tanstack/react-router": "^1.18.1",
|
"@tanstack/react-query-persist-client": "^5.29.0",
|
||||||
"i18next": "^23.10.0",
|
"@tanstack/react-router": "^1.26.19",
|
||||||
"i18next-resources-to-backend": "^1.2.0",
|
"i18next": "^23.11.1",
|
||||||
"nostr-tools": "^2.3.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
|
"minidenticons": "^4.2.1",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"nostr-tools": "^2.4.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^14.0.5",
|
"react-hook-form": "^7.51.2",
|
||||||
"slate": "^0.101.5",
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"slate-react": "^0.101.6",
|
"react-i18next": "^14.1.0",
|
||||||
"sonner": "^1.4.3",
|
"slate": "^0.102.0",
|
||||||
"virtua": "^0.27.5"
|
"slate-react": "^0.102.0",
|
||||||
|
"sonner": "^1.4.41",
|
||||||
|
"use-debounce": "^10.0.0",
|
||||||
|
"virtua": "^0.29.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.18.1",
|
"@tanstack/router-devtools": "^1.26.19",
|
||||||
"@tanstack/router-vite-plugin": "^1.18.1",
|
"@tanstack/router-vite-plugin": "^1.26.16",
|
||||||
"@types/react": "^18.2.61",
|
"@types/react": "^18.2.75",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^18.2.24",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.2.8",
|
||||||
"vite-plugin-top-level-await": "^1.4.1",
|
"vite-plugin-top-level-await": "^1.4.1",
|
||||||
"vite-tsconfig-paths": "^4.3.1"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
apps/desktop2/public/anime.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/desktop2/public/antenas.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/desktop2/public/antenas@2x.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
apps/desktop2/public/art.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/desktop2/public/foryou.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/desktop2/public/foryou@2x.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
apps/desktop2/public/gaming.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/desktop2/public/global.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/desktop2/public/global@2x.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
apps/desktop2/public/group.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/desktop2/public/group@2x.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
apps/desktop2/public/movie.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/desktop2/public/music.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
apps/desktop2/public/nsfw.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/desktop2/public/photography.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/desktop2/public/technology.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/desktop2/public/trending.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/desktop2/public/trending@2x.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
apps/desktop2/public/waifu.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/desktop2/public/waifu@2x.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
@@ -1,6 +1,44 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
@tailwind components;
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
@apply w-[5px];
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
@apply rounded bg-black dark:bg-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.content-break {
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-toolbar {
|
||||||
|
box-shadow:
|
||||||
|
0 0 #0000,
|
||||||
|
0 0 #0000,
|
||||||
|
0 8px 24px 0 rgba(0, 0, 0, 0.2),
|
||||||
|
0 2px 8px 0 rgba(0, 0, 0, 0.08),
|
||||||
|
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-primary {
|
||||||
|
box-shadow: 0px 0px 4px rgba(66, 65, 73, 0.14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Overide some default styles
|
||||||
|
*/
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -22,25 +60,3 @@ input::-ms-clear {
|
|||||||
::-webkit-input-placeholder {
|
::-webkit-input-placeholder {
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
media-controller {
|
|
||||||
@apply w-full overflow-hidden rounded-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.content-break {
|
|
||||||
word-break: break-word;
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-toolbar {
|
|
||||||
box-shadow:
|
|
||||||
0 0 #0000,
|
|
||||||
0 0 #0000,
|
|
||||||
0 8px 24px 0 rgba(0, 0, 0, 0.2),
|
|
||||||
0 2px 8px 0 rgba(0, 0, 0, 0.08),
|
|
||||||
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
|
|
||||||
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { ArkProvider } from "./ark";
|
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
@@ -8,11 +6,11 @@ import { I18nextProvider } from "react-i18next";
|
|||||||
import "./app.css";
|
import "./app.css";
|
||||||
import i18n from "./locale";
|
import i18n from "./locale";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { locale, platform } from "@tauri-apps/plugin-os";
|
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
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 { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
||||||
|
import { Ark } from "@lume/ark";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -27,16 +25,13 @@ const persister = createSyncStoragePersister({
|
|||||||
storage: window.localStorage,
|
storage: window.localStorage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const platformName = await platform();
|
const ark = new Ark();
|
||||||
const osLocale = (await locale()).slice(0, 2);
|
|
||||||
|
|
||||||
// Set up a Router instance
|
// Set up a Router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
ark: undefined!,
|
ark,
|
||||||
platform: platformName,
|
|
||||||
locale: osLocale,
|
|
||||||
queryClient,
|
queryClient,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -48,17 +43,8 @@ declare module "@tanstack/react-router" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function InnerApp() {
|
|
||||||
const ark = useArk();
|
|
||||||
return <RouterProvider router={router} context={{ ark }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return <RouterProvider router={router} />;
|
||||||
<ArkProvider>
|
|
||||||
<InnerApp />
|
|
||||||
</ArkProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: idk
|
// biome-ignore lint/style/noNonNullAssertion: idk
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Ark, ArkContext } from "@lume/ark";
|
|
||||||
import { PropsWithChildren, useMemo } from "react";
|
|
||||||
|
|
||||||
export const ArkProvider = ({ children }: PropsWithChildren<object>) => {
|
|
||||||
const ark = useMemo(() => new Ark(), []);
|
|
||||||
return <ArkContext.Provider value={ark}>{children}</ArkContext.Provider>;
|
|
||||||
};
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { Account } from "@lume/types";
|
import { Account } from "@lume/types";
|
||||||
import { User } from "@lume/ui";
|
import { User } from "@lume/ui";
|
||||||
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
import {
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
useRouteContext,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
|
||||||
import { BackupDialog } from "./backup";
|
|
||||||
import { LoginDialog } from "./login";
|
|
||||||
|
|
||||||
export function Accounts() {
|
export function Accounts() {
|
||||||
const ark = useArk();
|
const { ark } = useRouteContext({ strict: false });
|
||||||
const params = useParams({ strict: false });
|
const params = useParams({ strict: false });
|
||||||
|
|
||||||
const [accounts, setAccounts] = useState<Account[]>(null);
|
const [accounts, setAccounts] = useState<Account[]>(null);
|
||||||
@@ -24,7 +23,7 @@ export function Accounts() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-tauri-drag-region className="flex items-center gap-4">
|
<div data-tauri-drag-region className="flex items-center gap-3">
|
||||||
{accounts
|
{accounts
|
||||||
? accounts.map((account) =>
|
? accounts.map((account) =>
|
||||||
// @ts-ignore, useless
|
// @ts-ignore, useless
|
||||||
@@ -40,13 +39,12 @@ export function Accounts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Inactive({ pubkey }: { pubkey: string }) {
|
function Inactive({ pubkey }: { pubkey: string }) {
|
||||||
const ark = useArk();
|
const { ark } = useRouteContext({ strict: false });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const changeAccount = async (npub: string) => {
|
const changeAccount = async (npub: string) => {
|
||||||
const select = await ark.load_selected_account(npub);
|
const select = await ark.load_selected_account(npub);
|
||||||
if (select)
|
if (select) navigate({ to: "/$account/home", params: { account: npub } });
|
||||||
navigate({ to: "/$account/home/local", params: { account: npub } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,91 +59,11 @@ function Inactive({ pubkey }: { pubkey: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Active({ pubkey }: { pubkey: string }) {
|
function Active({ pubkey }: { pubkey: string }) {
|
||||||
const ark = useArk();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// @ts-ignore, magic !!!
|
|
||||||
const { guest } = useSearch({ strict: false });
|
|
||||||
// @ts-ignore, magic !!!
|
|
||||||
const { account } = useParams({ strict: false });
|
|
||||||
|
|
||||||
if (guest) {
|
|
||||||
return (
|
|
||||||
<Popover.Root open={true}>
|
|
||||||
<Popover.Trigger asChild>
|
|
||||||
<button type="button">
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Portal>
|
|
||||||
<Popover.Content
|
|
||||||
className="flex w-[280px] flex-col gap-4 rounded-xl bg-black p-5 text-neutral-100 focus:outline-none dark:bg-white dark:text-neutral-900 dark:shadow-none"
|
|
||||||
sideOffset={10}
|
|
||||||
side="bottom"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 className="mb-1 font-semibold">
|
|
||||||
You're using random account
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-600">
|
|
||||||
You can continue by claim and backup this account, or you can
|
|
||||||
import your own account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<BackupDialog />
|
|
||||||
<LoginDialog />
|
|
||||||
</div>
|
|
||||||
<Popover.Arrow className="fill-black dark:fill-white" />
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</Popover.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Root>
|
<User.Provider pubkey={pubkey}>
|
||||||
<DropdownMenu.Trigger>
|
<User.Root className="rounded-full ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950">
|
||||||
<User.Provider pubkey={pubkey}>
|
<User.Avatar className="aspect-square h-auto w-7 rounded-full object-cover" />
|
||||||
<User.Root className="rounded-full ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950">
|
</User.Root>
|
||||||
<User.Avatar className="aspect-square h-auto w-7 rounded-full object-cover" />
|
</User.Provider>
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
className="flex w-[220px] flex-col rounded-xl bg-black p-2 text-neutral-100 focus:outline-none dark:bg-white dark:text-neutral-900 dark:shadow-none"
|
|
||||||
sideOffset={10}
|
|
||||||
side="bottom"
|
|
||||||
>
|
|
||||||
<DropdownMenu.Item className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100">
|
|
||||||
Add account
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onClick={() => ark.open_profile(account)}
|
|
||||||
className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100"
|
|
||||||
>
|
|
||||||
Profile
|
|
||||||
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
|
|
||||||
⌘+Shift+P
|
|
||||||
</div>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onClick={() => navigate({ to: "/", search: { manually: true } })}
|
|
||||||
className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
|
|
||||||
⌘+Shift+L
|
|
||||||
</div>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
42
apps/desktop2/src/components/avatarUploader.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { Dispatch, ReactNode, SetStateAction, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function AvatarUploader({
|
||||||
|
setPicture,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
setPicture: Dispatch<SetStateAction<string>>;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const uploadAvatar = async () => {
|
||||||
|
// start loading
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const image = await ark.upload();
|
||||||
|
setPicture(image);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop loading
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => uploadAvatar()}
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
>
|
||||||
|
{loading ? <LoaderIcon className="size-4 animate-spin" /> : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
|
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
|
||||||
import { Link, useParams } from "@tanstack/react-router";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function BackupDialog() {
|
|
||||||
// @ts-ignore, magic!!!
|
|
||||||
const { account } = useParams({ strict: false });
|
|
||||||
|
|
||||||
const [key, setKey] = useState(null);
|
|
||||||
const [passphase, setPassphase] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const encryptKey = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const encrypted: string = await invoke("get_encrypted_key", {
|
|
||||||
npub: account,
|
|
||||||
password: passphase,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (encrypted) {
|
|
||||||
setKey(encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root>
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-900 text-sm font-medium leading-tight text-neutral-100 hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
Claim & Backup
|
|
||||||
</button>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur dark:bg-white/30" />
|
|
||||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
|
||||||
<Dialog.Close className="absolute right-5 top-5 flex w-12 flex-col items-center justify-center gap-1 text-white">
|
|
||||||
<CancelIcon className="size-8" />
|
|
||||||
<span className="text-sm font-medium uppercase text-neutral-400 dark:text-neutral-600">
|
|
||||||
Esc
|
|
||||||
</span>
|
|
||||||
</Dialog.Close>
|
|
||||||
<div className="relative flex h-min w-full max-w-xl flex-col gap-8 rounded-xl bg-white p-5 dark:bg-black">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
This is your account key
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
It's use for login to Lume or other Nostr clients. You will lost
|
|
||||||
access to your account if you lose this key.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="nsec">Set a passphase to secure your key</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
name="passphase"
|
|
||||||
type="password"
|
|
||||||
value={passphase}
|
|
||||||
onChange={(e) => setPassphase(e.target.value)}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{key ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="nsec">
|
|
||||||
Copy this key and keep it in safe place
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="nsec"
|
|
||||||
type="text"
|
|
||||||
value={key}
|
|
||||||
readOnly
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{!key ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={encryptKey}
|
|
||||||
disabled={loading}
|
|
||||||
className="inline-flex h-11 w-full items-center justify-between gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
<div className="size-5" />
|
|
||||||
<div>Submit</div>
|
|
||||||
<ArrowRightIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
to="/$account/home/local"
|
|
||||||
params={{ account }}
|
|
||||||
search={{ guest: false }}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
I've safely store my account key
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,11 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { User } from "@lume/ui";
|
import { User } from "@lume/ui";
|
||||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export function Balance({
|
export function Balance({ account }: { account: string }) {
|
||||||
recipient,
|
const { ark } = useRouteContext({ strict: false });
|
||||||
account,
|
|
||||||
}: {
|
|
||||||
recipient: string;
|
|
||||||
account: string;
|
|
||||||
}) {
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [balance, setBalance] = useState(0);
|
const [balance, setBalance] = useState(0);
|
||||||
|
|
||||||
const ark = useArk();
|
|
||||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
77
apps/desktop2/src/components/col.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { LumeColumn } from "@lume/types";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
|
||||||
|
export function Col({
|
||||||
|
column,
|
||||||
|
account,
|
||||||
|
isScroll,
|
||||||
|
}: {
|
||||||
|
column: LumeColumn;
|
||||||
|
account: string;
|
||||||
|
isScroll: boolean;
|
||||||
|
}) {
|
||||||
|
const webview = useRef<string | undefined>(undefined);
|
||||||
|
const container = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const repositionWebview = async () => {
|
||||||
|
if (webview.current && webview.current.length > 1) {
|
||||||
|
const newRect = container.current.getBoundingClientRect();
|
||||||
|
await invoke("reposition_column", {
|
||||||
|
label: webview.current,
|
||||||
|
x: newRect.x,
|
||||||
|
y: newRect.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isScroll) {
|
||||||
|
repositionWebview();
|
||||||
|
}
|
||||||
|
}, [isScroll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const rect = container.current.getBoundingClientRect();
|
||||||
|
const windowLabel = `column-${column.label}`;
|
||||||
|
const url =
|
||||||
|
column.content +
|
||||||
|
`?account=${account}&label=${column.label}&name=${column.name}`;
|
||||||
|
|
||||||
|
// create new webview
|
||||||
|
webview.current = await invoke("create_column", {
|
||||||
|
label: windowLabel,
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// close webview when unmounted
|
||||||
|
return () => {
|
||||||
|
if (webview.current && webview.current.length > 1) {
|
||||||
|
invoke("close_column", {
|
||||||
|
label: webview.current,
|
||||||
|
}).then(() => {
|
||||||
|
webview.current = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={container} className="h-full w-[440px] shrink-0 p-2">
|
||||||
|
{column.label !== "open" ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center rounded-xl flex-col bg-black/5 dark:bg-white/5 backdrop-blur-lg">
|
||||||
|
<button type="button" className="size-5" disabled>
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
|
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function LoginDialog() {
|
|
||||||
const ark = useArk();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [nsec, setNsec] = useState("");
|
|
||||||
const [passphase, setPassphase] = useState("");
|
|
||||||
|
|
||||||
const login = async () => {
|
|
||||||
try {
|
|
||||||
if (!nsec.length) {
|
|
||||||
return toast.info("You must enter a valid nsec or ncrypto");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nsec.startsWith("ncrypto") && !passphase.length) {
|
|
||||||
return toast.warning("You must provide a passphase for ncrypto key");
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = await ark.save_account(nsec, passphase);
|
|
||||||
|
|
||||||
if (save) {
|
|
||||||
navigate({ to: "/", search: { guest: false } });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root>
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-900 text-sm font-medium leading-tight text-neutral-100 hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
Add account
|
|
||||||
</button>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur dark:bg-white/30" />
|
|
||||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
|
||||||
<Dialog.Close className="absolute right-5 top-5 flex w-12 flex-col items-center justify-center gap-1 text-white">
|
|
||||||
<CancelIcon className="size-8" />
|
|
||||||
<span className="text-sm font-medium uppercase text-neutral-400 dark:text-neutral-600">
|
|
||||||
Esc
|
|
||||||
</span>
|
|
||||||
</Dialog.Close>
|
|
||||||
<div className="relative flex h-min w-full max-w-xl flex-col gap-8 rounded-xl bg-white p-5 dark:bg-black">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<h3 className="text-lg font-semibold">Add new account with</h3>
|
|
||||||
<div className="flex h-11 items-center overflow-hidden rounded-lg bg-neutral-100 p-1 dark:bg-neutral-900">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="h-full flex-1 rounded-md bg-white text-sm font-medium dark:bg-black"
|
|
||||||
>
|
|
||||||
nsec
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-full flex-1 flex-col items-center justify-center rounded-md text-sm font-medium"
|
|
||||||
>
|
|
||||||
<span className="leading-tight">nsecBunker</span>
|
|
||||||
<span className="text-xs font-normal leading-tight text-neutral-700 dark:text-neutral-300">
|
|
||||||
coming soon
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-full flex-1 flex-col items-center justify-center rounded-md text-sm font-medium"
|
|
||||||
>
|
|
||||||
<span className="leading-tight">Address</span>
|
|
||||||
<span className="text-xs font-normal leading-tight text-neutral-700 dark:text-neutral-300">
|
|
||||||
coming soon
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="nsec">
|
|
||||||
Enter sign in key start with nsec or ncrypto
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="nsec"
|
|
||||||
type="text"
|
|
||||||
placeholder="nsec or ncrypto..."
|
|
||||||
value={nsec}
|
|
||||||
onChange={(e) => setNsec(e.target.value)}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="nsec">Passphase (optional)</label>
|
|
||||||
<input
|
|
||||||
name="passphase"
|
|
||||||
type="password"
|
|
||||||
value={passphase}
|
|
||||||
onChange={(e) => setPassphase(e.target.value)}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={login}
|
|
||||||
className="inline-flex h-11 w-full items-center justify-between gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
<div className="size-5" />
|
|
||||||
<div>Add account</div>
|
|
||||||
<ArrowRightIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,8 @@ import { Event } from "@lume/types";
|
|||||||
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 { useTranslation } from "react-i18next";
|
||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { Note, User } from "@lume/ui";
|
import { Note, User } from "@lume/ui";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
|
||||||
export function RepostNote({
|
export function RepostNote({
|
||||||
event,
|
event,
|
||||||
@@ -13,8 +13,7 @@ export function RepostNote({
|
|||||||
event: Event;
|
event: Event;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const ark = useArk();
|
const { ark, settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -44,7 +43,12 @@ export function RepostNote({
|
|||||||
|
|
||||||
if (isError || !repostEvent) {
|
if (isError || !repostEvent) {
|
||||||
return (
|
return (
|
||||||
<Note.Root className={className}>
|
<Note.Root
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<User.Provider pubkey={event.pubkey}>
|
||||||
<User.Root className="flex h-14 gap-2 px-3">
|
<User.Root className="flex h-14 gap-2 px-3">
|
||||||
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
||||||
@@ -71,7 +75,7 @@ export function RepostNote({
|
|||||||
return (
|
return (
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-5 flex flex-col gap-2 border-b border-neutral-100 pb-5 dark:border-neutral-900",
|
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -100,7 +104,7 @@ export function RepostNote({
|
|||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
<div className="-ml-1 inline-flex items-center gap-4">
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
{settings.zap ? <Note.Zap /> : null}
|
||||||
</div>
|
</div>
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { User } from "@lume/ui";
|
|
||||||
|
|
||||||
export function Suggest() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isLoading, isError, data } = useQuery({
|
|
||||||
queryKey: ["trending-users"],
|
|
||||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
|
||||||
const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to fetch trending users from nostr.band API.");
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900">
|
|
||||||
<div className="h-10 shrink-0 text-lg font-semibold">
|
|
||||||
Suggested Follows
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-44 w-full items-center justify-center">
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : isError ? (
|
|
||||||
<div className="flex h-44 w-full items-center justify-center">
|
|
||||||
{t("suggestion.error")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data?.profiles.map((item: { pubkey: string }) => (
|
|
||||||
<div key={item.pubkey} className="h-max w-full overflow-hidden py-5">
|
|
||||||
<User.Provider pubkey={item.pubkey}>
|
|
||||||
<User.Root>
|
|
||||||
<div className="flex h-full w-full flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<User.Avatar className="size-10 shrink-0 rounded-full" />
|
|
||||||
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
|
|
||||||
</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" />
|
|
||||||
</div>
|
|
||||||
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Event } from "@lume/types";
|
import { 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,
|
||||||
@@ -9,11 +10,13 @@ export function TextNote({
|
|||||||
event: Event;
|
event: Event;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-5 flex flex-col gap-2 border-b border-neutral-100 pb-5 dark:border-neutral-900",
|
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -27,7 +30,7 @@ export function TextNote({
|
|||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
<div className="-ml-1 inline-flex items-center gap-4">
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
{settings.zap ? <Note.Zap /> : null}
|
||||||
</div>
|
</div>
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
15
apps/desktop2/src/components/toolbar.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ReactNode } from "@tanstack/react-router";
|
||||||
|
import { useLayoutEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export function Toolbar({ children }: { children: ReactNode }) {
|
||||||
|
const [domReady, setDomReady] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setDomReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return domReady
|
||||||
|
? createPortal(children, document.getElementById("toolbar"))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
155
apps/desktop2/src/routes/$account.home.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Col } from "@/components/col";
|
||||||
|
import { Toolbar } from "@/components/toolbar";
|
||||||
|
import { ArrowLeftIcon, ArrowRightIcon, LoaderIcon } from "@lume/icons";
|
||||||
|
import { EventColumns, LumeColumn } from "@lume/types";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { resolveResource } from "@tauri-apps/api/path";
|
||||||
|
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { VList, VListHandle } from "virtua";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/$account/home")({
|
||||||
|
component: Screen,
|
||||||
|
pendingComponent: Pending,
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const resourcePath = await resolveResource("resources/system_columns.json");
|
||||||
|
const systemColumns: LumeColumn[] = JSON.parse(
|
||||||
|
await readTextFile(resourcePath),
|
||||||
|
);
|
||||||
|
const userColumns = await ark.get_columns();
|
||||||
|
|
||||||
|
return {
|
||||||
|
storedColumns: !userColumns.length ? systemColumns : userColumns,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { account } = Route.useParams();
|
||||||
|
const { ark, storedColumns } = Route.useRouteContext();
|
||||||
|
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const [isScroll, setIsScroll] = useState(false);
|
||||||
|
const [columns, setColumns] = useState(storedColumns);
|
||||||
|
|
||||||
|
const vlistRef = useRef<VListHandle>(null);
|
||||||
|
|
||||||
|
const goLeft = () => {
|
||||||
|
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||||
|
setSelectedIndex(prevIndex);
|
||||||
|
vlistRef.current.scrollToIndex(prevIndex, {
|
||||||
|
align: "center",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const goRight = () => {
|
||||||
|
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
||||||
|
setSelectedIndex(nextIndex);
|
||||||
|
vlistRef.current.scrollToIndex(nextIndex, {
|
||||||
|
align: "center",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||||
|
column["label"] = column.label + "-" + nanoid();
|
||||||
|
|
||||||
|
setColumns((state) => [...state, column]);
|
||||||
|
setSelectedIndex(columns.length + 1);
|
||||||
|
|
||||||
|
// scroll to the last column
|
||||||
|
vlistRef.current.scrollToIndex(columns.length + 1, {
|
||||||
|
align: "end",
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
const remove = useDebouncedCallback((label: string) => {
|
||||||
|
setColumns((state) => state.filter((t) => t.label !== label));
|
||||||
|
setSelectedIndex(columns.length - 1);
|
||||||
|
|
||||||
|
// scroll to the first column
|
||||||
|
vlistRef.current.scrollToIndex(0, {
|
||||||
|
align: "start",
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save state
|
||||||
|
ark.set_columns(columns);
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unlisten: Awaited<ReturnType<typeof listen>> | undefined = undefined;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (unlisten) return;
|
||||||
|
unlisten = await listen<EventColumns>("columns", (data) => {
|
||||||
|
if (data.payload.type === "add") add(data.payload.column);
|
||||||
|
if (data.payload.type === "remove") remove(data.payload.label);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlisten) unlisten();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<VList
|
||||||
|
ref={vlistRef}
|
||||||
|
horizontal
|
||||||
|
tabIndex={-1}
|
||||||
|
itemSize={440}
|
||||||
|
overscan={3}
|
||||||
|
onScroll={() => {
|
||||||
|
setIsScroll(true);
|
||||||
|
}}
|
||||||
|
onScrollEnd={() => {
|
||||||
|
setIsScroll(false);
|
||||||
|
}}
|
||||||
|
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
||||||
|
>
|
||||||
|
{columns.map((column, index) => (
|
||||||
|
<Col
|
||||||
|
key={column.label + index}
|
||||||
|
column={column}
|
||||||
|
account={account}
|
||||||
|
isScroll={isScroll}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VList>
|
||||||
|
<Toolbar>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goRight()}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Toolbar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pending() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<button type="button" className="size-5" disabled>
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,131 +1,55 @@
|
|||||||
import {
|
import { ComposeFilledIcon, PlusIcon } from "@lume/icons";
|
||||||
BellFilledIcon,
|
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
BellIcon,
|
|
||||||
ComposeFilledIcon,
|
|
||||||
HomeFilledIcon,
|
|
||||||
HomeIcon,
|
|
||||||
HorizontalDotsIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
SpaceFilledIcon,
|
|
||||||
SpaceIcon,
|
|
||||||
} from "@lume/icons";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Accounts } from "@/components/accounts";
|
import { Accounts } from "@/components/accounts";
|
||||||
import { useArk } from "@lume/ark";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
import { Box } from "@lume/ui";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account")({
|
export const Route = createFileRoute("/$account")({
|
||||||
component: App,
|
component: App,
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const platformName = await platform();
|
||||||
|
return { platform: platformName };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const ark = useArk();
|
const navigate = useNavigate();
|
||||||
const context = Route.useRouteContext();
|
const { ark, platform } = Route.useRouteContext();
|
||||||
|
|
||||||
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">
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 shrink-0 items-center justify-between pr-4",
|
"flex h-11 shrink-0 items-center justify-between pr-2",
|
||||||
context.platform === "macos" ? "pl-24" : "pl-4",
|
platform === "macos" ? "ml-2 pl-20" : "pl-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Navigation />
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Accounts />
|
<Accounts />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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
|
|
||||||
type="button"
|
|
||||||
onClick={() => ark.open_settings()}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<HorizontalDotsIcon className="size-5" />
|
|
||||||
</button>
|
</button>
|
||||||
|
<div id="toolbar" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Box>
|
<div className="flex-1">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Navigation() {
|
|
||||||
// @ts-ignore, useless
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex h-full flex-1 items-center gap-2"
|
|
||||||
>
|
|
||||||
<Link to="/$account/home/local" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3",
|
|
||||||
isActive
|
|
||||||
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
|
||||||
: "hover:bg-black/10 dark:hover:bg-white/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isActive ? (
|
|
||||||
<HomeFilledIcon className="size-5" />
|
|
||||||
) : (
|
|
||||||
<HomeIcon className="size-5" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium">Home</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link to="/$account/space" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 hover:bg-black/10 dark:hover:bg-white/10",
|
|
||||||
isActive
|
|
||||||
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
|
||||||
: "hover:bg-black/10 dark:hover:bg-white/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isActive ? (
|
|
||||||
<SpaceFilledIcon className="size-5" />
|
|
||||||
) : (
|
|
||||||
<SpaceIcon className="size-5" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium">Space</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link to="/$account/activity" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 hover:bg-black/10 dark:hover:bg-white/10",
|
|
||||||
isActive
|
|
||||||
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
|
||||||
: "hover:bg-black/10 dark:hover:bg-white/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isActive ? (
|
|
||||||
<BellFilledIcon className="size-5" />
|
|
||||||
) : (
|
|
||||||
<BellIcon className="size-5" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium">Activity</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/activity")({
|
|
||||||
component: Activity,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Activity() {
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full overflow-hidden rounded-xl bg-white 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">
|
|
||||||
<p>Activity</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { GlobalIcon, LoaderIcon, LocalIcon, RefreshIcon } from "@lume/icons";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/home")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
const queryKey = `${window.location.pathname.split("/").at(-1)}_newsfeed`;
|
|
||||||
await queryClient.refetchQueries({ queryKey: [queryKey, account] });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-full max-w-xl shrink-0 items-center justify-between border-b border-neutral-100 dark:border-neutral-900">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link to="/$account/home/local">
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm leading-tight hover:bg-neutral-100 dark:hover:bg-neutral-900",
|
|
||||||
isActive
|
|
||||||
? "bg-neutral-100 font-semibold text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
|
||||||
: "text-neutral-600 dark:text-neutral-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LocalIcon className="size-4" />
|
|
||||||
Local
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link to="/$account/home/global">
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm leading-tight hover:bg-neutral-100 dark:hover:bg-neutral-900",
|
|
||||||
isActive
|
|
||||||
? "bg-neutral-100 font-semibold text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
|
||||||
: "text-neutral-600 dark:text-neutral-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GlobalIcon className="size-4" />
|
|
||||||
Global
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={refresh}
|
|
||||||
className="text-neutral-700 hover:text-blue-500 dark:text-neutral-300"
|
|
||||||
>
|
|
||||||
<RefreshIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { Suggest } from "@/components/suggest";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
|
|
||||||
import { Event, Kind } from "@lume/types";
|
|
||||||
import { FETCH_LIMIT } from "@lume/utils";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/home/global")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const ark = useArk();
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
hasNextPage,
|
|
||||||
isLoading,
|
|
||||||
isRefetching,
|
|
||||||
isFetchingNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: ["global_newsfeed", account],
|
|
||||||
initialPageParam: 0,
|
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
|
||||||
const events = await ark.get_events(
|
|
||||||
"global",
|
|
||||||
FETCH_LIMIT,
|
|
||||||
pageParam,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
return events;
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
if (!lastEvent) return;
|
|
||||||
return lastEvent.created_at - 1;
|
|
||||||
},
|
|
||||||
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 (
|
|
||||||
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col">
|
|
||||||
<div className="flex-1">
|
|
||||||
{isLoading || isRefetching ? (
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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 ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
|
||||||
Load more
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { Suggest } from "@/components/suggest";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
|
|
||||||
import { Event, Kind } from "@lume/types";
|
|
||||||
import { FETCH_LIMIT } from "@lume/utils";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/home/local")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const ark = useArk();
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
hasNextPage,
|
|
||||||
isLoading,
|
|
||||||
isRefetching,
|
|
||||||
isFetchingNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: ["local_newsfeed", account],
|
|
||||||
initialPageParam: 0,
|
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
|
||||||
const events = await ark.get_events(
|
|
||||||
"local",
|
|
||||||
FETCH_LIMIT,
|
|
||||||
pageParam,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
return events;
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
if (!lastEvent) return;
|
|
||||||
return lastEvent.created_at - 1;
|
|
||||||
},
|
|
||||||
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 (
|
|
||||||
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col">
|
|
||||||
<div className="flex-1">
|
|
||||||
{isLoading || isRefetching ? (
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : !data.length ? (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
|
|
||||||
<InfoIcon className="size-6" />
|
|
||||||
<p>
|
|
||||||
Empty newsfeed. Or you view the{" "}
|
|
||||||
<Link
|
|
||||||
to="/$account/home/global"
|
|
||||||
className="text-blue-500 hover:text-blue-600"
|
|
||||||
>
|
|
||||||
Global Newsfeed
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Suggest />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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 ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
|
||||||
Load more
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/space")({
|
|
||||||
component: Space,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Space() {
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full overflow-hidden rounded-xl bg-white 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">
|
|
||||||
<p>Space</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,22 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import { LoaderIcon } from "@lume/icons";
|
||||||
import {
|
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||||
Outlet,
|
|
||||||
ScrollRestoration,
|
|
||||||
createRootRouteWithContext,
|
|
||||||
} from "@tanstack/react-router";
|
|
||||||
import { type Ark } from "@lume/ark";
|
import { type Ark } from "@lume/ark";
|
||||||
import { type QueryClient } from "@tanstack/react-query";
|
import { type QueryClient } from "@tanstack/react-query";
|
||||||
import { type Platform } from "@tauri-apps/plugin-os";
|
import { type Platform } from "@tauri-apps/plugin-os";
|
||||||
|
import { Account, Interests, Settings } from "@lume/types";
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
ark: Ark;
|
ark: Ark;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
platform: Platform;
|
platform?: Platform;
|
||||||
locale: string;
|
locale?: string;
|
||||||
|
settings?: Settings;
|
||||||
|
interests?: Interests;
|
||||||
|
accounts?: Account[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
component: () => (
|
component: () => <Outlet />,
|
||||||
<>
|
|
||||||
<ScrollRestoration />
|
|
||||||
<Outlet />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
pendingComponent: Pending,
|
pendingComponent: Pending,
|
||||||
wrapInSuspense: true,
|
wrapInSuspense: true,
|
||||||
});
|
});
|
||||||
@@ -29,7 +24,9 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
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">
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<button type="button" className="size-5" disabled>
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
119
apps/desktop2/src/routes/antenas.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { RepostNote } from "@/components/repost";
|
||||||
|
import { TextNote } from "@/components/text";
|
||||||
|
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
|
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
||||||
|
import { Column } 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">
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</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 ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/desktop2/src/routes/auth.lazy.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Box, Container } from "@lume/ui";
|
||||||
|
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/auth")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
return (
|
||||||
|
<Container withDrag>
|
||||||
|
<Box className="px-3 pt-3">
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/create/")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [method, setMethod] = useState<"self" | "managed">("self");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
if (method === "self") {
|
|
||||||
navigate({ to: "/auth/create/self" });
|
|
||||||
} else {
|
|
||||||
navigate({ to: "/auth/create/managed" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h1 className="text-2xl font-semibold">{t("signup.title")}</h1>
|
|
||||||
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
|
|
||||||
{t("signup.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMethod("self")}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-start rounded-xl bg-neutral-100 px-4 py-3.5 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800",
|
|
||||||
method === "self"
|
|
||||||
? "ring-1 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-black"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="font-semibold">{t("signup.selfManageMethod")}</p>
|
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-500">
|
|
||||||
{t("signup.selfManageMethodDescription")}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMethod("managed")}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-start rounded-xl bg-neutral-100 px-4 py-3.5 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800",
|
|
||||||
method === "managed"
|
|
||||||
? "ring-1 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-black"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="font-semibold">{t("signup.providerMethod")}</p>
|
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-500">
|
|
||||||
{t("signup.providerMethodDescription")}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={next}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-blue-500 text-lg font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.continue")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{method === "managed" ? (
|
|
||||||
<div className="flex flex-col gap-1.5 rounded-xl border border-red-200 bg-red-100 p-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900 dark:text-red-200">
|
|
||||||
<p className="font-semibold text-red-900 dark:text-red-100">
|
|
||||||
Attention:
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You're chosing Managed by Provider, this feature still in
|
|
||||||
"Beta".
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Some functions still missing or not work as expected, you
|
|
||||||
shouldn't create your main account with this method
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://github.com/kind-0/nsecbunkerd/blob/master/OAUTH-LIKE-FLOW.md"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-blue-500"
|
|
||||||
>
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute('/auth/create/managed')({
|
|
||||||
component: () => <div>Hello /auth/create/managed!</div>
|
|
||||||
})
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { CheckIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from "@lume/icons";
|
|
||||||
import { Keys } from "@lume/types";
|
|
||||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
|
||||||
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/create/self")({
|
|
||||||
component: Create,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Create() {
|
|
||||||
const ark = useArk();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showKey, setShowKey] = useState(false);
|
|
||||||
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
|
||||||
const [keys, setKeys] = useState<Keys>(null);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await ark.save_account(keys);
|
|
||||||
navigate({
|
|
||||||
to: "/$account/home/local",
|
|
||||||
params: { account: keys.npub },
|
|
||||||
search: { onboarding: true },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function genKeys() {
|
|
||||||
const res = await ark.create_keys();
|
|
||||||
setKeys(res);
|
|
||||||
}
|
|
||||||
genKeys();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h1 className="text-2xl font-semibold">
|
|
||||||
{t("signupWithSelfManage.title")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
|
|
||||||
{t("signupWithSelfManage.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mb-0 flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
{keys ? (
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
value={keys.nsec}
|
|
||||||
type={showKey ? "text" : "password"}
|
|
||||||
className="h-12 w-full resize-none rounded-xl border-transparent bg-neutral-100 pl-3 pr-14 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowKey((state) => !state)}
|
|
||||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
|
||||||
>
|
|
||||||
{showKey ? (
|
|
||||||
<EyeOnIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<EyeOffIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox.Root
|
|
||||||
checked={confirm.c1}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
|
||||||
}
|
|
||||||
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-100 outline-none dark:bg-neutral-900"
|
|
||||||
id="confirm1"
|
|
||||||
>
|
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</Checkbox.Indicator>
|
|
||||||
</Checkbox.Root>
|
|
||||||
<label
|
|
||||||
className="text-sm text-neutral-700 dark:text-neutral-400"
|
|
||||||
htmlFor="confirm1"
|
|
||||||
>
|
|
||||||
{t("signupWithSelfManage.confirm1")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox.Root
|
|
||||||
checked={confirm.c3}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
|
||||||
}
|
|
||||||
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-100 outline-none dark:bg-neutral-900"
|
|
||||||
id="confirm3"
|
|
||||||
>
|
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</Checkbox.Indicator>
|
|
||||||
</Checkbox.Root>
|
|
||||||
<label
|
|
||||||
className="text-sm text-neutral-700 dark:text-neutral-400"
|
|
||||||
htmlFor="confirm3"
|
|
||||||
>
|
|
||||||
{t("signupWithSelfManage.confirm3")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox.Root
|
|
||||||
checked={confirm.c2}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
|
||||||
}
|
|
||||||
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-100 outline-none dark:bg-neutral-900"
|
|
||||||
id="confirm2"
|
|
||||||
>
|
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</Checkbox.Indicator>
|
|
||||||
</Checkbox.Root>
|
|
||||||
<label
|
|
||||||
className="text-sm text-neutral-700 dark:text-neutral-400"
|
|
||||||
htmlFor="confirm2"
|
|
||||||
>
|
|
||||||
{t("signupWithSelfManage.confirm2")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
disabled={!confirm.c1 || !confirm.c2 || !confirm.c3}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-blue-500 text-lg font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("signupWithSelfManage.button")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/import")({
|
|
||||||
component: Import,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Import() {
|
|
||||||
const ark = useArk();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!key.startsWith("nsec1")) return;
|
|
||||||
if (key.length < 30) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const npub: string = await invoke("get_public_key", { nsec: key });
|
|
||||||
await ark.save_account({
|
|
||||||
npub,
|
|
||||||
nsec: key,
|
|
||||||
});
|
|
||||||
navigate({
|
|
||||||
to: "/$account/home/local",
|
|
||||||
params: { account: npub },
|
|
||||||
search: { onboarding: true },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNip05 = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/.test(key);
|
|
||||||
const isNip49 = key.startsWith("ncryptsec");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h1 className="text-2xl font-semibold">{t("login.title")}</h1>
|
|
||||||
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
|
|
||||||
{t("login.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
value={key}
|
|
||||||
type="text"
|
|
||||||
onChange={(e) => setKey(e.target.value)}
|
|
||||||
className="h-12 w-full resize-none rounded-xl border-transparent bg-neutral-100 pl-3 pr-10 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isNip05 || isNip49 ? (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label
|
|
||||||
htmlFor="password"
|
|
||||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
|
||||||
>
|
|
||||||
Password *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={password}
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="h-12 w-full resize-none rounded-xl border-transparent bg-neutral-100 pl-3 pr-10 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-blue-500 text-lg font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Import"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
191
apps/desktop2/src/routes/auth/new/backup.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { displayNsec } from "@lume/utils";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
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")({
|
||||||
|
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { account } = Route.useSearch();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [key, setKey] = useState(null);
|
||||||
|
const [passphase, setPassphase] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
if (key) {
|
||||||
|
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
|
||||||
|
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", {
|
||||||
|
npub: account,
|
||||||
|
password: passphase,
|
||||||
|
});
|
||||||
|
|
||||||
|
setKey(encrypted);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyKey = async () => {
|
||||||
|
try {
|
||||||
|
await writeText(key);
|
||||||
|
setCopied(true);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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="flex flex-col text-center">
|
||||||
|
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
||||||
|
<p className="text-neutral-700 dark:text-neutral-300">
|
||||||
|
It's use for login to Lume or other Nostr clients. You will lost
|
||||||
|
access to your account if you lose this key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="passphase" className="font-medium">
|
||||||
|
Set a passphase to secure your key
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
name="passphase"
|
||||||
|
type="password"
|
||||||
|
value={passphase}
|
||||||
|
onChange={(e) => setPassphase(e.target.value)}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{key ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="nsec" className="font-medium">
|
||||||
|
Copy this key and keep it in safe place
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
name="nsec"
|
||||||
|
type="text"
|
||||||
|
value={displayNsec(key, 36)}
|
||||||
|
readOnly
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyKey}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="font-medium">Before you continue:</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox.Root
|
||||||
|
checked={confirm.c1}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||||
|
}
|
||||||
|
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
||||||
|
id="confirm1"
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox.Root>
|
||||||
|
<label
|
||||||
|
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||||
|
htmlFor="confirm1"
|
||||||
|
>
|
||||||
|
{t("backup.confirm1")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox.Root
|
||||||
|
checked={confirm.c2}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||||
|
}
|
||||||
|
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
||||||
|
id="confirm2"
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox.Root>
|
||||||
|
<label
|
||||||
|
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||||
|
htmlFor="confirm2"
|
||||||
|
>
|
||||||
|
{t("backup.confirm2")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox.Root
|
||||||
|
checked={confirm.c3}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
||||||
|
}
|
||||||
|
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
||||||
|
id="confirm3"
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox.Root>
|
||||||
|
<label
|
||||||
|
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||||
|
htmlFor="confirm3"
|
||||||
|
>
|
||||||
|
{t("backup.confirm3")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
apps/desktop2/src/routes/auth/new/profile.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { AvatarUploader } from "@/components/avatarUploader";
|
||||||
|
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
||||||
|
import { Metadata } from "@lume/types";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/auth/new/profile")({
|
||||||
|
component: Screen,
|
||||||
|
loader: ({ context }) => {
|
||||||
|
return context.ark.create_keys();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const keys = Route.useLoaderData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { ark } = Route.useRouteContext();
|
||||||
|
const { register, handleSubmit } = useForm();
|
||||||
|
|
||||||
|
const [picture, setPicture] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async (data: {
|
||||||
|
name: string;
|
||||||
|
about: string;
|
||||||
|
website: string;
|
||||||
|
}) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save account keys
|
||||||
|
const save = await ark.save_account(keys.nsec);
|
||||||
|
|
||||||
|
// Then create profile
|
||||||
|
if (save) {
|
||||||
|
const profile: Metadata = { ...data, picture };
|
||||||
|
const eventId = await ark.create_profile(profile);
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
navigate({
|
||||||
|
to: "/auth/new/backup",
|
||||||
|
search: { account: keys.npub },
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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="text-center">
|
||||||
|
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||||
|
{picture ? (
|
||||||
|
<img
|
||||||
|
src={picture}
|
||||||
|
alt="avatar"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarUploader
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-8" />
|
||||||
|
</AvatarUploader>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex w-full flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="display_name" className="font-medium">
|
||||||
|
{t("user.displayName")} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={"text"}
|
||||||
|
{...register("display_name", { required: true, minLength: 1 })}
|
||||||
|
placeholder="e.g. Alice in Nostrland"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="name" className="font-medium">
|
||||||
|
{t("user.name")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={"text"}
|
||||||
|
{...register("name")}
|
||||||
|
placeholder="e.g. alice"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="about" className="font-medium">
|
||||||
|
{t("user.bio")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register("about")}
|
||||||
|
placeholder="e.g. Artist, anime-lover, and k-pop fan"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="website" className="font-medium">
|
||||||
|
{t("user.website")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
{...register("website")}
|
||||||
|
placeholder="e.g. https://alice.me"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("global.continue")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/desktop2/src/routes/auth/privkey.lazy.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/auth/privkey")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { ark } = Route.useRouteContext();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!key.startsWith("nsec1"))
|
||||||
|
return toast.warning(
|
||||||
|
"You need to enter a valid private key starts with nsec or ncryptsec",
|
||||||
|
);
|
||||||
|
if (key.length < 30)
|
||||||
|
return toast.warning("You need to enter a valid private key");
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const npub = await ark.save_account(key, password);
|
||||||
|
navigate({
|
||||||
|
to: "/auth/settings",
|
||||||
|
search: { account: npub, new: false },
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
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="text-center">
|
||||||
|
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="key"
|
||||||
|
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
Private Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="key"
|
||||||
|
type="text"
|
||||||
|
placeholder="nsec or ncryptsec..."
|
||||||
|
value={key}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
Password (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{loading ? <LoaderIcon className="size-4 animate-spin" /> : "Login"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/settings/")({
|
export const Route = createLazyFileRoute("/auth/remote")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
return <div>Settings</div>;
|
return <div>#todo</div>;
|
||||||
}
|
}
|
||||||
188
apps/desktop2/src/routes/auth/settings.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { LaurelIcon, LoaderIcon } from "@lume/icons";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/auth/settings")({
|
||||||
|
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const permissionGranted = await isPermissionGranted(); // get notification permission
|
||||||
|
const ark = context.ark;
|
||||||
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: { ...settings, notification: permissionGranted },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
pendingComponent: Pending,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { account } = Route.useSearch();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { ark, settings } = Route.useRouteContext();
|
||||||
|
|
||||||
|
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
||||||
|
|
||||||
|
const toggleNofitication = async () => {
|
||||||
|
await requestPermission();
|
||||||
|
setNewSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
notification: !newSettings.notification,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAutoUpdate = () => {
|
||||||
|
setNewSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
autoUpdate: !newSettings.autoUpdate,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEnhancedPrivacy = () => {
|
||||||
|
setNewSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enhancedPrivacy: !newSettings.enhancedPrivacy,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleZap = () => {
|
||||||
|
setNewSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
zap: !newSettings.zap,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
const eventId = await ark.set_settings(settings);
|
||||||
|
if (eventId) {
|
||||||
|
navigate({ to: "/$account/home", params: { account }, replace: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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="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">
|
||||||
|
<LaurelIcon className="size-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{t("onboardingSettings.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t("onboardingSettings.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<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">
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.notification}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">Push Notification</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Enabling push notifications will allow you to receive
|
||||||
|
notifications from Lume.
|
||||||
|
</p>
|
||||||
|
</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">
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.enhancedPrivacy}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">Enhanced Privacy</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Lume will display external resources like image, video or link
|
||||||
|
preview as plain text.
|
||||||
|
</p>
|
||||||
|
</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">
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.autoUpdate}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">Auto Update</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Automatically download and install new version.
|
||||||
|
</p>
|
||||||
|
</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">
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.zap}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">Zap</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Show the Zap button in each note and user's profile screen, use
|
||||||
|
for send Bitcoin tip to other users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-50 px-5 py-4 dark:bg-neutral-950">
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
There are many more settings you can configure from the 'Settings'
|
||||||
|
Screen. Be sure to visit it later.
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pending() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<button type="button" className="size-5" disabled>
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/desktop2/src/routes/create-group.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { CheckCircleIcon } from "@lume/icons";
|
||||||
|
import { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Column, User } from "@lume/ui";
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-group")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const contacts = await ark.get_contact_list();
|
||||||
|
return contacts;
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const contacts = Route.useLoaderData();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { ark } = Route.useRouteContext();
|
||||||
|
const { label, name, redirect } = Route.useSearch();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState<string>("Just a new group");
|
||||||
|
const [users, setUsers] = useState<Array<string>>([]);
|
||||||
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
|
const toggleUser = (pubkey: string) => {
|
||||||
|
const arr = users.includes(pubkey)
|
||||||
|
? users.filter((i) => i !== pubkey)
|
||||||
|
: [...users, pubkey];
|
||||||
|
setUsers(arr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
if (isDone) return router.history.push(redirect);
|
||||||
|
|
||||||
|
const groups = await ark.set_nstore(
|
||||||
|
`lume_group_${label}`,
|
||||||
|
JSON.stringify(users),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (groups) setIsDone(true);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column.Root>
|
||||||
|
<Column.Header label={label} name={name} />
|
||||||
|
<Column.Content>
|
||||||
|
<div className="flex flex-col gap-5 p-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="name" className="font-medium">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
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 className="inline-flex items-center justify-between">
|
||||||
|
<span className="font-medium">Pick user</span>
|
||||||
|
<span className="text-xs text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{contacts.map((item: string) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
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.Avatar className="size-10 rounded-full object-cover" />
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<User.Name className="font-medium" />
|
||||||
|
<User.NIP05 className="text-neutral-700 dark:text-neutral-300" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
{users.includes(item) ? (
|
||||||
|
<CheckCircleIcon className="size-5 text-teal-500" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="fixed z-10 flex items-center justify-center w-full bottom-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
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-36 h-11 hover:bg-blue-600 focus:outline-none disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isDone ? "Back" : "Update"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Column.Content>
|
||||||
|
</Column.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { AddMediaIcon, LoaderIcon } from "@lume/icons";
|
import { AddMediaIcon, LoaderIcon } from "@lume/icons";
|
||||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
import { cn, insertImage, isImagePath } from "@lume/utils";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -6,9 +5,10 @@ import { useSlateStatic } from "slate-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { UnlistenFn } from "@tauri-apps/api/event";
|
import { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
|
||||||
export function MediaButton({ className }: { className?: string }) {
|
export function MediaButton({ className }: { className?: string }) {
|
||||||
const ark = useArk();
|
const { ark } = useRouteContext({ strict: false });
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { LoaderIcon, TrashIcon } from "@lume/icons";
|
import { LoaderIcon, TrashIcon } from "@lume/icons";
|
||||||
import {
|
import {
|
||||||
Portal,
|
Portal,
|
||||||
@@ -61,6 +60,7 @@ export const Route = createFileRoute("/editor/")({
|
|||||||
function Screen() {
|
function Screen() {
|
||||||
// @ts-ignore, useless
|
// @ts-ignore, useless
|
||||||
const { reply_to, quote } = Route.useSearch();
|
const { reply_to, quote } = Route.useSearch();
|
||||||
|
const { ark } = Route.useRouteContext();
|
||||||
|
|
||||||
let initialValue: EditorElement[];
|
let initialValue: EditorElement[];
|
||||||
|
|
||||||
@@ -89,7 +89,6 @@ function Screen() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ark = useArk();
|
|
||||||
const ref = useRef<HTMLDivElement | null>();
|
const ref = useRef<HTMLDivElement | null>();
|
||||||
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
|
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
|
||||||
|
|
||||||
@@ -149,11 +148,12 @@ function Screen() {
|
|||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
await sendNativeNotification("You've publish new post successfully.");
|
await sendNativeNotification("You've publish new post successfully.");
|
||||||
return reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loading
|
// stop loading
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
// reset form
|
||||||
|
reset();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
await sendNativeNotification(String(e));
|
await sendNativeNotification(String(e));
|
||||||
@@ -210,7 +210,7 @@ function Screen() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={publish}
|
onClick={publish}
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
className="inline-flex h-9 w-24 items-center justify-center rounded-full bg-blue-500 px-3 font-medium text-white hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
@@ -221,12 +221,7 @@ function Screen() {
|
|||||||
</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 ? (
|
{reply_to && !quote ? <MentionNote eventId={reply_to} /> : null}
|
||||||
<div className="flex flex-col 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">
|
|
||||||
<h3 className="font-medium">Reply to:</h3>
|
|
||||||
<MentionNote eventId={reply_to} />
|
|
||||||
</div>
|
|
||||||
) : 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)}
|
||||||
@@ -235,7 +230,9 @@ function Screen() {
|
|||||||
autoCorrect="none"
|
autoCorrect="none"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
renderElement={(props) => <Element {...props} />}
|
renderElement={(props) => <Element {...props} />}
|
||||||
placeholder={t("editor.placeholder")}
|
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 && (
|
||||||
@@ -278,8 +275,13 @@ function Screen() {
|
|||||||
|
|
||||||
function Pending() {
|
function Pending() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center gap-2.5">
|
<div
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
data-tauri-drag-region
|
||||||
|
className="flex h-full w-full items-center justify-center gap-2.5"
|
||||||
|
>
|
||||||
|
<button type="button" disabled>
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</button>
|
||||||
<p>Loading cache...</p>
|
<p>Loading cache...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useEvent } from "@lume/ark";
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import { LoaderIcon } from "@lume/icons";
|
||||||
import { Box, Container, Note, User } from "@lume/ui";
|
import { Box, Container, Note, User } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { WindowVirtualizer } from "virtua";
|
|
||||||
import { ReplyList } from "./-components/replyList";
|
import { ReplyList } from "./-components/replyList";
|
||||||
import { Event } from "@lume/types";
|
import { WindowVirtualizer } from "virtua";
|
||||||
|
import { type Event } from "@lume/types";
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/events/$eventId")({
|
export const Route = createLazyFileRoute("/events/$eventId")({
|
||||||
component: Event,
|
component: Event,
|
||||||
@@ -29,14 +29,14 @@ function Event() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WindowVirtualizer>
|
<Container withDrag>
|
||||||
<Container withDrag>
|
<Box className="px-3 pt-3 scrollbar-none">
|
||||||
<Box className="px-3 pt-3">
|
<WindowVirtualizer>
|
||||||
<MainNote data={data} />
|
<MainNote data={data} />
|
||||||
{data ? <ReplyList eventId={eventId} /> : null}
|
{data ? <ReplyList eventId={eventId} /> : null}
|
||||||
</Box>
|
</WindowVirtualizer>
|
||||||
</Container>
|
</Box>
|
||||||
</WindowVirtualizer>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ function MainNote({ data }: { data: Event }) {
|
|||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
<Note.Thread className="mb-2" />
|
<Note.Thread className="mb-2" />
|
||||||
<Note.Content className="min-w-0" />
|
<Note.Content className="min-w-0" compact={false} />
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
<div className="-ml-1 inline-flex items-center gap-4">
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { LoaderIcon } from "@lume/icons";
|
import { LoaderIcon } from "@lume/icons";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
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 { EventWithReplies } from "@lume/types";
|
||||||
import { Reply } from "./reply";
|
import { Reply } from "./reply";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
|
||||||
export function ReplyList({
|
export function ReplyList({
|
||||||
eventId,
|
eventId,
|
||||||
@@ -13,8 +13,7 @@ export function ReplyList({
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const ark = useArk();
|
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);
|
||||||
|
|
||||||
|
|||||||
145
apps/desktop2/src/routes/foryou.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { RepostNote } from "@/components/repost";
|
||||||
|
import { TextNote } from "@/components/text";
|
||||||
|
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
|
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
||||||
|
import { Column } from "@lume/ui";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/foryou")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeLoad: async ({ search, context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const interests = await ark.get_interest();
|
||||||
|
|
||||||
|
if (!interests) {
|
||||||
|
throw redirect({
|
||||||
|
to: "/interests",
|
||||||
|
search: {
|
||||||
|
...search,
|
||||||
|
redirect: "/foryou",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
interests,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Screen() {
|
||||||
|
const { label, name, account } = Route.useSearch();
|
||||||
|
const { ark, interests } = 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_from_interests(
|
||||||
|
interests.hashtags,
|
||||||
|
20,
|
||||||
|
pageParam,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
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">
|
||||||
|
<button type="button" className="size-5" disabled>
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : !data.length ? (
|
||||||
|
<Empty />
|
||||||
|
) : (
|
||||||
|
<Virtualizer overscan={3}>
|
||||||
|
{data.map((item) => renderItem(item))}
|
||||||
|
</Virtualizer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.length && hasNextPage ? (
|
||||||
|
<div className="flex h-20 items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage || 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 ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
|
Load more
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
apps/desktop2/src/routes/global.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { RepostNote } from "@/components/repost";
|
||||||
|
import { TextNote } from "@/components/text";
|
||||||
|
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
|
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
||||||
|
import { Column } from "@lume/ui";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/global")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
|
return { settings };
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Screen() {
|
||||||
|
const { label, name, account } = Route.useSearch();
|
||||||
|
const { ark } = Route.useRouteContext();
|
||||||
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ["global", account],
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
|
const events = await ark.get_events(20, pageParam, undefined, true);
|
||||||
|
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">
|
||||||
|
<button type="button" className="size-5" disabled>
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : !data.length ? (
|
||||||
|
<Empty />
|
||||||
|
) : (
|
||||||
|
<Virtualizer overscan={3}>
|
||||||
|
{data.map((item) => renderItem(item))}
|
||||||
|
</Virtualizer>
|
||||||
|
)}
|
||||||
|
{data?.length && hasNextPage ? (
|
||||||
|
<div className="flex h-20 items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage || isLoading}
|
||||||
|
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 ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
|
Load more
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/desktop2/src/routes/group.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { RepostNote } from "@/components/repost";
|
||||||
|
import { TextNote } from "@/components/text";
|
||||||
|
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
|
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
||||||
|
import { Column } from "@lume/ui";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/group")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeLoad: async ({ search, context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const groups = await ark.get_nstore(`lume_group_${search.label}`);
|
||||||
|
|
||||||
|
if (!groups) {
|
||||||
|
throw redirect({
|
||||||
|
to: "/create-group",
|
||||||
|
search: {
|
||||||
|
...search,
|
||||||
|
redirect: "/group",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
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">
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</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 ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
||||||
import { User } from "@lume/ui";
|
import { User } from "@lume/ui";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
@@ -6,55 +5,52 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
beforeLoad: async ({ search, 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:
|
||||||
const guest = await ark.create_guest_account();
|
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/$account/home/local",
|
to: "/landing",
|
||||||
params: { account: guest },
|
|
||||||
search: { guest: true },
|
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
// Only 1 account, skip account selection screen
|
// Only 1 account, skip account selection screen
|
||||||
case 1:
|
case 1:
|
||||||
// @ts-ignore, totally fine !!!
|
|
||||||
if (search.manually) return;
|
|
||||||
|
|
||||||
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/local",
|
to: "/$account/home",
|
||||||
params: { account },
|
params: { account },
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Account selection
|
// Account selection
|
||||||
default:
|
default:
|
||||||
return;
|
return { accounts };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const ark = useArk();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
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 loadAccount = await ark.load_selected_account(npub);
|
const loadAccount = await ark.load_selected_account(npub);
|
||||||
|
|
||||||
if (loadAccount) {
|
if (loadAccount) {
|
||||||
navigate({
|
return navigate({
|
||||||
to: "/$account/home/local",
|
to: "/$account/home",
|
||||||
params: { account: npub },
|
params: { account: npub },
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
@@ -81,7 +77,7 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{ark.accounts.map((account) => (
|
{context.accounts.map((account) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={account.npub}
|
key={account.npub}
|
||||||
|
|||||||
122
apps/desktop2/src/routes/interests.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Column } from "@lume/ui";
|
||||||
|
import { TOPICS, cn } from "@lume/utils";
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/interests")({
|
||||||
|
component: Screen,
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { label, name, redirect } = Route.useSearch();
|
||||||
|
const { ark } = Route.useRouteContext();
|
||||||
|
|
||||||
|
const [hashtags, setHashtags] = useState<string[]>([]);
|
||||||
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const toggleHashtag = (item: string) => {
|
||||||
|
const arr = hashtags.includes(item)
|
||||||
|
? hashtags.filter((i) => i !== item)
|
||||||
|
: [...hashtags, item];
|
||||||
|
setHashtags(arr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = (item: string[]) => {
|
||||||
|
const sets = new Set([...hashtags, ...item]);
|
||||||
|
setHashtags([...sets]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
if (isDone) {
|
||||||
|
return router.history.push(redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = await ark.set_interest(undefined, undefined, hashtags);
|
||||||
|
if (eventId) {
|
||||||
|
setIsDone(true);
|
||||||
|
toast.success("Interest has been updated successfully.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column.Root>
|
||||||
|
<Column.Header label={label} name={name} />
|
||||||
|
<Column.Content>
|
||||||
|
<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">
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<h3 className="font-semibold">Interests</h3>
|
||||||
|
<p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
|
Pick things you'd like to see.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col p-3">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{TOPICS.map((topic) => (
|
||||||
|
<div key={topic.title} className="flex flex-col gap-4">
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="inline-flex items-center gap-2.5">
|
||||||
|
<img
|
||||||
|
src={topic.icon}
|
||||||
|
alt={topic.title}
|
||||||
|
className="size-8 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
<h3 className="text-lg font-semibold">{topic.title}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAll(topic.content)}
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
{t("interests.followAll")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{topic.content.map((hashtag) => (
|
||||||
|
<button
|
||||||
|
key={hashtag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleHashtag(hashtag)}
|
||||||
|
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",
|
||||||
|
hashtags.includes(hashtag)
|
||||||
|
? "border-blue-500 text-blue-500"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hashtag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Column.Content>
|
||||||
|
</Column.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { KeyIcon, RemoteIcon } from "@lume/icons";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -6,52 +7,77 @@ export const Route = createFileRoute("/landing/")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const context = Route.useRouteContext();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen bg-black">
|
<div className="relative flex h-screen w-screen bg-black">
|
||||||
<div className="flex h-full w-full flex-col items-center justify-between">
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
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 />
|
<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-${context.locale}.png`}
|
src={`/heading-en.png`}
|
||||||
srcSet={`/heading-${context.locale}@2x.png 2x`}
|
srcSet={`/heading-en@2x.png 2x`}
|
||||||
alt="lume"
|
alt="lume"
|
||||||
className="w-2/3"
|
className="xl:w-2/3"
|
||||||
/>
|
/>
|
||||||
<p className="mt-5 whitespace-pre-line text-lg font-medium leading-snug text-neutral-700">
|
<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-2">
|
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
|
||||||
<Link
|
<Link
|
||||||
to="/auth/create"
|
to="/auth/new/profile"
|
||||||
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-blue-500 text-lg font-medium text-white hover:bg-blue-600"
|
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>
|
||||||
<Link
|
<div className="flex items-center gap-2">
|
||||||
to="/auth/import"
|
<div className="h-px flex-1 bg-white/20" />
|
||||||
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-neutral-950 text-lg font-medium text-white hover:bg-neutral-900"
|
<div className="text-white/70">{t("login.or")}</div>
|
||||||
>
|
<div className="h-px flex-1 bg-white/20" />
|
||||||
{t("welcome.login")}
|
</div>
|
||||||
</Link>
|
<div className="flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
|
||||||
|
Nostr Connect
|
||||||
|
<div className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
|
||||||
|
Private Key
|
||||||
|
<div className="size-5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-11 items-center justify-center">
|
<div className="flex h-11 items-center justify-center"></div>
|
||||||
<p className="text-neutral-800">
|
</div>
|
||||||
{t("welcome.footer")}{" "}
|
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
|
||||||
<Link
|
<div className="absolute inset-0 h-full w-full">
|
||||||
to="https://nostr.com"
|
<img
|
||||||
target="_blank"
|
src="/lock-screen.jpg"
|
||||||
className="text-blue-500"
|
srcSet="/lock-screen@2x.jpg 2x"
|
||||||
>
|
alt="Lock Screen Background"
|
||||||
here
|
className="h-full w-full object-cover"
|
||||||
</Link>
|
/>
|
||||||
</p>
|
<a
|
||||||
</div>
|
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Design by NoGood
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
134
apps/desktop2/src/routes/newsfeed.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { RepostNote } from "@/components/repost";
|
||||||
|
import { TextNote } from "@/components/text";
|
||||||
|
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
|
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
||||||
|
import { Column } from "@lume/ui";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/newsfeed")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
|
return { settings };
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Screen() {
|
||||||
|
const { label, name, account } = Route.useSearch();
|
||||||
|
const { ark } = Route.useRouteContext();
|
||||||
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: [label, 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">
|
||||||
|
<button type="button" className="size-5" disabled>
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : !data.length ? (
|
||||||
|
<Empty />
|
||||||
|
) : (
|
||||||
|
<Virtualizer overscan={3}>
|
||||||
|
{data.map((item) => renderItem(item))}
|
||||||
|
</Virtualizer>
|
||||||
|
)}
|
||||||
|
{data?.length && hasNextPage ? (
|
||||||
|
<div className="flex h-20 items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage || isLoading}
|
||||||
|
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 ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
|
Load more
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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="/global"
|
||||||
|
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 global newsfeed
|
||||||
|
</Link>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useArk } from "@lume/ark";
|
import { ZapIcon } from "@lume/icons";
|
||||||
import { ArrowRightIcon, ZapIcon } from "@lume/icons";
|
|
||||||
import { Container } from "@lume/ui";
|
import { Container } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -9,21 +8,18 @@ export const Route = createLazyFileRoute("/nwc")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const ark = useArk();
|
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);
|
||||||
if (nwc) {
|
|
||||||
setIsDone(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container withDrag>
|
<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 ? (
|
||||||
<>
|
<>
|
||||||
@@ -45,17 +41,15 @@ function Screen() {
|
|||||||
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 resize-none rounded-lg border-transparent bg-white placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-black dark:focus:ring-blue-900"
|
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-between 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"
|
||||||
>
|
>
|
||||||
<div className="size-5" />
|
Save & Connect
|
||||||
<div>Save & Connect</div>
|
|
||||||
<ArrowRightIcon className="size-5" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
51
apps/desktop2/src/routes/open.lazy.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { PlusIcon } from "@lume/icons";
|
||||||
|
import { LumeColumn } from "@lume/types";
|
||||||
|
import { Column } from "@lume/ui";
|
||||||
|
import { createLazyRoute } from "@tanstack/react-router";
|
||||||
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
|
export const Route = createLazyRoute("/open")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const install = async (column: LumeColumn) => {
|
||||||
|
const mainWindow = getCurrent();
|
||||||
|
await mainWindow.emit("columns", { type: "add", column });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column.Root shadow={false} background={false}>
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
install({
|
||||||
|
label: "store",
|
||||||
|
name: "Store",
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
install({
|
||||||
|
label: "store",
|
||||||
|
name: "Store",
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-8" />
|
||||||
|
</button>
|
||||||
|
</Column.Content>
|
||||||
|
</Column.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
apps/desktop2/src/routes/settings.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { SettingsIcon, UserIcon, ZapIcon, SecureIcon } from "@lume/icons";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/settings")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col bg-neutral-100 dark:bg-neutral-950">
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link to="/settings/general">
|
||||||
|
{({ isActive }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
|
isActive
|
||||||
|
? "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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="size-5 shrink-0" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t("settings.general.title")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
<Link to="/settings/user">
|
||||||
|
{({ isActive }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
|
isActive
|
||||||
|
? "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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UserIcon className="size-5 shrink-0" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t("settings.user.title")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
<Link to="/settings/zap">
|
||||||
|
{({ isActive }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
|
isActive
|
||||||
|
? "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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ZapIcon className="size-5 shrink-0" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t("settings.zap.title")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
<Link to="/settings/backup">
|
||||||
|
{({ isActive }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
|
isActive
|
||||||
|
? "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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SecureIcon className="size-5 shrink-0" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t("settings.backup.title")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/desktop2/src/routes/settings/backup.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { type Account } from "@lume/types";
|
||||||
|
import { User } from "@lume/ui";
|
||||||
|
import { displayNsec } from "@lume/utils";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/settings/backup")({
|
||||||
|
component: Screen,
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const npubs = await ark.get_all_accounts();
|
||||||
|
|
||||||
|
let accounts: Account[] = [];
|
||||||
|
|
||||||
|
for (const account of npubs) {
|
||||||
|
const nsec: string = await invoke("get_stored_nsec", {
|
||||||
|
npub: account.npub,
|
||||||
|
});
|
||||||
|
accounts.push({ ...account, nsec });
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const accounts = Route.useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||||
|
{accounts.map((account, index) => (
|
||||||
|
<div key={account.npub} className="flex items-start gap-6 py-3">
|
||||||
|
<div className="w-36 shrink-0 text-end font-medium">
|
||||||
|
Account {index}
|
||||||
|
</div>
|
||||||
|
<Account account={account} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Account({ account }: { account: Account }) {
|
||||||
|
const [key, setKey] = useState(account.nsec);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [passphase, setPassphase] = useState("");
|
||||||
|
|
||||||
|
const encrypt = async () => {
|
||||||
|
const encrypted: string = await invoke("get_encrypted_key", {
|
||||||
|
npub: account.npub,
|
||||||
|
password: passphase,
|
||||||
|
});
|
||||||
|
setKey(encrypted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyKey = async () => {
|
||||||
|
try {
|
||||||
|
await writeText(key);
|
||||||
|
setCopied(true);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<User.Provider pubkey={account.npub}>
|
||||||
|
<User.Root className="flex items-center gap-2">
|
||||||
|
<User.Avatar className="size-8 rounded-full object-cover" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<User.Name className="text-sm leading-tight" />
|
||||||
|
<User.NIP05 className="text-sm leading-tight text-neutral-700 dark:text-neutral-300" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
type="text"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="passphase"
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Set a passphase to secure your key
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
name="passphase"
|
||||||
|
type="password"
|
||||||
|
value={passphase}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/desktop2/src/routes/settings/general.lazy.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/settings/general')({
|
||||||
|
component: () => <div>Hello /settings/general!</div>
|
||||||
|
})
|
||||||
5
apps/desktop2/src/routes/settings/user.lazy.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/settings/user')({
|
||||||
|
component: () => <div>Hello /settings/user!</div>
|
||||||
|
})
|
||||||
96
apps/desktop2/src/routes/settings/zap.lazy.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/settings/zap")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<Connection />
|
||||||
|
<DefaultAmount />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Connection() {
|
||||||
|
const [uri, setUri] = useState("");
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
try {
|
||||||
|
await invoke("set_nwc", { uri });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
<div className="w-36 shrink-0 text-end font-medium">Connection</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="nwc"
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Nostr Wallet Connect
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
name="nwc"
|
||||||
|
type="text"
|
||||||
|
value={uri}
|
||||||
|
onChange={(e) => setUri(e.target.value)}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefaultAmount() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
<div className="w-36 shrink-0 text-end font-medium">Default amount</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="amount"
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Set default amount for quick zapping
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
name="amount"
|
||||||
|
type="number"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/desktop2/src/routes/store.community.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/store/community")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
<p>Coming Soon</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/desktop2/src/routes/store.official.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { LumeColumn } from "@lume/types";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { resolveResource } from "@tauri-apps/api/path";
|
||||||
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
|
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/store/official")({
|
||||||
|
component: Screen,
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const resourcePath = await resolveResource(
|
||||||
|
"resources/official_columns.json",
|
||||||
|
);
|
||||||
|
const officialColumns: LumeColumn[] = JSON.parse(
|
||||||
|
await readTextFile(resourcePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
officialColumns,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { officialColumns } = Route.useRouteContext();
|
||||||
|
|
||||||
|
const install = async (column: LumeColumn) => {
|
||||||
|
const mainWindow = getCurrent();
|
||||||
|
await mainWindow.emit("columns", { type: "add", column });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
{officialColumns.map((column) => (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{column.cover ? (
|
||||||
|
<img
|
||||||
|
src={column.cover}
|
||||||
|
srcSet={column.coverRetina}
|
||||||
|
alt={column.name}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="absolute left-0 top-0 z-10 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<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>
|
||||||
|
<h1 className="font-semibold text-white">{column.name}</h1>
|
||||||
|
<p className="max-w-[24rem] truncate text-sm text-white/80">
|
||||||
|
{column.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/desktop2/src/routes/store.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { GlobalIcon, LaurelIcon } from "@lume/icons";
|
||||||
|
import { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Column } from "@lume/ui";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/store")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { label, name } = Route.useSearch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column.Root>
|
||||||
|
<Column.Header label={label} name={name}>
|
||||||
|
<div className="inline-flex h-full w-full items-center gap-1">
|
||||||
|
<Link to="/store/official">
|
||||||
|
{({ isActive }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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"
|
||||||
|
: "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LaurelIcon className="size-4" />
|
||||||
|
Official
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Link to="/store/community">
|
||||||
|
{({ isActive }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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"
|
||||||
|
: "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GlobalIcon className="size-4" />
|
||||||
|
Community
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Column.Header>
|
||||||
|
<Column.Content>
|
||||||
|
<Outlet />
|
||||||
|
</Column.Content>
|
||||||
|
</Column.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/desktop2/src/routes/trending.notes.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { RepostNote } from "@/components/repost";
|
||||||
|
import { TextNote } from "@/components/text";
|
||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { Event, Kind } from "@lume/types";
|
||||||
|
import { Await, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Virtualizer } from "virtua";
|
||||||
|
import { defer } from "@tanstack/react-router";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/trending/notes")({
|
||||||
|
loader: async ({ abortController }) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
data: defer(
|
||||||
|
fetch("https://api.nostr.band/v0/trending/notes", {
|
||||||
|
signal: abortController.signal,
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((res) => res.notes.map((item) => item.event) as Event[]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Screen() {
|
||||||
|
const { data } = Route.useLoaderData();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<Virtualizer overscan={3}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<LoaderIcon className="animate-spin size-5" />
|
||||||
|
Loading...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Await promise={data}>
|
||||||
|
{(notes) => notes.map((event) => renderItem(event))}
|
||||||
|
</Await>
|
||||||
|
</Suspense>
|
||||||
|
</Virtualizer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/desktop2/src/routes/trending.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
|
||||||
|
import { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Column } from "@lume/ui";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { Link, Outlet } from "@tanstack/react-router";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/trending")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
|
return { settings };
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Screen() {
|
||||||
|
const { label, name } = Route.useSearch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column.Root>
|
||||||
|
<Column.Header label={label} name={name}>
|
||||||
|
<div className="inline-flex h-full w-full items-center gap-1">
|
||||||
|
<Link to="/trending/notes">
|
||||||
|
{({ isActive }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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"
|
||||||
|
: "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArticleIcon className="size-4" />
|
||||||
|
Notes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Link to="/trending/users">
|
||||||
|
{({ isActive }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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"
|
||||||
|
: "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GroupFeedsIcon className="size-4" />
|
||||||
|
Users
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Column.Header>
|
||||||
|
<Column.Content>
|
||||||
|
<Outlet />
|
||||||
|
</Column.Content>
|
||||||
|
</Column.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
apps/desktop2/src/routes/trending.users.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { User } from "@lume/ui";
|
||||||
|
import { Await, defer } from "@tanstack/react-router";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/trending/users")({
|
||||||
|
loader: async ({ abortController }) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
data: defer(
|
||||||
|
fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||||
|
signal: abortController.signal,
|
||||||
|
}).then((res) => res.json()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Screen() {
|
||||||
|
const { data } = Route.useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full px-3">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Await promise={data}>
|
||||||
|
{(users) =>
|
||||||
|
users.profiles.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.pubkey}
|
||||||
|
className="h-max w-full overflow-hidden py-5 border-b border-neutral-100 dark:border-neutral-900"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={item.pubkey}>
|
||||||
|
<User.Root>
|
||||||
|
<div className="flex h-full w-full flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
|
||||||
|
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||||
|
</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" />
|
||||||
|
</div>
|
||||||
|
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Await>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { WindowVirtualizer } from "virtua";
|
import { WindowVirtualizer } from "virtua";
|
||||||
import { User } from "@lume/ui";
|
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")({
|
||||||
@@ -11,36 +11,33 @@ function Screen() {
|
|||||||
const { pubkey } = Route.useParams();
|
const { pubkey } = Route.useParams();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WindowVirtualizer>
|
<Container withDrag>
|
||||||
<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">
|
<Box className="px-0 scrollbar-none">
|
||||||
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
|
<WindowVirtualizer>
|
||||||
<div className="flex h-full min-h-0 w-full">
|
<User.Provider pubkey={pubkey}>
|
||||||
<div className="h-full w-full flex-1 px-2 pb-2">
|
<User.Root>
|
||||||
<div className="h-full w-full overflow-hidden overflow-y-auto rounded-xl bg-white 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">
|
<User.Cover className="h-44 w-full object-cover" />
|
||||||
<User.Provider pubkey={pubkey}>
|
<div className="relative -mt-8 flex flex-col gap-4 px-3">
|
||||||
<User.Root>
|
<User.Avatar className="size-14 rounded-full" />
|
||||||
<User.Cover className="h-44 w-full object-cover" />
|
<div className="inline-flex items-start justify-between">
|
||||||
<div className="relative -mt-8 flex flex-col gap-4 px-5">
|
<div>
|
||||||
<User.Avatar className="size-14 rounded-full" />
|
<User.Name className="font-semibold leading-tight" />
|
||||||
<div className="inline-flex items-start justify-between">
|
<User.NIP05 className="text-sm leading-tight text-neutral-600 dark:text-neutral-400" />
|
||||||
<div>
|
|
||||||
<User.Name className="font-semibold leading-tight" />
|
|
||||||
<User.NIP05 className="text-sm leading-tight text-neutral-600 dark:text-neutral-400" />
|
|
||||||
</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" />
|
|
||||||
</div>
|
|
||||||
<User.About />
|
|
||||||
</div>
|
</div>
|
||||||
</User.Root>
|
<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.Provider>
|
</div>
|
||||||
<div className="mt-4 px-5">
|
<User.About />
|
||||||
<h3 className="mb-4 text-lg font-semibold">Notes</h3>
|
|
||||||
<EventList id={pubkey} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="px-3">
|
||||||
|
<h3 className="text-lg font-semibold">Latest notes</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<EventList id={pubkey} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</WindowVirtualizer>
|
||||||
</div>
|
</Box>
|
||||||
</WindowVirtualizer>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { ArrowRightCircleIcon, InfoIcon, LoaderIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon, InfoIcon, LoaderIcon } from "@lume/icons";
|
||||||
import { Event, Kind } from "@lume/types";
|
import { Event, Kind } from "@lume/types";
|
||||||
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";
|
||||||
|
|
||||||
export function EventList({ id }: { id: string }) {
|
export function EventList({ id }: { id: string }) {
|
||||||
const ark = useArk();
|
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],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Balance } from "@/components/balance";
|
import { Balance } from "@/components/balance";
|
||||||
import { useArk } from "@lume/ark";
|
|
||||||
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 { useState } from "react";
|
||||||
@@ -16,6 +15,7 @@ export const Route = createLazyFileRoute("/zap/$id")({
|
|||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
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();
|
||||||
@@ -25,8 +25,6 @@ function Screen() {
|
|||||||
const [isCompleted, setIsCompleted] = useState(false);
|
const [isCompleted, setIsCompleted] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const ark = useArk();
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
// start loading
|
// start loading
|
||||||
@@ -48,7 +46,7 @@ function Screen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Balance recipient={pubkey} 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">
|
||||||
|
|||||||
@@ -10,17 +10,17 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.4.1",
|
"@astrojs/check": "^0.5.10",
|
||||||
"@astrojs/tailwind": "^5.1.0",
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
"@fontsource/geist-mono": "^5.0.1",
|
"@fontsource/geist-mono": "^5.0.2",
|
||||||
"astro": "^4.4.9",
|
"astro": "^4.5.18",
|
||||||
"astro-seo-meta": "^4.1.0",
|
"astro-seo-meta": "^4.1.0",
|
||||||
"astro-seo-schema": "^4.0.0",
|
"astro-seo-schema": "^4.0.0",
|
||||||
"schema-dts": "^1.1.2",
|
"schema-dts": "^1.1.2",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.10"
|
"@tailwindcss/typography": "^0.5.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
package.json
@@ -11,27 +11,26 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.5.3",
|
"@biomejs/biome": "^1.6.4",
|
||||||
"@tauri-apps/cli": "2.0.0-beta.6",
|
"@tauri-apps/cli": "2.0.0-beta.12",
|
||||||
"turbo": "^1.12.4"
|
"turbo": "^1.13.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@8.9.0",
|
"packageManager": "pnpm@8.9.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "2.0.0-beta.3",
|
"@tauri-apps/api": "2.0.0-beta.7",
|
||||||
"@tauri-apps/plugin-autostart": "2.0.0-beta.1",
|
"@tauri-apps/plugin-autostart": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "2.0.0-beta.1",
|
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.0",
|
||||||
"@tauri-apps/plugin-dialog": "2.0.0-beta.1",
|
"@tauri-apps/plugin-dialog": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-fs": "2.0.0-beta.1",
|
"@tauri-apps/plugin-fs": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-http": "2.0.0-beta.1",
|
"@tauri-apps/plugin-http": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-notification": "2.0.0-beta.1",
|
"@tauri-apps/plugin-notification": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-os": "2.0.0-beta.1",
|
"@tauri-apps/plugin-os": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-process": "2.0.0-beta.1",
|
"@tauri-apps/plugin-process": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-shell": "2.0.0-beta.1",
|
"@tauri-apps/plugin-shell": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-sql": "2.0.0-beta.1",
|
"@tauri-apps/plugin-updater": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-updater": "2.0.0-beta.1",
|
"@tauri-apps/plugin-upload": "2.0.0-beta.2"
|
||||||
"@tauri-apps/plugin-upload": "2.0.0-beta.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getalby/sdk": "^3.3.1",
|
"@getalby/sdk": "^3.4.3",
|
||||||
"@lume/icons": "workspace:^",
|
"@lume/icons": "workspace:^",
|
||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
@@ -14,27 +14,28 @@
|
|||||||
"@radix-ui/react-hover-card": "^1.0.7",
|
"@radix-ui/react-hover-card": "^1.0.7",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@tanstack/react-query": "^5.24.1",
|
"@tanstack/react-query": "^5.29.0",
|
||||||
|
"@tanstack/react-router": "^1.26.19",
|
||||||
"get-urls": "^12.1.0",
|
"get-urls": "^12.1.0",
|
||||||
"media-chrome": "^2.2.5",
|
"media-chrome": "^3.2.0",
|
||||||
"minidenticons": "^4.2.1",
|
"minidenticons": "^4.2.1",
|
||||||
"nanoid": "^5.0.6",
|
"nanoid": "^5.0.7",
|
||||||
"qrcode.react": "^3.1.0",
|
"qrcode.react": "^3.1.0",
|
||||||
"re-resizable": "^6.9.11",
|
"re-resizable": "^6.9.11",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-i18next": "^14.0.5",
|
"react-i18next": "^14.1.0",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"sonner": "^1.4.3",
|
"sonner": "^1.4.41",
|
||||||
"string-strip-html": "^13.4.6",
|
"string-strip-html": "^13.4.8",
|
||||||
"virtua": "^0.27.5"
|
"virtua": "^0.29.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lume/tailwindcss": "workspace:^",
|
"@lume/tailwindcss": "workspace:^",
|
||||||
"@lume/tsconfig": "workspace:^",
|
"@lume/tsconfig": "workspace:^",
|
||||||
"@lume/types": "workspace:^",
|
"@lume/types": "workspace:^",
|
||||||
"@types/react": "^18.2.61",
|
"@types/react": "^18.2.75",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,28 @@ import type {
|
|||||||
Contact,
|
Contact,
|
||||||
Event,
|
Event,
|
||||||
EventWithReplies,
|
EventWithReplies,
|
||||||
|
Interests,
|
||||||
Keys,
|
Keys,
|
||||||
|
LumeColumn,
|
||||||
Metadata,
|
Metadata,
|
||||||
|
Settings,
|
||||||
} from "@lume/types";
|
} from "@lume/types";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
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";
|
import { generateContentTags } from "@lume/utils";
|
||||||
|
|
||||||
|
enum NSTORE_KEYS {
|
||||||
|
settings = "lume_user_settings",
|
||||||
|
interests = "lume_user_interests",
|
||||||
|
columns = "lume_user_columns",
|
||||||
|
}
|
||||||
|
|
||||||
export class Ark {
|
export class Ark {
|
||||||
public accounts: Account[];
|
public windows: WebviewWindow[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.accounts = [];
|
this.windows = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get_all_accounts() {
|
public async get_all_accounts() {
|
||||||
@@ -28,8 +37,6 @@ export class Ark {
|
|||||||
for (const item of cmd) {
|
for (const item of cmd) {
|
||||||
accounts.push({ npub: item.replace(".npub", "") });
|
accounts.push({ npub: item.replace(".npub", "") });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.accounts = accounts;
|
|
||||||
return accounts;
|
return accounts;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -71,7 +78,7 @@ export class Ark {
|
|||||||
|
|
||||||
public async save_account(nsec: string, password: string = "") {
|
public async save_account(nsec: string, password: string = "") {
|
||||||
try {
|
try {
|
||||||
const cmd: boolean = await invoke("save_key", {
|
const cmd: string = await invoke("save_key", {
|
||||||
nsec,
|
nsec,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
@@ -126,21 +133,26 @@ export class Ark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async get_events(
|
public async get_events(
|
||||||
type: "local" | "global",
|
|
||||||
limit: number,
|
limit: number,
|
||||||
asOf?: number,
|
asOf?: number,
|
||||||
dedup?: boolean,
|
contacts?: string[],
|
||||||
|
global?: boolean,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
let until: string = undefined;
|
let until: string = undefined;
|
||||||
|
let isGlobal = global ?? false;
|
||||||
|
|
||||||
if (asOf && asOf > 0) until = asOf.toString();
|
if (asOf && asOf > 0) until = asOf.toString();
|
||||||
|
|
||||||
|
const dedup = true;
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
const dedupQueue = new Set<string>();
|
const dedupQueue = new Set<string>();
|
||||||
|
|
||||||
const nostrEvents: Event[] = await invoke(`get_${type}_events`, {
|
const nostrEvents: Event[] = await invoke("get_events", {
|
||||||
limit,
|
limit,
|
||||||
until,
|
until,
|
||||||
|
contacts,
|
||||||
|
global: isGlobal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dedup) {
|
if (dedup) {
|
||||||
@@ -155,7 +167,6 @@ export class Ark {
|
|||||||
dedupQueue.add(event.id);
|
dedupQueue.add(event.id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
seenIds.add(tag);
|
seenIds.add(tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,12 +177,58 @@ export class Ark {
|
|||||||
.sort((a, b) => b.created_at - a.created_at);
|
.sort((a, b) => b.created_at - a.created_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
|
return nostrEvents;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error(String(e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async get_events_from_interests(
|
||||||
|
hashtags: string[],
|
||||||
|
limit: number,
|
||||||
|
asOf?: number,
|
||||||
|
global?: boolean,
|
||||||
|
) {
|
||||||
|
let until: string = undefined;
|
||||||
|
if (asOf && asOf > 0) until = asOf.toString();
|
||||||
|
|
||||||
|
const dedup = true;
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const dedupQueue = new Set<string>();
|
||||||
|
|
||||||
|
const nostrEvents: Event[] = await invoke("get_events_from_interests", {
|
||||||
|
hashtags,
|
||||||
|
limit,
|
||||||
|
until,
|
||||||
|
global,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dedup) {
|
||||||
|
for (const event of nostrEvents) {
|
||||||
|
const tags = event.tags
|
||||||
|
.filter((el) => el[0] === "e")
|
||||||
|
?.map((item) => item[1]);
|
||||||
|
|
||||||
|
if (tags.length) {
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (seenIds.has(tag)) {
|
||||||
|
dedupQueue.add(event.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seenIds.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nostrEvents
|
||||||
|
.filter((event) => !dedupQueue.has(event.id))
|
||||||
|
.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
public async publish(content: string, reply_to?: string, quote?: boolean) {
|
public async publish(content: string, reply_to?: string, quote?: boolean) {
|
||||||
try {
|
try {
|
||||||
const g = await generateContentTags(content);
|
const g = await generateContentTags(content);
|
||||||
@@ -338,6 +395,25 @@ export class Ark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async create_profile(profile: Metadata) {
|
||||||
|
try {
|
||||||
|
const event: string = await invoke("create_profile", {
|
||||||
|
name: profile.name || "",
|
||||||
|
display_name: profile.display_name || "",
|
||||||
|
displayName: profile.display_name || "",
|
||||||
|
about: profile.about || "",
|
||||||
|
picture: profile.picture || "",
|
||||||
|
banner: profile.banner || "",
|
||||||
|
nip05: profile.nip05 || "",
|
||||||
|
lud16: profile.lud16 || "",
|
||||||
|
website: profile.website || "",
|
||||||
|
});
|
||||||
|
return event;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async get_contact_list() {
|
public async get_contact_list() {
|
||||||
try {
|
try {
|
||||||
const cmd: string[] = await invoke("get_contact_list");
|
const cmd: string[] = await invoke("get_contact_list");
|
||||||
@@ -499,87 +575,238 @@ export class Ark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async get_columns() {
|
||||||
|
try {
|
||||||
|
const cmd: string = await invoke("get_nstore", {
|
||||||
|
key: NSTORE_KEYS.columns,
|
||||||
|
});
|
||||||
|
const columns: LumeColumn[] = cmd ? JSON.parse(cmd) : [];
|
||||||
|
return columns;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set_columns(columns: LumeColumn[]) {
|
||||||
|
try {
|
||||||
|
const cmd: string = await invoke("set_nstore", {
|
||||||
|
key: NSTORE_KEYS.columns,
|
||||||
|
content: JSON.stringify(columns),
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get_settings() {
|
||||||
|
try {
|
||||||
|
const cmd: string = await invoke("get_nstore", {
|
||||||
|
key: NSTORE_KEYS.settings,
|
||||||
|
});
|
||||||
|
const settings: Settings = cmd ? JSON.parse(cmd) : null;
|
||||||
|
return settings;
|
||||||
|
} catch {
|
||||||
|
const defaultSettings: Settings = {
|
||||||
|
autoUpdate: false,
|
||||||
|
enhancedPrivacy: false,
|
||||||
|
notification: false,
|
||||||
|
zap: false,
|
||||||
|
};
|
||||||
|
return defaultSettings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set_settings(settings: Settings) {
|
||||||
|
try {
|
||||||
|
const cmd: string = await invoke("set_nstore", {
|
||||||
|
key: NSTORE_KEYS.settings,
|
||||||
|
content: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get_interest() {
|
||||||
|
try {
|
||||||
|
const cmd: string = await invoke("get_nstore", {
|
||||||
|
key: NSTORE_KEYS.interests,
|
||||||
|
});
|
||||||
|
const interests: Interests = cmd ? JSON.parse(cmd) : null;
|
||||||
|
return interests;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set_interest(
|
||||||
|
words: string[],
|
||||||
|
users: string[],
|
||||||
|
hashtags: string[],
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const interests: Interests = {
|
||||||
|
words: words ?? [],
|
||||||
|
users: users ?? [],
|
||||||
|
hashtags: hashtags ?? [],
|
||||||
|
};
|
||||||
|
const cmd: string = await invoke("set_nstore", {
|
||||||
|
key: NSTORE_KEYS.interests,
|
||||||
|
content: JSON.stringify(interests),
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get_nstore(key: string) {
|
||||||
|
try {
|
||||||
|
const cmd: string = await invoke("get_nstore", {
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
const parse: string | string[] = cmd ? JSON.parse(cmd) : null;
|
||||||
|
if (!parse.length) return null;
|
||||||
|
return parse;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set_nstore(key: string, content: string) {
|
||||||
|
try {
|
||||||
|
const cmd: string = await invoke("set_nstore", {
|
||||||
|
key,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public open_thread(id: string) {
|
public open_thread(id: string) {
|
||||||
return new WebviewWindow(`event-${id}`, {
|
try {
|
||||||
title: "Thread",
|
const window = new WebviewWindow(`event-${id}`, {
|
||||||
url: `/events/${id}`,
|
title: "Thread",
|
||||||
minWidth: 500,
|
url: `/events/${id}`,
|
||||||
width: 600,
|
minWidth: 500,
|
||||||
height: 800,
|
minHeight: 800,
|
||||||
hiddenTitle: true,
|
width: 500,
|
||||||
titleBarStyle: "overlay",
|
height: 800,
|
||||||
});
|
hiddenTitle: true,
|
||||||
|
titleBarStyle: "overlay",
|
||||||
|
center: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.windows.push(window);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_profile(pubkey: string) {
|
public open_profile(pubkey: string) {
|
||||||
return new WebviewWindow(`user-${pubkey}`, {
|
try {
|
||||||
title: "Profile",
|
const window = new WebviewWindow(`user-${pubkey}`, {
|
||||||
url: `/users/${pubkey}`,
|
title: "Profile",
|
||||||
minWidth: 500,
|
url: `/users/${pubkey}`,
|
||||||
width: 500,
|
minWidth: 500,
|
||||||
height: 800,
|
minHeight: 800,
|
||||||
hiddenTitle: true,
|
width: 500,
|
||||||
titleBarStyle: "overlay",
|
height: 800,
|
||||||
});
|
hiddenTitle: true,
|
||||||
|
titleBarStyle: "overlay",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.windows.push(window);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_editor(reply_to?: string, quote: boolean = false) {
|
public open_editor(reply_to?: string, quote: boolean = false) {
|
||||||
let url: string;
|
try {
|
||||||
|
let url: string;
|
||||||
|
|
||||||
if (reply_to) {
|
if (reply_to) {
|
||||||
url = `/editor?reply_to=${reply_to}"e=${quote}`;
|
url = `/editor?reply_to=${reply_to}"e=${quote}`;
|
||||||
} else {
|
} else {
|
||||||
url = "/editor";
|
url = "/editor";
|
||||||
|
}
|
||||||
|
|
||||||
|
const window = new WebviewWindow(`editor-${reply_to ? reply_to : 0}`, {
|
||||||
|
title: "Editor",
|
||||||
|
url,
|
||||||
|
minWidth: 500,
|
||||||
|
minHeight: 400,
|
||||||
|
width: 600,
|
||||||
|
height: 400,
|
||||||
|
hiddenTitle: true,
|
||||||
|
titleBarStyle: "overlay",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.windows.push(window);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WebviewWindow("editor", {
|
|
||||||
title: "Editor",
|
|
||||||
url,
|
|
||||||
minWidth: 500,
|
|
||||||
width: 600,
|
|
||||||
height: 400,
|
|
||||||
hiddenTitle: true,
|
|
||||||
titleBarStyle: "overlay",
|
|
||||||
fileDropEnabled: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_nwc() {
|
public open_nwc() {
|
||||||
return new WebviewWindow("nwc", {
|
try {
|
||||||
title: "Nostr Wallet Connect",
|
const window = new WebviewWindow("nwc", {
|
||||||
url: "/nwc",
|
title: "Nostr Wallet Connect",
|
||||||
minWidth: 400,
|
url: "/nwc",
|
||||||
width: 400,
|
minWidth: 400,
|
||||||
height: 600,
|
minHeight: 600,
|
||||||
hiddenTitle: true,
|
width: 400,
|
||||||
titleBarStyle: "overlay",
|
height: 600,
|
||||||
fileDropEnabled: true,
|
hiddenTitle: true,
|
||||||
});
|
titleBarStyle: "overlay",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.windows.push(window);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_zap(id: string, pubkey: string, account: string) {
|
public open_zap(id: string, pubkey: string, account: string) {
|
||||||
return new WebviewWindow(`zap-${id}`, {
|
try {
|
||||||
title: "Nostr Wallet Connect",
|
const window = new WebviewWindow(`zap-${id}`, {
|
||||||
url: `/zap/${id}?pubkey=${pubkey}&account=${account}`,
|
title: "Zap",
|
||||||
minWidth: 400,
|
url: `/zap/${id}?pubkey=${pubkey}&account=${account}`,
|
||||||
width: 400,
|
minWidth: 400,
|
||||||
height: 500,
|
minHeight: 500,
|
||||||
hiddenTitle: true,
|
width: 400,
|
||||||
titleBarStyle: "overlay",
|
height: 500,
|
||||||
fileDropEnabled: true,
|
hiddenTitle: true,
|
||||||
});
|
titleBarStyle: "overlay",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.windows.push(window);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_settings() {
|
public open_settings() {
|
||||||
return new WebviewWindow("settings", {
|
try {
|
||||||
title: "Settings",
|
const window = new WebviewWindow("settings", {
|
||||||
url: "/settings",
|
title: "Settings",
|
||||||
minWidth: 600,
|
url: "/settings",
|
||||||
width: 800,
|
minWidth: 600,
|
||||||
height: 500,
|
minHeight: 500,
|
||||||
hiddenTitle: true,
|
width: 800,
|
||||||
titleBarStyle: "overlay",
|
height: 500,
|
||||||
fileDropEnabled: true,
|
hiddenTitle: true,
|
||||||
});
|
titleBarStyle: "overlay",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.windows.push(window);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { createContext } from "react";
|
|
||||||
import { type Ark } from "./ark";
|
|
||||||
|
|
||||||
export const ArkContext = createContext<Ark | null>(undefined);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import { ArkContext } from "../context";
|
|
||||||
|
|
||||||
export const useArk = () => {
|
|
||||||
const context = useContext(ArkContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useArk must be used within an ArkProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import { Event } from "@lume/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useArk } from "./useArk";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
export function useEvent(id: string) {
|
export function useEvent(id: string) {
|
||||||
const ark = useArk();
|
|
||||||
const { isLoading, isError, data } = useQuery({
|
const { isLoading, isError, data } = useQuery({
|
||||||
queryKey: ["event", id],
|
queryKey: ["event", id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const event = await ark.get_event(id);
|
const eventId: string = id
|
||||||
|
.replace("nostr:", "")
|
||||||
|
.split("'")[0]
|
||||||
|
.split(".")[0];
|
||||||
|
const cmd: string = await invoke("get_event", { id: eventId });
|
||||||
|
const event: Event = JSON.parse(cmd);
|
||||||
return event;
|
return event;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
|
|||||||
24
packages/ark/src/hooks/usePreview.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export function usePreview(url: string) {
|
||||||
|
const { isLoading, isError, data } = useQuery({
|
||||||
|
queryKey: ["url", url],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const cmd = await invoke("fetch_opg", { url });
|
||||||
|
console.log(cmd);
|
||||||
|
return cmd;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { isLoading, isError, data };
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useArk } from "./useArk";
|
import { Metadata } from "@lume/types";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
export function useProfile(pubkey: string) {
|
export function useProfile(pubkey: string) {
|
||||||
const ark = useArk();
|
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
@@ -11,8 +11,14 @@ export function useProfile(pubkey: string) {
|
|||||||
queryKey: ["user", pubkey],
|
queryKey: ["user", pubkey],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const profile = await ark.get_profile(pubkey);
|
const id = pubkey
|
||||||
return profile;
|
.replace("nostr:", "")
|
||||||
|
.split("'")[0]
|
||||||
|
.split(".")[0]
|
||||||
|
.split(",")[0]
|
||||||
|
.split("?")[0];
|
||||||
|
const cmd: Metadata = await invoke("get_profile", { id });
|
||||||
|
return cmd;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export * from "./ark";
|
export * from "./ark";
|
||||||
export * from "./context";
|
|
||||||
export * from "./hooks/useArk";
|
|
||||||
export * from "./hooks/useEvent";
|
export * from "./hooks/useEvent";
|
||||||
export * from "./hooks/useProfile";
|
export * from "./hooks/useProfile";
|
||||||
|
|||||||
@@ -120,3 +120,7 @@ export * from "./src/local";
|
|||||||
export * from "./src/global";
|
export * from "./src/global";
|
||||||
export * from "./src/infoCircle";
|
export * from "./src/infoCircle";
|
||||||
export * from "./src/cancelCircle";
|
export * from "./src/cancelCircle";
|
||||||
|
export * from "./src/laurel";
|
||||||
|
export * from "./src/quote";
|
||||||
|
export * from "./src/key";
|
||||||
|
export * from "./src/remote";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lume/tsconfig": "workspace:*",
|
"@lume/tsconfig": "workspace:*",
|
||||||
"@types/react": "^18.2.61",
|
"@types/react": "^18.2.75",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
export function ArrowLeftIcon(props: JSX.IntrinsicElements['svg']) {
|
export function ArrowLeftIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
||||||
{...props}
|
<path
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
strokeLinecap="round"
|
||||||
width="24"
|
strokeLinejoin="round"
|
||||||
height="24"
|
strokeWidth="1.5"
|
||||||
fill="none"
|
d="M10 5.75 3.75 12 10 18.25M4.5 12h15.75"
|
||||||
stroke="currentColor"
|
/>
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M8.83 6a30.23 30.23 0 0 0-5.62 5.406A.949.949 0 0 0 3 12m5.83 6a30.233 30.233 0 0 1-5.62-5.406A.949.949 0 0 1 3 12m0 0h18" />
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export function ArrowRightIcon(props: JSX.IntrinsicElements["svg"]) {
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth="2"
|
strokeWidth="1.5"
|
||||||
d="m14 6 6 6-6 6m5-6H4"
|
d="M14 5.75 20.25 12 14 18.25M19.5 12H3.75"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import { SVGProps } from 'react';
|
import { SVGProps } from "react";
|
||||||
|
|
||||||
export function ArticleIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
export function ArticleIcon(
|
||||||
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
||||||
xmlns="http://www.w3.org/2000/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="M16.25 12V4.75a1 1 0 00-1-1H3.75a1 1 0 00-1 1v13a2.5 2.5 0 002.5 2.5H18.5M16.25 12v5.75a2.5 2.5 0 005 0V13a1 1 0 00-1-1h-4zm-9.5 3.75h5.5m-5.5-8h5.5v4.5h-5.5v-4.5z"
|
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"
|
||||||
></path>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ export function CancelIcon(props: JSX.IntrinsicElements["svg"]) {
|
|||||||
<path
|
<path
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeWidth="2"
|
strokeWidth="1.5"
|
||||||
d="m5 5 14 14m0-14L5 19"
|
d="m4.75 4.75 14.5 14.5m0-14.5-14.5 14.5"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import { SVGProps } from "react";
|
import { SVGProps } from "react";
|
||||||
|
|
||||||
export function CheckIcon(
|
export function CheckIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path
|
||||||
width="24"
|
stroke="currentColor"
|
||||||
height="24"
|
strokeLinecap="round"
|
||||||
fill="none"
|
strokeWidth="1.5"
|
||||||
viewBox="0 0 24 24"
|
d="M4.75 12.777 10 19.25l9.25-14.5"
|
||||||
{...props}
|
/>
|
||||||
>
|
</svg>
|
||||||
<path
|
);
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M5 12.713l5.017 5.012.4-.701a28.598 28.598 0 018.7-9.42L20 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||