Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8398ae80d3 | |||
| fe60f75e96 | |||
|
|
e098743d43 | ||
| 0c19ada1ab | |||
| 09db39fce1 | |||
|
|
f0fc89724d | ||
| afa9327bb7 | |||
| 5c3644f977 | |||
| 0a8eed9a46 | |||
| bacfaed48a | |||
| 3d5085785b | |||
| 9152c3e122 | |||
| a5574bef6c | |||
| 2c7f3685b6 | |||
| be0abc4075 | |||
| dafe35cd1f | |||
| b23903240b | |||
| 872a6cee36 | |||
|
|
ac7ce726c5 | ||
|
|
e5e290c0c3 | ||
| 2eab6f04c7 | |||
|
|
e06b0334a5 | ||
|
|
74d8bf2ead | ||
|
|
d128af1db8 | ||
|
|
f6eb5eea44 | ||
|
|
bca2e0b7b7 | ||
|
|
61ad96ca63 | ||
|
|
26ae473521 | ||
|
|
bcc5e18082 | ||
|
|
307fff7a53 | ||
|
|
ce7828310b | ||
|
|
beac1a189e | ||
|
|
4cb49d44c7 | ||
|
|
be16d5c21d | ||
|
|
da8162069b | ||
|
|
e2103ae23a | ||
|
|
4c6d1c768a | ||
|
|
9b75a04f91 |
@@ -1,44 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
**/node_modules
|
|
||||||
.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# Local env files
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# Turbo
|
|
||||||
.turbo
|
|
||||||
|
|
||||||
# Vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# Build Outputs
|
|
||||||
**/.next/
|
|
||||||
**/out/
|
|
||||||
**/build
|
|
||||||
**/dist
|
|
||||||
**/target
|
|
||||||
|
|
||||||
|
|
||||||
# Debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# Unnecessary files
|
|
||||||
**/.git/
|
|
||||||
.github/
|
|
||||||
flatpak/*.xml
|
|
||||||
flatpak/*.desktop
|
|
||||||
flatpak/*.yml
|
|
||||||
72
.github/workflows/flatpak-bundle.yml
vendored
@@ -1,72 +0,0 @@
|
|||||||
name: Flatpak
|
|
||||||
on: workflow_dispatch
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prepare-repo:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: 'recursive'
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: cache of container
|
|
||||||
id: cache-container
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: prepare-dist
|
|
||||||
key: ${{ runner.os }}-container-${{ hashFiles('prepare-dist') }}
|
|
||||||
- name: Run latest-tag
|
|
||||||
id: latest-tag
|
|
||||||
uses: oprypin/find-latest-tag@v1
|
|
||||||
with:
|
|
||||||
repository:
|
|
||||||
lumehq/lume
|
|
||||||
#FIXME: lumehq after merged fix, now it just won't find tags
|
|
||||||
# repository: ${{ github.repository }}
|
|
||||||
|
|
||||||
- name: Build container
|
|
||||||
# if: steps.cache-container.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
docker buildx build -t flatpak-prepare-lume --build-arg=${{steps.latest-tag.outputs.tag}} --rm --output=. --target=final -f flatpak/Containerfile .
|
|
||||||
- name: Copy flatpak files content
|
|
||||||
run: |
|
|
||||||
cp -r flatpak/*.xml flatpak/*.desktop flatpak/*.yml prepare-dist
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: repo-dist
|
|
||||||
path: prepare-dist
|
|
||||||
flatpak:
|
|
||||||
name: flatpak-bundle
|
|
||||||
needs: prepare-repo
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: bilelmoussaoui/flatpak-github-actions:gnome-45
|
|
||||||
options: --privileged
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: repo-dist
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: flathub/shared-modules
|
|
||||||
path: shared-modules
|
|
||||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
|
||||||
with:
|
|
||||||
bundle: lume.flatpak
|
|
||||||
manifest-path: nu.lume.Lume.yml
|
|
||||||
restore-cache: false
|
|
||||||
# cache-key: flatpak-builder-${{ github.sha }}
|
|
||||||
- name: Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
with:
|
|
||||||
append_body: true
|
|
||||||
files: lume.flatpak
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- uses: geekyeggo/delete-artifact@v4
|
|
||||||
with:
|
|
||||||
name: repo-dist
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
4
.github/workflows/main.yml
vendored
@@ -16,10 +16,8 @@ jobs:
|
|||||||
args: "--target aarch64-apple-darwin"
|
args: "--target aarch64-apple-darwin"
|
||||||
- platform: "macos-latest" # for Intel based macs.
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
args: "--target x86_64-apple-darwin"
|
args: "--target x86_64-apple-darwin"
|
||||||
- platform: "macos-latest" # for Intel based macs.
|
- platform: "macos-latest" # for Intel & Arm based macs.
|
||||||
args: "--target universal-apple-darwin"
|
args: "--target universal-apple-darwin"
|
||||||
- platform: 'ubuntu-22.04'
|
|
||||||
args: ''
|
|
||||||
- platform: 'windows-latest'
|
- platform: 'windows-latest'
|
||||||
args: '--target x86_64-pc-windows-msvc'
|
args: '--target x86_64-pc-windows-msvc'
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|||||||
57
.gitignore
vendored
@@ -1,38 +1,27 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
node_modules
|
node_modules
|
||||||
.pnp
|
dist
|
||||||
.pnp.js
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
# Local env files
|
# Editor directories and files
|
||||||
.env
|
.vscode/*
|
||||||
.env.local
|
!.vscode/extensions.json
|
||||||
.env.development.local
|
.idea
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
coverage/
|
|
||||||
|
|
||||||
# Turbo
|
|
||||||
.turbo/
|
|
||||||
|
|
||||||
# Vercel
|
|
||||||
.vercel/
|
|
||||||
|
|
||||||
# Build Outputs
|
|
||||||
.next/
|
|
||||||
out/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
|
|
||||||
|
|
||||||
# Debug
|
|
||||||
*.log.*
|
|
||||||
|
|
||||||
# Misc
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.suo
|
||||||
.vscode/
|
*.ntvs*
|
||||||
.idea/
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
src/routes.gen.ts
|
||||||
|
src/commands.gen.ts
|
||||||
|
|||||||
26
apps/desktop2/.gitignore
vendored
@@ -1,26 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
src/router.gen.ts
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@lume/desktop2",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@getalby/bitcoin-connect-react": "^3.5.3",
|
|
||||||
"@lume/icons": "workspace:^",
|
|
||||||
"@lume/system": "workspace:^",
|
|
||||||
"@lume/ui": "workspace:^",
|
|
||||||
"@lume/utils": "workspace:^",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
|
||||||
"@radix-ui/react-checkbox": "^1.1.1",
|
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
|
||||||
"@radix-ui/react-switch": "^1.1.0",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.0",
|
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
|
||||||
"@tanstack/query-persist-client-core": "^5.51.9",
|
|
||||||
"@tanstack/react-query": "^5.51.9",
|
|
||||||
"@tanstack/react-router": "^1.45.4",
|
|
||||||
"embla-carousel-react": "^8.1.6",
|
|
||||||
"i18next": "^23.12.1",
|
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
|
||||||
"minidenticons": "^4.2.1",
|
|
||||||
"nanoid": "^5.0.7",
|
|
||||||
"nostr-tools": "^2.7.1",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-currency-input-field": "^3.8.0",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-hook-form": "^7.52.1",
|
|
||||||
"react-i18next": "^14.1.3",
|
|
||||||
"react-string-replace": "^1.1.1",
|
|
||||||
"slate": "^0.103.0",
|
|
||||||
"slate-react": "^0.105.0",
|
|
||||||
"use-debounce": "^10.0.1",
|
|
||||||
"virtua": "^0.31.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@lume/tailwindcss": "workspace:^",
|
|
||||||
"@lume/tsconfig": "workspace:^",
|
|
||||||
"@lume/types": "workspace:^",
|
|
||||||
"@tanstack/router-devtools": "^1.45.4",
|
|
||||||
"@tanstack/router-vite-plugin": "^1.45.3",
|
|
||||||
"@types/react": "^18.3.3",
|
|
||||||
"@types/react-dom": "^18.3.0",
|
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
|
||||||
"autoprefixer": "^10.4.19",
|
|
||||||
"postcss": "^8.4.39",
|
|
||||||
"tailwindcss": "^3.4.6",
|
|
||||||
"typescript": "^5.5.3",
|
|
||||||
"vite": "^5.3.4",
|
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 211 KiB |
@@ -1,110 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind utilities;
|
|
||||||
@tailwind components;
|
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
@apply cursor-default no-underline !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
@apply cursor-default focus:outline-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input::-ms-reveal,
|
|
||||||
input::-ms-clear {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-input-placeholder {
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-leaf {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: calc(50% - 12.5% / 2);
|
|
||||||
width: 12.5%;
|
|
||||||
height: 100%;
|
|
||||||
animation: spinner-leaf-fade 800ms linear infinite;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 30%;
|
|
||||||
background-color: currentColor;
|
|
||||||
@apply rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
&: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,43 +0,0 @@
|
|||||||
import { NostrQuery } from "@lume/system";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import {
|
|
||||||
type Dispatch,
|
|
||||||
type ReactNode,
|
|
||||||
type SetStateAction,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
export function AvatarUploader({
|
|
||||||
setPicture,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
setPicture: Dispatch<SetStateAction<string>>;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const uploadAvatar = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const image = await NostrQuery.upload();
|
|
||||||
setPicture(image);
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
await message(String(e), { title: "Lume", kind: "error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => uploadAvatar()}
|
|
||||||
className={cn("size-4", className)}
|
|
||||||
>
|
|
||||||
{loading ? <Spinner className="size-4" /> : children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { ZapIcon } from "@lume/icons";
|
|
||||||
import { LumeWindow } from "@lume/system";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useNoteContext } from "../provider";
|
|
||||||
|
|
||||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
|
||||||
const event = useNoteContext();
|
|
||||||
const { settings } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
if (!settings.display_zap_button) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => LumeWindow.openZap(event.id)}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
|
||||||
large
|
|
||||||
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
: "size-7",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ZapIcon className="size-4" />
|
|
||||||
{large ? "Zap" : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export function Hashtag({ tag }: { tag: string }) {
|
|
||||||
return (
|
|
||||||
<span className="leading-normal break-all cursor-default group text-start">
|
|
||||||
<span className="text-blue-500">#</span>
|
|
||||||
<span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
|
||||||
{tag.replace("#", "")}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { User } from "@/components/user";
|
|
||||||
import { LinkIcon } from "@lume/icons";
|
|
||||||
import { LumeWindow, useEvent } from "@lume/system";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
|
|
||||||
export function MentionNote({
|
|
||||||
eventId,
|
|
||||||
openable = true,
|
|
||||||
}: {
|
|
||||||
eventId: string;
|
|
||||||
openable?: boolean;
|
|
||||||
}) {
|
|
||||||
const { isLoading, isError, data } = useEvent(eventId);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="py-2">
|
|
||||||
<div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !data) {
|
|
||||||
return (
|
|
||||||
<div className="py-2">
|
|
||||||
<div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
|
|
||||||
<p className="text-sm font-medium text-red-500">
|
|
||||||
Event not found with your current relay set
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="py-2">
|
|
||||||
<div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
|
|
||||||
<User.Provider pubkey={data.pubkey}>
|
|
||||||
<User.Root className="flex items-center gap-2 h-8">
|
|
||||||
<User.Avatar className="rounded-full size-6" />
|
|
||||||
<div className="inline-flex items-center flex-1 gap-2">
|
|
||||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
|
||||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
|
||||||
<User.Time
|
|
||||||
time={data.created_at}
|
|
||||||
className="text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="select-text text-pretty line-clamp-3 content-break leading-normal">
|
|
||||||
{data.content}
|
|
||||||
</div>
|
|
||||||
{openable ? (
|
|
||||||
<div className="flex items-center justify-start mt-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
LumeWindow.openEvent(data);
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1 text-blue-500 text-sm"
|
|
||||||
>
|
|
||||||
View post
|
|
||||||
<LinkIcon className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-3" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { cn } from "@lume/utils";
|
|
||||||
import * as Avatar from "@radix-ui/react-avatar";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { minidenticon } from "minidenticons";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useUserContext } from "./provider";
|
|
||||||
|
|
||||||
export function UserAvatar({ className }: { className?: string }) {
|
|
||||||
const user = useUserContext();
|
|
||||||
const { settings } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
const picture = useMemo(() => {
|
|
||||||
if (
|
|
||||||
settings?.image_resize_service?.length &&
|
|
||||||
user.profile?.picture?.length
|
|
||||||
) {
|
|
||||||
const url = `${settings.image_resize_service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
|
|
||||||
return url;
|
|
||||||
} else {
|
|
||||||
return user.profile?.picture;
|
|
||||||
}
|
|
||||||
}, [user.profile?.picture]);
|
|
||||||
|
|
||||||
const fallback = useMemo(
|
|
||||||
() =>
|
|
||||||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
|
||||||
minidenticon(user.pubkey, 60, 50),
|
|
||||||
)}`,
|
|
||||||
[user.pubkey],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (settings && !settings.display_avatar) {
|
|
||||||
return (
|
|
||||||
<Avatar.Root
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Avatar.Fallback delayMs={120}>
|
|
||||||
<img
|
|
||||||
src={fallback}
|
|
||||||
alt={user.pubkey}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
|
||||||
/>
|
|
||||||
</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar.Root
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Avatar.Image
|
|
||||||
src={picture}
|
|
||||||
alt={user.pubkey}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
|
||||||
/>
|
|
||||||
<Avatar.Fallback>
|
|
||||||
<img
|
|
||||||
src={fallback}
|
|
||||||
alt={user.pubkey}
|
|
||||||
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
|
||||||
/>
|
|
||||||
</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { cn } from "@lume/utils";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { useUserContext } from "./provider";
|
|
||||||
import { NostrAccount } from "@lume/system";
|
|
||||||
|
|
||||||
export function UserFollowButton({
|
|
||||||
simple = false,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
simple?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const user = useUserContext();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [followed, setFollowed] = useState(false);
|
|
||||||
|
|
||||||
const toggleFollow = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const toggle = await NostrAccount.toggleContact(user.pubkey);
|
|
||||||
|
|
||||||
if (toggle) {
|
|
||||||
setFollowed((prev) => !prev);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
NostrAccount.checkContact(user.pubkey).then((status) => {
|
|
||||||
if (mounted) setFollowed(status);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => toggleFollow()}
|
|
||||||
className={cn("w-max", className)}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Spinner className="size-4" />
|
|
||||||
) : followed ? (
|
|
||||||
!simple ? (
|
|
||||||
"Unfollow"
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
"Follow"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import { User } from "@/components/user";
|
|
||||||
import {
|
|
||||||
ChevronDownIcon,
|
|
||||||
ComposeFilledIcon,
|
|
||||||
PlusIcon,
|
|
||||||
SearchIcon,
|
|
||||||
} from "@lume/icons";
|
|
||||||
import { LumeWindow, NostrAccount, NostrQuery } from "@lume/system";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { memo, useCallback, useState } from "react";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account")({
|
|
||||||
beforeLoad: async ({ params }) => {
|
|
||||||
const settings = await NostrQuery.getUserSettings();
|
|
||||||
const accounts = await NostrAccount.getAccounts();
|
|
||||||
const otherAccounts = accounts.filter(
|
|
||||||
(account) => account !== params.account,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { otherAccounts, settings };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { settings, platform } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const openLumeStore = async () => {
|
|
||||||
await getCurrentWindow().emit("columns", {
|
|
||||||
type: "add",
|
|
||||||
column: {
|
|
||||||
label: "store",
|
|
||||||
name: "Column Gallery",
|
|
||||||
content: "/store",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-screen h-screen">
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex h-11 shrink-0 items-center justify-between px-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className={cn(
|
|
||||||
"flex-1 flex items-center gap-2",
|
|
||||||
platform === "macos" ? "pl-[64px]" : "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openLumeStore()}
|
|
||||||
className="inline-flex items-center justify-center gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-5" />
|
|
||||||
Column
|
|
||||||
</button>
|
|
||||||
<div id="toolbar" />
|
|
||||||
</div>
|
|
||||||
<div data-tauri-drag-region className="hidden md:flex md:flex-1">
|
|
||||||
<Search />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex-1 flex items-center justify-end gap-3"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => LumeWindow.openEditor()}
|
|
||||||
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
|
|
||||||
>
|
|
||||||
<ComposeFilledIcon className="size-4" />
|
|
||||||
New Post
|
|
||||||
</button>
|
|
||||||
<Accounts />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex-1",
|
|
||||||
settings.vibrancy
|
|
||||||
? ""
|
|
||||||
: "bg-white dark:bg-black border-t border-black/20 dark:border-white/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Accounts = memo(function Accounts() {
|
|
||||||
const { otherAccounts } = Route.useRouteContext();
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
|
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
|
|
||||||
const showContextMenu = useCallback(
|
|
||||||
async (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const menuItems = await Promise.all([
|
|
||||||
MenuItem.new({
|
|
||||||
text: "New Post",
|
|
||||||
action: () => LumeWindow.openEditor(),
|
|
||||||
}),
|
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "View Profile",
|
|
||||||
action: () => LumeWindow.openProfile(account),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Open Settings",
|
|
||||||
action: () => LumeWindow.openSettings(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const menu = await Menu.new({
|
|
||||||
items: menuItems,
|
|
||||||
});
|
|
||||||
|
|
||||||
await menu.popup().catch((e) => console.error(e));
|
|
||||||
},
|
|
||||||
[account],
|
|
||||||
);
|
|
||||||
|
|
||||||
const changeAccount = useCallback(
|
|
||||||
async (npub: string) => {
|
|
||||||
// Change current account and update signer
|
|
||||||
const select = await NostrAccount.loadAccount(npub);
|
|
||||||
|
|
||||||
if (select) {
|
|
||||||
// Reset current columns
|
|
||||||
await getCurrentWindow().emit("columns", { type: "reset" });
|
|
||||||
|
|
||||||
// Redirect to new account
|
|
||||||
return navigate({
|
|
||||||
to: "/$account/home",
|
|
||||||
params: { account: npub },
|
|
||||||
resetScroll: true,
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await message("Something wrong.", { title: "Accounts", kind: "error" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[otherAccounts],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-tauri-drag-region className="hidden md:flex items-center gap-3">
|
|
||||||
{otherAccounts.map((npub) => (
|
|
||||||
<button key={npub} type="button" onClick={(e) => changeAccount(npub)}>
|
|
||||||
<User.Provider pubkey={npub}>
|
|
||||||
<User.Root className="shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto hover:ring-1 hover:ring-blue-500">
|
|
||||||
<User.Avatar className="rounded-full size-8" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => showContextMenu(e)}
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={account}>
|
|
||||||
<User.Root className="shrink-0 rounded-full">
|
|
||||||
<User.Avatar className="rounded-full size-8" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<ChevronDownIcon className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const Search = memo(function Search() {
|
|
||||||
const [searchType, setSearchType] = useState<"notes" | "users">("notes");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const menuItems = await Promise.all([
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Notes",
|
|
||||||
action: () => setSearchType("notes"),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Users",
|
|
||||||
action: () => setSearchType("users"),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const menu = await Menu.new({
|
|
||||||
items: menuItems,
|
|
||||||
});
|
|
||||||
|
|
||||||
await menu.popup().catch((e) => console.error(e));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-8 w-full px-3 text-sm rounded-full inline-flex items-center bg-black/5 dark:bg-white/5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => showContextMenu(e)}
|
|
||||||
className="inline-flex items-center gap-1 capitalize text-sm font-medium pr-2 border-r border-black/10 dark:border-white/10 text-black/50 dark:text-white/50"
|
|
||||||
>
|
|
||||||
{searchType}
|
|
||||||
<ChevronDownIcon className="size-3" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="search"
|
|
||||||
placeholder="Search..."
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
LumeWindow.openSearch(searchType, query);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-full w-full px-3 text-sm rounded-full border-none ring-0 focus:ring-0 focus:outline-none bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50"
|
|
||||||
/>
|
|
||||||
<SearchIcon className="size-5" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Container } from "@lume/ui";
|
|
||||||
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
return (
|
|
||||||
<Container withDrag>
|
|
||||||
<div className="max-w-sm mx-auto size-full">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { AvatarUploader } from "@/components/avatarUploader";
|
|
||||||
import { PlusIcon } from "@lume/icons";
|
|
||||||
import { NostrAccount } from "@lume/system";
|
|
||||||
import type { Metadata } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/create-profile")({
|
|
||||||
loader: async () => {
|
|
||||||
const account = await NostrAccount.createAccount();
|
|
||||||
return account;
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const account = Route.useLoaderData();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
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 NostrAccount.saveAccount(account.nsec);
|
|
||||||
|
|
||||||
// Then create profile
|
|
||||||
if (save) {
|
|
||||||
const profile: Metadata = { ...data, picture };
|
|
||||||
const eventId = await NostrAccount.createProfile(profile);
|
|
||||||
|
|
||||||
if (eventId) {
|
|
||||||
navigate({
|
|
||||||
to: "/auth/$account/backup",
|
|
||||||
params: { account: account.npub },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
await message(String(e), { title: "Create Profile", kind: "error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center size-full gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="w-full mb-0">
|
|
||||||
<div className="flex flex-col gap-3 w-full p-3 overflow-hidden bg-white rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
|
|
||||||
<div className="self-center relative rounded-full size-20 bg-neutral-200 dark:bg-white/70 my-3">
|
|
||||||
{picture ? (
|
|
||||||
<img
|
|
||||||
src={picture}
|
|
||||||
alt="avatar"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<AvatarUploader
|
|
||||||
setPicture={setPicture}
|
|
||||||
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full dark:text-black bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-8" />
|
|
||||||
</AvatarUploader>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label htmlFor="display_name" className="font-medium">
|
|
||||||
Display Name *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type={"text"}
|
|
||||||
{...register("display_name", { required: true, minLength: 1 })}
|
|
||||||
placeholder="e.g. Alice in Nostrland"
|
|
||||||
spellCheck={false}
|
|
||||||
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 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">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type={"text"}
|
|
||||||
{...register("name")}
|
|
||||||
placeholder="e.g. alice"
|
|
||||||
spellCheck={false}
|
|
||||||
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 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">
|
|
||||||
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">
|
|
||||||
Website
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
{...register("website")}
|
|
||||||
placeholder="e.g. https://alice.me"
|
|
||||||
spellCheck={false}
|
|
||||||
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? <Spinner /> : "Continue"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { NostrAccount } from "@lume/system";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/import")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
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 await message(
|
|
||||||
"You need to enter a valid private key starts with nsec or ncryptsec",
|
|
||||||
{ title: "Import Key", kind: "info" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const npub = await NostrAccount.saveAccount(key, password);
|
|
||||||
|
|
||||||
if (npub) {
|
|
||||||
navigate({ to: "/", replace: true });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
await message(String(e), { title: "Import Key", kind: "error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center size-full gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col w-full">
|
|
||||||
<div className="flex flex-col gap-3 w-full p-3 overflow-hidden bg-white rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
|
|
||||||
<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="px-3 border-transparent rounded-lg h-11 bg-neutral-100 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="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submit()}
|
|
||||||
disabled={loading}
|
|
||||||
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? <Spinner /> : "Login"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { NostrAccount } from "@lume/system";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/remote")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
|
|
||||||
const [uri, setUri] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!uri.startsWith("bunker://")) {
|
|
||||||
return await message(
|
|
||||||
"You need to enter a valid Connect URI starts with bunker://",
|
|
||||||
{ title: "Nostr Connect", kind: "info" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
|
|
||||||
|
|
||||||
if (remoteAccount?.length) {
|
|
||||||
navigate({ to: "/", replace: true });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
await message(String(e), { title: "Nostr Connect", kind: "error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center size-full gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col w-full">
|
|
||||||
<div className="flex flex-col gap-1 w-full p-3 overflow-hidden bg-white rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
|
|
||||||
<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="px-3 border-transparent rounded-lg h-11 bg-neutral-100 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 items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submit()}
|
|
||||||
disabled={loading}
|
|
||||||
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? <Spinner /> : "Login"}
|
|
||||||
</button>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
|
|
||||||
Waiting confirmation...
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { CancelIcon, PlusIcon } from "@lume/icons";
|
|
||||||
import { NostrQuery } from "@lume/system";
|
|
||||||
import type { Relay } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/bootstrap-relays")({
|
|
||||||
loader: async () => {
|
|
||||||
const bootstrapRelays = await NostrQuery.getBootstrapRelays();
|
|
||||||
return bootstrapRelays;
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const bootstrapRelays = Route.useLoaderData();
|
|
||||||
const { register, reset, handleSubmit } = useForm();
|
|
||||||
|
|
||||||
const [relays, setRelays] = useState<Relay[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const removeRelay = (url: string) => {
|
|
||||||
setRelays((prev) => prev.filter((relay) => relay.url !== url));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (data: { url: string; purpose: string }) => {
|
|
||||||
try {
|
|
||||||
if (!data.url.startsWith("wss://") || !data.url.startsWith("ws://")) {
|
|
||||||
return await message("Relay must be starts with wss:// or ws://", {
|
|
||||||
title: "Bootstrap Relays",
|
|
||||||
kind: "info",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const relay: Relay = { url: data.url, purpose: data.purpose };
|
|
||||||
setRelays((prev) => [...prev, relay]);
|
|
||||||
reset();
|
|
||||||
} catch (e) {
|
|
||||||
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
await NostrQuery.saveBootstrapRelays(relays);
|
|
||||||
} catch (e) {
|
|
||||||
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRelays(bootstrapRelays);
|
|
||||||
}, [bootstrapRelays]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="relative flex flex-col items-center justify-between w-full h-full"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="absolute top-0 left-0 h-14 w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex items-end justify-center flex-1 w-full px-4 pb-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-xl font-semibold">Customize Bootstrap Relays</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center flex-1 w-full">
|
|
||||||
<div className="flex flex-col w-full max-w-sm mx-auto p-3 overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
|
|
||||||
{relays.map((relay) => (
|
|
||||||
<div
|
|
||||||
key={relay.url}
|
|
||||||
className="flex items-center justify-between h-11"
|
|
||||||
>
|
|
||||||
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
|
||||||
{relay.url}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{relay.purpose?.length ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center px-2 text-xs font-medium uppercase rounded-md h-7 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
{relay.purpose}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeRelay(relay.url)}
|
|
||||||
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<CancelIcon className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="flex items-center border-t h-14 border-neutral-100 dark:border-white/5">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="flex items-center w-full gap-2 mb-0"
|
|
||||||
>
|
|
||||||
<div className="flex items-center flex-1 gap-2 border rounded-lg border-neutral-300 dark:border-white/20">
|
|
||||||
<input
|
|
||||||
{...register("url", {
|
|
||||||
required: true,
|
|
||||||
minLength: 1,
|
|
||||||
})}
|
|
||||||
name="url"
|
|
||||||
placeholder="wss://..."
|
|
||||||
spellCheck={false}
|
|
||||||
className="flex-1 px-3 bg-transparent border-none rounded-l-lg h-9 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
{...register("purpose")}
|
|
||||||
className="flex-1 p-0 m-0 text-sm bg-transparent border-none outline-none h-9 ring-0 focus:outline-none focus:ring-0"
|
|
||||||
>
|
|
||||||
<option value="read">Read</option>
|
|
||||||
<option value="write">Write</option>
|
|
||||||
<option value="">Both</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="inline-flex items-center justify-center px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 w-14 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-7" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full max-w-sm mx-auto">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => save()}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoading ? <Spinner /> : "Save & Relaunch"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { User } from "@/components/user";
|
|
||||||
import { NostrAccount } from "@lume/system";
|
|
||||||
import type { ColumnRouteSearch } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { Await, defer } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { Suspense, useState } from "react";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/create-newsfeed/users")({
|
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
label: search.label,
|
|
||||||
name: search.name,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
loader: async ({ abortController }) => {
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
data: defer(
|
|
||||||
fetch("https://api.nostr.band/v0/trending/profiles", {
|
|
||||||
signal: abortController.signal,
|
|
||||||
}).then((res) => res.json()),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
const { redirect } = Route.useSearch();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [follows, setFollows] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
|
|
||||||
const toggleFollow = (pubkey: string) => {
|
|
||||||
setFollows((prev) =>
|
|
||||||
prev.includes(pubkey)
|
|
||||||
? prev.filter((i) => i !== pubkey)
|
|
||||||
: [...prev, pubkey],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const newContactList = await NostrAccount.setContactList(follows);
|
|
||||||
|
|
||||||
if (newContactList) {
|
|
||||||
return navigate({ to: redirect });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setIsLoading(false);
|
|
||||||
await message(String(e), {
|
|
||||||
title: "Create Group",
|
|
||||||
kind: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center w-full gap-3">
|
|
||||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 rounded-xl">
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex flex-col items-center justify-center w-full h-20 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}>
|
|
||||||
{(users) =>
|
|
||||||
users.profiles.map((item: { pubkey: string }) => (
|
|
||||||
<div
|
|
||||||
key={item.pubkey}
|
|
||||||
className="w-full p-2 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20shadow-primary dark:ring-1 ring-neutral-800/50"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={item.pubkey}>
|
|
||||||
<User.Root>
|
|
||||||
<div className="flex flex-col w-full h-full gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<User.Avatar className="rounded-full size-7" />
|
|
||||||
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleFollow(item.pubkey)}
|
|
||||||
className="inline-flex items-center justify-center w-20 text-sm font-medium rounded-lg h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
{follows.includes(item.pubkey)
|
|
||||||
? "Unfollow"
|
|
||||||
: "Follow"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submit()}
|
|
||||||
disabled={isLoading || follows.length < 1}
|
|
||||||
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoading ? <Spinner /> : "Confirm"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { CheckCircleIcon } from "@lume/icons";
|
|
||||||
import { NostrQuery } from "@lume/system";
|
|
||||||
import type { ColumnRouteSearch } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { TOPICS } from "@lume/utils";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type Topic = {
|
|
||||||
title: string;
|
|
||||||
content: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/create-topic")({
|
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
label: search.label,
|
|
||||||
name: search.name,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const [topics, setTopics] = useState<Topic[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const search = Route.useSearch();
|
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
|
|
||||||
const toggleTopic = (topic: Topic) => {
|
|
||||||
setTopics((prev) =>
|
|
||||||
prev.find((item) => item.title === topic.title)
|
|
||||||
? prev.filter((i) => i.title !== topic.title)
|
|
||||||
: [...prev, topic],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const key = `lume_topic_${search.label}`;
|
|
||||||
const createTopic = await NostrQuery.setNstore(
|
|
||||||
key,
|
|
||||||
JSON.stringify(topics),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (createTopic) {
|
|
||||||
return navigate({ to: search.redirect, search: { ...search } });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setIsLoading(false);
|
|
||||||
await message(String(e), {
|
|
||||||
title: "Create Topic",
|
|
||||||
kind: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
|
||||||
<div className="flex flex-col items-center justify-center text-center">
|
|
||||||
<h1 className="font-serif text-2xl font-medium">
|
|
||||||
What are your interests?
|
|
||||||
</h1>
|
|
||||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
|
||||||
Add some topics you want to focus on.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
|
||||||
<div className="flex items-center justify-between w-full px-3 rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
|
|
||||||
<span className="text-sm font-medium">Added: {topics.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center w-full gap-3">
|
|
||||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 rounded-xl">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{TOPICS.map((topic) => (
|
|
||||||
<button
|
|
||||||
key={topic.title}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleTopic(topic)}
|
|
||||||
className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
|
|
||||||
>
|
|
||||||
<div className="inline-flex items-center gap-1">
|
|
||||||
<div>{topic.icon}</div>
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
<span>{topic.title}</span>
|
|
||||||
<span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
|
|
||||||
{topic.content.length} hashtags
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{topics.find((item) => item.title === topic.title) ? (
|
|
||||||
<CheckCircleIcon className="text-teal-500 size-4" />
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submit()}
|
|
||||||
disabled={isLoading || topics.length < 1}
|
|
||||||
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoading ? <Spinner /> : "Confirm"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { AddMediaIcon } from "@lume/icons";
|
|
||||||
import { NostrQuery } from "@lume/system";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { insertImage, isImagePath } from "@lume/utils";
|
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useSlateStatic } from "slate-react";
|
|
||||||
|
|
||||||
export function MediaButton() {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const upload = async () => {
|
|
||||||
try {
|
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const image = await NostrQuery.upload();
|
|
||||||
insertImage(editor, image);
|
|
||||||
|
|
||||||
// reset loading
|
|
||||||
setLoading(false);
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
await message(String(e), { title: "Upload", kind: "error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let unlisten: UnlistenFn = undefined;
|
|
||||||
|
|
||||||
async function listenFileDrop() {
|
|
||||||
const window = getCurrentWindow();
|
|
||||||
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 NostrQuery.upload(item);
|
|
||||||
insertImage(editor, image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listenFileDrop();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (unlisten) unlisten();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => upload()}
|
|
||||||
disabled={loading}
|
|
||||||
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Spinner className="size-4" />
|
|
||||||
) : (
|
|
||||||
<AddMediaIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
Add media
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
import { Note } from "@/components/note";
|
|
||||||
import { MentionNote } from "@/components/note/mentions/note";
|
|
||||||
import { User } from "@/components/user";
|
|
||||||
import { ComposeFilledIcon } from "@lume/icons";
|
|
||||||
import { LumeEvent, useEvent } from "@lume/system";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { nip19 } from "nostr-tools";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
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 { PowButton } from "./-components/pow";
|
|
||||||
import { WarningButton } from "./-components/warning";
|
|
||||||
|
|
||||||
type EditorSearch = {
|
|
||||||
reply_to: string;
|
|
||||||
quote: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EditorElement = {
|
|
||||||
type: string;
|
|
||||||
children: Descendant[];
|
|
||||||
eventId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/editor/")({
|
|
||||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
|
||||||
return {
|
|
||||||
reply_to: search.reply_to,
|
|
||||||
quote: search.quote,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
beforeLoad: ({ search }) => {
|
|
||||||
let initialValue: EditorElement[];
|
|
||||||
|
|
||||||
if (search?.quote?.length) {
|
|
||||||
const eventId = nip19.noteEncode(search.quote);
|
|
||||||
initialValue = [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "event",
|
|
||||||
eventId: `nostr:${eventId}`,
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
initialValue = [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return { initialValue };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { reply_to } = Route.useSearch();
|
|
||||||
const { initialValue } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [warning, setWarning] = useState({ enable: false, reason: "" });
|
|
||||||
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
|
|
||||||
const [editor] = useState(() =>
|
|
||||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
|
||||||
);
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
|
||||||
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// @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(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Node.string(n);
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const publish = async () => {
|
|
||||||
try {
|
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const content = serialize(editor.children);
|
|
||||||
const eventId = await LumeEvent.publish(
|
|
||||||
content,
|
|
||||||
warning.enable && warning.reason.length ? warning.reason : null,
|
|
||||||
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
|
|
||||||
reply_to,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (eventId) {
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
// reset form
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setEditorValue(initialValue);
|
|
||||||
}, [initialValue]);
|
|
||||||
|
|
||||||
if (!editorValue) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-full h-full">
|
|
||||||
<Slate editor={editor} initialValue={editorValue}>
|
|
||||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
|
||||||
<div className="flex flex-col flex-1 overflow-y-auto">
|
|
||||||
{reply_to?.length ? (
|
|
||||||
<div className="flex items-center gap-3 px-2.5 pb-3 border-b border-black/5 dark:border-white/5">
|
|
||||||
<div className="text-sm font-semibold shrink-0">Reply to:</div>
|
|
||||||
<ChildNote id={reply_to} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="px-4 py-4 overflow-y-auto">
|
|
||||||
<Editable
|
|
||||||
key={JSON.stringify(editorValue)}
|
|
||||||
autoFocus={true}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="none"
|
|
||||||
spellCheck={false}
|
|
||||||
renderElement={(props) => <Element {...props} />}
|
|
||||||
placeholder={
|
|
||||||
reply_to ? "Type your reply..." : "What're you up to?"
|
|
||||||
}
|
|
||||||
className="focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{warning.enable ? (
|
|
||||||
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
|
||||||
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
|
||||||
Reason:
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="NSFW..."
|
|
||||||
value={warning.reason}
|
|
||||||
onChange={(e) =>
|
|
||||||
setWarning((prev) => ({ ...prev, reason: e.target.value }))
|
|
||||||
}
|
|
||||||
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{difficulty.enable ? (
|
|
||||||
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
|
||||||
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
|
||||||
Difficulty:
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]"
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (!/[0-9]/.test(event.key)) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="21"
|
|
||||||
defaultValue={difficulty.num}
|
|
||||||
onChange={(e) =>
|
|
||||||
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
|
|
||||||
}
|
|
||||||
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => publish()}
|
|
||||||
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Spinner className="size-4" />
|
|
||||||
) : (
|
|
||||||
<ComposeFilledIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
Publish
|
|
||||||
</button>
|
|
||||||
<div className="inline-flex items-center flex-1 gap-2 pl-4">
|
|
||||||
<MediaButton />
|
|
||||||
<WarningButton setWarning={setWarning} />
|
|
||||||
<PowButton setDifficulty={setDifficulty} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Slate>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChildNote({ id }: { id: string }) {
|
|
||||||
const { isLoading, isError, data } = useEvent(id);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Spinner className="size-5" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !data) {
|
|
||||||
return <div>Event not found with your current relay set.</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Note.Provider event={data}>
|
|
||||||
<Note.Root className="flex items-center gap-2">
|
|
||||||
<User.Provider pubkey={data.pubkey}>
|
|
||||||
<User.Root className="shrink-0">
|
|
||||||
<User.Avatar className="rounded-full size-8" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="content-break line-clamp-1">{data.content}</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const withNostrEvent = (editor: ReactEditor) => {
|
|
||||||
const { insertData, isVoid } = editor;
|
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "event" ? true : isVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.insertData = (data) => {
|
|
||||||
const text = data.getData("text/plain");
|
|
||||||
|
|
||||||
if (text.startsWith("nevent") || text.startsWith("note")) {
|
|
||||||
insertNostrEvent(editor, text);
|
|
||||||
} else {
|
|
||||||
insertData(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withMentions = (editor: ReactEditor) => {
|
|
||||||
const { isInline, isVoid, markableVoid } = editor;
|
|
||||||
|
|
||||||
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.markableVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "mention" || markableVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withImages = (editor: ReactEditor) => {
|
|
||||||
const { insertData, isVoid } = editor;
|
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "image" ? true : isVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.insertData = (data) => {
|
|
||||||
const text = data.getData("text/plain");
|
|
||||||
|
|
||||||
if (isImageUrl(text)) {
|
|
||||||
insertImage(editor, text);
|
|
||||||
} else {
|
|
||||||
insertData(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Image = ({ attributes, element, children }) => {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const selected = useSelected();
|
|
||||||
const focused = useFocused();
|
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...attributes}>
|
|
||||||
{children}
|
|
||||||
<img
|
|
||||||
src={element.url}
|
|
||||||
alt={element.url}
|
|
||||||
className={cn(
|
|
||||||
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
|
|
||||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
|
||||||
)}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Mention = ({ attributes, 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 text-blue-500 align-baseline hover:text-blue-600"
|
|
||||||
>{`@${element.name}`}</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Event = ({ attributes, element, children }) => {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...attributes}>
|
|
||||||
{children}
|
|
||||||
<div
|
|
||||||
contentEditable={false}
|
|
||||||
className="relative my-2 user-select-none"
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
>
|
|
||||||
<MentionNote eventId={element.eventId} openable={false} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const 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-[15px]">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { Note } from "@/components/note";
|
|
||||||
import { LumeEvent, NostrQuery } from "@lume/system";
|
|
||||||
import type { Meta } from "@lume/types";
|
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
import NoteParent from "./-components/parent";
|
|
||||||
|
|
||||||
type Payload = {
|
|
||||||
raw: string;
|
|
||||||
parsed: Meta;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/events/$id")({
|
|
||||||
beforeLoad: async () => {
|
|
||||||
const settings = await NostrQuery.getUserSettings();
|
|
||||||
return { settings };
|
|
||||||
},
|
|
||||||
loader: async ({ params }) => {
|
|
||||||
const event = await NostrQuery.getEvent(params.id);
|
|
||||||
return event;
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="shrink-0 h-8 w-full border-b border-black/5 dark:border-white/5"
|
|
||||||
/>
|
|
||||||
<ScrollArea.Root
|
|
||||||
type={"scroll"}
|
|
||||||
scrollHideDelay={300}
|
|
||||||
className="overflow-hidden size-full flex-1"
|
|
||||||
>
|
|
||||||
<ScrollArea.Viewport ref={ref} className="h-full p-3">
|
|
||||||
<RootEvent />
|
|
||||||
<Virtualizer scrollRef={ref}>
|
|
||||||
<ReplyList />
|
|
||||||
</Virtualizer>
|
|
||||||
</ScrollArea.Viewport>
|
|
||||||
<ScrollArea.Scrollbar
|
|
||||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
|
||||||
orientation="vertical"
|
|
||||||
>
|
|
||||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
|
||||||
</ScrollArea.Scrollbar>
|
|
||||||
<ScrollArea.Corner className="bg-transparent" />
|
|
||||||
</ScrollArea.Root>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootEvent() {
|
|
||||||
const event = Route.useLoaderData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root className="bg-white dark:bg-black/10 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
|
|
||||||
<div className="flex items-center justify-between px-3 h-14">
|
|
||||||
<Note.User />
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
<Note.ContentLarge className="px-3" />
|
|
||||||
<div className="flex items-center gap-2 px-3 mt-6 h-12 rounded-b-xl bg-neutral-50 dark:bg-white/5">
|
|
||||||
<Note.Reply large />
|
|
||||||
<Note.Repost large />
|
|
||||||
<Note.Zap large />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReplyList() {
|
|
||||||
const event = Route.useLoaderData();
|
|
||||||
const [replies, setReplies] = useState<LumeEvent[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unlistenEvent = getCurrentWindow().listen<Payload>(
|
|
||||||
"new_reply",
|
|
||||||
(data) => {
|
|
||||||
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
|
|
||||||
setReplies((prev) => [event, ...prev]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const unlistenWindow = getCurrentWindow().onCloseRequested(async () => {
|
|
||||||
await event.unlistenEventReply();
|
|
||||||
await getCurrentWindow().destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlistenEvent.then((f) => f());
|
|
||||||
unlistenWindow.then((f) => f());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
async function getReplies() {
|
|
||||||
const data = await event.getEventReplies();
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setReplies(data);
|
|
||||||
// Start listen for new reply
|
|
||||||
event.listenEventReply();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getReplies();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center text-sm font-semibold h-14 text-neutral-600 dark:text-white/30">
|
|
||||||
All replies
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{!replies.length ? (
|
|
||||||
<div className="flex items-center justify-center w-full">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-4">
|
|
||||||
<h3 className="text-3xl">👋</h3>
|
|
||||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
|
||||||
Be the first to Reply!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
replies.map((event) => <NoteParent key={event.id} event={event} />)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Note } from "@/components/note";
|
|
||||||
import type { LumeEvent } from "@lume/system";
|
|
||||||
import NoteParent from "./parent";
|
|
||||||
import { memo } from "react";
|
|
||||||
|
|
||||||
const NoteChild = memo(function NoteChild({ event }: { event: LumeEvent }) {
|
|
||||||
return (
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root className="flex flex-col gap-6 mb-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Note.User />
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="w-8 shrink-0" />
|
|
||||||
<div className="flex-1 flex flex-col gap-2">
|
|
||||||
<Note.ContentLarge />
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Note.Reply />
|
|
||||||
<Note.Repost />
|
|
||||||
<Note.Zap />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{event.replies?.length ? (
|
|
||||||
<div className="flex flex-col gap-3 pl-4">
|
|
||||||
<div className="flex flex-col pl-6 border-l border-black/10 dark:border-white/10">
|
|
||||||
{event.replies?.map((childEvent) => (
|
|
||||||
<NoteParent key={childEvent.id} event={childEvent} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default NoteChild;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Note } from "@/components/note";
|
|
||||||
import type { LumeEvent } from "@lume/system";
|
|
||||||
import NoteChild from "./child";
|
|
||||||
import { memo } from "react";
|
|
||||||
|
|
||||||
const NoteParent = memo(function NoteParent({ event }: { event: LumeEvent }) {
|
|
||||||
return (
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root className="flex flex-col gap-6 mb-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Note.User />
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="w-8 shrink-0" />
|
|
||||||
<div className="flex-1 flex flex-col gap-2">
|
|
||||||
<Note.ContentLarge />
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Note.Reply />
|
|
||||||
<Note.Repost />
|
|
||||||
<Note.Zap />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{event.replies?.length ? (
|
|
||||||
<div className="flex flex-col gap-3 pl-4">
|
|
||||||
<div className="flex flex-col gap-3 pl-6 border-l border-black/10 dark:border-white/10">
|
|
||||||
{event.replies?.map((childEvent) => (
|
|
||||||
<NoteChild key={childEvent.id} event={childEvent} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default NoteParent;
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { User } from "@/components/user";
|
|
||||||
import { PlusIcon, RelayIcon } from "@lume/icons";
|
|
||||||
import { NostrAccount } from "@lume/system";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { checkForAppUpdates, displayNpub } from "@lume/utils";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
|
||||||
beforeLoad: async () => {
|
|
||||||
// Check for app updates
|
|
||||||
// TODO: move this function to rust
|
|
||||||
await checkForAppUpdates(true);
|
|
||||||
|
|
||||||
// Get all accounts
|
|
||||||
// TODO: use emit & listen
|
|
||||||
const accounts = await NostrAccount.getAccounts();
|
|
||||||
|
|
||||||
if (accounts.length < 1) {
|
|
||||||
throw redirect({
|
|
||||||
to: "/landing",
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { accounts };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
const context = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState({ npub: "", status: false });
|
|
||||||
|
|
||||||
const select = async (npub: string) => {
|
|
||||||
try {
|
|
||||||
setLoading({ npub, status: true });
|
|
||||||
|
|
||||||
const status = await NostrAccount.loadAccount(npub);
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
return navigate({
|
|
||||||
to: "/$account/home",
|
|
||||||
params: { account: npub },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading({ npub: "", status: false });
|
|
||||||
await message(String(e), {
|
|
||||||
title: "Account",
|
|
||||||
kind: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentDate = new Date().toLocaleString("default", {
|
|
||||||
weekday: "long",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="relative flex flex-col items-center justify-between w-full h-full"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="absolute top-0 left-0 h-14 w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex items-end justify-center flex-1 w-full px-4 pb-10">
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="mb-1 text-lg text-neutral-700 dark:text-neutral-300">
|
|
||||||
{currentDate}
|
|
||||||
</h2>
|
|
||||||
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center flex-1 w-full gap-3">
|
|
||||||
<div className="flex flex-col w-full max-w-sm mx-auto overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
|
|
||||||
{context.accounts.map((account) => (
|
|
||||||
<div
|
|
||||||
key={account}
|
|
||||||
onClick={() => select(account)}
|
|
||||||
onKeyDown={() => select(account)}
|
|
||||||
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={account}>
|
|
||||||
<User.Root className="flex items-center gap-2.5 p-3">
|
|
||||||
<User.Avatar className="rounded-full size-10" />
|
|
||||||
<div className="inline-flex flex-col items-start">
|
|
||||||
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
|
|
||||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
{displayNpub(account, 16)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="inline-flex items-center justify-center size-10">
|
|
||||||
{loading.npub === account ? (
|
|
||||||
loading.status ? (
|
|
||||||
<Spinner />
|
|
||||||
) : null
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Link
|
|
||||||
to="/landing"
|
|
||||||
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5 p-3">
|
|
||||||
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
|
|
||||||
<PlusIcon className="size-5" />
|
|
||||||
</div>
|
|
||||||
<span className="max-w-[6rem] truncate text-sm font-medium leading-tight">
|
|
||||||
Add account
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="w-full max-w-sm mx-auto">
|
|
||||||
<Link
|
|
||||||
to="/bootstrap-relays"
|
|
||||||
className="inline-flex items-center justify-center w-full h-8 gap-2 px-2 text-xs font-medium rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-700 dark:text-white/40"
|
|
||||||
>
|
|
||||||
<RelayIcon className="size-4" />
|
|
||||||
Custom Bootstrap Relays
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { KeyIcon, RemoteIcon } from "@lume/icons";
|
|
||||||
import { Link, createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/landing")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex flex-col items-center justify-center w-screen h-screen"
|
|
||||||
>
|
|
||||||
<div className="w-full max-w-xs mx-auto lg:max-w-md">
|
|
||||||
<div className="flex flex-col w-full gap-2 px-2 bg-white rounded-xl shadow-primary dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
|
|
||||||
<div className="flex items-center h-20 border-b border-neutral-100 dark:border-white/5">
|
|
||||||
<Link
|
|
||||||
to="/auth/create-profile"
|
|
||||||
className="flex items-center justify-center w-full gap-2 px-2 rounded-lg h-14 hover:bg-neutral-100 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<div className="inline-flex items-center justify-center rounded-full size-9 shrink-0">
|
|
||||||
<img
|
|
||||||
src="/icon.jpeg"
|
|
||||||
alt="App Icon"
|
|
||||||
className="object-cover rounded-full size-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex flex-col flex-1">
|
|
||||||
<span className="font-semibold leading-tight">
|
|
||||||
Create new account
|
|
||||||
</span>
|
|
||||||
<span className="text-sm leading-tight text-neutral-500">
|
|
||||||
Use everywhere
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 pb-2.5">
|
|
||||||
<Link
|
|
||||||
to="/auth/import"
|
|
||||||
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<div className="inline-flex items-center justify-center size-9">
|
|
||||||
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
Login with Private Key
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/auth/remote"
|
|
||||||
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<div className="inline-flex items-center justify-center size-9">
|
|
||||||
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
Nostr Connect
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import { Conversation } from "@/components/conversation";
|
|
||||||
import { Quote } from "@/components/quote";
|
|
||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { ArrowRightCircleIcon, ArrowUpIcon } from "@lume/icons";
|
|
||||||
import { LumeEvent, NostrAccount, NostrQuery } from "@lume/system";
|
|
||||||
import { type ColumnRouteSearch, Kind, type Meta } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
type Payload = {
|
|
||||||
raw: string;
|
|
||||||
parsed: Meta;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/newsfeed")({
|
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
label: search.label,
|
|
||||||
name: search.name,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
beforeLoad: async ({ search }) => {
|
|
||||||
const isContactListEmpty = await NostrAccount.isContactListEmpty();
|
|
||||||
const settings = await NostrQuery.getUserSettings();
|
|
||||||
|
|
||||||
if (isContactListEmpty) {
|
|
||||||
throw redirect({
|
|
||||||
to: "/create-newsfeed/users",
|
|
||||||
search: {
|
|
||||||
...search,
|
|
||||||
redirect: "/newsfeed",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { settings };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function Screen() {
|
|
||||||
const { queryClient } = Route.useRouteContext();
|
|
||||||
const { label, account } = Route.useSearch();
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
isFetchingNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: [label, account],
|
|
||||||
initialPageParam: 0,
|
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
|
||||||
const events = await NostrQuery.getLocalEvents(pageParam);
|
|
||||||
return events;
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
|
||||||
select: (data) => data?.pages.flat(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
|
||||||
(event: LumeEvent) => {
|
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
|
||||||
case Kind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
default: {
|
|
||||||
if (event.isConversation) {
|
|
||||||
return (
|
|
||||||
<Conversation key={event.id} className="mb-3" event={event} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (event.isQuote) {
|
|
||||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unlisten = listen("synced", async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: [label, account] });
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten.then((f) => f());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea.Root
|
|
||||||
type={"scroll"}
|
|
||||||
scrollHideDelay={300}
|
|
||||||
className="overflow-hidden size-full"
|
|
||||||
>
|
|
||||||
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
|
|
||||||
<Listerner />
|
|
||||||
<Virtualizer scrollRef={ref}>
|
|
||||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
|
||||||
<div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Getting new notes...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center w-full h-16 gap-2">
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
<span className="text-sm font-medium">Loading...</span>
|
|
||||||
</div>
|
|
||||||
) : !data.length ? (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
Yo. You're catching up on all the things happening around you.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data.map((item) => renderItem(item))
|
|
||||||
)}
|
|
||||||
{data?.length && hasNextPage ? (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
disabled={isFetchingNextPage || isLoading}
|
|
||||||
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 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}
|
|
||||||
</Virtualizer>
|
|
||||||
</ScrollArea.Viewport>
|
|
||||||
<ScrollArea.Scrollbar
|
|
||||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
|
||||||
orientation="vertical"
|
|
||||||
>
|
|
||||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
|
||||||
</ScrollArea.Scrollbar>
|
|
||||||
<ScrollArea.Corner className="bg-transparent" />
|
|
||||||
</ScrollArea.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Listerner() {
|
|
||||||
const { queryClient } = Route.useRouteContext();
|
|
||||||
const { label, account } = Route.useSearch();
|
|
||||||
|
|
||||||
const [events, setEvents] = useState<LumeEvent[]>([]);
|
|
||||||
|
|
||||||
const pushNewEvents = async () => {
|
|
||||||
await queryClient.setQueryData(
|
|
||||||
[label, account],
|
|
||||||
(oldData: InfiniteData<LumeEvent[], number> | undefined) => {
|
|
||||||
const firstPage = oldData?.pages[0];
|
|
||||||
|
|
||||||
if (firstPage) {
|
|
||||||
return {
|
|
||||||
...oldData,
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
...firstPage,
|
|
||||||
posts: [...events, ...firstPage],
|
|
||||||
},
|
|
||||||
...oldData.pages.slice(1),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: [label, account] });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unlisten = getCurrentWindow().listen<Payload>("new_event", (data) => {
|
|
||||||
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
|
|
||||||
setEvents((prev) => [event, ...prev]);
|
|
||||||
});
|
|
||||||
|
|
||||||
NostrQuery.listenLocalEvent().then(() => console.log("listen"));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten.then((f) => f());
|
|
||||||
NostrQuery.unlisten().then(() => console.log("unlisten"));
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!events?.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => pushNewEvents()}
|
|
||||||
className="w-max h-8 pl-2 pr-3 inline-flex items-center justify-center gap-1.5 rounded-full shadow-lg text-sm font-medium text-white bg-black dark:text-black dark:bg-white"
|
|
||||||
>
|
|
||||||
<ArrowUpIcon className="size-4" />
|
|
||||||
{events.length} new notes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
import { Note } from "@/components/note";
|
|
||||||
import { User } from "@/components/user";
|
|
||||||
import { HorizontalDotsIcon, InfoIcon, RepostIcon } from "@lume/icons";
|
|
||||||
import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system";
|
|
||||||
import { Kind } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import {
|
|
||||||
checkForAppUpdates,
|
|
||||||
decodeZapInvoice,
|
|
||||||
formatCreatedAt,
|
|
||||||
} from "@lume/utils";
|
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import * as Tabs from "@radix-ui/react-tabs";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { open } from "@tauri-apps/plugin-shell";
|
|
||||||
import { type ReactNode, useCallback, useEffect, useRef } from "react";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/panel/$account")({
|
|
||||||
beforeLoad: async ({ context }) => {
|
|
||||||
console.log(context);
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
const { queryClient } = Route.useRouteContext();
|
|
||||||
const { isLoading, data } = useQuery({
|
|
||||||
queryKey: ["notification", account],
|
|
||||||
queryFn: async () => {
|
|
||||||
console.log(queryClient);
|
|
||||||
const events = await NostrQuery.getNotifications();
|
|
||||||
return events;
|
|
||||||
},
|
|
||||||
select: (events) => {
|
|
||||||
const zaps = new Map<string, LumeEvent[]>();
|
|
||||||
const reactions = new Map<string, LumeEvent[]>();
|
|
||||||
const texts = events.filter((ev) => ev.kind === Kind.Text);
|
|
||||||
const zapEvents = events.filter((ev) => ev.kind === Kind.ZapReceipt);
|
|
||||||
const reactEvents = events.filter(
|
|
||||||
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const event of reactEvents) {
|
|
||||||
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
|
|
||||||
|
|
||||||
if (rootId) {
|
|
||||||
if (reactions.has(rootId)) {
|
|
||||||
reactions.get(rootId).push(event);
|
|
||||||
} else {
|
|
||||||
reactions.set(rootId, [event]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const event of zapEvents) {
|
|
||||||
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
|
|
||||||
|
|
||||||
if (rootId) {
|
|
||||||
if (zaps.has(rootId)) {
|
|
||||||
zaps.get(rootId).push(event);
|
|
||||||
} else {
|
|
||||||
zaps.set(rootId, [event]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { texts, zaps, reactions };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const menuItems = await Promise.all([
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Open Lume",
|
|
||||||
action: () => LumeWindow.openMainWindow(),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "New Post",
|
|
||||||
action: () => LumeWindow.openEditor(),
|
|
||||||
}),
|
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "About Lume",
|
|
||||||
action: async () => await open("https://lume.nu"),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Check for Updates",
|
|
||||||
action: async () => await checkForAppUpdates(false),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Settings",
|
|
||||||
action: () => LumeWindow.openSettings(),
|
|
||||||
}),
|
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Quit",
|
|
||||||
action: async () => await invoke("force_quit"),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const menu = await Menu.new({
|
|
||||||
items: menuItems,
|
|
||||||
});
|
|
||||||
|
|
||||||
await menu.popup().catch((e) => console.error(e));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unlisten = getCurrentWindow().listen("notification", async (data) => {
|
|
||||||
const event: LumeEvent = JSON.parse(data.payload as string);
|
|
||||||
await queryClient.setQueryData(
|
|
||||||
["notification", account],
|
|
||||||
(data: LumeEvent[]) => [event, ...data],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten.then((f) => f());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="size-full flex items-center justify-center">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col size-full overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-4 border-b h-11 shrink-0 border-black/5 dark:border-white/5">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-sm font-semibold">Notifications</h1>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<User.Provider pubkey={account}>
|
|
||||||
<User.Root>
|
|
||||||
<User.Avatar className="rounded-full size-7" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => showContextMenu(e)}
|
|
||||||
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
|
|
||||||
>
|
|
||||||
<HorizontalDotsIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Tabs.Root defaultValue="replies" className="flex flex-col h-full">
|
|
||||||
<Tabs.List className="h-8 shrink-0 flex items-center">
|
|
||||||
<Tabs.Trigger
|
|
||||||
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
|
|
||||||
value="replies"
|
|
||||||
>
|
|
||||||
Replies
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger
|
|
||||||
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
|
|
||||||
value="reactions"
|
|
||||||
>
|
|
||||||
Reactions
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger
|
|
||||||
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
|
|
||||||
value="zaps"
|
|
||||||
>
|
|
||||||
Zaps
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</Tabs.List>
|
|
||||||
<ScrollArea.Root
|
|
||||||
type={"scroll"}
|
|
||||||
scrollHideDelay={300}
|
|
||||||
className="min-h-0 flex-1 overflow-x-hidden"
|
|
||||||
>
|
|
||||||
<Tab value="replies">
|
|
||||||
{data.texts.map((event, index) => (
|
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
|
||||||
<TextNote key={event.id + index} event={event} />
|
|
||||||
))}
|
|
||||||
</Tab>
|
|
||||||
<Tab value="reactions">
|
|
||||||
{[...data.reactions.entries()].map(([root, events]) => (
|
|
||||||
<div
|
|
||||||
key={root}
|
|
||||||
className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 bg-black/10 dark:bg-white/10"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col flex-1 min-w-0 gap-2">
|
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
|
|
||||||
<RootNote id={root} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{events.map((event) => (
|
|
||||||
<User.Provider key={event.id} pubkey={event.pubkey}>
|
|
||||||
<User.Root className="shrink-0 flex rounded-full h-8 bg-black/10 dark:bg-white/10 p-[2px]">
|
|
||||||
<User.Avatar className="flex-1 rounded-full size-7" />
|
|
||||||
<div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-7">
|
|
||||||
{event.kind === Kind.Reaction ? (
|
|
||||||
event.content === "+" ? (
|
|
||||||
"👍"
|
|
||||||
) : (
|
|
||||||
event.content
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<RepostIcon className="text-teal-400 size-4 dark:text-teal-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Tab>
|
|
||||||
<Tab value="zaps">
|
|
||||||
{[...data.zaps.entries()].map(([root, events]) => (
|
|
||||||
<div
|
|
||||||
key={root}
|
|
||||||
className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 bg-black/10 dark:bg-white/10"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col flex-1 min-w-0 gap-2">
|
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
|
|
||||||
<RootNote id={root} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{events.map((event) => (
|
|
||||||
<User.Provider
|
|
||||||
key={event.id}
|
|
||||||
pubkey={event.tags.find((tag) => tag[0] === "P")[1]}
|
|
||||||
>
|
|
||||||
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-8 bg-black/10 dark:bg-white/10 p-[2px]">
|
|
||||||
<User.Avatar className="rounded-full size-7" />
|
|
||||||
<div className="flex-1 h-7 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-sm truncate">
|
|
||||||
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Tab>
|
|
||||||
<ScrollArea.Scrollbar
|
|
||||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
|
||||||
orientation="vertical"
|
|
||||||
>
|
|
||||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
|
||||||
</ScrollArea.Scrollbar>
|
|
||||||
<ScrollArea.Corner className="bg-transparent" />
|
|
||||||
</ScrollArea.Root>
|
|
||||||
</Tabs.Root>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Tab({ value, children }: { value: string; children: ReactNode[] }) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs.Content value={value} className="size-full">
|
|
||||||
<ScrollArea.Viewport ref={ref} className="h-full px-2 pt-2">
|
|
||||||
<Virtualizer scrollRef={ref}>{children}</Virtualizer>
|
|
||||||
</ScrollArea.Viewport>
|
|
||||||
</Tabs.Content>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootNote({ id }: { id: string }) {
|
|
||||||
const { isLoading, isError, data } = useEvent(id);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center pb-2 mb-2">
|
|
||||||
<div className="rounded-full size-8 shrink-0 bg-black/20 dark:bg-white/20 animate-pulse" />
|
|
||||||
<div className="w-2/3 h-4 rounded-md animate-pulse bg-black/20 dark:bg-white/20" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !data) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
|
|
||||||
<InfoIcon className="size-5" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
Event not found with your current relay set
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Note.Provider event={data}>
|
|
||||||
<Note.Root className="flex items-center gap-2">
|
|
||||||
<User.Provider pubkey={data.pubkey}>
|
|
||||||
<User.Root className="shrink-0">
|
|
||||||
<User.Avatar className="rounded-full size-8" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="line-clamp-1">{data.content}</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextNote({ event }: { event: LumeEvent }) {
|
|
||||||
const pTags = event.tags
|
|
||||||
.filter((tag) => tag[0] === "p")
|
|
||||||
.map((tag) => tag[1])
|
|
||||||
.slice(0, 3);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root className="flex flex-col p-2 mb-2 rounded-lg shrink-0 bg-black/10 dark:bg-white/10">
|
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="inline-flex items-center gap-2">
|
|
||||||
<User.Avatar className="rounded-full size-9" />
|
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
<div className="flex items-baseline justify-between w-full">
|
|
||||||
<User.Name className="text-sm font-semibold leading-tight" />
|
|
||||||
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
|
|
||||||
{formatCreatedAt(event.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-baseline gap-1 text-xs">
|
|
||||||
<span className="leading-tight text-black/50 dark:text-white/50">
|
|
||||||
Reply to:
|
|
||||||
</span>
|
|
||||||
<div className="inline-flex items-baseline gap-1">
|
|
||||||
{[...new Set(pTags)].map((replyTo) => (
|
|
||||||
<User.Provider key={replyTo} pubkey={replyTo}>
|
|
||||||
<User.Root>
|
|
||||||
<User.Name className="font-medium leading-tight" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="w-9 shrink-0" />
|
|
||||||
<div className="line-clamp-1 text-start">{event.content}</div>
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { Conversation } from "@/components/conversation";
|
|
||||||
import { Quote } from "@/components/quote";
|
|
||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { LumeEvent, NostrQuery } from "@lume/system";
|
|
||||||
import { Kind, type NostrEvent } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { fetch } from "@tauri-apps/plugin-http";
|
|
||||||
import { useCallback, useRef } from "react";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
type Search = {
|
|
||||||
query: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/search/notes")({
|
|
||||||
validateSearch: (search: Record<string, string>): Search => {
|
|
||||||
return {
|
|
||||||
query: search.query,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
beforeLoad: async () => {
|
|
||||||
const settings = await NostrQuery.getUserSettings();
|
|
||||||
return { settings };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { query } = Route.useSearch();
|
|
||||||
const { isLoading, data } = useQuery({
|
|
||||||
queryKey: ["search", query],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`https://api.nostr.wine/search?query=${query}&kind=1&limit=50`,
|
|
||||||
);
|
|
||||||
const content = await res.json();
|
|
||||||
const events = content.data as NostrEvent[];
|
|
||||||
const lumeEvents = await Promise.all(
|
|
||||||
events.map(async (item): Promise<LumeEvent> => {
|
|
||||||
const event = await LumeEvent.build(item);
|
|
||||||
return event;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return lumeEvents.sort((a, b) => b.created_at - a.created_at);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
|
||||||
(event: LumeEvent) => {
|
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
|
||||||
case Kind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
default: {
|
|
||||||
if (event.isConversation) {
|
|
||||||
return (
|
|
||||||
<Conversation key={event.id} className="mb-3" event={event} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (event.isQuote) {
|
|
||||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea.Viewport ref={ref} className="h-full p-3">
|
|
||||||
<Virtualizer scrollRef={ref}>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center w-full h-11 gap-2">
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
<span className="text-sm font-medium">Searching...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data.map((item) => renderItem(item))
|
|
||||||
)}
|
|
||||||
</Virtualizer>
|
|
||||||
</ScrollArea.Viewport>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
type Search = {
|
|
||||||
query: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/search")({
|
|
||||||
validateSearch: (search: Record<string, string>): Search => {
|
|
||||||
return {
|
|
||||||
query: search.query,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { query } = Route.useSearch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="shrink-0 flex items-end gap-1 h-20 px-3 pb-3 w-full border-b border-black/10 dark:border-white/10"
|
|
||||||
>
|
|
||||||
Search result for: <span className="font-semibold">{query}</span>
|
|
||||||
</div>
|
|
||||||
<ScrollArea.Root
|
|
||||||
type={"scroll"}
|
|
||||||
scrollHideDelay={300}
|
|
||||||
className="overflow-hidden size-full flex-1"
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
<ScrollArea.Scrollbar
|
|
||||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
|
||||||
orientation="vertical"
|
|
||||||
>
|
|
||||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
|
||||||
</ScrollArea.Scrollbar>
|
|
||||||
<ScrollArea.Corner className="bg-transparent" />
|
|
||||||
</ScrollArea.Root>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { User } from "@/components/user";
|
|
||||||
import { LumeWindow, NostrQuery } from "@lume/system";
|
|
||||||
import type { NostrEvent } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { fetch } from "@tauri-apps/plugin-http";
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
type Search = {
|
|
||||||
query: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserItem = {
|
|
||||||
pubkey: string;
|
|
||||||
profile: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/search/users")({
|
|
||||||
validateSearch: (search: Record<string, string>): Search => {
|
|
||||||
return {
|
|
||||||
query: search.query,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
beforeLoad: async () => {
|
|
||||||
const settings = await NostrQuery.getUserSettings();
|
|
||||||
return { settings };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { query } = Route.useSearch();
|
|
||||||
const { isLoading, data } = useQuery({
|
|
||||||
queryKey: ["search", query],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`https://api.nostr.wine/search?query=${query}&kind=0&limit=100`,
|
|
||||||
);
|
|
||||||
const content = await res.json();
|
|
||||||
const events = content.data as NostrEvent[];
|
|
||||||
const users: UserItem[] = events.map((ev) => ({
|
|
||||||
pubkey: ev.pubkey,
|
|
||||||
profile: ev.content,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return users;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea.Viewport ref={ref} className="h-full px-3 pt-3">
|
|
||||||
<Virtualizer scrollRef={ref}>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center w-full h-11 gap-2">
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
<span className="text-sm font-medium">Searching...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.pubkey}
|
|
||||||
className="w-full p-3 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={item.pubkey} embedProfile={item.profile}>
|
|
||||||
<User.Root className="flex flex-col w-full h-full gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<User.Avatar className="rounded-full size-7" />
|
|
||||||
<div className="inline-flex items-center gap-1">
|
|
||||||
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
|
|
||||||
<User.NIP05 />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => LumeWindow.openProfile(item.pubkey)}
|
|
||||||
className="inline-flex items-center justify-center w-16 text-sm font-medium rounded-md h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Virtualizer>
|
|
||||||
</ScrollArea.Viewport>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-full h-full">
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex items-center justify-center w-full h-20 border-b shrink-0 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">General</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">User</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/wallet">
|
|
||||||
{({ 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">Wallet</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">Backup</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 w-full px-5 py-4 overflow-y-auto scrollbar-none">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { User } from "@/components/user";
|
|
||||||
import { NostrAccount } from "@lume/system";
|
|
||||||
import { displayNpub } 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 { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface Account {
|
|
||||||
npub: string;
|
|
||||||
nsec: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings/backup")({
|
|
||||||
beforeLoad: async () => {
|
|
||||||
const accounts = await NostrAccount.getAccounts();
|
|
||||||
return { accounts };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { accounts } = Route.useRouteContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-xl mx-auto">
|
|
||||||
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
|
||||||
{accounts.map((account) => (
|
|
||||||
<Account key={account} account={account} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Account({ account }: { account: string }) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const copyKey = async () => {
|
|
||||||
try {
|
|
||||||
const data: string = await invoke("get_private_key", { npub: account });
|
|
||||||
await writeText(data);
|
|
||||||
setCopied(true);
|
|
||||||
} catch (e) {
|
|
||||||
await message(String(e), { title: "Backup", kind: "error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2 py-3">
|
|
||||||
<User.Provider pubkey={account}>
|
|
||||||
<User.Root className="flex items-center gap-2">
|
|
||||||
<User.Avatar className="rounded-full size-8" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<User.Name className="text-sm leading-tight" />
|
|
||||||
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
|
|
||||||
{displayNpub(account, 16)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => copyKey()}
|
|
||||||
className="inline-flex items-center justify-center h-8 text-sm font-medium rounded-md w-36 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
{copied ? "Copied" : "Copy Private Key"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import { NostrQuery, type Settings } from "@lume/system";
|
|
||||||
import * as Switch from "@radix-ui/react-switch";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
|
|
||||||
type Theme = "auto" | "light" | "dark";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings/general")({
|
|
||||||
beforeLoad: async () => {
|
|
||||||
const initialSettings = await NostrQuery.getUserSettings();
|
|
||||||
return { initialSettings };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { initialSettings } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [theme, setTheme] = useState<Theme>(null);
|
|
||||||
const [settings, setSettings] = useState<Settings>(null);
|
|
||||||
|
|
||||||
const changeTheme = async (theme: string) => {
|
|
||||||
if (theme === "auto" || theme === "light" || theme === "dark") {
|
|
||||||
invoke("plugin:theme|set_theme", {
|
|
||||||
theme: theme,
|
|
||||||
}).then(() => setTheme(theme));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSettings = useDebouncedCallback(async () => {
|
|
||||||
const newSettings = JSON.stringify(settings);
|
|
||||||
await NostrQuery.setUserSettings(newSettings);
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateSettings();
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
invoke("plugin:theme|get_theme").then((data: Theme) => setTheme(data));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSettings(initialSettings);
|
|
||||||
}, [initialSettings]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-xl mx-auto">
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex items-center w-full px-3 text-sm rounded-lg h-11 bg-black/5 dark:bg-white/5">
|
|
||||||
* Setting changes require restarting the app to take effect.
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
|
||||||
General
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
|
||||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium">Relay Hint</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Use the relay hint if necessary.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end w-36 shrink-0">
|
|
||||||
<Switch.Root
|
|
||||||
checked={settings.use_relay_hint}
|
|
||||||
onClick={() =>
|
|
||||||
setSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
use_relay_hint: !prev.use_relay_hint,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
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 items-start justify-between w-full gap-4 py-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium">Content Warning</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Shows a warning for notes that have a content warning.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end w-36 shrink-0">
|
|
||||||
<Switch.Root
|
|
||||||
checked={settings.content_warning}
|
|
||||||
onClick={() =>
|
|
||||||
setSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
content_warning: !prev.content_warning,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
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="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
|
||||||
Appearance
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
|
||||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium">Appearance</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Require restarting the app to take effect.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end w-36 shrink-0">
|
|
||||||
<select
|
|
||||||
name="theme"
|
|
||||||
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
|
|
||||||
defaultValue={theme}
|
|
||||||
onChange={(e) => changeTheme(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="auto">Auto</option>
|
|
||||||
<option value="light">Light</option>
|
|
||||||
<option value="dark">Dark</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium">Vibrancy Effect</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Make the window transparent.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end w-36 shrink-0">
|
|
||||||
<Switch.Root
|
|
||||||
checked={settings.vibrancy}
|
|
||||||
onClick={() =>
|
|
||||||
setSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
vibrancy: !prev.vibrancy,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
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 items-start justify-between w-full gap-4 py-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium">Zap Button</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Shows the Zap button when viewing a note.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end w-36 shrink-0">
|
|
||||||
<Switch.Root
|
|
||||||
checked={settings.display_zap_button}
|
|
||||||
onClick={() =>
|
|
||||||
setSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
display_zap_button: !prev.display_zap_button,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
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 items-start justify-between w-full gap-4 py-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium">Repost Button</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Shows the Repost button when viewing a note.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end w-36 shrink-0">
|
|
||||||
<Switch.Root
|
|
||||||
checked={settings.display_zap_button}
|
|
||||||
onClick={() =>
|
|
||||||
setSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
display_zap_button: !prev.display_zap_button,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
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="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
|
||||||
Privacy & Performance
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
|
||||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium">Proxy</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Set proxy address.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end w-36 shrink-0">
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
defaultValue={settings.proxy}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
proxy: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="py-1 bg-transparent rounded-lg shadow-none outline-none w-44 border-1 border-black/10 dark:border-white/10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium">Image Resize Service</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Use weserv/images for resize image on-the-fly.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end w-36 shrink-0">
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
defaultValue={settings.image_resize_service}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
image_resize_service: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="py-1 bg-transparent rounded-lg shadow-none outline-none w-44 border-1 border-black/10 dark:border-white/10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium">Load Remote Media</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
View the remote media directly.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end w-36 shrink-0">
|
|
||||||
<Switch.Root
|
|
||||||
checked={settings.display_media}
|
|
||||||
onClick={() =>
|
|
||||||
setSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
display_image_link: !prev.display_media,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { AvatarUploader } from "@/components/avatarUploader";
|
|
||||||
import { PlusIcon } from "@lume/icons";
|
|
||||||
import { NostrAccount } from "@lume/system";
|
|
||||||
import type { Metadata } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings/user")({
|
|
||||||
beforeLoad: async () => {
|
|
||||||
const profile = await NostrAccount.getProfile();
|
|
||||||
return { profile };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { profile } = Route.useRouteContext();
|
|
||||||
const { register, handleSubmit } = useForm({ defaultValues: profile });
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [picture, setPicture] = useState<string>("");
|
|
||||||
|
|
||||||
const onSubmit = async (data: Metadata) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const newProfile: Metadata = { ...profile, ...data, picture };
|
|
||||||
await NostrAccount.createProfile(newProfile);
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
await message(String(e), { title: "Profile", kind: "error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full h-full">
|
|
||||||
<div className="flex flex-col items-center justify-center flex-1 h-full gap-3">
|
|
||||||
<div className="relative rounded-full size-24 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 object-cover w-full h-full rounded-full"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<AvatarUploader
|
|
||||||
setPicture={setPicture}
|
|
||||||
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-8" />
|
|
||||||
</AvatarUploader>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center text-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="inline-flex items-center justify-center px-5 text-sm font-medium text-blue-500 bg-blue-100 border border-blue-300 rounded-full h-9 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800"
|
|
||||||
>
|
|
||||||
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 flex-col w-full 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")}
|
|
||||||
spellCheck={false}
|
|
||||||
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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 flex-col w-full 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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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 flex-col w-full 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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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 flex-col w-full 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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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 flex-col w-full 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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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 flex-col w-full 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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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 items-center justify-center w-32 px-2 text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? <Spinner className="size-4" /> : "Update Profile"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { CommunityIcon, LaurelIcon } from "@lume/icons";
|
|
||||||
import type { LumeColumn } from "@lume/types";
|
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/store")({
|
|
||||||
beforeLoad: async () => {
|
|
||||||
const path = "resources/official_columns.json";
|
|
||||||
const resourcePath = await resolveResource(path);
|
|
||||||
const fileContent = await readTextFile(resourcePath);
|
|
||||||
const officialColumns: LumeColumn[] = JSON.parse(fileContent);
|
|
||||||
|
|
||||||
return {
|
|
||||||
officialColumns,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { officialColumns } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const install = async (column: LumeColumn) => {
|
|
||||||
const mainWindow = getCurrentWindow();
|
|
||||||
await mainWindow.emit("columns", { type: "add", column });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="size-full">
|
|
||||||
<ScrollArea.Root
|
|
||||||
type={"scroll"}
|
|
||||||
scrollHideDelay={300}
|
|
||||||
className="flex-1 overflow-hidden size-full"
|
|
||||||
>
|
|
||||||
<ScrollArea.Viewport className="h-full px-3 ">
|
|
||||||
<div className="flex flex-col gap-3 mb-10">
|
|
||||||
<div className="inline-flex items-center gap-1.5 font-semibold leading-tight">
|
|
||||||
<div className="size-7 rounded-md inline-flex items-center justify-center bg-black/10 dark:bg-white/10">
|
|
||||||
<LaurelIcon className="size-4" />
|
|
||||||
</div>
|
|
||||||
Official
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
{officialColumns.map((column) => (
|
|
||||||
<div
|
|
||||||
key={column.label}
|
|
||||||
className="relative group flex flex-col w-full aspect-square overflow-hidden bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5"
|
|
||||||
>
|
|
||||||
<div className="hidden group-hover:flex items-center justify-center absolute inset-0 size-full rounded-xl bg-white/20 dark:bg-black/20 backdrop-blur-md">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => install(column)}
|
|
||||||
className="w-16 h-8 inline-flex items-center justify-center rounded-full bg-black dark:bg-white text-white dark:text-black text-sm font-semibold"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
{column.cover ? (
|
|
||||||
<img
|
|
||||||
src={column.cover}
|
|
||||||
srcSet={column.coverRetina}
|
|
||||||
alt={column.name}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="size-full object-cover"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 h-9 px-3 flex items-center">
|
|
||||||
<h3 className="text-sm font-semibold truncate w-full">
|
|
||||||
{column.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="inline-flex items-center gap-1.5 font-semibold leading-tight">
|
|
||||||
<div className="size-7 rounded-md inline-flex items-center justify-center bg-black/10 dark:bg-white/10">
|
|
||||||
<CommunityIcon className="size-4" />
|
|
||||||
</div>
|
|
||||||
Community
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-20 rounded-xl flex items-center justify-center text-sm font-medium bg-black/5 dark:bg-white/5">
|
|
||||||
Coming Soon.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea.Viewport>
|
|
||||||
<ScrollArea.Scrollbar
|
|
||||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
|
||||||
orientation="vertical"
|
|
||||||
>
|
|
||||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
|
||||||
</ScrollArea.Scrollbar>
|
|
||||||
<ScrollArea.Corner className="bg-transparent" />
|
|
||||||
</ScrollArea.Root>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { Conversation } from "@/components/conversation";
|
|
||||||
import { Quote } from "@/components/quote";
|
|
||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { LumeEvent } from "@lume/system";
|
|
||||||
import { Kind, type NostrEvent } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import { Await, createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { defer } from "@tanstack/react-router";
|
|
||||||
import { Suspense, useCallback, useRef } 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) => {
|
|
||||||
const events: NostrEvent[] = res.notes.map(
|
|
||||||
(item: { event: NostrEvent }) => item.event,
|
|
||||||
);
|
|
||||||
const lumeEvents = Promise.all(
|
|
||||||
events.map(async (ev) => await LumeEvent.build(ev)),
|
|
||||||
);
|
|
||||||
return lumeEvents;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function Screen() {
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const renderItem = useCallback((event: LumeEvent) => {
|
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
|
||||||
case Kind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
default: {
|
|
||||||
if (event.isConversation) {
|
|
||||||
return <Conversation key={event.id} className="mb-3" event={event} />;
|
|
||||||
}
|
|
||||||
if (event.isQuote) {
|
|
||||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea.Root
|
|
||||||
type={"scroll"}
|
|
||||||
scrollHideDelay={300}
|
|
||||||
className="overflow-hidden size-full"
|
|
||||||
>
|
|
||||||
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
|
|
||||||
<Virtualizer scrollRef={ref}>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex flex-col items-center justify-center w-full h-20 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) => renderItem(event))}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</Virtualizer>
|
|
||||||
</ScrollArea.Viewport>
|
|
||||||
<ScrollArea.Scrollbar
|
|
||||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
|
||||||
orientation="vertical"
|
|
||||||
>
|
|
||||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
|
||||||
</ScrollArea.Scrollbar>
|
|
||||||
<ScrollArea.Corner className="bg-transparent" />
|
|
||||||
</ScrollArea.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
|
|
||||||
import { NostrQuery } from "@lume/system";
|
|
||||||
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 () => {
|
|
||||||
const settings = await NostrQuery.getUserSettings();
|
|
||||||
return { settings };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const search = Route.useSearch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="shrink-0 h-11 flex items-center w-full gap-1 px-3">
|
|
||||||
<div className="flex w-full h-full gap-1">
|
|
||||||
<Link to="/trending/notes" search={search}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
|
||||||
isActive ? "bg-black/10 dark:bg-white/10" : "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ArticleIcon className="size-4" />
|
|
||||||
Notes
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link to="/trending/users" search={search}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
|
||||||
isActive ? "bg-black/10 dark:bg-white/10" : "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GroupFeedsIcon className="size-4" />
|
|
||||||
Users
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 w-full h-full overflow-y-auto scrollbar-none">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { User } from "@/components/user";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { Await, defer } from "@tanstack/react-router";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/trending/users")({
|
|
||||||
loader: async ({ abortController }) => {
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
data: defer(
|
|
||||||
fetch("https://api.nostr.band/v0/trending/profiles", {
|
|
||||||
signal: abortController.signal,
|
|
||||||
}).then((res) => res.json()),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function Screen() {
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full">
|
|
||||||
<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}>
|
|
||||||
{(users) =>
|
|
||||||
users.profiles.map((item: { pubkey: string }) => (
|
|
||||||
<div
|
|
||||||
key={item.pubkey}
|
|
||||||
className="h-max w-full overflow-hidden mb-3 p-2 bg-black/5 dark:bg-white/5 rounded-xl"
|
|
||||||
>
|
|
||||||
<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 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-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
|
||||||
</div>
|
|
||||||
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { Conversation } from "@/components/conversation";
|
|
||||||
import { Quote } from "@/components/quote";
|
|
||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { User } from "@/components/user";
|
|
||||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
|
||||||
import { Kind } from "@lume/types";
|
|
||||||
import { Box, Container, Spinner } from "@lume/ui";
|
|
||||||
import { createFileRoute, defer } from "@tanstack/react-router";
|
|
||||||
import { Await } from "@tanstack/react-router";
|
|
||||||
import { Suspense, useCallback } from "react";
|
|
||||||
import { WindowVirtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/users/$pubkey")({
|
|
||||||
beforeLoad: async () => {
|
|
||||||
const settings = await NostrQuery.getUserSettings();
|
|
||||||
return { settings };
|
|
||||||
},
|
|
||||||
loader: async ({ params }) => {
|
|
||||||
return { data: defer(NostrQuery.getUserEvents(params.pubkey)) };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { pubkey } = Route.useParams();
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
|
||||||
(event: LumeEvent) => {
|
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
|
||||||
case Kind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
default: {
|
|
||||||
if (event.isConversation) {
|
|
||||||
return (
|
|
||||||
<Conversation key={event.id} className="mb-3" event={event} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (event.isQuote) {
|
|
||||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container withDrag>
|
|
||||||
<Box className="px-0 scrollbar-none bg-black/5 dark:bg-white/5">
|
|
||||||
<WindowVirtualizer>
|
|
||||||
<User.Provider pubkey={pubkey}>
|
|
||||||
<User.Root>
|
|
||||||
<User.Cover className="object-cover w-full h-44" />
|
|
||||||
<div className="relative flex flex-col px-3 -mt-8">
|
|
||||||
<User.Avatar className="rounded-full size-14" />
|
|
||||||
<div className="inline-flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<User.Name className="text-lg font-semibold leading-tight" />
|
|
||||||
<User.NIP05 />
|
|
||||||
</div>
|
|
||||||
<User.Button className="inline-flex items-center justify-center w-24 text-sm font-medium text-white bg-black rounded-full h-9 hover:bg-neutral-900 dark:bg-neutral-900" />
|
|
||||||
</div>
|
|
||||||
<User.About />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="px-3 mt-5">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="text-lg font-semibold">Latest notes</h3>
|
|
||||||
</div>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex h-20 w-full items-center justify-center gap-1.5 text-sm font-medium">
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Await promise={data}>
|
|
||||||
{(events) => events.map((event) => renderItem(event))}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</WindowVirtualizer>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
|
|
||||||
import preset from "@lume/tailwindcss";
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
content: [
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
"../../packages/@columns/**/*{.js,.ts,.jsx,.tsx}",
|
|
||||||
"../../packages/ark/**/*{.js,.ts,.jsx,.tsx}",
|
|
||||||
"../../packages/ui/**/*{.js,.ts,.jsx,.tsx}",
|
|
||||||
"index.html",
|
|
||||||
],
|
|
||||||
presets: [preset],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@lume/tsconfig/base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"baseUrl": "./",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
|
||||||
import react from "@vitejs/plugin-react-swc";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import viteTsconfigPaths from "vite-tsconfig-paths";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react(), viteTsconfigPaths(), TanStackRouterVite()],
|
|
||||||
build: {
|
|
||||||
outDir: "../../dist",
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
strictPort: true,
|
|
||||||
port: 3000,
|
|
||||||
},
|
|
||||||
clearScreen: false,
|
|
||||||
});
|
|
||||||
21
apps/web/.gitignore
vendored
@@ -1,21 +0,0 @@
|
|||||||
# build output
|
|
||||||
dist/
|
|
||||||
# generated types
|
|
||||||
.astro/
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# logs
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
|
|
||||||
# environment variables
|
|
||||||
.env
|
|
||||||
.env.production
|
|
||||||
|
|
||||||
# macOS-specific files
|
|
||||||
.DS_Store
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Astro Starter Kit: Minimal
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm create astro@latest -- --template minimal
|
|
||||||
```
|
|
||||||
|
|
||||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
|
||||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
|
|
||||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
|
|
||||||
|
|
||||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
|
||||||
|
|
||||||
## 🚀 Project Structure
|
|
||||||
|
|
||||||
Inside of your Astro project, you'll see the following folders and files:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/
|
|
||||||
├── public/
|
|
||||||
├── src/
|
|
||||||
│ └── pages/
|
|
||||||
│ └── index.astro
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
|
||||||
|
|
||||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
|
||||||
|
|
||||||
Any static assets, like images, can be placed in the `public/` directory.
|
|
||||||
|
|
||||||
## 🧞 Commands
|
|
||||||
|
|
||||||
All commands are run from the root of the project, from a terminal:
|
|
||||||
|
|
||||||
| Command | Action |
|
|
||||||
| :------------------------ | :----------------------------------------------- |
|
|
||||||
| `npm install` | Installs dependencies |
|
|
||||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
|
||||||
| `npm run build` | Build your production site to `./dist/` |
|
|
||||||
| `npm run preview` | Preview your build locally, before deploying |
|
|
||||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
|
||||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
|
||||||
|
|
||||||
## 👀 Want to learn more?
|
|
||||||
|
|
||||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { defineConfig } from "astro/config";
|
|
||||||
|
|
||||||
import tailwind from "@astrojs/tailwind";
|
|
||||||
|
|
||||||
// https://astro.build/config
|
|
||||||
export default defineConfig({
|
|
||||||
integrations: [tailwind()],
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@lume/web",
|
|
||||||
"type": "module",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "astro dev",
|
|
||||||
"start": "astro dev",
|
|
||||||
"build": "astro check && astro build",
|
|
||||||
"preview": "astro preview",
|
|
||||||
"astro": "astro"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@astrojs/check": "^0.5.10",
|
|
||||||
"@astrojs/tailwind": "^5.1.0",
|
|
||||||
"@fontsource/alice": "^5.0.13",
|
|
||||||
"astro": "^4.11.6",
|
|
||||||
"astro-seo-meta": "^4.1.1",
|
|
||||||
"astro-seo-schema": "^4.0.2",
|
|
||||||
"schema-dts": "^1.1.2",
|
|
||||||
"tailwindcss": "^3.4.6",
|
|
||||||
"typescript": "^5.5.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/typography": "^0.5.13"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 889 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 714 KiB |
|
Before Width: | Height: | Size: 318 KiB |
1
apps/web/src/env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="astro/client" />
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
---
|
|
||||||
import { Seo } from "astro-seo-meta";
|
|
||||||
---
|
|
||||||
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<meta name="generator" content={Astro.generator} />
|
|
||||||
<title>Lume: The nostr client for desktop</title>
|
|
||||||
<Seo
|
|
||||||
title="Lume"
|
|
||||||
description="A friendly and scalable Nostr desktop client."
|
|
||||||
keywords={[
|
|
||||||
"nostr",
|
|
||||||
"nostr client",
|
|
||||||
"social network",
|
|
||||||
"desktop app",
|
|
||||||
"timeline",
|
|
||||||
"application",
|
|
||||||
"columns",
|
|
||||||
"tweetdeck",
|
|
||||||
]}
|
|
||||||
themeColor="#fafafa"
|
|
||||||
colorScheme="light"
|
|
||||||
facebook={{
|
|
||||||
image: "/og-image.jpg",
|
|
||||||
url: "https://lume.nu",
|
|
||||||
type: "website",
|
|
||||||
}}
|
|
||||||
twitter={{
|
|
||||||
image: "/og-image.jpg",
|
|
||||||
card: "summary",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body
|
|
||||||
class="w-full h-full antialiased bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50"
|
|
||||||
>
|
|
||||||
<div class="py-10 flex flex-col gap-10">
|
|
||||||
<div class="mx-auto max-w-xl w-full flex flex-col gap-2">
|
|
||||||
<div class="mb-5">
|
|
||||||
<img
|
|
||||||
src="/icon.png"
|
|
||||||
alt="App Icon"
|
|
||||||
class="size-14 shadow-md shadow-neutral-500/50 rounded-xl object-cover transform-gpu -rotate-6 hover:animate-spin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-xl font-serif font-semibold">
|
|
||||||
A friendly and scalable Nostr desktop client.
|
|
||||||
</h1>
|
|
||||||
<p class="text-sm font-medium text-neutral-700">
|
|
||||||
Lume is a <b>Nostr client</b> for desktop, including Linux, Windows, and
|
|
||||||
macOS. It is free and open-source; you can look at the source code on <a
|
|
||||||
href="https://github.com/lumehq/lume">GitHub</a
|
|
||||||
>. Lume is actively improving the app and adding new features; you can
|
|
||||||
expect a new update every month.
|
|
||||||
</p>
|
|
||||||
<p class="text-sm font-medium text-neutral-700">
|
|
||||||
<b>Latest version</b>: 4.0.4
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
class="w-full h-[120px] sm:h-[80px] flex flex-col sm:flex-row sm:items-center sm:justify-start justify-center gap-2"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="https://github.com/lumehq/lume/releases/latest"
|
|
||||||
class="inline-flex items-center justify-center w-44 h-11 rounded-full bg-black hover:ring-2 ring-blue-500 ring-offset-2 text-white font-medium text-sm"
|
|
||||||
>Download for macOS</a
|
|
||||||
>
|
|
||||||
<span class="italic text-xs text-neutral-700"
|
|
||||||
>(Windows & Linux are coming later)</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm italic text-neutral-600">
|
|
||||||
* If you still need to use Lume on Windows and Linux, you can try v3 <a
|
|
||||||
href="https://github.com/lumehq/lume/releases/tag/v3.0.2"
|
|
||||||
class="text-blue-500">here</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="sm:max-w-3xl w-full mx-auto px-3 sm:px-0">
|
|
||||||
<video
|
|
||||||
class="aspect-video w-full h-auto rounded-xl"
|
|
||||||
autoplay
|
|
||||||
muted
|
|
||||||
controls
|
|
||||||
>
|
|
||||||
<source
|
|
||||||
src="https://video.nostr.build/4cc4df88caeb861b62e3f73bddbb5e0b5cf63617472a97d22f427e273ee0e127.mp4"
|
|
||||||
type="video/mp4"
|
|
||||||
/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
|
|
||||||
const defaultTheme = require("tailwindcss/defaultTheme");
|
|
||||||
|
|
||||||
export default {
|
|
||||||
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
serif: ["Alice", ...defaultTheme.fontFamily.serif],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [require("@tailwindcss/typography")],
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "astro/tsconfigs/strict"
|
|
||||||
}
|
|
||||||
58
biome.json
@@ -1,31 +1,31 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.4.1/schema.json",
|
"$schema": "https://biomejs.dev/schemas/1.4.1/schema.json",
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"apps/desktop2/src/router.gen.ts",
|
"./src/routes.gen.ts",
|
||||||
"packages/system/src/commands.ts"
|
"./src/commands.gen.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"style": {
|
"style": {
|
||||||
"noNonNullAssertion": "warn",
|
"noNonNullAssertion": "warn",
|
||||||
"noUselessElse": "off"
|
"noUselessElse": "off"
|
||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"useExhaustiveDependencies": "off"
|
"useExhaustiveDependencies": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"noSvgWithoutTitle": "off"
|
"noSvgWithoutTitle": "off"
|
||||||
},
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noStaticOnlyClass": "off"
|
"noStaticOnlyClass": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
flake.lock
generated
@@ -1,130 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1694529238,
|
|
||||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-utils_2": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems_2"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681202837,
|
|
||||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1697723726,
|
|
||||||
"narHash": "sha256-SaTWPkI8a5xSHX/rrKzUe+/uVNy6zCGMXgoeMb7T9rg=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "7c9cc5a6e5d38010801741ac830a3f8fd667a7a0",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681358109,
|
|
||||||
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixpkgs-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs",
|
|
||||||
"rust-overlay": "rust-overlay"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rust-overlay": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils_2",
|
|
||||||
"nixpkgs": "nixpkgs_2"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1697940838,
|
|
||||||
"narHash": "sha256-eyk92QqAoRNC0V99KOcKcBZjLPixxNBS0PRc4KlSQVs=",
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"rev": "a3e829c06eadf848f13d109c7648570ce37ebccd",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
72
flake.nix
@@ -1,72 +0,0 @@
|
|||||||
# Nix.flake to build Lume based on Tauri's Guides:
|
|
||||||
# Prerequisites -> Installing -> Setting Up Linux -> NixOS
|
|
||||||
# https://tauri.app/v1/guides/getting-started/prerequisites/#1-system-dependencies
|
|
||||||
#
|
|
||||||
# To build Rust backend of Tauri `rust-overlay` is used
|
|
||||||
# https://github.com/oxalica/rust-overlay
|
|
||||||
|
|
||||||
{
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
|
||||||
let
|
|
||||||
overlays = [ (import rust-overlay) ];
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system overlays;
|
|
||||||
};
|
|
||||||
|
|
||||||
libraries = with pkgs;[
|
|
||||||
webkitgtk
|
|
||||||
gtk3
|
|
||||||
cairo
|
|
||||||
gdk-pixbuf
|
|
||||||
glib
|
|
||||||
dbus
|
|
||||||
openssl_3
|
|
||||||
librsvg
|
|
||||||
libappindicator-gtk3
|
|
||||||
];
|
|
||||||
|
|
||||||
packages = with pkgs; [
|
|
||||||
curl
|
|
||||||
wget
|
|
||||||
pkg-config
|
|
||||||
dbus
|
|
||||||
openssl_3
|
|
||||||
glib
|
|
||||||
gtk3
|
|
||||||
libsoup
|
|
||||||
webkitgtk
|
|
||||||
librsvg
|
|
||||||
];
|
|
||||||
|
|
||||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
|
||||||
extensions = [ "rust-src" ]; # needed by rust-analyzer
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
buildInputs = [
|
|
||||||
rustToolchain
|
|
||||||
pkgs.nodejs
|
|
||||||
pkgs.nodePackages.pnpm
|
|
||||||
pkgs.bun # experimental in Lume
|
|
||||||
] ++ packages;
|
|
||||||
|
|
||||||
shellHook =
|
|
||||||
''
|
|
||||||
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
|
||||||
export XDG_DATA_DIRS=${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}:$XDG_DATA_DIRS
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Avoid white screen running with Nix
|
|
||||||
# https://github.com/tauri-apps/tauri/issues/4315#issuecomment-1207755694
|
|
||||||
WEBKIT_DISABLE_COMPOSITING_MODE = 1;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
|
|
||||||
index 21f5d9a5..9a46f36d 100644
|
|
||||||
--- a/src-tauri/tauri.conf.json
|
|
||||||
+++ b/src-tauri/tauri.conf.json
|
|
||||||
@@ -64,7 +64,7 @@
|
|
||||||
"shortDescription": "",
|
|
||||||
"targets": "all",
|
|
||||||
"updater": {
|
|
||||||
- "active": true,
|
|
||||||
+ "active": false,
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU3OTdCMkM3RjU5QzE2NzkKUldSNUZwejF4N0tYNTVHYjMrU0JkL090SlEyNUVLYU5TM2hTU3RXSWtEWngrZWJ4a0pydUhXZHEK",
|
|
||||||
"windows": {
|
|
||||||
"installMode": "quiet"
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
FROM node:20-slim as prepare
|
|
||||||
|
|
||||||
RUN apt update && apt install -y git
|
|
||||||
|
|
||||||
# Taken from tauri docs https://beta.tauri.app/guides/prerequisites/#rust
|
|
||||||
RUN apt install libwebkit2gtk-4.1-dev -y \
|
|
||||||
build-essential \
|
|
||||||
curl \
|
|
||||||
wget \
|
|
||||||
file \
|
|
||||||
libssl-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
protobuf-compiler \
|
|
||||||
librsvg2-dev
|
|
||||||
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
|
||||||
|
|
||||||
|
|
||||||
FROM prepare as build
|
|
||||||
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
|
||||||
|
|
||||||
#RUN corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
ADD . /lume/.
|
|
||||||
|
|
||||||
WORKDIR /lume
|
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Path for disable updater
|
|
||||||
#ADD flatpak/0001-disable-tauri-updater.patch .
|
|
||||||
#RUN patch -p1 -t -i flatpak/0001-disable-tauri-updater.patch
|
|
||||||
|
|
||||||
#ENV VITE_FLATPAK_RESOURCE="/app/lib/lume/resources/config.toml"
|
|
||||||
|
|
||||||
# debian build
|
|
||||||
RUN pnpm tauri build -b deb
|
|
||||||
|
|
||||||
ARG VERSION=3.0.1
|
|
||||||
ARG ARCH=amd64
|
|
||||||
|
|
||||||
RUN cp -r ./src-tauri/target/release/bundle/deb/lume_${VERSION}_${ARCH}/data lume-package
|
|
||||||
|
|
||||||
FROM scratch as final
|
|
||||||
|
|
||||||
COPY --from=build lume/lume-package prepare-dist
|
|
||||||
#ADD flatpak/*.xml flatpak/*.desktop flatpak/*.yml prepare-dist
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<component type="desktop-application">
|
|
||||||
<id>
|
|
||||||
nu.lume.Lume
|
|
||||||
</id>
|
|
||||||
<launchable type="desktop-id">
|
|
||||||
nu.lume.Lume.desktop
|
|
||||||
</launchable>
|
|
||||||
<name>
|
|
||||||
Lume
|
|
||||||
</name>
|
|
||||||
<summary>
|
|
||||||
A cross-platform desktop nostr client
|
|
||||||
</summary>
|
|
||||||
<developer_name>
|
|
||||||
Ren Amamiya
|
|
||||||
</developer_name>
|
|
||||||
<metadata_license>
|
|
||||||
CC0-1.0
|
|
||||||
</metadata_license>
|
|
||||||
<project_license>
|
|
||||||
GPL-3.0-only
|
|
||||||
</project_license>
|
|
||||||
<url type="homepage">
|
|
||||||
https://lume.nu
|
|
||||||
</url>
|
|
||||||
<url type="bugtracker">
|
|
||||||
https://github.com/lumehq/lume/issues
|
|
||||||
</url>
|
|
||||||
<url type="donation">
|
|
||||||
https://nostree.me/npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445
|
|
||||||
</url>
|
|
||||||
<supports>
|
|
||||||
<control>
|
|
||||||
pointing
|
|
||||||
</control>
|
|
||||||
<control>
|
|
||||||
keyboard
|
|
||||||
</control>
|
|
||||||
<control>
|
|
||||||
touch
|
|
||||||
</control>
|
|
||||||
</supports>
|
|
||||||
<description>
|
|
||||||
<p>
|
|
||||||
Lume a cross-platform nostr client, supported nsecbunker, chats and notifications
|
|
||||||
</p>
|
|
||||||
</description>
|
|
||||||
<custom>
|
|
||||||
<value key="Purism::form_factor">
|
|
||||||
workstation
|
|
||||||
</value>
|
|
||||||
<value key="Purism::form_factor">
|
|
||||||
mobile
|
|
||||||
</value>
|
|
||||||
</custom>
|
|
||||||
<screenshots>
|
|
||||||
<screenshot type="default">
|
|
||||||
<image>
|
|
||||||
https://raw.githubusercontent.com/lumehq/lume/flatpak/screenshots/login-screen.png
|
|
||||||
</image>
|
|
||||||
</screenshot>
|
|
||||||
<screenshot>
|
|
||||||
<image>
|
|
||||||
https://raw.githubusercontent.com/lumehq/lume/flatpak/screenshots/collumns.png
|
|
||||||
</image>
|
|
||||||
</screenshot>
|
|
||||||
<screenshot>
|
|
||||||
<image>
|
|
||||||
https://raw.githubusercontent.com/lumehq/lume/flatpak/screenshots/home-screen.png
|
|
||||||
</image>
|
|
||||||
</screenshot>
|
|
||||||
</screenshots>
|
|
||||||
<releases>
|
|
||||||
<release version="3.0.1" date="2024-02-02" />
|
|
||||||
</releases>
|
|
||||||
<content_rating type="oars-1.1" />
|
|
||||||
</component>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[Desktop Entry]
|
|
||||||
Version=1.0
|
|
||||||
Type=Application
|
|
||||||
|
|
||||||
Name=Lume
|
|
||||||
Comment=A cross-platform desktop nostr client
|
|
||||||
Icon=lume
|
|
||||||
Exec=lume
|
|
||||||
Terminal=false
|
|
||||||
Categories=Network;InstantMessaging;
|
|
||||||
Keywords=nostr;client;chat;
|
|
||||||
X-Purism-FormFactor=Workstation;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
id: nu.lume.Lume
|
|
||||||
runtime: org.gnome.Platform
|
|
||||||
runtime-version: '45'
|
|
||||||
sdk: org.gnome.Sdk
|
|
||||||
command: lume
|
|
||||||
rename-icon: lume
|
|
||||||
|
|
||||||
finish-args:
|
|
||||||
- --socket=wayland
|
|
||||||
- --socket=fallback-x11
|
|
||||||
- --socket=pulseaudio
|
|
||||||
- --share=ipc
|
|
||||||
- --share=network
|
|
||||||
#- --filesystem=home
|
|
||||||
#- --filesystem=xdg-download
|
|
||||||
- --talk-name=org.freedesktop.secrets
|
|
||||||
- --talk-name=org.freedesktop.Notifications
|
|
||||||
- --talk-name=org.kde.StatusNotifierWatcher
|
|
||||||
- --filesystem=xdg-run/keyring
|
|
||||||
- --device=dri
|
|
||||||
|
|
||||||
modules:
|
|
||||||
- shared-modules/libappindicator/libappindicator-gtk3-12.10.json
|
|
||||||
- name: lume
|
|
||||||
sources:
|
|
||||||
- type: dir
|
|
||||||
path: usr
|
|
||||||
- type: file
|
|
||||||
path: nu.lume.Lume.desktop
|
|
||||||
- type: file
|
|
||||||
path: nu.lume.Lume.appdata.xml
|
|
||||||
buildsystem: simple
|
|
||||||
build-commands:
|
|
||||||
- install -Dm755 bin/lume /app/bin/lume
|
|
||||||
- mkdir -p /app/lib/lume/resources
|
|
||||||
- cp -r lib/lume/resources /app/lib/lume/resources
|
|
||||||
- mkdir -p /app/share/icons/hicolor/
|
|
||||||
- cp -r share/icons/hicolor/ /app/share/icons/
|
|
||||||
- install -Dm644 nu.lume.Lume.appdata.xml /app/share/metainfo/nu.lume.Lume.appdata.xml
|
|
||||||
- install -Dm644 nu.lume.Lume.desktop /app/share/applications/nu.lume.Lume.desktop
|
|
||||||
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 606 KiB |
118
package.json
@@ -1,35 +1,87 @@
|
|||||||
{
|
{
|
||||||
"name": "lume",
|
"name": "lume",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "4.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"type": "module",
|
||||||
"build": "turbo run build",
|
"scripts": {
|
||||||
"dev": "turbo run dev",
|
"dev": "vite",
|
||||||
"web:dev": "turbo run dev --filter web",
|
"build": "vite build",
|
||||||
"desktop:dev": "turbo run dev --filter desktop2",
|
"preview": "vite preview",
|
||||||
"desktop:build": "turbo run build --filter desktop2",
|
"tauri": "tauri"
|
||||||
"tauri": "tauri"
|
},
|
||||||
},
|
"dependencies": {
|
||||||
"devDependencies": {
|
"@getalby/bitcoin-connect-react": "^3.6.2",
|
||||||
"@biomejs/biome": "^1.8.3",
|
"@phosphor-icons/react": "^2.1.7",
|
||||||
"@tauri-apps/cli": "2.0.0-beta.22",
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
"turbo": "^1.13.4"
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
},
|
"@radix-ui/react-popover": "^1.1.2",
|
||||||
"packageManager": "pnpm@8.9.0",
|
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||||
"engines": {
|
"@radix-ui/react-switch": "^1.1.1",
|
||||||
"node": ">=18"
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
},
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
"dependencies": {
|
"@tanstack/query-persist-client-core": "^5.59.0",
|
||||||
"@tauri-apps/api": "2.0.0-beta.15",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.5",
|
"@tanstack/react-router": "^1.58.16",
|
||||||
"@tauri-apps/plugin-dialog": "2.0.0-beta.7",
|
"@tanstack/react-store": "^0.5.5",
|
||||||
"@tauri-apps/plugin-fs": "2.0.0-beta.7",
|
"@tanstack/store": "^0.5.5",
|
||||||
"@tauri-apps/plugin-http": "2.0.0-beta.8",
|
"@tauri-apps/api": "^2.0.1",
|
||||||
"@tauri-apps/plugin-os": "2.0.0-beta.7",
|
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
||||||
"@tauri-apps/plugin-process": "2.0.0-beta.7",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "2.0.0-beta.8",
|
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||||
"@tauri-apps/plugin-updater": "2.0.0-beta.7",
|
"@tauri-apps/plugin-http": "^2.0.0",
|
||||||
"@tauri-apps/plugin-upload": "2.0.0-beta.8",
|
"@tauri-apps/plugin-os": "^2.0.0",
|
||||||
"@tauri-apps/plugin-window-state": "2.0.0-beta.8"
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
}
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-store": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-upload": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-window-state": "^2.0.0",
|
||||||
|
"bitcoin-units": "^1.0.0",
|
||||||
|
"boring-avatars": "^1.11.2",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"embla-carousel-react": "^8.3.0",
|
||||||
|
"i18next": "^23.15.1",
|
||||||
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
|
"light-bolt11-decoder": "^3.2.0",
|
||||||
|
"minidenticons": "^4.2.1",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"nostr-tools": "^2.7.2",
|
||||||
|
"react": "19.0.0-rc-d025ddd3-20240722",
|
||||||
|
"react-currency-input-field": "^3.8.0",
|
||||||
|
"react-dom": "19.0.0-rc-d025ddd3-20240722",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
|
"react-i18next": "^15.0.2",
|
||||||
|
"react-string-replace": "^1.1.1",
|
||||||
|
"rich-textarea": "^0.26.3",
|
||||||
|
"use-debounce": "^10.0.3",
|
||||||
|
"virtua": "^0.34.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.3",
|
||||||
|
"@evilmartians/harmony": "^1.2.0",
|
||||||
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@tanstack/router-devtools": "^1.58.16",
|
||||||
|
"@tanstack/router-plugin": "^1.58.12",
|
||||||
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"@types/react": "npm:types-react@19.0.0-rc.1",
|
||||||
|
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"babel-plugin-react-compiler": "0.0.0-experimental-b4db8c3-20241001",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwind-gradient-mask-image": "^1.2.0",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
|
"tailwind-scrollbar": "^3.1.0",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"tailwindcss-content-visibility": "^1.0.0",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8",
|
||||||
|
"vite-tsconfig-paths": "^5.0.1"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@types/react": "npm:types-react@rc",
|
||||||
|
"@types/react-dom": "npm:types-react-dom@rc"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
export * from "./src/addWidget";
|
|
||||||
export * from "./src/arrowLeft";
|
|
||||||
export * from "./src/arrowRight";
|
|
||||||
export * from "./src/bell";
|
|
||||||
export * from "./src/cancel";
|
|
||||||
export * from "./src/checkCircle";
|
|
||||||
export * from "./src/chevronDown";
|
|
||||||
export * from "./src/chevronRight";
|
|
||||||
export * from "./src/compose";
|
|
||||||
export * from "./src/copy";
|
|
||||||
export * from "./src/edit";
|
|
||||||
export * from "./src/enter";
|
|
||||||
export * from "./src/eyeOff";
|
|
||||||
export * from "./src/eyeOn";
|
|
||||||
export * from "./src/feed";
|
|
||||||
export * from "./src/heartbeat";
|
|
||||||
export * from "./src/hide";
|
|
||||||
export * from "./src/image";
|
|
||||||
export * from "./src/like";
|
|
||||||
export * from "./src/lume";
|
|
||||||
export * from "./src/media";
|
|
||||||
export * from "./src/mute";
|
|
||||||
export * from "./src/space";
|
|
||||||
export * from "./src/spaceFilled";
|
|
||||||
export * from "./src/navArrowDown";
|
|
||||||
export * from "./src/plus";
|
|
||||||
export * from "./src/plusCircle";
|
|
||||||
export * from "./src/refresh";
|
|
||||||
export * from "./src/reply";
|
|
||||||
export * from "./src/replyMessage";
|
|
||||||
export * from "./src/repost";
|
|
||||||
export * from "./src/threads";
|
|
||||||
export * from "./src/trash";
|
|
||||||
export * from "./src/world";
|
|
||||||
export * from "./src/zap";
|
|
||||||
export * from "./src/trending";
|
|
||||||
export * from "./src/empty";
|
|
||||||
export * from "./src/cmd";
|
|
||||||
export * from "./src/verticalDots";
|
|
||||||
export * from "./src/signal";
|
|
||||||
export * from "./src/unverified";
|
|
||||||
export * from "./src/settings";
|
|
||||||
export * from "./src/logout";
|
|
||||||
export * from "./src/follow";
|
|
||||||
export * from "./src/unfollow";
|
|
||||||
export * from "./src/reaction";
|
|
||||||
export * from "./src/thread";
|
|
||||||
export * from "./src/strangers";
|
|
||||||
export * from "./src/download";
|
|
||||||
export * from "./src/horizontalDots";
|
|
||||||
export * from "./src/arrowRightCircle";
|
|
||||||
export * from "./src/hashtag";
|
|
||||||
export * from "./src/file";
|
|
||||||
export * from "./src/share";
|
|
||||||
export * from "./src/expand";
|
|
||||||
export * from "./src/focus";
|
|
||||||
export * from "./src/chevronUp";
|
|
||||||
export * from "./src/secure";
|
|
||||||
export * from "./src/verified";
|
|
||||||
export * from "./src/mention";
|
|
||||||
export * from "./src/groupFeeds";
|
|
||||||
export * from "./src/article";
|
|
||||||
export * from "./src/follows";
|
|
||||||
export * from "./src/alby";
|
|
||||||
export * from "./src/stars";
|
|
||||||
export * from "./src/nwc";
|
|
||||||
export * from "./src/timeline";
|
|
||||||
export * from "./src/dots";
|
|
||||||
export * from "./src/handArrowDown";
|
|
||||||
export * from "./src/relay";
|
|
||||||
export * from "./src/explore";
|
|
||||||
export * from "./src/explore2";
|
|
||||||
export * from "./src/home";
|
|
||||||
export * from "./src/chats";
|
|
||||||
export * from "./src/community";
|
|
||||||
export * from "./src/heading1";
|
|
||||||
export * from "./src/heading2";
|
|
||||||
export * from "./src/heading3";
|
|
||||||
export * from "./src/bold";
|
|
||||||
export * from "./src/italic";
|
|
||||||
export * from "./src/user";
|
|
||||||
export * from "./src/advancedSettings";
|
|
||||||
export * from "./src/info";
|
|
||||||
export * from "./src/light";
|
|
||||||
export * from "./src/dark";
|
|
||||||
export * from "./src/system";
|
|
||||||
export * from "./src/announcement";
|
|
||||||
export * from "./src/depot";
|
|
||||||
export * from "./src/search";
|
|
||||||
export * from "./src/run";
|
|
||||||
export * from "./src/gossip";
|
|
||||||
export * from "./src/userAdd";
|
|
||||||
export * from "./src/userRemove";
|
|
||||||
export * from "./src/pin";
|
|
||||||
export * from "./src/homeFilled";
|
|
||||||
export * from "./src/relayFilled";
|
|
||||||
export * from "./src/depotFilled";
|
|
||||||
export * from "./src/nwcFilled";
|
|
||||||
export * from "./src/moveLeft";
|
|
||||||
export * from "./src/moveRight";
|
|
||||||
export * from "./src/help";
|
|
||||||
export * from "./src/plusSquare";
|
|
||||||
export * from "./src/column";
|
|
||||||
export * from "./src/addMedia";
|
|
||||||
export * from "./src/check";
|
|
||||||
export * from "./src/popperFilled";
|
|
||||||
export * from "./src/composeFilled";
|
|
||||||
export * from "./src/settingsFilled";
|
|
||||||
export * from "./src/bellFilled";
|
|
||||||
export * from "./src/foryou";
|
|
||||||
export * from "./src/editInterest";
|
|
||||||
export * from "./src/newColumn";
|
|
||||||
export * from "./src/searchFilled";
|
|
||||||
export * from "./src/arrowUp";
|
|
||||||
export * from "./src/arrowUpSquare";
|
|
||||||
export * from "./src/arrowDown";
|
|
||||||
export * from "./src/link";
|
|
||||||
export * from "./src/local";
|
|
||||||
export * from "./src/global";
|
|
||||||
export * from "./src/infoCircle";
|
|
||||||
export * from "./src/cancelCircle";
|
|
||||||
export * from "./src/laurel";
|
|
||||||
export * from "./src/quote";
|
|
||||||
export * from "./src/key";
|
|
||||||
export * from "./src/remote";
|
|
||||||
export * from "./src/nsfw";
|
|
||||||
export * from "./src/visit";
|
|
||||||
export * from "./src/pow";
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@lume/icons",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"main": "./index.ts",
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^18.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@lume/tsconfig": "workspace:*",
|
|
||||||
"@types/react": "^18.3.3",
|
|
||||||
"typescript": "^5.5.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export function AddMediaIcon(props: JSX.IntrinsicElements["svg"]) {
|
|
||||||
return (
|
|
||||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
d="M15.25 8.75v-4a2 2 0 0 0-2-2h-8.5a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h4M3.1 11.9l1.794-1.176a2 2 0 0 1 2.206.01l1.279.852M6 6.25h.5m8 8.75h.5M6.75 6.25a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm7 6.95v3.6l2.8-1.8-2.8-1.8Zm5.5 8.05h-8.5a2 2 0 0 1-2-2v-8.5a2 2 0 0 1 2-2h8.5a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function AddWidgetIcon(
|
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
d="M12.25 21.25h-6.5a1 1 0 01-1-1V3.75a1 1 0 011-1h12.5a1 1 0 011 1v8.5m-1 3v3m0 0v3m0-3h-3m3 0h3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function AdvancedSettingsIcon(
|
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
d="M13.75 7h-10m10 0a3.25 3.25 0 116.5 0 3.25 3.25 0 11-6.5 0zm6.5 10h-8m0 0a3.25 3.25 0 11-6.5 0m6.5 0a3.25 3.25 0 10-6.5 0m0 0h-2"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function AlbyIcon(
|
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="400"
|
|
||||||
height="578"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 400 578"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="#000"
|
|
||||||
d="M201.283 577.511c54.122 0 97.998-8.1 97.998-18.092 0-9.992-43.876-18.092-97.998-18.092-54.123 0-97.998 8.1-97.998 18.092 0 9.992 43.875 18.092 97.998 18.092z"
|
|
||||||
opacity="0.1"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill="#fff"
|
|
||||||
stroke="#000"
|
|
||||||
strokeWidth="15.077"
|
|
||||||
d="M295.75 471.344c50.627 0 73.67-112.102 73.67-154.608 0-33.13-22.86-53.208-52.913-53.208-29.866 0-54.113 12.843-54.414 28.747-.001 41.971-7.388 179.069 33.657 179.069zM110.837 471.344c-50.627 0-73.67-112.102-73.67-154.608 0-33.13 22.86-53.208 52.913-53.208 29.866 0 54.113 12.843 54.414 28.747.001 41.971 7.388 179.069-33.657 179.069z"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill="#FFDF6F"
|
|
||||||
stroke="#000"
|
|
||||||
strokeWidth="15"
|
|
||||||
d="M68.83 303.262v-.002c-.054-.519.052-.82.16-1.016.127-.232.368-.508.773-.738.84-.477 2.014-.563 3.108.076 37.603 22.042 80.976 34.678 128.13 34.678 47.163 0 91.339-12.881 129.184-35.307 1.087-.645 2.26-.565 3.102-.091.407.229.65.504.779.737.109.197.216.499.163 1.019-5.854 58.014-37.322 105.977-79.618 128.054-13.969 7.293-23.576 19.962-32.013 31.089l-.452.597-.002.002c-6.857 9.046-13.063 17.147-20.648 23.116-7.584-5.969-13.791-14.07-20.648-23.116l-.001-.002-.452-.597c-8.437-11.127-18.043-23.796-32.013-31.089-42.135-21.992-73.523-69.677-79.551-127.41z"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill="#000"
|
|
||||||
stroke="#000"
|
|
||||||
strokeWidth="15.077"
|
|
||||||
d="M201.786 346.338c73.274 0 132.674-19.8 132.674-44.225s-59.4-44.225-132.674-44.225-132.674 19.8-132.674 44.225 59.4 44.225 132.674 44.225z"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
stroke="#000"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeWidth="15.077"
|
|
||||||
d="M95.245 376.491s65.44 22.112 107.546 22.112c42.105 0 107.546-22.112 107.546-22.112"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill="#000"
|
|
||||||
d="M77 143c-16.569 0-30-13.431-30-30 0-16.569 13.431-30 30-30 16.569 0 30 13.431 30 30 0 16.569-13.431 30-30 30z"
|
|
||||||
></path>
|
|
||||||
<path stroke="#000" strokeWidth="15" d="M72 108.5l56 56"></path>
|
|
||||||
<path
|
|
||||||
fill="#000"
|
|
||||||
d="M322 143c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30-16.569 0-30 13.431-30 30 0 16.569 13.431 30 30 30z"
|
|
||||||
></path>
|
|
||||||
<path stroke="#000" strokeWidth="15" d="M327.5 108.5l-56 56"></path>
|
|
||||||
<path
|
|
||||||
fill="#FFDF6F"
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M85.516 292.019c-16.17-7.698-25.58-24.983-22.427-42.612C76.618 173.747 133 117 200.5 117c67.663 0 124.155 57.023 137.509 132.958 3.106 17.66-6.381 34.937-22.605 42.572C280.687 308.868 241.91 318 201 318c-41.335 0-80.493-9.323-115.484-25.981z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill="#000"
|
|
||||||
d="M70.472 250.728C83.544 177.62 137.582 124.5 200.5 124.5v-15c-72.082 0-130.809 60.375-144.794 138.587l14.766 2.641zM200.5 124.5c63.069 0 117.218 53.379 130.122 126.757l14.774-2.598C331.592 170.166 272.758 109.5 200.5 109.5v15zm111.71 161.244C278.472 301.621 240.783 310.5 201 310.5v15c42.037 0 81.902-9.386 117.597-26.183l-6.387-13.573zM201 310.5c-40.196 0-78.255-9.064-112.26-25.253l-6.448 13.544C118.269 315.918 158.526 325.5 201 325.5v-15zm129.622-59.243c2.49 14.159-5.091 28.219-18.412 34.487l6.387 13.573c19.128-9.002 30.52-29.497 26.799-50.658l-14.774 2.598zm-274.916-3.17c-3.778 21.124 7.524 41.629 26.586 50.704l6.447-13.544c-13.276-6.32-20.795-20.387-18.267-34.519l-14.766-2.641z"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill="#000"
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M114.365 273.209c-13.015-5.301-20.736-19.149-16.226-32.459C112.047 199.704 152.618 170 200.5 170c47.882 0 88.453 29.704 102.361 70.75 4.51 13.31-3.211 27.158-16.226 32.459C260.053 284.035 230.973 290 200.5 290c-30.473 0-59.553-5.965-86.135-16.791z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill="#fff"
|
|
||||||
d="M235 254c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20zM163.432 254.012c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export function AnnouncementIcon(props: JSX.IntrinsicElements["svg"]) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
{...props}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M16.36 3.014A27.429 27.429 0 0 1 8.143 8.04l-4.67 1.825a5.126 5.126 0 0 0 1.7 6.34l1.631-.25m9.556-12.94c-.875.234-.824 3.262.114 6.764.938 3.501 2.408 6.15 3.283 5.915M16.36 3.014c.875-.234 2.345 2.414 3.284 5.915.938 3.502.989 6.53.113 6.765m0 0a27.428 27.428 0 0 0-8.595-.382m0 0L13.295 22H8.92l-2.116-6.044m4.358-.644c-.345.04-.69.085-1.034.138l-3.324.506" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export function AntenasIcon(props: JSX.IntrinsicElements["svg"]) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M8 14a5 5 0 118 0m1 4.483a9 9 0 10-10 0M12 22l1.367-4.103a1.441 1.441 0 10-2.735 0L12 22zm0-10a1 1 0 110-2 1 1 0 010 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function ArrowDownIcon(
|
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M6.5 14.17a30.23 30.23 0 005.406 5.62c.174.14.384.21.594.21m6-5.83a30.232 30.232 0 01-5.406 5.62.949.949 0 01-.594.21m0 0V4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export function ArrowLeftIcon(props: JSX.IntrinsicElements["svg"]) {
|
|
||||||
return (
|
|
||||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
d="M10 5.75 3.75 12 10 18.25M4.5 12h15.75"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export function ArrowRightIcon(props: JSX.IntrinsicElements["svg"]) {
|
|
||||||
return (
|
|
||||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
d="M14 5.75 20.25 12 14 18.25M19.5 12H3.75"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function ArrowRightCircleIcon(
|
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
d="M7.75 12h8M13 8.75l2.896 2.896a.5.5 0 010 .708L13 15.25M21.25 12a9.25 9.25 0 11-18.5 0 9.25 9.25 0 0118.5 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function ArrowUpIcon(
|
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M6 9.83a30.23 30.23 0 015.406-5.62A.949.949 0 0112 4m6 5.83a30.233 30.233 0 00-5.406-5.62A.949.949 0 0012 4m0 0v16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function ArrowUpSquareIcon(
|
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M8.5 11.949a20.335 20.335 0 013.604-3.807A.626.626 0 0112.5 8m4 3.949a20.334 20.334 0 00-3.604-3.807A.626.626 0 0012.5 8m0 0v8m0 5c-2.796 0-4.193 0-5.296-.457a6 6 0 01-3.247-3.247C3.5 16.194 3.5 14.796 3.5 12c0-2.796 0-4.193.457-5.296a6 6 0 013.247-3.247C8.307 3 9.704 3 12.5 3c2.796 0 4.194 0 5.296.457a6 6 0 013.247 3.247c.457 1.103.457 2.5.457 5.296 0 2.796 0 4.194-.457 5.296a6 6 0 01-3.247 3.247C16.694 21 15.296 21 12.5 21z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function ArticleIcon(
|
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
d="M20.248 15.25H17.25a2 2 0 0 0-2 2v2.998m4.998-4.998c.002-.026.002-.052.002-.078V5.75a2 2 0 0 0-2-2H5.75a2 2 0 0 0-2 2v12.5a2 2 0 0 0 2 2h9.422c.026 0 .052 0 .078-.002m4.998-4.998a2 2 0 0 1-.584 1.336l-3.078 3.078a2 2 0 0 1-1.336.584M8.75 8.75h6.5m-6.5 4h2.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||