Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
135d0918b3 | ||
|
|
e1fbcf0460 | ||
|
|
99aaf3da82 | ||
|
|
3ef13e43f1 | ||
|
|
8939196ae4 | ||
|
|
571d4b4004 | ||
|
|
73f80f27fb | ||
|
|
b46a5cf68f | ||
|
|
8c0d03aed0 | ||
|
|
777eb15b4f | ||
|
|
c8e1b8b8bd | ||
|
|
437cd71f7e | ||
|
|
afb7c87fa3 | ||
|
|
c843626bca | ||
|
|
28337e5915 | ||
|
|
a4aef25adb | ||
|
|
61d1f095d4 | ||
|
|
f027eae52d | ||
|
|
174a3cc74e | ||
|
|
c00a7749b4 | ||
|
|
c755b8d137 | ||
|
|
17766d29d6 | ||
|
|
3b13dfeed8 | ||
|
|
17ba79e01b | ||
|
|
bafad544e9 | ||
|
|
89c36423ae | ||
|
|
cd31b99559 | ||
|
|
f3c52237fa | ||
|
|
413d8d82df | ||
|
|
2eb2010d43 | ||
|
|
94d400cab2 | ||
|
|
09b143cb08 | ||
|
|
e3ede34108 | ||
|
|
ed6aca41ea | ||
|
|
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,53 +1,54 @@
|
||||
name: 'Publish'
|
||||
name: "Publish"
|
||||
on: workflow_dispatch
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: short
|
||||
RUSTFLAGS: '-W unreachable-pub -W rust-2021-compatibility'
|
||||
RUSTFLAGS: "-W unreachable-pub -W rust-2021-compatibility"
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target universal-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
- platform: 'windows-latest'
|
||||
args: '--target x86_64-pc-windows-msvc'
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
include:
|
||||
- platform: "macos-latest" # for Arm based macs (M1 and above).
|
||||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel based macs.
|
||||
args: "--target x86_64-apple-darwin"
|
||||
#- platform: 'ubuntu-22.04'
|
||||
# args: ''
|
||||
#- platform: 'windows-latest'
|
||||
# args: '--target x86_64-pc-windows-msvc'
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.settings.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
||||
- name: Install pnpm
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install PNPM
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8.x.x
|
||||
run_install: false
|
||||
- name: Setup node and cache for package data
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-on-failure: true
|
||||
- run: pnpm install
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- uses: tauri-apps/tauri-action@dev
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -62,9 +63,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'v__VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
releaseName: "v__VERSION__"
|
||||
releaseBody: "See the assets to download this version and install."
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.settings.args }}
|
||||
args: ${{ matrix.args }}
|
||||
includeDebug: false
|
||||
|
||||
@@ -35,3 +35,4 @@ dist/
|
||||
.DS_Store
|
||||
*.pem
|
||||
.vscode/
|
||||
ndb/
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Lume Desktop</title>
|
||||
</head>
|
||||
<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>
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
|
||||
@@ -1,54 +1,61 @@
|
||||
{
|
||||
"name": "@lume/desktop2",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/ui": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@tanstack/query-sync-storage-persister": "^5.24.1",
|
||||
"@tanstack/react-query": "^5.24.1",
|
||||
"@tanstack/react-query-persist-client": "^5.24.1",
|
||||
"@tanstack/react-router": "^1.18.1",
|
||||
"i18next": "^23.10.0",
|
||||
"i18next-resources-to-backend": "^1.2.0",
|
||||
"nostr-tools": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-currency-input-field": "^3.8.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^14.0.5",
|
||||
"slate": "^0.101.5",
|
||||
"slate-react": "^0.101.6",
|
||||
"sonner": "^1.4.3",
|
||||
"virtua": "^0.27.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@tanstack/router-devtools": "^1.18.1",
|
||||
"@tanstack/router-vite-plugin": "^1.18.1",
|
||||
"@types/react": "^18.2.61",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-tsconfig-paths": "^4.3.1"
|
||||
}
|
||||
"name": "@lume/desktop2",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/ui": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/query-sync-storage-persister": "^5.32.0",
|
||||
"@tanstack/react-query": "^5.32.0",
|
||||
"@tanstack/react-query-persist-client": "^5.32.0",
|
||||
"@tanstack/react-router": "1.29.2",
|
||||
"i18next": "^23.11.3",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"minidenticons": "^4.2.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nostr-tools": "^2.5.1",
|
||||
"react": "^18.3.1",
|
||||
"react-currency-input-field": "^3.8.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^14.1.1",
|
||||
"slate": "^0.102.0",
|
||||
"slate-react": "^0.102.0",
|
||||
"sonner": "^1.4.41",
|
||||
"use-debounce": "^10.0.0",
|
||||
"virtua": "^0.30.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@tanstack/router-devtools": "^1.31.3",
|
||||
"@tanstack/router-vite-plugin": "^1.30.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.10",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 257 KiB |
@@ -1,6 +1,44 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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 {
|
||||
font-size: 14px;
|
||||
@@ -23,24 +61,62 @@ input::-ms-clear {
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
media-controller {
|
||||
@apply w-full overflow-hidden rounded-xl;
|
||||
}
|
||||
.spinner-leaf {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(50% - 12.5% / 2);
|
||||
width: 12.5%;
|
||||
height: 100%;
|
||||
animation: spinner-leaf-fade 800ms linear infinite;
|
||||
|
||||
@layer utilities {
|
||||
.content-break {
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
background-color: currentColor;
|
||||
@apply rounded;
|
||||
}
|
||||
|
||||
.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);
|
||||
&:where(:nth-child(1)) {
|
||||
transform: rotate(0deg);
|
||||
animation-delay: -800ms;
|
||||
}
|
||||
&:where(:nth-child(2)) {
|
||||
transform: rotate(45deg);
|
||||
animation-delay: -700ms;
|
||||
}
|
||||
&:where(:nth-child(3)) {
|
||||
transform: rotate(90deg);
|
||||
animation-delay: -600ms;
|
||||
}
|
||||
&:where(:nth-child(4)) {
|
||||
transform: rotate(135deg);
|
||||
animation-delay: -500ms;
|
||||
}
|
||||
&:where(:nth-child(5)) {
|
||||
transform: rotate(180deg);
|
||||
animation-delay: -400ms;
|
||||
}
|
||||
&:where(:nth-child(6)) {
|
||||
transform: rotate(225deg);
|
||||
animation-delay: -300ms;
|
||||
}
|
||||
&:where(:nth-child(7)) {
|
||||
transform: rotate(270deg);
|
||||
animation-delay: -200ms;
|
||||
}
|
||||
&:where(:nth-child(8)) {
|
||||
transform: rotate(315deg);
|
||||
animation-delay: -100ms;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner-leaf-fade {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +1,72 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { ArkProvider } from "./ark";
|
||||
import { Ark } from "@lume/ark";
|
||||
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import React, { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Toaster } from "sonner";
|
||||
import "./app.css";
|
||||
import i18n from "./locale";
|
||||
import { Toaster } from "sonner";
|
||||
import { locale, platform } from "@tauri-apps/plugin-os";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { routeTree } from "./router.gen"; // auto generated file
|
||||
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
const ark = new Ark();
|
||||
const queryClient = new QueryClient();
|
||||
const platformName = await platform();
|
||||
|
||||
const persister = createSyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
storage: window.localStorage,
|
||||
});
|
||||
|
||||
const platformName = await platform();
|
||||
const osLocale = (await locale()).slice(0, 2);
|
||||
|
||||
// Set up a Router instance
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
ark: undefined!,
|
||||
platform: platformName,
|
||||
locale: osLocale,
|
||||
queryClient,
|
||||
},
|
||||
routeTree,
|
||||
context: {
|
||||
ark,
|
||||
queryClient,
|
||||
platform: platformName,
|
||||
},
|
||||
});
|
||||
|
||||
// Register things for typesafety
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
function InnerApp() {
|
||||
const ark = useArk();
|
||||
return <RouterProvider router={router} context={{ ark }} />;
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ArkProvider>
|
||||
<InnerApp />
|
||||
</ArkProvider>
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: idk
|
||||
const rootElement = document.getElementById("root")!;
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister }}
|
||||
>
|
||||
<StrictMode>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
icons={{
|
||||
success: <CheckCircleIcon className="size-5" />,
|
||||
info: <InfoCircleIcon className="size-5" />,
|
||||
error: <CancelCircleIcon className="size-5" />,
|
||||
}}
|
||||
closeButton
|
||||
theme="system"
|
||||
/>
|
||||
<App />
|
||||
</StrictMode>
|
||||
</PersistQueryClientProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister }}
|
||||
>
|
||||
<StrictMode>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
icons={{
|
||||
success: <CheckCircleIcon className="size-5" />,
|
||||
info: <InfoCircleIcon className="size-5" />,
|
||||
error: <CancelCircleIcon className="size-5" />,
|
||||
}}
|
||||
closeButton
|
||||
theme="system"
|
||||
/>
|
||||
<App />
|
||||
</StrictMode>
|
||||
</PersistQueryClientProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,151 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { Account } from "@lume/types";
|
||||
import { User } from "@lume/ui";
|
||||
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
||||
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() {
|
||||
const ark = useArk();
|
||||
const params = useParams({ strict: false });
|
||||
|
||||
const [accounts, setAccounts] = useState<Account[]>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getAllAccounts() {
|
||||
const data = await ark.get_all_accounts();
|
||||
if (data) setAccounts(data);
|
||||
}
|
||||
|
||||
getAllAccounts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex items-center gap-4">
|
||||
{accounts
|
||||
? accounts.map((account) =>
|
||||
// @ts-ignore, useless
|
||||
account.npub === params.account ? (
|
||||
<Active key={account.npub} pubkey={account.npub} />
|
||||
) : (
|
||||
<Inactive key={account.npub} pubkey={account.npub} />
|
||||
),
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Inactive({ pubkey }: { pubkey: string }) {
|
||||
const ark = useArk();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const changeAccount = async (npub: string) => {
|
||||
const select = await ark.load_selected_account(npub);
|
||||
if (select)
|
||||
navigate({ to: "/$account/home/local", params: { account: npub } });
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => changeAccount(pubkey)}>
|
||||
<User.Provider pubkey={pubkey}>
|
||||
<User.Root className="rounded-full">
|
||||
<User.Avatar className="aspect-square h-auto w-8 rounded-full object-cover" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Active({ pubkey }: { pubkey: string }) {
|
||||
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 (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type 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 ? <Spinner className="size-4" /> : 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,51 +1,42 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { User } from "@lume/ui";
|
||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function Balance({
|
||||
recipient,
|
||||
account,
|
||||
}: {
|
||||
recipient: string;
|
||||
account: string;
|
||||
}) {
|
||||
const [t] = useTranslation();
|
||||
const [balance, setBalance] = useState(0);
|
||||
export function Balance({ account }: { account: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [balance, setBalance] = useState(0);
|
||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||
|
||||
const ark = useArk();
|
||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||
useEffect(() => {
|
||||
async function getBalance() {
|
||||
const val = await ark.get_balance();
|
||||
setBalance(val);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function getBalance() {
|
||||
const val = await ark.get_balance();
|
||||
setBalance(val);
|
||||
}
|
||||
getBalance();
|
||||
}, []);
|
||||
|
||||
getBalance();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-16 items-center justify-end px-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-end">
|
||||
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Your balance
|
||||
</div>
|
||||
<div className="font-medium leading-tight">
|
||||
₿ {value.bitcoinFormatted}
|
||||
</div>
|
||||
</div>
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-9 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-16 items-center justify-end px-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-end">
|
||||
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Your balance
|
||||
</div>
|
||||
<div className="font-medium leading-tight">
|
||||
₿ {value.bitcoinFormatted}
|
||||
</div>
|
||||
</div>
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-9 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { CancelIcon, CheckIcon } from "@lume/icons";
|
||||
import type { LumeColumn } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function Col({
|
||||
column,
|
||||
account,
|
||||
isScroll,
|
||||
isResize,
|
||||
}: {
|
||||
column: LumeColumn;
|
||||
account: string;
|
||||
isScroll: boolean;
|
||||
isResize: boolean;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const [webview, setWebview] = useState<string | undefined>(undefined);
|
||||
|
||||
const repositionWebview = async () => {
|
||||
if (webview && webview.length > 1) {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("reposition_column", {
|
||||
label: webview,
|
||||
x: newRect.x,
|
||||
y: newRect.y,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resizeWebview = async () => {
|
||||
if (webview && webview.length > 1) {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("resize_column", {
|
||||
label: webview,
|
||||
width: newRect.width,
|
||||
height: newRect.height,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resizeWebview();
|
||||
}, [isResize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScroll) repositionWebview();
|
||||
}, [isScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (webview && webview.length > 1) return;
|
||||
|
||||
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
|
||||
const label: string = await invoke("create_column", {
|
||||
label: windowLabel,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
url,
|
||||
});
|
||||
|
||||
setWebview(label);
|
||||
})();
|
||||
|
||||
// close webview when unmounted
|
||||
return () => {
|
||||
if (webview && webview.length > 1) {
|
||||
invoke("close_column", {
|
||||
label: webview,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [webview]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-[440px] shrink-0 p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col w-full h-full rounded-xl",
|
||||
column.label !== "open"
|
||||
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{column.label !== "open" ? (
|
||||
<Header label={column.label} name={column.name} />
|
||||
) : null}
|
||||
<div ref={container} className="flex-1 w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ label, name }: { label: string; name: string }) {
|
||||
const [title, setTitle] = useState(name);
|
||||
const [isChanged, setIsChanged] = useState(false);
|
||||
|
||||
const saveNewTitle = async () => {
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "set_title", label, title });
|
||||
|
||||
// update search params
|
||||
// @ts-ignore, hahaha
|
||||
search.name = title;
|
||||
|
||||
// reset state
|
||||
setIsChanged(false);
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "remove", label });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (title.length !== name.length) setIsChanged(true);
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
|
||||
<div className="size-7" />
|
||||
<div className="shrink-0 h-9 flex items-center justify-center">
|
||||
<div className="relative flex gap-2 items-center">
|
||||
<div
|
||||
contentEditable
|
||||
suppressContentEditableWarning={true}
|
||||
onBlur={(e) => setTitle(e.currentTarget.textContent)}
|
||||
className="text-sm font-medium focus:outline-none"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{isChanged ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveNewTitle()}
|
||||
className="text-teal-500 hover:text-teal-600"
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close()}
|
||||
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
<CancelIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ThreadIcon } from "@lume/icons";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export function Conversation({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const thread = ark.parse_event_thread(event.tags);
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null}
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<ThreadIcon className="size-4" />
|
||||
Thread
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||
</div>
|
||||
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
|
||||
<div>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function Notification({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { QuoteIcon } from "@lume/icons";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function Quote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
const quoteEventId = event.tags.find(
|
||||
(tag) => tag[0] === "q" || tag[3] === "mention",
|
||||
)?.[1];
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Note.Child eventId={quoteEventId} isRoot />
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<QuoteIcon className="size-4" />
|
||||
Quote
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" quote={false} clean />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +1,83 @@
|
||||
import { RepostIcon } from "@lume/icons";
|
||||
import { Event } from "@lume/types";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note, Spinner, User } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useArk } from "@lume/ark";
|
||||
import { Note, User } from "@lume/ui";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export function RepostNote({
|
||||
event,
|
||||
className,
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
const ark = useArk();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
data: repostEvent,
|
||||
} = useQuery({
|
||||
queryKey: ["repost", event.id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
if (event.content.length > 50) {
|
||||
const embed: Event = JSON.parse(event.content);
|
||||
return embed;
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
data: repostEvent,
|
||||
} = useQuery({
|
||||
queryKey: ["repost", event.id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
if (event.content.length > 50) {
|
||||
const embed: Event = JSON.parse(event.content);
|
||||
return embed;
|
||||
}
|
||||
const id = event.tags.find((el) => el[0] === "e")[1];
|
||||
return await ark.get_event(id);
|
||||
} catch {
|
||||
throw new Error("Failed to get repost event");
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
const id = event.tags.find((el) => el[0] === "e")?.[1];
|
||||
const repostEvent = await ark.get_event(id);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="w-full px-3 pb-3">Loading...</div>;
|
||||
}
|
||||
return repostEvent;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
if (isError || !repostEvent) {
|
||||
return (
|
||||
<Note.Root className={className}>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex h-14 gap-2 px-3">
|
||||
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="mb-3 select-text px-3">
|
||||
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
|
||||
<p className="text-red-500">Failed to get event</p>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"mb-5 flex flex-col gap-2 border-b border-neutral-100 pb-5 dark:border-neutral-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex gap-3">
|
||||
<div className="inline-flex w-11 shrink-0 items-center justify-center">
|
||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<Note.Provider event={repostEvent}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Note.User />
|
||||
<div className="flex gap-3">
|
||||
<div className="size-11 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Note.Content />
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="-ml-1 inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
<Note.Menu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Provider>
|
||||
</Note.Root>
|
||||
);
|
||||
return (
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
|
||||
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Reposted by
|
||||
</div>
|
||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
{isLoading ? (
|
||||
<div className="flex h-20 items-center justify-center gap-2">
|
||||
<Spinner />
|
||||
Loading event...
|
||||
</div>
|
||||
) : isError || !repostEvent ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
Event not found within your current relay set
|
||||
</div>
|
||||
) : (
|
||||
<Note.Provider event={repostEvent}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
)}
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,39 +1,34 @@
|
||||
import { Event } from "@lume/types";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function TextNote({
|
||||
event,
|
||||
className,
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"mb-5 flex flex-col gap-2 border-b border-neutral-100 pb-5 dark:border-neutral-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Note.User />
|
||||
<div className="flex gap-3">
|
||||
<div className="size-11 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Note.Content className="mb-2" />
|
||||
<Note.Thread />
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="-ml-1 inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
<Note.Menu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { 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;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Col } from "@/components/col";
|
||||
import { Toolbar } from "@/components/toolbar";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import type { 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 { getCurrent } from "@tauri-apps/api/window";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { VList, type VListHandle } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/$account/home")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
try {
|
||||
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,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
}
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const vlistRef = useRef<VListHandle>(null);
|
||||
|
||||
const { account } = Route.useParams();
|
||||
const { ark, storedColumns } = Route.useRouteContext();
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [columns, setColumns] = useState(storedColumns);
|
||||
const [isScroll, setIsScroll] = useState(false);
|
||||
const [isResize, setIsResize] = useState(false);
|
||||
|
||||
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) => {
|
||||
// update col label
|
||||
column.label = `${column.label}-${nanoid()}`;
|
||||
|
||||
// create new cols
|
||||
const cols = [...columns];
|
||||
const openColIndex = cols.findIndex((col) => col.label === "open");
|
||||
const newCols = [
|
||||
...cols.slice(0, openColIndex),
|
||||
column,
|
||||
...cols.slice(openColIndex),
|
||||
];
|
||||
|
||||
setColumns(newCols);
|
||||
setSelectedIndex(newCols.length);
|
||||
setIsScroll(true);
|
||||
|
||||
// scroll to the newest column
|
||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||
align: "end",
|
||||
});
|
||||
}, 150);
|
||||
|
||||
const remove = useDebouncedCallback((label: string) => {
|
||||
const newCols = columns.filter((t) => t.label !== label);
|
||||
|
||||
setColumns(newCols);
|
||||
setSelectedIndex(newCols.length);
|
||||
setIsScroll(true);
|
||||
|
||||
// scroll to the first column
|
||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||
align: "start",
|
||||
});
|
||||
}, 150);
|
||||
|
||||
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||
const currentColIndex = columns.findIndex((col) => col.label === label);
|
||||
|
||||
const updatedCol = Object.assign({}, columns[currentColIndex]);
|
||||
updatedCol.name = title;
|
||||
|
||||
const newCols = columns.slice();
|
||||
newCols[currentColIndex] = updatedCol;
|
||||
|
||||
setColumns(newCols);
|
||||
}, 150);
|
||||
|
||||
const startResize = useDebouncedCallback(
|
||||
() => setIsResize((prev) => !prev),
|
||||
150,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// save state
|
||||
ark.set_columns(columns);
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
|
||||
if (data.payload.type === "add") add(data.payload.column);
|
||||
if (data.payload.type === "remove") remove(data.payload.label);
|
||||
if (data.payload.type === "set_title")
|
||||
updateName(data.payload.label, data.payload.title);
|
||||
});
|
||||
|
||||
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
|
||||
startResize();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenColEvent.then((f) => f());
|
||||
unlistenWindowResize.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
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) => (
|
||||
<Col
|
||||
key={column.label}
|
||||
column={column}
|
||||
account={account}
|
||||
isScroll={isScroll}
|
||||
isResize={isResize}
|
||||
/>
|
||||
))}
|
||||
</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-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<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-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +1,178 @@
|
||||
import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
|
||||
import { Event, Kind } from "@lume/types";
|
||||
import { User } from "@lume/ui";
|
||||
import {
|
||||
BellFilledIcon,
|
||||
BellIcon,
|
||||
ComposeFilledIcon,
|
||||
HomeFilledIcon,
|
||||
HomeIcon,
|
||||
HorizontalDotsIcon,
|
||||
SettingsIcon,
|
||||
SpaceFilledIcon,
|
||||
SpaceIcon,
|
||||
} from "@lume/icons";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
cn,
|
||||
decodeZapInvoice,
|
||||
displayNpub,
|
||||
sendNativeNotification,
|
||||
} from "@lume/utils";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Accounts } from "@/components/accounts";
|
||||
import { useArk } from "@lume/ark";
|
||||
import { Box } from "@lume/ui";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/$account")({
|
||||
component: App,
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const accounts = await ark.get_all_accounts();
|
||||
|
||||
return { accounts };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function App() {
|
||||
const ark = useArk();
|
||||
const context = Route.useRouteContext();
|
||||
function Screen() {
|
||||
const { ark, platform } = Route.useRouteContext();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
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
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"flex h-11 shrink-0 items-center justify-between pr-4",
|
||||
context.platform === "macos" ? "pl-24" : "pl-4",
|
||||
)}
|
||||
>
|
||||
<Navigation />
|
||||
<div className="flex items-center gap-3">
|
||||
<Accounts />
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<Box>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"flex h-11 shrink-0 items-center justify-between pr-2",
|
||||
platform === "macos" ? "ml-2 pl-20" : "pl-4",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Accounts />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/landing" })}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
New Post
|
||||
</button>
|
||||
<Bell />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_search()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<SearchIcon className="size-5" />
|
||||
</button>
|
||||
<div id="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Navigation() {
|
||||
// @ts-ignore, useless
|
||||
const { account } = Route.useParams();
|
||||
function Accounts() {
|
||||
const navigate = Route.useNavigate();
|
||||
const { ark, accounts } = Route.useRouteContext();
|
||||
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>
|
||||
);
|
||||
const changeAccount = async (npub: string) => {
|
||||
if (npub === account) return;
|
||||
|
||||
const select = await ark.load_selected_account(npub);
|
||||
|
||||
if (select) {
|
||||
return navigate({ to: "/$account/home", params: { account: npub } });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex items-center gap-3">
|
||||
{accounts.map((user) => (
|
||||
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
||||
<User.Provider pubkey={user}>
|
||||
<User.Root
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
user === account
|
||||
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<User.Avatar
|
||||
className={cn(
|
||||
"aspect-square h-auto rounded-full object-cover",
|
||||
user === account ? "w-7" : "w-8",
|
||||
)}
|
||||
/>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bell() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = getCurrent().listen<string>(
|
||||
"activity",
|
||||
async (payload) => {
|
||||
setCount((prevCount) => prevCount + 1);
|
||||
await invoke("set_badge", { count });
|
||||
|
||||
const event: Event = JSON.parse(payload.payload);
|
||||
const user = await ark.get_profile(event.pubkey);
|
||||
const userName =
|
||||
user.display_name || user.name || displayNpub(event.pubkey, 16);
|
||||
|
||||
switch (event.kind) {
|
||||
case Kind.Text: {
|
||||
sendNativeNotification("Mentioned you in a note", userName);
|
||||
break;
|
||||
}
|
||||
case Kind.Repost: {
|
||||
sendNativeNotification("Reposted your note", userName);
|
||||
break;
|
||||
}
|
||||
case Kind.ZapReceipt: {
|
||||
const amount = decodeZapInvoice(event.tags);
|
||||
sendNativeNotification(
|
||||
`Zapped ₿ ${amount.bitcoinFormatted}`,
|
||||
userName,
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCount(0);
|
||||
ark.open_activity(account);
|
||||
}}
|
||||
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<BellIcon className="size-5" />
|
||||
{count > 0 ? (
|
||||
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,35 +1,45 @@
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import {
|
||||
Outlet,
|
||||
ScrollRestoration,
|
||||
createRootRouteWithContext,
|
||||
} from "@tanstack/react-router";
|
||||
import { type Ark } from "@lume/ark";
|
||||
import { type QueryClient } from "@tanstack/react-query";
|
||||
import { type Platform } from "@tauri-apps/plugin-os";
|
||||
import type { Ark } from "@lume/ark";
|
||||
import type { Interests, Metadata, Settings } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import type { Platform } from "@tauri-apps/plugin-os";
|
||||
import type { Descendant } from "slate";
|
||||
|
||||
type EditorElement = {
|
||||
type: string;
|
||||
children: Descendant[];
|
||||
eventId?: string;
|
||||
};
|
||||
|
||||
interface RouterContext {
|
||||
ark: Ark;
|
||||
queryClient: QueryClient;
|
||||
platform: Platform;
|
||||
locale: string;
|
||||
// System
|
||||
ark: Ark;
|
||||
queryClient: QueryClient;
|
||||
// App info
|
||||
platform?: Platform;
|
||||
locale?: string;
|
||||
// Settings
|
||||
settings?: Settings;
|
||||
interests?: Interests;
|
||||
// Profile
|
||||
accounts?: string[];
|
||||
profile?: Metadata;
|
||||
isNewUser?: boolean;
|
||||
// Editor
|
||||
initialValue?: EditorElement[];
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: () => (
|
||||
<>
|
||||
<ScrollRestoration />
|
||||
<Outlet />
|
||||
</>
|
||||
),
|
||||
pendingComponent: Pending,
|
||||
wrapInSuspense: true,
|
||||
component: () => <Outlet />,
|
||||
pendingComponent: Pending,
|
||||
wrapInSuspense: true,
|
||||
});
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/activity/$account/messages")({
|
||||
component: () => <div>Hello /activity/$account/messages!</div>,
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Note, Spinner } from "@lume/ui";
|
||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/activity/$account/texts")({
|
||||
loader: async ({ context, params }) => {
|
||||
const ark = context.ark;
|
||||
return { data: defer(ark.get_activities(params.account, "1")) };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { data } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<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
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(events) =>
|
||||
events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Activity className="px-3" />
|
||||
<Note.Content className="px-3" quote={false} clean />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Virtualizer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Box, Container } 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("/activity/$account")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
|
||||
return (
|
||||
<Container withDrag withNavigate={false}>
|
||||
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
|
||||
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
|
||||
<div className="inline-flex h-full w-full items-center gap-1">
|
||||
<Link to="/activity/$account/texts" params={{ account }}>
|
||||
{({ 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-50 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
Notes
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/activity/$account/zaps" params={{ account }}>
|
||||
{({ 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-50 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
Zaps
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
|
||||
<Outlet />
|
||||
</div>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Note, Spinner, User } from "@lume/ui";
|
||||
import { decodeZapInvoice } from "@lume/utils";
|
||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/activity/$account/zaps")({
|
||||
loader: async ({ context, params }) => {
|
||||
const ark = context.ark;
|
||||
return { data: defer(ark.get_activities(params.account, "9735")) };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { data } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<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
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(events) =>
|
||||
events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex flex-col">
|
||||
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
|
||||
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
||||
</div>
|
||||
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-7 rounded-full shrink-0" />
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
zapped you
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Virtualizer>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { CheckIcon } from "@lume/icons";
|
||||
import type { AppRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { displayNsec } from "@lume/utils";
|
||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||
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";
|
||||
|
||||
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 [loading, setLoading] = 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");
|
||||
}
|
||||
|
||||
return navigate({
|
||||
to: "/auth/settings",
|
||||
search: { account },
|
||||
});
|
||||
}
|
||||
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
invoke("get_encrypted_key", {
|
||||
npub: account,
|
||||
password: passphase,
|
||||
}).then((encrypted: string) => {
|
||||
// update state
|
||||
setKey(encrypted);
|
||||
setLoading(false);
|
||||
});
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
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="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{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="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<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-white/20 dark:hover:bg-white/30"
|
||||
>
|
||||
{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-white/10 dark:hover:bg-white/20"
|
||||
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-white/10 dark:hover:bg-white/20"
|
||||
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-white/10 dark:hover:bg-white/20"
|
||||
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()}
|
||||
disabled={loading}
|
||||
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"
|
||||
>
|
||||
{loading ? <Spinner /> : t("global.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { AvatarUploader } from "@/components/avatarUploader";
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import type { Metadata } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
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 dark:text-black bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<PlusIcon className="size-8" />
|
||||
</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-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</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-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</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-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</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-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</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 ? <Spinner /> : t("global.continue")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createLazyFileRoute } 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 = Route.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",
|
||||
);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const npub = await ark.save_account(key, password);
|
||||
|
||||
if (npub) {
|
||||
navigate({
|
||||
to: "/auth/settings",
|
||||
search: { account: npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
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="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-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</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-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</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 ? <Spinner /> : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createLazyFileRoute("/auth/remote")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const [uri, setUri] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!uri.startsWith("bunker://"))
|
||||
return toast.warning(
|
||||
"You need to enter a valid Connect URI starts with bunker://",
|
||||
);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const npub = await ark.nostr_connect(uri);
|
||||
|
||||
if (npub) {
|
||||
navigate({
|
||||
to: "/auth/settings",
|
||||
search: { account: npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
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="text-center">
|
||||
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="uri"
|
||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Connect URI
|
||||
</label>
|
||||
<input
|
||||
name="uri"
|
||||
type="text"
|
||||
placeholder="bunker://..."
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<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 ? <Spinner /> : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { LaurelIcon } from "@lume/icons";
|
||||
import type { AppRouteSearch, Settings } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/auth/settings")({
|
||||
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 [loading, setLoading] = useState(false);
|
||||
|
||||
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 toggleNsfw = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
nsfw: !newSettings.nsfw,
|
||||
}));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
// publish settings
|
||||
const eventId = await ark.set_settings(newSettings);
|
||||
|
||||
if (eventId) {
|
||||
return navigate({
|
||||
to: "/$account/home",
|
||||
params: { account },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
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 dark:bg-teal-950 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-white/10">
|
||||
<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>
|
||||
<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-white/20"
|
||||
>
|
||||
<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>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<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>
|
||||
<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-white/20"
|
||||
>
|
||||
<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>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<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>
|
||||
<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-white/20"
|
||||
>
|
||||
<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>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<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>
|
||||
<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-white/20"
|
||||
>
|
||||
<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>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
By default, Lume will display all content which have Content
|
||||
Warning tag, it's may include NSFW content.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.nsfw}
|
||||
onClick={() => toggleNsfw()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{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>
|
||||
<Spinner className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner, 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, redirect } = Route.useSearch();
|
||||
|
||||
const [title, setTitle] = useState<string>("Just a new group");
|
||||
const [users, setUsers] = useState<Array<string>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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);
|
||||
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
const groups = await ark.set_nstore(
|
||||
`lume_group_${label}`,
|
||||
JSON.stringify(users),
|
||||
);
|
||||
|
||||
if (groups) {
|
||||
toast.success("Group has been created successfully.");
|
||||
// start loading
|
||||
setIsDone(true);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto scrollbar-none">
|
||||
<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-10 rounded-lg bg-transparent border border-neutral-300 dark:border-neutral-700 px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</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-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-lg bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
|
||||
>
|
||||
<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 items-center gap-1">
|
||||
<User.Name className="font-medium" />
|
||||
<User.NIP05 />
|
||||
</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-6">
|
||||
{users.length >= 1 ? (
|
||||
<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-32 h-10 hover:bg-blue-600 focus:outline-none"
|
||||
>
|
||||
{isDone ? "Back" : loading ? <Spinner /> : "Update"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +1,90 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { AddMediaIcon, LoaderIcon } from "@lume/icons";
|
||||
import { AddMediaIcon } from "@lume/icons";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSlateStatic } from "slate-react";
|
||||
import { toast } from "sonner";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export function MediaButton({ className }: { className?: string }) {
|
||||
const ark = useArk();
|
||||
const editor = useSlateStatic();
|
||||
const editor = useSlateStatic();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const uploadToNostrBuild = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
const uploadToNostrBuild = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const image = await ark.upload();
|
||||
insertImage(editor, image);
|
||||
|
||||
const image = await ark.upload();
|
||||
// reset loading
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(`Upload failed, error: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (image) {
|
||||
insertImage(editor, image);
|
||||
}
|
||||
useEffect(() => {
|
||||
let unlisten: UnlistenFn = undefined;
|
||||
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(`Upload failed, error: ${e}`);
|
||||
}
|
||||
};
|
||||
async function listenFileDrop() {
|
||||
const window = getCurrent();
|
||||
if (!unlisten) {
|
||||
unlisten = await window.listen("tauri://file-drop", async (event) => {
|
||||
// @ts-ignore, lfg !!!
|
||||
const items: string[] = event.payload.paths;
|
||||
// start loading
|
||||
setLoading(true);
|
||||
// upload all images
|
||||
for (const item of items) {
|
||||
if (isImagePath(item)) {
|
||||
const image = await ark.upload(item);
|
||||
insertImage(editor, image);
|
||||
}
|
||||
}
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: UnlistenFn = undefined;
|
||||
listenFileDrop();
|
||||
|
||||
async function listenFileDrop() {
|
||||
const window = getCurrent();
|
||||
if (!unlisten) {
|
||||
unlisten = await window.listen("tauri://file-drop", async (event) => {
|
||||
// @ts-ignore, lfg !!!
|
||||
const items: string[] = event.payload.paths;
|
||||
// start loading
|
||||
setLoading(true);
|
||||
// upload all images
|
||||
for (const item of items) {
|
||||
if (isImagePath(item)) {
|
||||
const image = await ark.upload(item);
|
||||
insertImage(editor, image);
|
||||
}
|
||||
}
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
listenFileDrop();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadToNostrBuild()}
|
||||
disabled={loading}
|
||||
className={cn("inline-flex items-center justify-center", className)}
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
<AddMediaIcon className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadToNostrBuild()}
|
||||
disabled={loading}
|
||||
className={cn("inline-flex items-center justify-center", className)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<AddMediaIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Upload media
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MentionIcon } from "@lume/icons";
|
||||
import { cn, insertMention } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { User } from "@lume/ui";
|
||||
import { useSlateStatic } from "slate-react";
|
||||
import type { Contact } from "@lume/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function MentionButton({ className }: { className?: string }) {
|
||||
const editor = useSlateStatic();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [contacts, setContacts] = useState<string[]>([]);
|
||||
|
||||
const select = async (user: string) => {
|
||||
try {
|
||||
const metadata = await ark.get_profile(user);
|
||||
const contact: Contact = { pubkey: user, profile: metadata };
|
||||
|
||||
insertMention(editor, contact);
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function getContacts() {
|
||||
const data = await ark.get_contact_list();
|
||||
setContacts(data);
|
||||
}
|
||||
|
||||
getContacts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<MentionIcon className="size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
</DropdownMenu.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Mention
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||
{contacts.map((contact) => (
|
||||
<DropdownMenu.Item
|
||||
key={contact}
|
||||
onClick={() => select(contact)}
|
||||
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
||||
>
|
||||
<User.Provider pubkey={contact}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
||||
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NsfwIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export function NsfwToggle({
|
||||
nsfw,
|
||||
setNsfw,
|
||||
className,
|
||||
}: {
|
||||
nsfw: boolean;
|
||||
setNsfw: Dispatch<SetStateAction<boolean>>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNsfw((prev) => !prev)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
className,
|
||||
nsfw ? "bg-blue-500 text-white" : "",
|
||||
)}
|
||||
>
|
||||
<NsfwIcon className="size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Mark as sensitive content
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NsfwIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export function PowToggle({
|
||||
pow,
|
||||
setPow,
|
||||
className,
|
||||
}: {
|
||||
pow: boolean;
|
||||
setPow: Dispatch<SetStateAction<boolean>>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPow((prev) => !prev)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
className,
|
||||
pow ? "bg-blue-500 text-white" : "",
|
||||
)}
|
||||
>
|
||||
<NsfwIcon className="size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Proof of Work
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,438 +1,341 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon, TrashIcon } from "@lume/icons";
|
||||
import {
|
||||
Portal,
|
||||
cn,
|
||||
insertImage,
|
||||
insertMention,
|
||||
insertNostrEvent,
|
||||
isImageUrl,
|
||||
sendNativeNotification,
|
||||
} from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MediaButton } from "./-components/media";
|
||||
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { MentionNote } from "@lume/ui/src/note/mentions/note";
|
||||
import {
|
||||
Descendant,
|
||||
Editor,
|
||||
Node,
|
||||
Range,
|
||||
Transforms,
|
||||
createEditor,
|
||||
} from "slate";
|
||||
import {
|
||||
ReactEditor,
|
||||
useSlateStatic,
|
||||
useSelected,
|
||||
useFocused,
|
||||
withReact,
|
||||
Slate,
|
||||
Editable,
|
||||
} from "slate-react";
|
||||
import { Contact } from "@lume/types";
|
||||
import { User } from "@lume/ui";
|
||||
cn,
|
||||
insertImage,
|
||||
insertNostrEvent,
|
||||
isImageUrl,
|
||||
sendNativeNotification,
|
||||
} from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
||||
import {
|
||||
Editable,
|
||||
ReactEditor,
|
||||
Slate,
|
||||
useFocused,
|
||||
useSelected,
|
||||
useSlateStatic,
|
||||
withReact,
|
||||
} from "slate-react";
|
||||
import { MediaButton } from "./-components/media";
|
||||
import { NsfwToggle } from "./-components/nsfw";
|
||||
import { MentionButton } from "./-components/mention";
|
||||
|
||||
type EditorElement = {
|
||||
type: string;
|
||||
children: Descendant[];
|
||||
eventId?: string;
|
||||
type EditorSearch = {
|
||||
reply_to: string;
|
||||
quote: boolean;
|
||||
};
|
||||
|
||||
const contactQueryOptions = queryOptions({
|
||||
queryKey: ["contacts"],
|
||||
queryFn: () => invoke("get_contact_metadata"),
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/editor/")({
|
||||
loader: ({ context }) =>
|
||||
context.queryClient.ensureQueryData(contactQueryOptions),
|
||||
component: Screen,
|
||||
pendingComponent: Pending,
|
||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||
return {
|
||||
reply_to: search.reply_to,
|
||||
quote: search.quote === "true" ?? false,
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search }) => {
|
||||
return {
|
||||
initialValue: search.quote
|
||||
? [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
{
|
||||
type: "event",
|
||||
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
// @ts-ignore, useless
|
||||
const { reply_to, quote } = Route.useSearch();
|
||||
const { reply_to, quote } = Route.useSearch();
|
||||
const { ark, initialValue } = Route.useRouteContext();
|
||||
|
||||
let initialValue: EditorElement[];
|
||||
const [t] = useTranslation();
|
||||
const [editorValue, setEditorValue] = useState(initialValue);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nsfw, setNsfw] = useState(false);
|
||||
const [editor] = useState(() =>
|
||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||
);
|
||||
|
||||
if (quote) {
|
||||
initialValue = [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
{
|
||||
type: "event",
|
||||
eventId: `nostr:${nip19.noteEncode(reply_to)}`,
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
];
|
||||
} else {
|
||||
initialValue = [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
const reset = () => {
|
||||
// @ts-expect-error, backlog
|
||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
||||
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
||||
};
|
||||
|
||||
const ark = useArk();
|
||||
const ref = useRef<HTMLDivElement | null>();
|
||||
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
|
||||
const serialize = (nodes: Descendant[]) => {
|
||||
return nodes
|
||||
.map((n) => {
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "image") return n.url;
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "event") return n.eventId;
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [editorValue, setEditorValue] = useState(initialValue);
|
||||
const [target, setTarget] = useState<Range | undefined>();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editor] = useState(() =>
|
||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||
);
|
||||
// @ts-expect-error, backlog
|
||||
if (n.children.length) {
|
||||
// @ts-expect-error, backlog
|
||||
return n.children
|
||||
.map((n) => {
|
||||
if (n.type === "mention") return n.npub;
|
||||
return Node.string(n).trim();
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const filters = contacts
|
||||
?.filter((c) =>
|
||||
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),
|
||||
)
|
||||
?.slice(0, 5);
|
||||
return Node.string(n);
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
// @ts-expect-error, backlog
|
||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
||||
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
||||
};
|
||||
const publish = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
const serialize = (nodes: Descendant[]) => {
|
||||
return nodes
|
||||
.map((n) => {
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "image") return n.url;
|
||||
// @ts-expect-error, backlog
|
||||
if (n.type === "event") return n.eventId;
|
||||
const content = serialize(editor.children);
|
||||
const eventId = await ark.publish(content, reply_to, quote);
|
||||
|
||||
// @ts-expect-error, backlog
|
||||
if (n.children.length) {
|
||||
// @ts-expect-error, backlog
|
||||
return n.children
|
||||
.map((n) => {
|
||||
if (n.type === "mention") return n.npub;
|
||||
return Node.string(n).trim();
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
if (eventId) {
|
||||
await sendNativeNotification(
|
||||
"Your note has been published successfully.",
|
||||
"Lume",
|
||||
);
|
||||
}
|
||||
|
||||
return Node.string(n);
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
|
||||
const publish = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
// reset form
|
||||
reset();
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await sendNativeNotification(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const content = serialize(editor.children);
|
||||
const eventId = await ark.publish(content, reply_to, quote);
|
||||
|
||||
if (eventId) {
|
||||
await sendNativeNotification("You've publish new post successfully.");
|
||||
return reset();
|
||||
}
|
||||
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await sendNativeNotification(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (target && filters.length > 0) {
|
||||
const el = ref.current;
|
||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
||||
const rect = domRange.getBoundingClientRect();
|
||||
el.style.top = `${rect.top + window.scrollY + 24}px`;
|
||||
el.style.left = `${rect.left + window.scrollX}px`;
|
||||
}
|
||||
}, [filters.length, editor, index, search, target]);
|
||||
|
||||
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">
|
||||
<Slate
|
||||
editor={editor}
|
||||
initialValue={editorValue}
|
||||
onChange={() => {
|
||||
const { selection } = editor;
|
||||
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const [start] = Range.edges(selection);
|
||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
||||
const beforeRange = before && Editor.range(editor, before, start);
|
||||
const beforeText =
|
||||
beforeRange && Editor.string(editor, beforeRange);
|
||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
||||
const after = Editor.after(editor, start);
|
||||
const afterRange = Editor.range(editor, start, after);
|
||||
const afterText = Editor.string(editor, afterRange);
|
||||
const afterMatch = afterText.match(/^(\s|$)/);
|
||||
|
||||
if (beforeMatch && afterMatch) {
|
||||
setTarget(beforeRange);
|
||||
setSearch(beforeMatch[1]);
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setTarget(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-16 w-full shrink-0 items-center justify-end gap-3 px-2"
|
||||
>
|
||||
<MediaButton className="size-9 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" />
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
t("global.post")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
{reply_to && !quote ? (
|
||||
<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">
|
||||
<Editable
|
||||
key={JSON.stringify(editorValue)}
|
||||
autoFocus={true}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder={t("editor.placeholder")}
|
||||
className="focus:outline-none"
|
||||
/>
|
||||
{target && filters.length > 0 && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-xl border border-neutral-50 bg-white p-2 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
|
||||
>
|
||||
{filters.map((contact) => (
|
||||
<button
|
||||
key={contact.pubkey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
Transforms.select(editor, target);
|
||||
insertMention(editor, contact);
|
||||
setTarget(null);
|
||||
}}
|
||||
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<User.Provider pubkey={contact.pubkey}>
|
||||
<User.Root className="flex w-full items-center gap-2">
|
||||
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Slate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center gap-2.5">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
<p>Loading cache...</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<Slate editor={editor} initialValue={editorValue}>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
|
||||
>
|
||||
<NsfwToggle
|
||||
nsfw={nsfw}
|
||||
setNsfw={setNsfw}
|
||||
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
/>
|
||||
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
||||
<MediaButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => publish()}
|
||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
)}
|
||||
{t("global.post")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-1 flex-col">
|
||||
{reply_to && !quote ? (
|
||||
<div className="px-4 py-2">
|
||||
<MentionNote eventId={reply_to} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-y-auto p-4">
|
||||
<Editable
|
||||
key={JSON.stringify(editorValue)}
|
||||
autoFocus={true}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder={
|
||||
reply_to ? "Type your reply..." : t("editor.placeholder")
|
||||
}
|
||||
className="focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Slate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const withNostrEvent = (editor: ReactEditor) => {
|
||||
const { insertData, isVoid } = editor;
|
||||
const { insertData, isVoid } = editor;
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "event" ? true : isVoid(element);
|
||||
};
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "event" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
|
||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
||||
insertNostrEvent(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
||||
insertNostrEvent(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
|
||||
return editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
const withMentions = (editor: ReactEditor) => {
|
||||
const { isInline, isVoid, markableVoid } = editor;
|
||||
const { isInline, isVoid, markableVoid } = editor;
|
||||
|
||||
editor.isInline = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isInline(element);
|
||||
};
|
||||
editor.isInline = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isInline(element);
|
||||
};
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isVoid(element);
|
||||
};
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.markableVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" || markableVoid(element);
|
||||
};
|
||||
editor.markableVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" || markableVoid(element);
|
||||
};
|
||||
|
||||
return editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
const withImages = (editor: ReactEditor) => {
|
||||
const { insertData, isVoid } = editor;
|
||||
const { insertData, isVoid } = editor;
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "image" ? true : isVoid(element);
|
||||
};
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "image" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
|
||||
if (isImageUrl(text)) {
|
||||
insertImage(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
if (isImageUrl(text)) {
|
||||
insertImage(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
|
||||
return editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
const Image = ({ attributes, children, element }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
<div contentEditable={false} className="relative my-2">
|
||||
<img
|
||||
src={element.url}
|
||||
alt={element.url}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
contentEditable={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
<div contentEditable={false} className="relative my-2">
|
||||
<img
|
||||
src={element.url}
|
||||
alt={element.url}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
contentEditable={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Mention = ({ attributes, element }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
||||
>{`@${element.name}`}</span>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
||||
>{`@${element.name}`}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Event = ({ attributes, element, children }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||
<div
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="user-select-none relative my-2"
|
||||
>
|
||||
<MentionNote
|
||||
eventId={element.eventId.replace("nostr:", "")}
|
||||
openable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||
<div
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="user-select-none relative my-2"
|
||||
>
|
||||
<MentionNote
|
||||
eventId={element.eventId.replace("nostr:", "")}
|
||||
openable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Element = (props) => {
|
||||
const { attributes, children, element } = props;
|
||||
const { attributes, children, element } = props;
|
||||
|
||||
switch (element.type) {
|
||||
case "image":
|
||||
return <Image {...props} />;
|
||||
case "mention":
|
||||
return <Mention {...props} />;
|
||||
case "event":
|
||||
return <Event {...props} />;
|
||||
default:
|
||||
return (
|
||||
<p {...attributes} className="text-lg">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
switch (element.type) {
|
||||
case "image":
|
||||
return <Image {...props} />;
|
||||
case "mention":
|
||||
return <Mention {...props} />;
|
||||
case "event":
|
||||
return <Event {...props} />;
|
||||
default:
|
||||
return (
|
||||
<p {...attributes} className="text-[15px]">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { Box, Container, Note, User } from "@lume/ui";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { WindowVirtualizer } from "virtua";
|
||||
import { ReplyList } from "./-components/replyList";
|
||||
import { Event } from "@lume/types";
|
||||
|
||||
export const Route = createLazyFileRoute("/events/$eventId")({
|
||||
component: Event,
|
||||
});
|
||||
|
||||
function Event() {
|
||||
const { eventId } = Route.useParams();
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p>Not found.</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<WindowVirtualizer>
|
||||
<Container withDrag>
|
||||
<Box className="px-3 pt-3">
|
||||
<MainNote data={data} />
|
||||
{data ? <ReplyList eventId={eventId} /> : null}
|
||||
</Box>
|
||||
</Container>
|
||||
</WindowVirtualizer>
|
||||
);
|
||||
}
|
||||
|
||||
function MainNote({ data }: { data: Event }) {
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className="flex flex-col pb-3">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="mb-3 flex flex-1 items-center gap-3">
|
||||
<User.Avatar className="size-11 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<User.Time time={data.created_at} />
|
||||
<span>·</span>
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<Note.Thread className="mb-2" />
|
||||
<Note.Content className="min-w-0" />
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="-ml-1 inline-flex items-center gap-4">
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
<Note.Menu />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Box, Container, Note, Spinner } from "@lume/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { WindowVirtualizer } from "virtua";
|
||||
import { ReplyList } from "./-components/replyList";
|
||||
|
||||
export const Route = createFileRoute("/events/$eventId")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { eventId } = Route.useParams();
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p>Not found.</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container withDrag>
|
||||
<Box className="scrollbar-none">
|
||||
<WindowVirtualizer>
|
||||
<MainNote data={data} />
|
||||
{data ? (
|
||||
<ReplyList eventId={eventId} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
</WindowVirtualizer>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function MainNote({ data }: { data: Event }) {
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.ContentLarge className="px-3" />
|
||||
<div className="mt-4 h-11 gap-2 flex items-center justify-end px-3">
|
||||
<Note.Reply large />
|
||||
<Note.Repost large />
|
||||
<Note.Zap large />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,36 @@
|
||||
import { EventWithReplies } from "@lume/types";
|
||||
import type { EventWithReplies } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note, User } from "@lume/ui";
|
||||
import { SubReply } from "./subReply";
|
||||
|
||||
export function Reply({ event }: { event: EventWithReplies }) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="border-t border-neutral-100 pt-3 dark:border-neutral-900">
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="mb-2 flex items-center justify-between">
|
||||
<div className="inline-flex gap-2">
|
||||
<User.Avatar className="size-6 rounded-full" />
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Name className="font-semibold" />
|
||||
<User.NIP05 className="text-base lowercase text-neutral-600 dark:text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
<User.Time time={event.created_at} />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<Note.Content />
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="-ml-1 inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
event.replies?.length > 0
|
||||
? "my-3 mt-6 flex flex-col gap-3 divide-y divide-neutral-100 border-l-2 border-neutral-100 pl-6 dark:divide-neutral-900 dark:border-neutral-900"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{event.replies?.length > 0
|
||||
? event.replies?.map((childEvent) => (
|
||||
<SubReply key={childEvent.id} event={childEvent} />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.ContentLarge className="px-3" />
|
||||
<div className="mt-3 flex items-center gap-4 px-3 h-14">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
event.replies?.length > 0
|
||||
? "py-2 pl-3 flex flex-col gap-3 divide-y divide-neutral-100 bg-neutral-50 dark:bg-white/5 border-l-2 border-blue-500 dark:divide-neutral-900"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{event.replies?.length > 0
|
||||
? event.replies?.map((childEvent) => (
|
||||
<SubReply key={childEvent.id} event={childEvent} />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,51 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import type { EventWithReplies } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EventWithReplies } from "@lume/types";
|
||||
import { Reply } from "./reply";
|
||||
|
||||
export function ReplyList({
|
||||
eventId,
|
||||
className,
|
||||
eventId,
|
||||
className,
|
||||
}: {
|
||||
eventId: string;
|
||||
className?: string;
|
||||
eventId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const ark = useArk();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [t] = useTranslation();
|
||||
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
||||
useEffect(() => {
|
||||
async function getReplies() {
|
||||
const events = await ark.get_event_thread(eventId);
|
||||
setData(events);
|
||||
}
|
||||
getReplies();
|
||||
}, [eventId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getReplies() {
|
||||
const events = await ark.get_event_thread(eventId);
|
||||
setData(events);
|
||||
}
|
||||
getReplies();
|
||||
}, [eventId]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
{!data ? (
|
||||
<div className="mt-4 flex h-16 items-center justify-center p-3">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="mt-4 flex w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||
<h3 className="text-3xl">👋</h3>
|
||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||
{t("note.reply.empty")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((event) => <Reply key={event.id} event={event} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
<div className="h-11 flex px-3 items-center text-sm font-semibold text-neutral-700 dark:text-neutral-300 border-t border-neutral-100 dark:border-neutral-900">
|
||||
Replies ({data?.length ?? 0})
|
||||
</div>
|
||||
{!data ? (
|
||||
<div className="flex h-16 items-center justify-center p-3">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||
<h3 className="text-3xl">👋</h3>
|
||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||
{t("note.reply.empty")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((event) => <Reply key={event.id} event={event} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
import { Event } from "@lume/types";
|
||||
import { Note, User } from "@lume/ui";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
|
||||
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="pt-3">
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="mb-2 flex items-center justify-between">
|
||||
<div className="inline-flex gap-2">
|
||||
<User.Avatar className="size-6 rounded-full" />
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Name className="font-semibold" />
|
||||
<User.NIP05 className="text-base lowercase text-neutral-600 dark:text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
<User.Time time={event.created_at} />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<Note.Content />
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="-ml-1 inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
<Note.Menu />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.ContentLarge className="px-3" />
|
||||
<div className="mt-3 flex items-center gap-4 px-3">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||
import { Spinner } 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();
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
if (!interests) {
|
||||
throw redirect({
|
||||
to: "/interests",
|
||||
search: {
|
||||
...search,
|
||||
redirect: "/foryou",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
interests,
|
||||
settings,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { name, account } = Route.useSearch();
|
||||
const { ark, interests } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
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,
|
||||
);
|
||||
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 (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="w-full h-11 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
</Virtualizer>
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/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 { account } = Route.useSearch();
|
||||
const { ark } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
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: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
if (isConversation) {
|
||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="w-full h-11 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
</Virtualizer>
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||
import { Spinner } 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}`,
|
||||
)) as string[];
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
if (!groups) {
|
||||
throw redirect({
|
||||
to: "/create-group",
|
||||
search: {
|
||||
...search,
|
||||
redirect: "/group",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
settings,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { name, account } = Route.useSearch();
|
||||
const { ark, groups } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [name, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await ark.get_events(20, pageParam, groups);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage?.at(-1);
|
||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
||||
},
|
||||
select: (data) =>
|
||||
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: Event) => {
|
||||
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="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="w-full h-11 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
</Virtualizer>
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,128 +1,122 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import { Spinner, User } from "@lume/ui";
|
||||
import { checkForAppUpdates } from "@lume/utils";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: async ({ search, context }) => {
|
||||
const ark = context.ark;
|
||||
const accounts = await ark.get_all_accounts();
|
||||
beforeLoad: async ({ context }) => {
|
||||
// check for app updates
|
||||
await checkForAppUpdates(true);
|
||||
|
||||
switch (accounts.length) {
|
||||
// Guest account
|
||||
case 0:
|
||||
const guest = await ark.create_guest_account();
|
||||
throw redirect({
|
||||
to: "/$account/home/local",
|
||||
params: { account: guest },
|
||||
search: { guest: true },
|
||||
replace: true,
|
||||
});
|
||||
// Only 1 account, skip account selection screen
|
||||
case 1:
|
||||
// @ts-ignore, totally fine !!!
|
||||
if (search.manually) return;
|
||||
const ark = context.ark;
|
||||
const accounts = await ark.get_all_accounts();
|
||||
|
||||
const account = accounts[0].npub;
|
||||
const loadedAccount = await ark.load_selected_account(account);
|
||||
if (!accounts.length) {
|
||||
throw redirect({
|
||||
to: "/landing",
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (loadedAccount) {
|
||||
throw redirect({
|
||||
to: "/$account/home/local",
|
||||
params: { account },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
// Account selection
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
component: Screen,
|
||||
// Run notification service
|
||||
await invoke("run_notification", { accounts });
|
||||
|
||||
return { accounts };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const ark = useArk();
|
||||
const navigate = useNavigate();
|
||||
const navigate = Route.useNavigate();
|
||||
const { ark, accounts } = Route.useRouteContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const select = async (npub: string) => {
|
||||
setLoading(true);
|
||||
const loadAccount = await ark.load_selected_account(npub);
|
||||
if (loadAccount) {
|
||||
navigate({
|
||||
to: "/$account/home/local",
|
||||
params: { account: npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
const select = async (npub: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const currentDate = new Date().toLocaleString("default", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const loadAccount = await ark.load_selected_account(npub);
|
||||
if (loadAccount) {
|
||||
return navigate({
|
||||
to: "/$account/home",
|
||||
params: { account: npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div className="relative z-20 flex flex-col items-center gap-16">
|
||||
<div className="text-center text-white">
|
||||
<h2 className="mb-1 text-2xl">{currentDate}</h2>
|
||||
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
{loading ? (
|
||||
<div className="inline-flex size-6 items-center justify-center">
|
||||
<LoaderIcon className="size-6 animate-spin text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{ark.accounts.map((account) => (
|
||||
<button
|
||||
type="button"
|
||||
key={account.npub}
|
||||
onClick={() => select(account.npub)}
|
||||
>
|
||||
<User.Provider pubkey={account.npub}>
|
||||
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
|
||||
<User.Avatar className="size-20 rounded-full object-cover" />
|
||||
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
<Link to="/landing">
|
||||
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
||||
<PlusIcon className="size-5" />
|
||||
</div>
|
||||
<p className="text-lg font-medium leading-tight">Add</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
<img
|
||||
src="/lock-screen.jpg"
|
||||
srcSet="/lock-screen@2x.jpg 2x"
|
||||
alt="Lock Screen Background"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<a
|
||||
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 dark:bg-black/20"
|
||||
>
|
||||
Design by NoGood
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const currentDate = new Date().toLocaleString("default", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div className="relative z-20 flex flex-col items-center gap-16">
|
||||
<div className="text-center text-white">
|
||||
<h2 className="mb-1 text-2xl">{currentDate}</h2>
|
||||
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
{loading ? (
|
||||
<div className="inline-flex size-6 items-center justify-center">
|
||||
<Spinner className="size-6 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
type="button"
|
||||
key={account}
|
||||
onClick={() => select(account)}
|
||||
>
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
|
||||
<User.Avatar className="size-20 rounded-full object-cover" />
|
||||
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
<Link to="/landing">
|
||||
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
||||
<PlusIcon className="size-5" />
|
||||
</div>
|
||||
<p className="text-lg font-medium leading-tight">Add</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
<img
|
||||
src="/lock-screen.jpg"
|
||||
srcSet="/lock-screen@2x.jpg 2x"
|
||||
alt="Lock Screen Background"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<a
|
||||
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 dark:bg-black/20"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Design by NoGood
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
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")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className="h-full flex flex-col px-2">
|
||||
<div className="shrink-0 flex h-16 items-center justify-between">
|
||||
<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-1 flex flex-col gap-3 pb-2 scrollbar-none overflow-y-auto">
|
||||
{TOPICS.map((topic) => (
|
||||
<div
|
||||
key={topic.title}
|
||||
className="flex flex-col gap-4 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<div className="px-3 flex w-full items-center justify-between h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900">
|
||||
<div className="inline-flex items-center gap-2.5">
|
||||
<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="px-3 pb-3 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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,85 @@
|
||||
import { KeyIcon, RemoteIcon } from "@lume/icons";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const Route = createFileRoute("/landing/")({
|
||||
component: Screen,
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const context = Route.useRouteContext();
|
||||
const { t } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen bg-black">
|
||||
<div className="flex h-full w-full flex-col items-center justify-between">
|
||||
<div />
|
||||
<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">
|
||||
<img
|
||||
src={`/heading-${context.locale}.png`}
|
||||
srcSet={`/heading-${context.locale}@2x.png 2x`}
|
||||
alt="lume"
|
||||
className="w-2/3"
|
||||
/>
|
||||
<p className="mt-5 whitespace-pre-line text-lg font-medium leading-snug text-neutral-700">
|
||||
{t("welcome.title")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-2">
|
||||
<Link
|
||||
to="/auth/create"
|
||||
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"
|
||||
>
|
||||
{t("welcome.signup")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/import"
|
||||
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"
|
||||
>
|
||||
{t("welcome.login")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-11 items-center justify-center">
|
||||
<p className="text-neutral-800">
|
||||
{t("welcome.footer")}{" "}
|
||||
<Link
|
||||
to="https://nostr.com"
|
||||
target="_blank"
|
||||
className="text-blue-500"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative flex h-screen w-screen bg-black">
|
||||
<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 className="mx-auto flex w-full max-w-4xl flex-col items-center gap-10">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<img
|
||||
src="/heading-en.png"
|
||||
srcSet="/heading-en@2x.png 2x"
|
||||
alt="lume"
|
||||
className="xl:w-2/3"
|
||||
/>
|
||||
<p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70">
|
||||
{t("welcome.title")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
|
||||
<Link
|
||||
to="/auth/new/profile"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
|
||||
>
|
||||
{t("welcome.signup")}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-white/20" />
|
||||
<div className="text-white/70">{t("login.or")}</div>
|
||||
<div className="h-px flex-1 bg-white/20" />
|
||||
</div>
|
||||
<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 className="flex h-11 items-center justify-center" />
|
||||
</div>
|
||||
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
<img
|
||||
src="/lock-screen.jpg"
|
||||
srcSet="/lock-screen@2x.jpg 2x"
|
||||
alt="Lock Screen Background"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<a
|
||||
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"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Design by NoGood
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { 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, account } = Route.useSearch();
|
||||
const { ark } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
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: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
if (isConversation) {
|
||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="w-full h-11 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
</Virtualizer>
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
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,68 +1,62 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { ArrowRightIcon, ZapIcon } from "@lume/icons";
|
||||
import { ZapIcon } from "@lume/icons";
|
||||
import { Container } from "@lume/ui";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/nwc")({
|
||||
component: Screen,
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const ark = useArk();
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
const [uri, setUri] = useState("");
|
||||
const [isDone, setIsDone] = useState(false);
|
||||
const [uri, setUri] = useState("");
|
||||
const [isDone, setIsDone] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
const nwc = await ark.set_nwc(uri);
|
||||
const save = async () => {
|
||||
const nwc = await ark.set_nwc(uri);
|
||||
setIsDone(nwc);
|
||||
};
|
||||
|
||||
if (nwc) {
|
||||
setIsDone(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container withDrag>
|
||||
<div className="h-full w-full flex-1 px-5">
|
||||
{!isDone ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
|
||||
<ZapIcon className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-light">
|
||||
Connect <span className="font-semibold">bitcoin wallet</span>{" "}
|
||||
to start zapping to your favorite content and creator.
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label>Paste a Nostr Wallet Connect connection string</label>
|
||||
<textarea
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<div className="size-5" />
|
||||
<div>Save & Connect</div>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>Done</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
return (
|
||||
<Container withDrag withNavigate={false}>
|
||||
<div className="h-full w-full flex-1 px-5">
|
||||
{!isDone ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
|
||||
<ZapIcon className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-light">
|
||||
Connect <span className="font-semibold">bitcoin wallet</span>{" "}
|
||||
to start zapping to your favorite content and creator.
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label>Paste a Nostr Wallet Connect connection string</label>
|
||||
<textarea
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
placeholder="nostrconnect://"
|
||||
className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Save & Connect
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>Done</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch, LumeColumn } from "@lume/types";
|
||||
import { Spinner, User } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/onboarding")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { label } = Route.useSearch();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isValid, isSubmitting },
|
||||
} = useForm();
|
||||
|
||||
const [userType, setUserType] = useState<"new" | "veteran">(null);
|
||||
|
||||
const install = async (column: LumeColumn) => {
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "add", column });
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "remove", label });
|
||||
};
|
||||
|
||||
const friendToFriend = async (data: { npub: string }) => {
|
||||
if (!data.npub.startsWith("npub1"))
|
||||
return toast.warning(
|
||||
"NPUB is invalid. NPUB must be starts with npub1...",
|
||||
);
|
||||
|
||||
try {
|
||||
const connect: boolean = await invoke("friend_to_friend", {
|
||||
npub: data.npub,
|
||||
});
|
||||
|
||||
if (connect) {
|
||||
const column = {
|
||||
label: "newsfeed",
|
||||
name: "Newsfeed",
|
||||
content: "/newsfeed",
|
||||
};
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "add", column });
|
||||
|
||||
// reset form
|
||||
reset();
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col py-6 gap-6 overflow-y-auto scrollbar-none">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">Welcome to Lume</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Here are a few suggestions to help you get started.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="mb-6 w-full h-44">
|
||||
<img
|
||||
src="/lock-screen.jpg"
|
||||
srcSet="/lock-screen@2x.jpg 2x"
|
||||
alt="background"
|
||||
className="h-full w-full object-cover rounded-xl outline outline-1 -outline-offset-1 outline-black/15"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
👋 Yo! I'm Mide, and I'll be your friendly guide to Nostr and
|
||||
beyond. Looking forward to our adventure together!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||
<CurrentUser />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold text-end">You</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
How can I get started?
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserType("new")}
|
||||
className={cn(
|
||||
"mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg",
|
||||
userType === "new"
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
I'm completely new to Nostr.
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserType("veteran")}
|
||||
className={cn(
|
||||
"mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg",
|
||||
userType === "veteran"
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
I've already been using another Nostr client.
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
So, I'm excited to give you a quick intro to Lume and all the
|
||||
awesome features it has to offer. Let's dive in!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||
<CurrentUser />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold text-end">You</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Thanks! But I already know about Lume.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
install({
|
||||
label: "newsfeed",
|
||||
name: "Newsfeed",
|
||||
content: "/newsfeed",
|
||||
})
|
||||
}
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
>
|
||||
Skip! Show my newsfeed
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
First off, Lume is a social media client for Nostr. It's a
|
||||
place where you can follow friends, dive into chats, and post
|
||||
what's on your mind.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
That's not all! What makes Lume unique is the column system.
|
||||
You can enhance your experience by adding new columns from the
|
||||
Lume Store.
|
||||
</div>
|
||||
<div className="mt-1 p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
If you're confused about the term "Column," you can imagine it
|
||||
as mini-apps, with each column providing its own experience.
|
||||
</div>
|
||||
<div className="mt-1 p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Here is a quick guide for how to add a new column:
|
||||
</div>
|
||||
<div className="mt-1 rounded-lg">
|
||||
<video
|
||||
className="h-auto w-full rounded-lg object-cover aspect-video outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
<source
|
||||
src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||
<CurrentUser />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold text-end">You</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Can you introduce me to the UI? I am still confused.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Of course, here is a quick introduction video for Lume.
|
||||
</div>
|
||||
<div className="mt-1 rounded-lg">
|
||||
<video
|
||||
className="h-auto w-full rounded-lg object-cover aspect-video outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
<source
|
||||
src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "new" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Diving into new social media platforms like Nostr can be a bit
|
||||
overwhelming, but don't worry! Here are some handy tips to
|
||||
help you navigate and discover what interests you.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
install({
|
||||
label: "foryou",
|
||||
name: "For you",
|
||||
content: "/foryou",
|
||||
})
|
||||
}
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
>
|
||||
Add some topics that you're interested in.
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
install({
|
||||
label: "trending_users",
|
||||
name: "Trending",
|
||||
content: "/trending/users",
|
||||
})
|
||||
}
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
>
|
||||
Follow some users.
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "new" ? (
|
||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||
<CurrentUser />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold text-end">You</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
My girlfriend introduced Nostr to me, and I have her NPUB. Can
|
||||
I get the same experiences as her?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "new" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Absolutely! Since your girlfriend shared her NPUB with you,
|
||||
you can dive into Nostr and explore it just like she does.
|
||||
It's a great way to share experiences and discover what Nostr
|
||||
has to offer together!
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(friendToFriend)}
|
||||
className="mt-1 flex flex-col items-end bg-white dark:bg-white/10 rounded-lg shadow-primary"
|
||||
>
|
||||
<input
|
||||
{...register("npub", { required: true })}
|
||||
name="npub"
|
||||
placeholder="Enter npub here..."
|
||||
className="w-full h-14 px-3 rounded-t-lg bg-transparent border-b border-x-0 border-t-0 border-neutral-100 dark:border-white/5 focus:border-neutral-200 dark:focus:border-white/20 focus:outline-none focus:ring-0 placeholder:text-neutral-600 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<div className="h-10 flex items-center px-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="px-2 h-8 w-20 inline-flex items-center justify-center bg-blue-500 text-white rounded-md text-sm font-medium hover:bg-blue-600"
|
||||
>
|
||||
{isSubmitting ? <Spinner className="size-4" /> : "Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType ? (
|
||||
<>
|
||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||
<CurrentUser />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold text-end">You</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Thank you. I can use Lume and explore Nostr by myself from
|
||||
now on.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
I really hope you enjoy your time on Nostr! If you're keen
|
||||
to dive deeper, here are some helpful resources to get you
|
||||
started:
|
||||
</div>
|
||||
<a
|
||||
href="https://nostr.org"
|
||||
target="_blank"
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
rel="noreferrer"
|
||||
>
|
||||
[Website] nostr.org
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/watch?v=5W-jtbbh3eA"
|
||||
target="_blank"
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
rel="noreferrer"
|
||||
>
|
||||
[Video] What is Nostr?
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/nostr-protocol/nostr"
|
||||
target="_blank"
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
rel="noreferrer"
|
||||
>
|
||||
[Develop] Github
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.nostrapps.com/"
|
||||
target="_blank"
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
rel="noreferrer"
|
||||
>
|
||||
[Ecosystem] nostrapps.com
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
If you want to close this onboarding board, you can click
|
||||
the button below.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close()}
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
>
|
||||
Close
|
||||
<CancelIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Mide() {
|
||||
return (
|
||||
<img
|
||||
src="/ai.jpg"
|
||||
alt="Ai-chan"
|
||||
className="shrink-0 size-10 rounded-full outline outline-1 -outline-offset-1 outline-black/15"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentUser() {
|
||||
const { account } = Route.useSearch();
|
||||
|
||||
return (
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="shrink-0">
|
||||
<User.Avatar className="size-10 rounded-full outline outline-1 -outline-offset-1 outline-black/15" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import type { LumeColumn } from "@lume/types";
|
||||
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 (
|
||||
<div 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { SearchIcon } from "@lume/icons";
|
||||
import { type Event, Kind } from "@lume/types";
|
||||
import { Note, Spinner, User } from "@lume/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export const Route = createFileRoute("/search")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchValue] = useDebounce(search, 500);
|
||||
|
||||
const searchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
|
||||
const res = await fetch(query);
|
||||
const content = await res.json();
|
||||
const events = content.data as Event[];
|
||||
const sorted = events.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
setLoading(false);
|
||||
setEvents(sorted);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (searchValue.length >= 3 && searchValue.length < 500) {
|
||||
searchEvents();
|
||||
}
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex flex-col w-full h-full">
|
||||
<div className="relative h-24 shrink-0 flex flex-col border-b border-black/5 dark:border-white/5">
|
||||
<div data-tauri-drag-region className="w-full h-4 shrink-0" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") searchEvents();
|
||||
}}
|
||||
placeholder="Search anything..."
|
||||
className="w-full h-20 pt-10 px-3 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 p-3 overflow-y-auto scrollbar-none">
|
||||
{loading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : events.length ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
|
||||
Users
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
{events
|
||||
.filter((ev) => ev.kind === Kind.Metadata)
|
||||
.map((event, index) => (
|
||||
<SearchUser key={event.pubkey + index} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
|
||||
Notes
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-3">
|
||||
{events
|
||||
.filter((ev) => ev.kind === Kind.Text)
|
||||
.map((event) => (
|
||||
<SearchNote key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !events.length ? (
|
||||
<div className="h-full flex items-center justify-center flex-col gap-3">
|
||||
<div className="size-16 bg-black/10 dark:bg-white/10 rounded-full inline-flex items-center justify-center">
|
||||
<SearchIcon className="size-6" />
|
||||
</div>
|
||||
Try searching for people, notes, or keywords
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchUser({ event }: { event: Event }) {
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => ark.open_profile(event.pubkey)}
|
||||
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<User.Avatar className="size-9 rounded-full shrink-0" />
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<User.Name className="font-semibold" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchNote({ event }: { event: Event }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" quote={false} mention={false} />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
RelayIcon,
|
||||
SecureIcon,
|
||||
SettingsIcon,
|
||||
UserIcon,
|
||||
ZapIcon,
|
||||
} 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">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-black/10 dark:border-white/10"
|
||||
>
|
||||
<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-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<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-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<UserIcon className="size-5 shrink-0" />
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.user.title")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
<Link to="/settings/relay">
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||
isActive
|
||||
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<RelayIcon className="size-5 shrink-0" />
|
||||
<p className="text-sm font-medium">Relay</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-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<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-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<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 scrollbar-none px-5 py-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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();
|
||||
|
||||
const 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="mx-auto w-full max-w-xl">
|
||||
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||
{accounts.map((account) => (
|
||||
<NostrAccount key={account.npub} account={account} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NostrAccount({ 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 py-3">
|
||||
<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 />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor="nsec"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Private Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
name="nsec"
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { Settings } from "@lume/types";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export const Route = createFileRoute("/settings/general")({
|
||||
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,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
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 toggleNsfw = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
nsfw: !newSettings.nsfw,
|
||||
}));
|
||||
};
|
||||
|
||||
const updateSettings = useDebouncedCallback(() => {
|
||||
ark.set_settings(newSettings);
|
||||
}, 200);
|
||||
|
||||
useEffect(() => {
|
||||
updateSettings();
|
||||
}, [newSettings]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||
General
|
||||
</h2>
|
||||
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Notification</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
By turning on push notifications, you'll start getting
|
||||
notifications from Lume directly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.notification}
|
||||
onClick={() => toggleNofitication()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Enhanced Privacy</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Lume presents external resources like images, videos, or link
|
||||
previews in plain text.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.enhancedPrivacy}
|
||||
onClick={() => toggleEnhancedPrivacy()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Auto Update</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Automatically download and install new version.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.autoUpdate}
|
||||
onClick={() => toggleAutoUpdate()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
By default, Lume will display all content which have Content
|
||||
Warning tag, it's may include NSFW content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.nsfw}
|
||||
onClick={() => toggleNsfw()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Interface
|
||||
</h2>
|
||||
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<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 className="w-36 flex justify-end shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.zap}
|
||||
onClick={() => toggleZap()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createLazyFileRoute("/settings/")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
return <div>Settings</div>;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/settings/relay")({
|
||||
loader: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const relays = await ark.get_relays();
|
||||
|
||||
return relays;
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const relayList = Route.useLoaderData();
|
||||
const [relays, setRelays] = useState(relayList.connected);
|
||||
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { register, reset, handleSubmit } = useForm();
|
||||
|
||||
const onSubmit = async (data: { url: string }) => {
|
||||
try {
|
||||
const add = await ark.add_relay(data.url);
|
||||
if (add) {
|
||||
setRelays((prev) => [...prev, data.url]);
|
||||
reset();
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Connected Relays
|
||||
</h2>
|
||||
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||
{relays.map((relay) => (
|
||||
<div
|
||||
key={relay}
|
||||
className="flex justify-between items-center h-11"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<span className="relative flex size-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full size-2 bg-teal-500"></span>
|
||||
</span>
|
||||
{relay}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<CancelIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center h-14">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full flex items-center gap-2 mb-0"
|
||||
>
|
||||
<input
|
||||
{...register("url", {
|
||||
required: true,
|
||||
minLength: 1,
|
||||
})}
|
||||
name="url"
|
||||
placeholder="wss://..."
|
||||
spellCheck={false}
|
||||
className="h-9 flex-1 rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 inline-flex h-9 w-16 px-2 items-center justify-center rounded-lg bg-black/20 dark:bg-white/20 font-medium text-sm text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="size-7" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||
User Relays (NIP-65)
|
||||
</h2>
|
||||
<div className="flex flex-col py-2 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||
<p className="text-sm text-yellow-500">
|
||||
Lume will automatically connect to the user's relay list, but the
|
||||
manager function (like adding, removing, changing relay purpose)
|
||||
is not yet available.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||
{relayList.read?.map((relay) => (
|
||||
<div
|
||||
key={relay}
|
||||
className="flex justify-between items-center h-11"
|
||||
>
|
||||
<div className="text-sm font-medium">{relay}</div>
|
||||
<div className="text-xs font-semibold">READ</div>
|
||||
</div>
|
||||
))}
|
||||
{relayList.write?.map((relay) => (
|
||||
<div
|
||||
key={relay}
|
||||
className="flex justify-between items-center h-11"
|
||||
>
|
||||
<div className="text-sm font-medium">{relay}</div>
|
||||
<div className="text-xs font-semibold">WRITE</div>
|
||||
</div>
|
||||
))}
|
||||
{relayList.both?.map((relay) => (
|
||||
<div
|
||||
key={relay}
|
||||
className="flex justify-between items-center h-11"
|
||||
>
|
||||
<div className="text-sm font-medium">{relay}</div>
|
||||
<div className="text-xs font-semibold">READ + WRITE</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { AvatarUploader } from "@/components/avatarUploader";
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import type { Metadata } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/settings/user")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const profile = await ark.get_current_user_profile();
|
||||
return { profile };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark, profile } = Route.useRouteContext();
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [picture, setPicture] = useState<string>("");
|
||||
|
||||
const onSubmit = async (data: Metadata) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const profile = { ...data, picture };
|
||||
await ark.create_profile(profile);
|
||||
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-full">
|
||||
<div className="flex-1 h-full flex items-center flex-col justify-center gap-3">
|
||||
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||
{profile.picture ? (
|
||||
<img
|
||||
src={picture || profile.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 className="text-center flex flex-col items-center">
|
||||
<div className="text-lg font-semibold">{profile.display_name}</div>
|
||||
<div className="text-neutral-800 dark:text-neutral-200">
|
||||
{profile.nip05}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
to="/settings/backup"
|
||||
className="px-5 h-9 border border-blue-300 text-sm font-medium hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-full bg-blue-100 text-blue-500 inline-flex items-center justify-center"
|
||||
>
|
||||
Backup Account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 h-full">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-3 mb-0"
|
||||
>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor="display_name"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
name="display_name"
|
||||
{...register("display_name", { required: true, minLength: 1 })}
|
||||
spellCheck={false}
|
||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
{...register("name")}
|
||||
spellCheck={false}
|
||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
name="website"
|
||||
type="url"
|
||||
{...register("website")}
|
||||
spellCheck={false}
|
||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor="banner"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Cover
|
||||
</label>
|
||||
<input
|
||||
name="banner"
|
||||
type="url"
|
||||
{...register("banner")}
|
||||
spellCheck={false}
|
||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor="nip05"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
NIP-05
|
||||
</label>
|
||||
<input
|
||||
name="nip05"
|
||||
type="email"
|
||||
{...register("nip05")}
|
||||
spellCheck={false}
|
||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor="lnaddress"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Lightning Address
|
||||
</label>
|
||||
<input
|
||||
name="lnaddress"
|
||||
type="email"
|
||||
{...register("lud16")}
|
||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex h-9 w-32 px-2 items-center justify-center rounded-lg bg-blue-500 font-medium text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner className="size-4" /> : "Update Profile"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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="mx-auto w-full max-w-xl">
|
||||
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||
<div className="flex flex-col gap-6 py-3">
|
||||
<Connection />
|
||||
<DefaultAmount />
|
||||
</div>
|
||||
</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 text-sm">
|
||||
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 text-sm">
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/store/community")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-3">
|
||||
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full">
|
||||
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="font-semibold text-lg">Coming Soon</h1>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300 leading-tight">
|
||||
Enhance your experience <br /> by adding column shared by community.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { 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")({
|
||||
beforeLoad: async () => {
|
||||
const resourcePath = await resolveResource(
|
||||
"resources/official_columns.json",
|
||||
);
|
||||
const officialColumns: LumeColumn[] = JSON.parse(
|
||||
await readTextFile(resourcePath),
|
||||
);
|
||||
|
||||
return {
|
||||
officialColumns,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { GlobalIcon, LaurelIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
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() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 mt-2 mb-1">
|
||||
<div className="p-1 shrink-0 inline-flex w-full rounded-lg items-center gap-1 bg-black/5 dark:bg-white/5">
|
||||
<Link to="/store/official" className="flex-1">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
|
||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
<LaurelIcon className="size-4" />
|
||||
Official
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/store/community" className="flex-1">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
|
||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
<GlobalIcon className="size-4" />
|
||||
Community
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto scrollbar-none">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TextNote } from "@/components/text";
|
||||
import { type Event } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Await, createFileRoute } from "@tanstack/react-router";
|
||||
import { defer } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
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();
|
||||
|
||||
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
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(notes) =>
|
||||
notes.map((event) => (
|
||||
<TextNote key={event.id} event={event} className="mb-3" />
|
||||
))
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Virtualizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
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,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="h-11 shrink-0 inline-flex w-full items-center gap-1 px-3">
|
||||
<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-50 dark:bg-white/10" : "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-50 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
<GroupFeedsIcon className="size-4" />
|
||||
Users
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||