don't hate me, old git is fuck up
17
.eslintrc
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error"
|
||||
},
|
||||
"ignorePatterns": ["dist", "**/*.js", "**/*.json", "node_modules"]
|
||||
}
|
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
out
|
||||
*.local
|
||||
.next
|
||||
.vscode
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
25
.prettierrc
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": true,
|
||||
"importOrder": [
|
||||
"^@layouts/(.*)$",
|
||||
"^@pages/(.*)$",
|
||||
"^@components/(.*)$",
|
||||
"^@utils/(.*)$",
|
||||
"^@stores/(.*)$",
|
||||
"^@assets/(.*)$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
|
||||
"pluginSearchDirs": false
|
||||
}
|
7
README.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Tauri + Next.js + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, Next.js and Typescript.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
5
next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
13
next.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
const removeImports = require('next-remove-imports')();
|
||||
|
||||
module.exports = removeImports({
|
||||
reactStrictMode: false,
|
||||
swcMinify: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.experiments = { ...config.experiments, topLevelAwait: true };
|
||||
return config;
|
||||
},
|
||||
});
|
59
package.json
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "lume",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 1420",
|
||||
"build": "next build && next export -o dist",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.11",
|
||||
"@nanostores/persistent": "^0.7.0",
|
||||
"@nanostores/react": "^0.4.1",
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"@uiw/react-markdown-preview": "^4.1.8",
|
||||
"@uiw/react-md-editor": "^3.20.5",
|
||||
"bitcoin-address-validation": "^2.2.1",
|
||||
"boring-avatars": "^1.7.0",
|
||||
"framer-motion": "^9.0.4",
|
||||
"moment": "^2.29.4",
|
||||
"nanostores": "^0.7.4",
|
||||
"next": "^13.1.6",
|
||||
"next-remove-imports": "^1.0.10",
|
||||
"nostr-react": "^0.6.4",
|
||||
"nostr-tools": "^1.6.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.43.1",
|
||||
"react-moment": "^1.1.3",
|
||||
"react-player": "^2.11.2",
|
||||
"react-virtuoso": "^4.1.0",
|
||||
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
|
||||
"unique-names-generator": "^4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tauri-apps/cli": "^1.2.3",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.0.0",
|
||||
"@types/node": "^18.14.0",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.53.0",
|
||||
"@typescript-eslint/parser": "^5.53.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"csstype": "^3.1.1",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-config-next": "^13.1.6",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^2.8.4",
|
||||
"prettier-plugin-tailwindcss": "^0.2.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
}
|
4722
pnpm-lock.yaml
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
4
src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
1
src-tauri/.rustfmt.toml
Normal file
@ -0,0 +1 @@
|
||||
tab_spaces=2
|
3888
src-tauri/Cargo.lock
generated
Normal file
37
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "lume"
|
||||
version = "0.1.0"
|
||||
description = "nostr client"
|
||||
authors = ["Ren Amamiya"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.57"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = ["shell-open", "window-start-dragging"] }
|
||||
webpage = "1.5.0"
|
||||
|
||||
[dependencies.tauri-plugin-sql]
|
||||
git = "https://github.com/tauri-apps/plugins-workspace"
|
||||
branch = "fix/sql-types"
|
||||
features = ["sqlite"]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2.7"
|
||||
cocoa = "0.24.1"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||
default = ["custom-protocol"]
|
||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
BIN
src-tauri/icons/128x128.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
After Width: | Height: | Size: 14 KiB |
76
src-tauri/src/main.rs
Normal file
@ -0,0 +1,76 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{Manager, WindowEvent};
|
||||
use webpage::{Webpage, WebpageOptions};
|
||||
use window_ext::WindowExt;
|
||||
|
||||
mod window_ext;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct OpenGraphResponse {
|
||||
title: String,
|
||||
description: String,
|
||||
url: String,
|
||||
image: String,
|
||||
}
|
||||
|
||||
async fn fetch_opengraph(url: String) -> OpenGraphResponse {
|
||||
let options = WebpageOptions {
|
||||
allow_insecure: false,
|
||||
max_redirections: 3,
|
||||
timeout: Duration::from_secs(30),
|
||||
useragent: "lume - desktop app".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = Webpage::from_url(&url, options).expect("Could not read from URL");
|
||||
let html = result.html;
|
||||
|
||||
return OpenGraphResponse {
|
||||
title: html.opengraph.properties["title"].to_string(),
|
||||
description: html.opengraph.properties["description"].to_string(),
|
||||
url: html.opengraph.properties["url"].to_string(),
|
||||
image: html.opengraph.images[0].url.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn opengraph(url: String) -> OpenGraphResponse {
|
||||
let result = fetch_opengraph(url).await;
|
||||
return result;
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
let main_window = app.get_window("main").unwrap();
|
||||
// set inset for traffic lights
|
||||
main_window.position_traffic_lights(8.0, 16.0);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![opengraph])
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.on_window_event(|e| {
|
||||
let apply_offset = || {
|
||||
let win = e.window();
|
||||
win.position_traffic_lights(8.0, 16.0);
|
||||
};
|
||||
|
||||
match e.event() {
|
||||
WindowEvent::Resized(..) => apply_offset(),
|
||||
WindowEvent::ThemeChanged(..) => apply_offset(),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
60
src-tauri/src/window_ext.rs
Normal file
@ -0,0 +1,60 @@
|
||||
use tauri::{Runtime, Window};
|
||||
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self, transparent: bool);
|
||||
fn position_traffic_lights(&self, x: f64, y: f64);
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExt for Window<R> {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self, transparent: bool) {
|
||||
use cocoa::appkit::{NSWindow, NSWindowTitleVisibility};
|
||||
|
||||
let window = self.ns_window().unwrap() as cocoa::base::id;
|
||||
|
||||
unsafe {
|
||||
window.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden);
|
||||
|
||||
if transparent {
|
||||
window.setTitlebarAppearsTransparent_(cocoa::base::YES);
|
||||
} else {
|
||||
window.setTitlebarAppearsTransparent_(cocoa::base::NO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(&self, x: f64, y: f64) {
|
||||
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
||||
use cocoa::foundation::NSRect;
|
||||
|
||||
let window = self.ns_window().unwrap() as cocoa::base::id;
|
||||
|
||||
unsafe {
|
||||
let close = window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
|
||||
let miniaturize = window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
|
||||
let zoom = window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
|
||||
|
||||
let title_bar_container_view = close.superview().superview();
|
||||
|
||||
let close_rect: NSRect = msg_send![close, frame];
|
||||
let button_height = close_rect.size.height;
|
||||
|
||||
let title_bar_frame_height = button_height + y;
|
||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||
title_bar_rect.size.height = title_bar_frame_height;
|
||||
title_bar_rect.origin.y = NSView::frame(window).size.height - title_bar_frame_height;
|
||||
let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];
|
||||
|
||||
let window_buttons = vec![close, miniaturize, zoom];
|
||||
let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
|
||||
|
||||
for (i, button) in window_buttons.into_iter().enumerate() {
|
||||
let mut rect: NSRect = NSView::frame(button);
|
||||
rect.origin.x = x + (i as f64 * space_between);
|
||||
button.setFrameOrigin(rect.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
78
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,78 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"devPath": "http://localhost:1420",
|
||||
"distDir": "../dist",
|
||||
"withGlobalTauri": true
|
||||
},
|
||||
"package": {
|
||||
"productName": "Lume",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false,
|
||||
"shell": {
|
||||
"all": false,
|
||||
"open": true
|
||||
},
|
||||
"window": {
|
||||
"startDragging": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"copyright": "",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.tauri.dev",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"updater": {
|
||||
"active": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"theme": "Dark",
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"transparent": false,
|
||||
"fullscreen": false,
|
||||
"resizable": true,
|
||||
"width": 1100,
|
||||
"height": 800,
|
||||
"minWidth": 1100,
|
||||
"minHeight": 800
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
25
src/App.css
Normal file
@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import './assets/editor.css';
|
||||
|
||||
/* For Webkit-based browsers (Chrome, Safari and Opera) */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* For IE, Edge and Firefox */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.bg-gradient-radial-page {
|
||||
background: radial-gradient(52.56% 52.56% at 50% 117.61%, #1c1c21 0, rgba(28, 28, 33, 0) 100%),
|
||||
radial-gradient(63.94% 63.94% at 50% 0, #1c1c21 0, rgba(28, 28, 33, 0) 78.13%), #07070d;
|
||||
}
|
||||
|
||||
.border {
|
||||
background-clip: padding-box;
|
||||
}
|
102
src/assets/directory.json
Normal file
@ -0,0 +1,102 @@
|
||||
[
|
||||
{
|
||||
"name": "jb55",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1362882895669436423/Jzsp1Ikr.jpg",
|
||||
"npub": "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s"
|
||||
},
|
||||
{
|
||||
"name": "jack",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1115644092329758721/AFjOr-K8.jpg",
|
||||
"npub": "npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m"
|
||||
},
|
||||
{
|
||||
"name": "derekmoss",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1609534946435076096/Gl1xeTPP.jpg",
|
||||
"npub": "npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424"
|
||||
},
|
||||
{
|
||||
"name": "ODELL",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1421584695746338819/Z_7ZfAeP.jpg",
|
||||
"npub": "npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx"
|
||||
},
|
||||
{
|
||||
"name": "yeg0rpetrov",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1593772940126035968/D_LQYRd9.jpg",
|
||||
"npub": "npub1z4m7gkva6yxgvdyclc7zp0vz4ta0s2d9jh8g83w03tp5vdf3kzdsxana6p"
|
||||
},
|
||||
{
|
||||
"name": "PrestonPysh",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1408783276081299462/f4Ye5n7-.jpg",
|
||||
"npub": "npub1s5yq6wadwrxde4lhfs56gn64hwzuhnfa6r9mj476r5s4hkunzgzqrs6q7z"
|
||||
},
|
||||
{
|
||||
"name": "fiatjaf",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/539211568035004416/sBMjPR9q.jpeg",
|
||||
"npub": "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"
|
||||
},
|
||||
{
|
||||
"name": "dergigi",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1566370176446119943/UeuACt-4.jpg",
|
||||
"npub": "npub1dergggklka99wwrs92yz8wdjs952h2ux2ha2ed598ngwu9w7a6fsh9xzpc"
|
||||
},
|
||||
{
|
||||
"name": "hodlonaut",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1570910274755911682/z8DJsufc.jpg",
|
||||
"npub": "npub1cjw49ftnxene9wdxujz3tp7zspp0kf862cjud4nm3j2usag6eg2smwj2rh"
|
||||
},
|
||||
{
|
||||
"name": "DylanLeClair_",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1599858581611941922/XxvPPWAt.jpg",
|
||||
"npub": "npub1pyp9fqq60689ppds9ec3vghsm7s6s4grfya0y342g2hs3a0y6t0segc0qq"
|
||||
},
|
||||
{
|
||||
"name": "ShadowOfNakadai",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1620811984374464514/V7GJo1ak.jpg",
|
||||
"npub": "npub1sqaxzwvh5fhgw9q3d7v658ucapvfeds3dcd2587fcwyesn7dnwuqt2r45v"
|
||||
},
|
||||
{
|
||||
"name": "jackmallers",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1599778945699909632/O0qc9ykA.jpg",
|
||||
"npub": "npub1cn4t4cd78nm900qc2hhqte5aa8c9njm6qkfzw95tszufwcwtcnsq7g3vle"
|
||||
},
|
||||
{
|
||||
"name": "remroya",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1616979727515881478/5ABZzBYO.jpg",
|
||||
"npub": "npub1csamkk8zu67zl9z4wkp90a462v53q775aqn5q6xzjdkxnkvcpd7srtz4x9"
|
||||
},
|
||||
{
|
||||
"name": "TakumiHisoka",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1623286991944302594/cXSJ04BF.jpg",
|
||||
"npub": "npub1yc8jxnzkzm2esndrqdae6lza6qlwzxpcz9drpy699j9k7xetrpkqgvkwe9"
|
||||
},
|
||||
{
|
||||
"name": "EvelinSchallert",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1448008447983763457/7k07LJxQ.jpg",
|
||||
"npub": "npub1l2gvp9wxajsl6wqnh6eulvz5sdk05gtajjwjn2yn45s9yvfru2kqf3r0gm"
|
||||
},
|
||||
{
|
||||
"name": "peer",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1623291991709700097/aBL_VpMC.jpg",
|
||||
"npub": "npub18zx8lw3947pghsgzqv2t0x8pe767sscag5djgj5afr755xkqd97qt530pr"
|
||||
},
|
||||
{
|
||||
"name": "francispouliot_",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1524789480439283719/5Q_XBKGb.jpg",
|
||||
"npub": "npub1t289s8ck5qfwynf2vsq49t2kypvvkpj7rhegayrur0ag9s2sezaqgunkzs"
|
||||
},
|
||||
{
|
||||
"name": "lanyihou",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1603653816175689729/Ctj5GXPt.jpg",
|
||||
"npub": "npub18hywyhcnn5rqhlgu80yxeyf57fyhghlrc54dzaqyd9vtts949u9s24rtva"
|
||||
},
|
||||
{
|
||||
"name": "marttimalmi",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1125299725828272129/n8NDo1LN.png",
|
||||
"npub": "npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk"
|
||||
},
|
||||
{
|
||||
"name": "Snowden",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/648888480974508032/66_cUYfj.jpg",
|
||||
"npub": "npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9"
|
||||
}
|
||||
]
|
326
src/assets/editor.css
Normal file
@ -0,0 +1,326 @@
|
||||
.w-md-editor-bar {
|
||||
position: absolute;
|
||||
cursor: s-resize;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
margin-top: -11px;
|
||||
margin-right: 0;
|
||||
width: 14px;
|
||||
z-index: 3;
|
||||
height: 10px;
|
||||
border-radius: 0 0 3px 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.w-md-editor-bar svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.w-md-editor-aree {
|
||||
overflow: auto;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.w-md-editor-text {
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
-webkit-font-variant-ligatures: common-ligatures;
|
||||
font-variant-ligatures: common-ligatures;
|
||||
@apply p-4;
|
||||
}
|
||||
.w-md-editor-text-pre,
|
||||
.w-md-editor-text-input,
|
||||
.w-md-editor-text > .w-md-editor-text-pre {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
box-sizing: inherit;
|
||||
display: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-style: inherit;
|
||||
-webkit-font-variant-ligatures: inherit;
|
||||
font-variant-ligatures: inherit;
|
||||
font-weight: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
tab-size: inherit;
|
||||
text-indent: inherit;
|
||||
text-rendering: inherit;
|
||||
text-transform: inherit;
|
||||
white-space: inherit;
|
||||
overflow-wrap: inherit;
|
||||
word-break: inherit;
|
||||
word-break: normal;
|
||||
padding: 0;
|
||||
}
|
||||
.w-md-editor-text-pre > code,
|
||||
.w-md-editor-text-input > code,
|
||||
.w-md-editor-text > .w-md-editor-text-pre > code {
|
||||
font-family: inherit;
|
||||
}
|
||||
.w-md-editor-text-pre {
|
||||
position: relative;
|
||||
margin: 0px !important;
|
||||
pointer-events: none;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.w-md-editor-text-pre > code {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.w-md-editor-text-input {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
outline: 0;
|
||||
padding: inherit;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-fill-color: transparent;
|
||||
@apply placeholder:text-zinc-500;
|
||||
}
|
||||
.w-md-editor-text-input:empty {
|
||||
-webkit-text-fill-color: inherit !important;
|
||||
}
|
||||
.w-md-editor-text-pre,
|
||||
.w-md-editor-text-input {
|
||||
word-wrap: pre;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/**
|
||||
* Hack to apply on some CSS on IE10 and IE11
|
||||
*/
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
/**
|
||||
* IE doesn't support '-webkit-text-fill-color'
|
||||
* So we use 'color: transparent' to make the text transparent on IE
|
||||
* Unlike other browsers, it doesn't affect caret color in IE
|
||||
*/
|
||||
.w-md-editor-text-input {
|
||||
color: transparent !important;
|
||||
}
|
||||
.w-md-editor-text-input::selection {
|
||||
background-color: #accef7 !important;
|
||||
color: transparent !important;
|
||||
}
|
||||
}
|
||||
.w-md-editor-text-pre .punctuation {
|
||||
color: var(--color-prettylights-syntax-comment) !important;
|
||||
}
|
||||
.w-md-editor-text-pre .token.url,
|
||||
.w-md-editor-text-pre .token.content {
|
||||
color: var(--color-prettylights-syntax-constant) !important;
|
||||
}
|
||||
.w-md-editor-text-pre .token.title.important {
|
||||
color: var(--color-prettylights-syntax-markup-bold);
|
||||
}
|
||||
.w-md-editor-text-pre .token.code-block .function {
|
||||
color: var(--color-prettylights-syntax-entity);
|
||||
}
|
||||
.w-md-editor-text-pre .token.bold {
|
||||
font-weight: unset !important;
|
||||
}
|
||||
.w-md-editor-text-pre .token.title {
|
||||
line-height: unset !important;
|
||||
font-size: unset !important;
|
||||
font-weight: unset !important;
|
||||
}
|
||||
.w-md-editor-text-pre .token.code.keyword {
|
||||
color: var(--color-prettylights-syntax-constant) !important;
|
||||
}
|
||||
.w-md-editor-text-pre .token.strike,
|
||||
.w-md-editor-text-pre .token.strike .content {
|
||||
color: var(--color-prettylights-syntax-markup-deleted-text) !important;
|
||||
}
|
||||
.w-md-editor-toolbar-child {
|
||||
position: absolute;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 0 1px var(--color-border-default),
|
||||
0 0 0 var(--color-border-default), 0 1px 1px var(--color-border-default);
|
||||
background-color: var(--color-canvas-default);
|
||||
z-index: 1;
|
||||
display: none;
|
||||
}
|
||||
.w-md-editor-toolbar-child.active {
|
||||
display: block;
|
||||
}
|
||||
.w-md-editor-toolbar-child .w-md-editor-toolbar {
|
||||
border-bottom: 0;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.w-md-editor-toolbar-child .w-md-editor-toolbar ul > li {
|
||||
display: block;
|
||||
}
|
||||
.w-md-editor-toolbar-child .w-md-editor-toolbar ul > li button:not(.cta-btn) {
|
||||
width: -webkit-fill-available;
|
||||
height: initial;
|
||||
box-sizing: border-box;
|
||||
padding: 3px 4px 2px 4px;
|
||||
margin: 0;
|
||||
}
|
||||
.w-md-editor-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.w-md-editor-toolbar.bottom {
|
||||
border-bottom: 0px;
|
||||
border-top: 1px solid var(--color-border-default);
|
||||
border-radius: 0 0 3px 3px;
|
||||
}
|
||||
.w-md-editor-toolbar ul,
|
||||
.w-md-editor-toolbar li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
line-height: initial;
|
||||
}
|
||||
.w-md-editor-toolbar li {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
}
|
||||
.w-md-editor-toolbar li + li {
|
||||
margin: 0;
|
||||
}
|
||||
.w-md-editor-toolbar li > button:not(.cta-btn) {
|
||||
border: none;
|
||||
height: 20px;
|
||||
line-height: 14px;
|
||||
background: none;
|
||||
text-transform: none;
|
||||
font-weight: normal;
|
||||
overflow: visible;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
white-space: nowrap;
|
||||
@apply rounded py-1 px-2 text-zinc-500;
|
||||
}
|
||||
.w-md-editor-toolbar li > button:not(.cta-btn):hover,
|
||||
.w-md-editor-toolbar li > button:not(.cta-btn):focus {
|
||||
@apply bg-zinc-700 text-zinc-100;
|
||||
}
|
||||
.w-md-editor-toolbar li > button:not(.cta-btn):active {
|
||||
background-color: var(--color-neutral-muted);
|
||||
color: var(--color-danger-fg);
|
||||
}
|
||||
.w-md-editor-toolbar li > button:not(.cta-btn):disabled {
|
||||
color: var(--color-border-default);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.w-md-editor-toolbar li > button:not(.cta-btn):disabled:hover {
|
||||
background-color: transparent;
|
||||
color: var(--color-border-default);
|
||||
}
|
||||
.w-md-editor-toolbar li.active > button:not(.cta-btn) {
|
||||
color: var(--color-accent-fg);
|
||||
background-color: var(--color-neutral-muted);
|
||||
}
|
||||
.w-md-editor-toolbar-divider {
|
||||
height: 14px;
|
||||
width: 1px;
|
||||
margin: -3px 3px 0 3px !important;
|
||||
vertical-align: middle;
|
||||
background-color: var(--color-border-default);
|
||||
}
|
||||
.w-md-editor {
|
||||
text-align: left;
|
||||
border-radius: 3px;
|
||||
padding-bottom: 1px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
@apply gap-3;
|
||||
}
|
||||
.w-md-editor.w-md-editor-rtl {
|
||||
direction: rtl !important;
|
||||
text-align: right !important;
|
||||
}
|
||||
.w-md-editor.w-md-editor-rtl .w-md-editor-preview {
|
||||
right: unset !important;
|
||||
left: 0;
|
||||
text-align: right !important;
|
||||
box-shadow: inset -1px 0 0 0 var(--color-border-default);
|
||||
}
|
||||
.w-md-editor.w-md-editor-rtl .w-md-editor-text {
|
||||
text-align: right !important;
|
||||
}
|
||||
.w-md-editor-toolbar {
|
||||
@apply h-10 shrink-0;
|
||||
}
|
||||
.w-md-editor-content {
|
||||
@apply relative h-full overflow-auto rounded-lg border-[0.5px] border-white/30 bg-zinc-800 shadow-inner;
|
||||
}
|
||||
.w-md-editor .copied {
|
||||
display: none !important;
|
||||
}
|
||||
.w-md-editor-input {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
}
|
||||
.w-md-editor-text-pre > code {
|
||||
word-break: break-word !important;
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
.w-md-editor-preview {
|
||||
width: 50%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: inset 1px 0 0 0 var(--color-border-default);
|
||||
position: absolute;
|
||||
padding: 10px 20px;
|
||||
overflow: auto;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 0 0 5px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.w-md-editor-preview .anchor {
|
||||
display: none;
|
||||
}
|
||||
.w-md-editor-preview .contains-task-list {
|
||||
list-style: none;
|
||||
}
|
||||
.w-md-editor-show-preview .w-md-editor-input {
|
||||
width: 0%;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-canvas-default);
|
||||
}
|
||||
.w-md-editor-show-preview .w-md-editor-preview {
|
||||
width: 100%;
|
||||
box-shadow: inset 0 0 0 0;
|
||||
}
|
||||
.w-md-editor-show-edit .w-md-editor-input {
|
||||
width: 100%;
|
||||
}
|
||||
.w-md-editor-show-edit .w-md-editor-preview {
|
||||
width: 0%;
|
||||
padding: 0;
|
||||
}
|
||||
.w-md-editor-fullscreen {
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
z-index: 99999;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100% !important;
|
||||
}
|
||||
.w-md-editor-fullscreen .w-md-editor-content {
|
||||
height: 100%;
|
||||
}
|
17
src/assets/icons/Bitcoin.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
export default function BitcoinIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path d="m11.283 10.967-.05-.015.528-2.117.212.05c.706.157 2.225.498 1.95 1.613-.295 1.173-2.049.646-2.64.469zm-.913 3.438.086.025c.728.217 2.796.832 3.076-.333.289-1.121-1.57-1.558-2.402-1.753-.091-.022-.17-.04-.232-.056l-.528 2.117z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm3.873-10.384c.206-1.302-.693-1.98-1.94-2.438l.438-1.77-.99-.246-.427 1.726-.193-.05c-.196-.05-.389-.1-.587-.144l.428-1.725-1.017-.252-.428 1.725-2.018-.5-.292 1.18s.749.163.721.179c.4.108.469.392.447.609l-.509 2.007-.698 2.823c-.055.134-.19.326-.464.26a22.05 22.05 0 0 1-.728-.191l-.486 1.245 1.958.534-.436 1.761.983.244.437-1.762c.158.046.318.083.473.119.116.027.229.053.336.082l-.437 1.761.985.244.437-1.761c1.695.323 2.975.172 3.547-1.497.46-1.33.03-2.09-.863-2.589.67-.163 1.167-.627 1.323-1.574z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/Bookmark.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function BookmarkIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/CheckCircle.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function CheckCircleIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/Eye.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function EyeIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/Home.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function HomeIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
17
src/assets/icons/Like.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
export default function LikeIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
11
src/assets/icons/LikeSolid.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
export default function LikeSolidIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}>
|
||||
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
14
src/assets/icons/Lume.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
export default function LumeIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M7.337 19.099a.32.32 0 0 1-.373.021 20.911 20.911 0 0 0-4.259-2.022c-.17-.063-.191-.297-.031-.383a13.876 13.876 0 0 0 4.886-4.639A13.715 13.715 0 0 0 9.69 5.14c0-.17.149-.309.32-.309h3.981c.17 0 .309.138.32.309.074 2.468.809 4.852 2.129 6.937a13.88 13.88 0 0 0 4.886 4.64c.16.095.139.33-.032.383-1.266.436-2.49.99-3.651 1.66-.203.116-.405.244-.607.361a.32.32 0 0 1-.373-.021 18.293 18.293 0 0 1-4.567-5.331l-.096-.16-.096.16a18.158 18.158 0 0 1-4.567 5.33Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/Message.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function MessageIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
20
src/assets/icons/MiniArrowRight.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
export default function MiniArrowRightIcon({
|
||||
className,
|
||||
}: {
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2 10a.75.75 0 01.75-.75h12.59l-2.1-1.95a.75.75 0 111.02-1.1l3.5 3.25a.75.75 0 010 1.1l-3.5 3.25a.75.75 0 11-1.02-1.1l2.1-1.95H2.75A.75.75 0 012 10z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
20
src/assets/icons/MiniChevronDown.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
export default function MiniChevronDownIcon({
|
||||
className,
|
||||
}: {
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
13
src/assets/icons/MiniEdit.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
export default function MiniEditIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
|
||||
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
16
src/assets/icons/MiniMail.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
export default function MiniMailIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.404 14.596A6.5 6.5 0 1116.5 10a1.25 1.25 0 01-2.5 0 4 4 0 10-.571 2.06A2.75 2.75 0 0018 10a8 8 0 10-2.343 5.657.75.75 0 00-1.06-1.06 6.5 6.5 0 01-9.193 0zM10 7.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
12
src/assets/icons/MiniPlus.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
export default function MiniPlusIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M10.75 6.75a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/More.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function MoreIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/MoreCircle.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function MoreCircleIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
13
src/assets/icons/Plus.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
export default function PlusIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
12
src/assets/icons/Post.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
export default function PostIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={className}>
|
||||
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
|
||||
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
16
src/assets/icons/Refresh.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
export default function RefreshIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
13
src/assets/icons/Relay.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
export default function RelayIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M16.364 3.636a.75.75 0 00-1.06 1.06 7.5 7.5 0 010 10.607.75.75 0 001.06 1.061 9 9 0 000-12.728zM4.697 4.697a.75.75 0 00-1.061-1.06 9 9 0 000 12.727.75.75 0 101.06-1.06 7.5 7.5 0 010-10.607z" />
|
||||
<path d="M12.475 6.465a.75.75 0 011.06 0 5 5 0 010 7.07.75.75 0 11-1.06-1.06 3.5 3.5 0 000-4.95.75.75 0 010-1.06zM7.525 6.465a.75.75 0 010 1.06 3.5 3.5 0 000 4.95.75.75 0 01-1.06 1.06 5 5 0 010-7.07.75.75 0 011.06 0zM11 10a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/Reply.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function ReplyIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
15
src/assets/icons/Repost.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
export default function RepostIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 5.25c1.213 0 2.415.046 3.605.135a3.256 3.256 0 013.01 3.01c.044.583.077 1.17.1 1.759L17.03 8.47a.75.75 0 10-1.06 1.06l3 3a.75.75 0 001.06 0l3-3a.75.75 0 00-1.06-1.06l-1.752 1.751c-.023-.65-.06-1.296-.108-1.939a4.756 4.756 0 00-4.392-4.392 49.422 49.422 0 00-7.436 0A4.756 4.756 0 003.89 8.282c-.017.224-.033.447-.046.672a.75.75 0 101.497.092c.013-.217.028-.434.044-.651a3.256 3.256 0 013.01-3.01c1.19-.09 2.392-.135 3.605-.135zm-6.97 6.22a.75.75 0 00-1.06 0l-3 3a.75.75 0 101.06 1.06l1.752-1.751c.023.65.06 1.296.108 1.939a4.756 4.756 0 004.392 4.392 49.413 49.413 0 007.436 0 4.756 4.756 0 004.392-4.392c.017-.223.032-.447.046-.672a.75.75 0 00-1.497-.092c-.013.217-.028.434-.044.651a3.256 3.256 0 01-3.01 3.01 47.953 47.953 0 01-7.21 0 3.256 3.256 0 01-3.01-3.01 47.759 47.759 0 01-.1-1.759L6.97 15.53a.75.75 0 001.06-1.06l-3-3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
22
src/assets/icons/SidebarToggle.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
export default function SidebarToggleIcon({
|
||||
className,
|
||||
}: {
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M7.5 3.75v12.5m-4.063 0h13.126c.517 0 .937-.42.937-.938V4.688a.938.938 0 0 0-.938-.938H3.438a.938.938 0 0 0-.937.938v10.625c0 .517.42.937.938.937Z"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
25
src/components/accountBar/account.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import Image from 'next/image';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const Account = memo(function Account({ user, current }: { user: any; current: string }) {
|
||||
const userData = JSON.parse(user.metadata);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-11 w-11 shrink overflow-hidden rounded-full ${
|
||||
current === user.pubkey ? 'ring-1 ring-fuchsia-500 ring-offset-4 ring-offset-black' : ''
|
||||
}`}>
|
||||
{userData?.picture !== undefined ? (
|
||||
<Image
|
||||
src={userData.picture}
|
||||
alt="user's avatar"
|
||||
fill={true}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-11 w-11 animate-pulse rounded-full bg-zinc-700" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
166
src/components/accountBar/index.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Account } from '@components/accountBar/account';
|
||||
|
||||
import { currentUser } from '@stores/currentUser';
|
||||
|
||||
import LumeIcon from '@assets/icons/Lume';
|
||||
import MiniPlusIcon from '@assets/icons/MiniPlus';
|
||||
import PostIcon from '@assets/icons/Post';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { commands } from '@uiw/react-md-editor';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { dateToUnix, useNostr } from 'nostr-react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
const MDEditor = dynamic(() => import('@uiw/react-md-editor').then((mod) => mod.default), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function AccountBar() {
|
||||
const { publish } = useNostr();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
const $currentUser: any = useStore(currentUser);
|
||||
const pubkey = $currentUser.pubkey;
|
||||
const privkey = $currentUser.privkey;
|
||||
|
||||
const postButton = {
|
||||
name: 'post',
|
||||
keyCommand: 'post',
|
||||
buttonProps: { className: 'cta-btn', 'aria-label': 'Post a message' },
|
||||
icon: (
|
||||
<div className="relative inline-flex h-10 w-16 transform cursor-pointer overflow-hidden rounded bg-zinc-900 px-2.5 ring-zinc-500/50 ring-offset-zinc-900 will-change-transform focus:outline-none focus:ring-1 focus:ring-offset-2 active:translate-y-1">
|
||||
<span className="absolute inset-px z-10 inline-flex items-center justify-center rounded bg-zinc-900 text-zinc-200">
|
||||
Post
|
||||
</span>
|
||||
<span className="absolute inset-0 z-0 scale-x-[2.0] blur before:absolute before:inset-0 before:top-1/2 before:aspect-square before:animate-disco before:bg-gradient-conic before:from-gray-300 before:via-fuchsia-600 before:to-orange-600"></span>
|
||||
</div>
|
||||
),
|
||||
execute: (state: { text: any }) => {
|
||||
const message = state.text;
|
||||
|
||||
if (message.length > 0) {
|
||||
const event: any = {
|
||||
content: message,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: pubkey,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
|
||||
publish(event);
|
||||
setValue('');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getAccounts = useCallback(async () => {
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
const result: any = await db.select('SELECT * FROM accounts');
|
||||
|
||||
setUsers(result);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getAccounts().catch(console.error);
|
||||
}, [getAccounts]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-between px-2 pt-12 pb-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{users.map((user, index) => (
|
||||
<Account key={index} user={user} current={$currentUser.pubkey} />
|
||||
))}
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center overflow-hidden rounded-full border-2 border-dashed border-zinc-600 hover:border-zinc-400">
|
||||
<MiniPlusIcon className="h-6 w-6 text-zinc-400 group-hover:text-zinc-200" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* post button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex h-11 w-11 transform items-center justify-center rounded-full bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 font-bold text-white shadow-lg active:translate-y-1">
|
||||
<PostIcon className="h-4 w-4" />
|
||||
</button>
|
||||
{/* end post button */}
|
||||
<LumeIcon className="h-8 w-auto text-zinc-700" />
|
||||
</div>
|
||||
{/* modal */}
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={() => setIsOpen(false)}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-30 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<Dialog.Panel className="relative w-full max-w-2xl transform overflow-hidden rounded-lg text-zinc-100 shadow-modal transition-all">
|
||||
<div className="absolute top-0 left-0 h-full w-full bg-black bg-opacity-10 backdrop-blur-md"></div>
|
||||
<div className="absolute bottom-0 left-0 h-24 w-full border-t border-white/10 bg-zinc-900"></div>
|
||||
<div className="relative z-10 px-4 pt-4 pb-2">
|
||||
<MDEditor
|
||||
value={value}
|
||||
preview={'edit'}
|
||||
height={200}
|
||||
minHeight={200}
|
||||
visibleDragbar={false}
|
||||
highlightEnable={false}
|
||||
defaultTabEnable={true}
|
||||
autoFocus={true}
|
||||
commands={[
|
||||
commands.bold,
|
||||
commands.italic,
|
||||
commands.strikethrough,
|
||||
commands.divider,
|
||||
commands.checkedListCommand,
|
||||
commands.unorderedListCommand,
|
||||
commands.orderedListCommand,
|
||||
commands.divider,
|
||||
commands.link,
|
||||
commands.image,
|
||||
]}
|
||||
extraCommands={[postButton]}
|
||||
textareaProps={{
|
||||
placeholder: "What's your thought?",
|
||||
}}
|
||||
onChange={(val) => setValue(val)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
{/* end modal */}
|
||||
</div>
|
||||
);
|
||||
}
|
45
src/components/activeLink.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import Link, { LinkProps } from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren, memo, useEffect, useState } from 'react';
|
||||
|
||||
type ActiveLinkProps = LinkProps & {
|
||||
className?: string;
|
||||
activeClassName: string;
|
||||
};
|
||||
|
||||
const ActiveLink = ({
|
||||
children,
|
||||
activeClassName,
|
||||
className,
|
||||
...props
|
||||
}: PropsWithChildren<ActiveLinkProps>) => {
|
||||
const { asPath, isReady } = useRouter();
|
||||
const [computedClassName, setComputedClassName] = useState(className);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the router fields are updated client-side
|
||||
if (isReady) {
|
||||
// Dynamic route will be matched via props.as
|
||||
// Static route will be matched via props.href
|
||||
const linkPathname = new URL((props.as || props.href) as string, location.href).pathname;
|
||||
|
||||
// Using URL().pathname to get rid of query and hash
|
||||
const activePathname = new URL(asPath, location.href).pathname;
|
||||
|
||||
const newClassName =
|
||||
linkPathname === activePathname ? `${className} ${activeClassName}`.trim() : className;
|
||||
|
||||
if (newClassName !== computedClassName) {
|
||||
setComputedClassName(newClassName);
|
||||
}
|
||||
}
|
||||
}, [asPath, isReady, props.as, props.href, activeClassName, className, computedClassName]);
|
||||
|
||||
return (
|
||||
<Link className={computedClassName} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ActiveLink);
|
86
src/components/checkAccount.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { currentUser } from '@stores/currentUser';
|
||||
import { follows } from '@stores/follows';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
export default function CheckAccount() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const accounts = async () => {
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
const result = await db.select('SELECT * FROM accounts ORDER BY id ASC LIMIT 1');
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getFollowings = async (account) => {
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
const result: any = await db.select(
|
||||
`SELECT pubkey FROM followings WHERE account = "${account.pubkey}"`
|
||||
);
|
||||
|
||||
const arr = [];
|
||||
|
||||
result.forEach((item: { pubkey: any }) => {
|
||||
arr.push(item.pubkey);
|
||||
});
|
||||
|
||||
return arr;
|
||||
};
|
||||
|
||||
accounts()
|
||||
.then((res: any) => {
|
||||
if (res.length === 0) {
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.push('/onboarding');
|
||||
}, 1500);
|
||||
} else {
|
||||
currentUser.set(res[0]);
|
||||
|
||||
getFollowings(res[0])
|
||||
.then(async (res) => {
|
||||
follows.set(res);
|
||||
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.push('/feed/following');
|
||||
}, 1500);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
103
src/components/empty.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import MiniMailIcon from '@assets/icons/MiniMail';
|
||||
import MiniPlusIcon from '@assets/icons/MiniPlus';
|
||||
import RefreshIcon from '@assets/icons/Refresh';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
const sampleData = [
|
||||
{
|
||||
name: 'Dick Whitman (🌎/21M)',
|
||||
role: 'dickwhitman@nostrplebs.com',
|
||||
imageUrl: 'https://pbs.twimg.com/profile_images/1594930968325984256/TjMXaXBE_400x400.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Jack',
|
||||
role: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
|
||||
imageUrl: 'https://pbs.twimg.com/profile_images/1115644092329758721/AFjOr-K8_400x400.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Sats Symbol',
|
||||
role: 'npub1mqngkfwfyv2ckv7hshck9pqucpz08tktde2jukr3hheatup2y2tqnzc32w',
|
||||
imageUrl: 'https://pbs.twimg.com/profile_images/1563388888860594177/7evrI1uB_400x400.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Empty() {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg pt-8">
|
||||
<div>
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-zinc-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 48 48"
|
||||
aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M34 40h10v-4a6 6 0 00-10.712-3.714M34 40H14m20 0v-4a9.971 9.971 0 00-.712-3.714M14 40H4v-4a6 6 0 0110.713-3.714M14 40v-4c0-1.313.253-2.566.713-3.714m0 0A10.003 10.003 0 0124 26c4.21 0 7.813 2.602 9.288 6.286M30 14a6 6 0 11-12 0 6 6 0 0112 0zm12 6a4 4 0 11-8 0 4 4 0 018 0zm-28 0a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||
</svg>
|
||||
<h2 className="mt-2 text-lg font-medium text-zinc-100">
|
||||
You haven't followed anyone yet
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
You can send invite via email to your friend and lume will onboard them into nostr or
|
||||
follow some people in suggested below
|
||||
</p>
|
||||
</div>
|
||||
<form action="#" className="relative mt-6">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
className="block h-11 w-full rounded-lg border-none px-4 shadow-md ring-1 ring-white/10 placeholder:text-zinc-500 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
placeholder="Enter an email"
|
||||
/>
|
||||
<button className="absolute right-0.5 top-1/2 inline-flex h-10 -translate-y-1/2 transform items-center gap-1 rounded-md border border-zinc-600 bg-zinc-700 px-4 text-sm font-medium text-zinc-200 shadow-md">
|
||||
<MiniMailIcon className="h-4 w-4" />
|
||||
Invite
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-col items-start gap-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-zinc-500">Suggestions</h3>
|
||||
<RefreshIcon className="h-4 w-4 text-zinc-600" />
|
||||
</div>
|
||||
<ul className="w-full divide-y divide-zinc-800 border-t border-b border-zinc-800">
|
||||
{sampleData.map((person, index) => (
|
||||
<li key={index} className="flex items-center justify-between space-x-3 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-3">
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
className="rounded-full object-cover"
|
||||
src={person.imageUrl}
|
||||
alt={person.name}
|
||||
fill={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-zinc-200">{person.name}</p>
|
||||
<p className="w-56 truncate text-sm font-medium text-zinc-500">{person.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-zinc-700 bg-zinc-800 px-3 py-1 text-xs font-medium text-zinc-400 shadow-sm hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2">
|
||||
<MiniPlusIcon className="-ml-1 h-5 w-5" />
|
||||
<span className="text-sm font-medium text-zinc-300">Follow</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 bg-clip-text text-sm font-bold text-transparent">
|
||||
Explore more →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
37
src/components/imageWithFallback.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import Avatar from 'boring-avatars';
|
||||
import Image from 'next/image';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
export const ImageWithFallback = memo(function ImageWithFallback({
|
||||
src,
|
||||
alt,
|
||||
fill,
|
||||
className,
|
||||
}: {
|
||||
src: any;
|
||||
alt: string;
|
||||
fill: boolean;
|
||||
className: string;
|
||||
}) {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error ? (
|
||||
<Avatar
|
||||
size={44}
|
||||
name={alt}
|
||||
variant="beam"
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
) : (
|
||||
<Image alt={alt} onError={setError} src={src} fill={fill} className={className} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
18
src/components/navigatorBar/incomingList.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export function IncomingList({ data }: { data: any }) {
|
||||
const list: any = Array.from(new Set(data.map((item: any) => item.pubkey)));
|
||||
|
||||
if (list.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{list.map((item, index) => (
|
||||
<div key={index}>
|
||||
<p>{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
66
src/components/navigatorBar/index.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ActiveLink from '@components/activeLink';
|
||||
|
||||
import PlusIcon from '@assets/icons/Plus';
|
||||
|
||||
export default function NavigatorBar() {
|
||||
return (
|
||||
<div className="flex h-full flex-col flex-wrap justify-between overflow-hidden px-2 pt-12 pb-4">
|
||||
{/* main */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Newsfeed */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-sm font-bold text-zinc-400">Newsfeed</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-6 w-6 items-center justify-center rounded-full hover:bg-zinc-900">
|
||||
<PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-zinc-500">
|
||||
<ActiveLink
|
||||
href={`/feed/following`}
|
||||
activeClassName="rounded-lg ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white"
|
||||
className="flex h-10 items-center gap-1 px-2.5 text-sm font-medium">
|
||||
<span>#</span>
|
||||
<span>following</span>
|
||||
</ActiveLink>
|
||||
<ActiveLink
|
||||
href={`/feed/global`}
|
||||
activeClassName="rounded-lg ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white"
|
||||
className="flex h-10 items-center gap-1 px-2.5 text-sm font-medium">
|
||||
<span>#</span>
|
||||
<span>global</span>
|
||||
</ActiveLink>
|
||||
</div>
|
||||
</div>
|
||||
{/* Channels
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-sm font-bold text-zinc-400">Channels</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-6 w-6 items-center justify-center rounded-full hover:bg-zinc-900">
|
||||
<PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
*/}
|
||||
{/* Direct messages */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-sm font-bold text-zinc-400">Direct Messages</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-6 w-6 items-center justify-center rounded-full hover:bg-zinc-900">
|
||||
<PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
83
src/components/note/atoms/reaction.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { currentUser } from '@stores/currentUser';
|
||||
|
||||
import LikeIcon from '@assets/icons/Like';
|
||||
import LikeSolidIcon from '@assets/icons/LikeSolid';
|
||||
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { dateToUnix, useNostr, useNostrEvents } from 'nostr-react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Reaction({
|
||||
eventID,
|
||||
eventPubkey,
|
||||
}: {
|
||||
eventID: string;
|
||||
eventPubkey: string;
|
||||
}) {
|
||||
const { publish } = useNostr();
|
||||
const [reaction, setReaction] = useState(0);
|
||||
const [isReact, setIsReact] = useState(false);
|
||||
|
||||
const $currentUser: any = useStore(currentUser);
|
||||
const pubkey = $currentUser.pubkey;
|
||||
const privkey = $currentUser.privkey;
|
||||
|
||||
const { onEvent } = useNostrEvents({
|
||||
filter: {
|
||||
'#e': [eventID],
|
||||
since: 0,
|
||||
kinds: [7],
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
|
||||
onEvent((rawMetadata) => {
|
||||
try {
|
||||
const content = rawMetadata.content;
|
||||
if (content === '🤙' || content === '+') {
|
||||
setReaction(reaction + 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err, rawMetadata);
|
||||
}
|
||||
});
|
||||
|
||||
const handleReaction = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const event: any = {
|
||||
content: '+',
|
||||
kind: 7,
|
||||
tags: [
|
||||
['e', eventID],
|
||||
['p', eventPubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
|
||||
publish(event);
|
||||
|
||||
setIsReact(true);
|
||||
setReaction(reaction + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => handleReaction(e)}
|
||||
className="group flex w-16 items-center gap-1.5 text-sm text-zinc-500">
|
||||
<div className="rounded-lg p-1 group-hover:bg-zinc-800">
|
||||
{isReact ? (
|
||||
<LikeSolidIcon className="h-5 w-5 text-red-500" />
|
||||
) : (
|
||||
<LikeIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<span>{reaction}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
23
src/components/note/atoms/reply.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import ReplyIcon from '@assets/icons/Reply';
|
||||
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
|
||||
export default function Reply({ eventID }: { eventID: string }) {
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
'#e': [eventID],
|
||||
since: 0,
|
||||
kinds: [1],
|
||||
limit: 10,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<button className="group flex w-16 items-center gap-1.5 text-sm text-zinc-500">
|
||||
<div className="rounded-lg p-1 group-hover:bg-zinc-800">
|
||||
<ReplyIcon />
|
||||
</div>
|
||||
<span>{events.length || 0}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
26
src/components/note/atoms/rootUser.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
|
||||
export default function RootUser({ userPubkey, action }: { userPubkey: string; action: string }) {
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
authors: [userPubkey],
|
||||
kinds: [0],
|
||||
},
|
||||
});
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
const userData: any = JSON.parse(events[0].content);
|
||||
return (
|
||||
<div className="text-zinc-400">
|
||||
<p>
|
||||
{userData?.name ? userData.name : truncate(userPubkey, 16, ' .... ')} {action}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
80
src/components/note/atoms/user.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import MoreIcon from '@assets/icons/More';
|
||||
|
||||
import Avatar from 'boring-avatars';
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
import { memo } from 'react';
|
||||
import Moment from 'react-moment';
|
||||
|
||||
export const User = memo(function User({ pubkey, time }: { pubkey: string; time: any }) {
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
authors: [pubkey],
|
||||
kinds: [0],
|
||||
},
|
||||
});
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
const userData: any = JSON.parse(events[0].content);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full border border-white/10">
|
||||
{userData?.picture ? (
|
||||
<ImageWithFallback
|
||||
src={userData.picture}
|
||||
alt={pubkey}
|
||||
fill={true}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
size={44}
|
||||
name={pubkey}
|
||||
variant="beam"
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-bold leading-tight">
|
||||
{userData?.name ? userData.name : truncate(pubkey, 16, ' .... ')}
|
||||
</span>
|
||||
<span className="text-zinc-500">·</span>
|
||||
<Moment fromNow unix className="text-zinc-500">
|
||||
{time}
|
||||
</Moment>
|
||||
</div>
|
||||
<div>
|
||||
<MoreIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="relative flex animate-pulse items-start gap-4">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full border border-white/10 bg-zinc-700"></div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-16 rounded bg-zinc-700" />
|
||||
<span className="text-zinc-500">·</span>
|
||||
<div className="h-4 w-16 rounded bg-zinc-700" />
|
||||
</div>
|
||||
<div>
|
||||
<MoreIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
20
src/components/note/content/ImagePreview.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function ImagePreview({ data }: { data: object }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-800`}>
|
||||
<div className="relative h-full w-full">
|
||||
<Image
|
||||
src={data['image']}
|
||||
alt="image preview"
|
||||
width="0"
|
||||
height="0"
|
||||
sizes="100vw"
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
70
src/components/note/content/index.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { MarkdownPreviewProps } from '@uiw/react-markdown-preview';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
const MarkdownPreview = dynamic<MarkdownPreviewProps>(() => import('@uiw/react-markdown-preview'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Content({ data }: { data: string }) {
|
||||
const imagesRef = useRef([]);
|
||||
const videosRef = useRef([]);
|
||||
|
||||
const urls = useMemo(
|
||||
() =>
|
||||
data.match(
|
||||
/((http|ftp|https):\/\/)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)?/gi
|
||||
),
|
||||
[data]
|
||||
);
|
||||
|
||||
const extractURL = useCallback((urls: any[]) => {
|
||||
if (urls !== null && urls.length > 0) {
|
||||
urls.forEach((url: string | URL) => {
|
||||
const parseURL = new URL(url);
|
||||
const path = parseURL.pathname.toLowerCase();
|
||||
switch (path) {
|
||||
case path.match(/\.(jpg|jpeg|gif|png|webp)$/)?.input:
|
||||
imagesRef.current.push(parseURL.href);
|
||||
break;
|
||||
case path.match(
|
||||
/(http:|https:)?\/\/(www\.)?(youtube.com|youtu.be)\/(watch)?(\?v=)?(\S+)?/
|
||||
)?.input:
|
||||
videosRef.current.push(parseURL.href);
|
||||
break;
|
||||
case path.match(/\.(mp4|webm|m4v|mov|avi|mkv|flv)$/)?.input:
|
||||
videosRef.current.push(parseURL.href);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
extractURL(urls);
|
||||
}, [extractURL, urls]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<MarkdownPreview
|
||||
source={data}
|
||||
className={
|
||||
'prose prose-zinc max-w-none break-words dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:leading-normal prose-ul:mt-2 prose-li:my-1'
|
||||
}
|
||||
linkTarget="_blank"
|
||||
disallowedElements={[
|
||||
'Table',
|
||||
'Heading ID',
|
||||
'Highlight',
|
||||
'Fenced Code Block',
|
||||
'Footnote',
|
||||
'Definition List',
|
||||
'Task List',
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
20
src/components/note/content/preview/imageCard.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function ImageCard({ data }: { data: object }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-800`}>
|
||||
<div className="relative h-full w-full">
|
||||
<Image
|
||||
src={data['image']}
|
||||
alt="image preview"
|
||||
width="0"
|
||||
height="0"
|
||||
sizes="100vw"
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
23
src/components/note/content/preview/linkCard.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function LinkCard({ data }: { data: object }) {
|
||||
return (
|
||||
<Link
|
||||
href={data['url']}
|
||||
target={'_blank'}
|
||||
className="relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-700">
|
||||
<div className="relative aspect-video h-auto w-full">
|
||||
<Image src={data['image']} alt="image preview" fill={true} className="object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div>
|
||||
<h5 className="font-semibold leading-tight">{data['title']}</h5>
|
||||
<p className="text-sm text-zinc-300">{data['description']}</p>
|
||||
</div>
|
||||
<span className="text-sm text-zinc-500">{data['url']}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
17
src/components/note/content/preview/video.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import ReactPlayer from 'react-player/lazy';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function Video({ data }: { data: object }) {
|
||||
return (
|
||||
<div className="relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-800">
|
||||
<ReactPlayer
|
||||
url={data['url']}
|
||||
controls={true}
|
||||
volume={0}
|
||||
className="aspect-video w-full"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
64
src/components/note/liked.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import Reaction from '@components/note/atoms/reaction';
|
||||
import Reply from '@components/note/atoms/reply';
|
||||
import RootUser from '@components/note/atoms/rootUser';
|
||||
import User from '@components/note/atoms/user';
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
|
||||
import LikeSolidIcon from '@assets/icons/LikeSolid';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
const DynamicContent = dynamic(() => import('@components/note/content'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<>
|
||||
<p>Loading...</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export const Liked = memo(function Liked({
|
||||
eventUser,
|
||||
sourceID,
|
||||
}: {
|
||||
eventUser: string;
|
||||
sourceID: string;
|
||||
}) {
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
ids: [sourceID],
|
||||
since: 0,
|
||||
kinds: [1],
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-6 px-6">
|
||||
<div className="flex items-center gap-1 pl-8 text-sm">
|
||||
<LikeSolidIcon className="h-4 w-4 text-zinc-400" />
|
||||
<div className="ml-2">
|
||||
<RootUser userPubkey={eventUser} action={'like'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<User pubkey={events[0].pubkey} time={events[0].created_at} />
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DynamicContent data={events[0].content} />
|
||||
<div className="-ml-1 flex items-center gap-8">
|
||||
<Reply eventID={events[0].id} />
|
||||
<Reaction eventID={events[0].id} eventPubkey={events[0].pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Placeholder />;
|
||||
}
|
||||
});
|
64
src/components/note/multi.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import Reaction from '@components/note/atoms/reaction';
|
||||
import Reply from '@components/note/atoms/reply';
|
||||
import User from '@components/note/atoms/user';
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
|
||||
const DynamicContent = dynamic(() => import('@components/note/content'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<>
|
||||
<p>Loading...</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function Multi({ event }: { event: any }) {
|
||||
const tags = event.tags;
|
||||
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
ids: [tags[0][1]],
|
||||
since: 0,
|
||||
kinds: [1],
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
return (
|
||||
<div className="relative flex h-min min-h-min w-full select-text flex-col overflow-hidden border-b border-zinc-800">
|
||||
<div className="absolute left-[45px] top-6 h-full w-[2px] bg-zinc-800"></div>
|
||||
<div className="flex flex-col bg-zinc-900 px-6 pt-6 pb-2">
|
||||
<User pubkey={events[0].pubkey} time={events[0].created_at} />
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DynamicContent data={events[0].content} />
|
||||
<div className="-ml-1 flex items-center gap-8">
|
||||
<Reply eventID={events[0].id} />
|
||||
<Reaction eventID={events[0].id} eventPubkey={events[0].pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col bg-zinc-900 px-6 pb-6">
|
||||
<User pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="relative z-10 -mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DynamicContent data={event.content} />
|
||||
<div className="-ml-1 flex items-center gap-8">
|
||||
<Reply eventID={event.id} />
|
||||
<Reaction eventID={event.id} eventPubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Placeholder />;
|
||||
}
|
||||
}
|
30
src/components/note/placeholder.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export const Placeholder = memo(function Placeholder() {
|
||||
return (
|
||||
<div className="relative z-10 flex h-min animate-pulse select-text flex-col py-6 px-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full bg-zinc-700" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-16 rounded bg-zinc-700" />
|
||||
<span className="text-zinc-500">·</span>
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
<div className="h-3 w-3 rounded-full bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-16 w-full rounded bg-zinc-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
64
src/components/note/repost.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import Reaction from '@components/note/atoms/reaction';
|
||||
import Reply from '@components/note/atoms/reply';
|
||||
import RootUser from '@components/note/atoms/rootUser';
|
||||
import User from '@components/note/atoms/user';
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
|
||||
import RepostIcon from '@assets/icons/Repost';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
const DynamicContent = dynamic(() => import('@components/note/content'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<>
|
||||
<p>Loading...</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export const Repost = memo(function Repost({
|
||||
eventUser,
|
||||
sourceID,
|
||||
}: {
|
||||
eventUser: string;
|
||||
sourceID: string;
|
||||
}) {
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
ids: [sourceID],
|
||||
since: 0,
|
||||
kinds: [1],
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-6 px-6">
|
||||
<div className="flex items-center gap-1 pl-8 text-sm">
|
||||
<RepostIcon className="h-4 w-4 text-zinc-400" />
|
||||
<div className="ml-2">
|
||||
<RootUser userPubkey={eventUser} action={'repost'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<User pubkey={events[0].pubkey} time={events[0].created_at} />
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DynamicContent data={events[0].content} />
|
||||
<div className="-ml-1 flex items-center gap-8">
|
||||
<Reply eventID={events[0].id} />
|
||||
<Reaction eventID={events[0].id} eventPubkey={events[0].pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Placeholder />;
|
||||
}
|
||||
});
|
36
src/components/note/single.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import Reaction from '@components/note/atoms/reaction';
|
||||
import Reply from '@components/note/atoms/reply';
|
||||
import { User } from '@components/note/atoms/user';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { memo } from 'react';
|
||||
|
||||
const DynamicContent = dynamic(() => import('@components/note/content'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<>
|
||||
<p>Loading...</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const Single = memo(function Single({ event }: { event: any }) {
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-6 px-6">
|
||||
<div className="flex flex-col">
|
||||
<User pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DynamicContent data={event.content} />
|
||||
<div className="-ml-1 flex items-center gap-8">
|
||||
<Reply eventID={event.id} />
|
||||
<Reaction eventID={event.id} eventPubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
50
src/components/thread.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { Liked } from '@components/note/liked';
|
||||
// import { Multi } from '@components/note/multi';
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
import { Repost } from '@components/note/repost';
|
||||
import { Single } from '@components/note/single';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function Thread({ data }: { data: any }) {
|
||||
const ItemContent = useCallback(
|
||||
(index: string | number) => {
|
||||
const event = data[index];
|
||||
|
||||
if (event.kind === 7) {
|
||||
// type: like
|
||||
return <Liked key={index} eventUser={event.pubkey} sourceID={event.tags[0][1]} />;
|
||||
} else if (event.content === '#[0]') {
|
||||
// type: repost
|
||||
return <Repost key={index} eventUser={event.pubkey} sourceID={event.tags[0][1]} />;
|
||||
} else {
|
||||
// type: default
|
||||
return <Single key={index} event={event} />;
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
data={data}
|
||||
itemContent={ItemContent}
|
||||
components={{
|
||||
EmptyPlaceholder: () => <Placeholder />,
|
||||
ScrollSeekPlaceholder: () => <Placeholder />,
|
||||
}}
|
||||
scrollSeekConfiguration={{
|
||||
enter: (velocity) => Math.abs(velocity) > 800,
|
||||
exit: (velocity) => Math.abs(velocity) < 500,
|
||||
}}
|
||||
overscan={800}
|
||||
increaseViewportBy={1000}
|
||||
className="scrollbar-hide relative h-full w-full"
|
||||
style={{
|
||||
contain: 'strict',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
7
src/layouts/baseLayout.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default function BaseLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-white text-zinc-900 dark:bg-near-black dark:text-white">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
15
src/layouts/fullLayout.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
export default function FullLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-gradient-radial-page relative h-full overflow-hidden">
|
||||
{/* dragging area */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="absolute top-0 left-0 z-20 h-16 w-full bg-transparent"
|
||||
/>
|
||||
{/* end dragging area */}
|
||||
{/* content */}
|
||||
<div className="relative z-10 h-full">{children}</div>
|
||||
{/* end content */}
|
||||
</div>
|
||||
);
|
||||
}
|
15
src/layouts/onboardingLayout.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
export default function OnboardingLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-row">
|
||||
<div className="relative h-full w-[70px] shrink-0 border-r border-zinc-900">
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 h-12 w-full" />
|
||||
</div>
|
||||
<div className="grid grow grid-cols-4">
|
||||
<div className="col-span-1"></div>
|
||||
<div className="col-span-3 m-3 ml-0 overflow-hidden rounded-lg bg-zinc-900 shadow-md ring-1 ring-inset dark:shadow-black/10 dark:ring-white/10">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
21
src/layouts/userLayout.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import AccountBar from '@components/accountBar';
|
||||
import NavigatorBar from '@components/navigatorBar';
|
||||
|
||||
export default function UserLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-row">
|
||||
<div className="relative h-full w-[70px] shrink-0 border-r border-zinc-900">
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 h-12 w-full" />
|
||||
<AccountBar />
|
||||
</div>
|
||||
<div className="grid grow grid-cols-4">
|
||||
<div className="col-span-1">
|
||||
<NavigatorBar />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 ml-0 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<div className="h-full w-full rounded-lg">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
32
src/pages/_app.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { relays } from '@stores/relays';
|
||||
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { NextPage } from 'next';
|
||||
import type { AppProps } from 'next/app';
|
||||
import { NostrProvider } from 'nostr-react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
import '../App.css';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
|
||||
getLayout?: (page: ReactElement) => ReactNode;
|
||||
};
|
||||
|
||||
type AppPropsWithLayout = AppProps & {
|
||||
Component: NextPageWithLayout;
|
||||
};
|
||||
|
||||
export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
// Get relays
|
||||
const $relays = useStore(relays);
|
||||
|
||||
return (
|
||||
<NostrProvider relayUrls={$relays} debug={false}>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</NostrProvider>
|
||||
);
|
||||
}
|
13
src/pages/_document.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Head, Html, Main, NextScript } from 'next/document';
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<body className="cursor-default select-none overflow-hidden font-sans antialiased">
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
58
src/pages/feed/following.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import BaseLayout from '@layouts/baseLayout';
|
||||
import UserLayout from '@layouts/userLayout';
|
||||
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
import { Thread } from '@components/thread';
|
||||
|
||||
import { hoursAgo } from '@utils/getDate';
|
||||
|
||||
import { follows } from '@stores/follows';
|
||||
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { dateToUnix, useNostrEvents } from 'nostr-react';
|
||||
import {
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactFragment,
|
||||
ReactPortal,
|
||||
Suspense,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const $follows = useStore(follows);
|
||||
const now = useRef(new Date());
|
||||
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
authors: $follows,
|
||||
since: dateToUnix(hoursAgo(6, now.current)),
|
||||
kinds: [1],
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Suspense fallback={<Placeholder />}>
|
||||
<Thread data={events} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<UserLayout>{page}</UserLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
42
src/pages/feed/global.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import BaseLayout from '@layouts/baseLayout';
|
||||
import UserLayout from '@layouts/userLayout';
|
||||
|
||||
import { Thread } from '@components/thread';
|
||||
|
||||
import { hoursAgo } from '@utils/getDate';
|
||||
|
||||
import { dateToUnix, useNostrEvents } from 'nostr-react';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useRef } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const now = useRef(new Date());
|
||||
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
since: dateToUnix(hoursAgo(1, now.current)),
|
||||
kinds: [1],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input">
|
||||
<Thread data={events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<UserLayout>{page}</UserLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
119
src/pages/index.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import BaseLayout from '@layouts/baseLayout';
|
||||
import FullLayout from '@layouts/fullLayout';
|
||||
|
||||
import CheckAccount from '@components/checkAccount';
|
||||
|
||||
import LumeIcon from '@assets/icons/Lume';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactFragment,
|
||||
ReactPortal,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
export default function Page() {
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const initDB = useCallback(async () => {
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, privkey TEXT NOT NULL, pubkey TEXT NOT NULL, npub TEXT, nsec TEXT, metadata JSON, UNIQUE(privkey));'
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS followings (id INTEGER PRIMARY KEY, pubkey TEXT NOT NULL, account TEXT, UNIQUE(pubkey));'
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS note_reactions (id INTEGER PRIMARY KEY, reaction_id TEXT NOT NULL, e TEXT, p TEXT, UNIQUE(reaction_id));'
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS note_replies (id INTEGER PRIMARY KEY, reply_id TEXT NOT NULL, e TEXT, p TEXT, UNIQUE(reply_id));'
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, event_id TEXT, event JSON, UNIQUE(event_id));'
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS block_pubkeys (id INTEGER PRIMARY KEY, pubkey TEXT, UNIQUE(pubkey));'
|
||||
);
|
||||
await db.close();
|
||||
|
||||
setDone(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
initDB().catch(console.error);
|
||||
}, [initDB]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col items-center justify-between">
|
||||
<div>{/* spacer */}</div>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<motion.div layoutId="logo" className="relative">
|
||||
<LumeIcon className="h-16 w-16 text-white" />
|
||||
</motion.div>
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<motion.h2
|
||||
layoutId="subtitle"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-4xl font-medium text-transparent">
|
||||
A censorship-resistant social network
|
||||
</motion.h2>
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 bg-clip-text text-5xl font-bold text-transparent">
|
||||
built on nostr
|
||||
</motion.h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-16">
|
||||
<div className="h-10">{done ? <CheckAccount /> : <></>}</div>
|
||||
</div>
|
||||
{/* background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-fuchsia-400/10 to-orange-100/10 opacity-100 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)]">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="dark:fill-white/2.5 absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black/50 mix-blend-overlay dark:stroke-white/5">
|
||||
<defs>
|
||||
<pattern
|
||||
id=":R11d6:"
|
||||
width="72"
|
||||
height="56"
|
||||
patternUnits="userSpaceOnUse"
|
||||
x="-12"
|
||||
y="4">
|
||||
<path d="M.5 56V.5H72" fill="none"></path>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth="0" fill="url(#:R11d6:)"></rect>
|
||||
<svg x="-12" y="4" className="overflow-visible">
|
||||
<rect strokeWidth="0" width="73" height="57" x="288" y="168"></rect>
|
||||
<rect strokeWidth="0" width="73" height="57" x="144" y="56"></rect>
|
||||
<rect strokeWidth="0" width="73" height="57" x="504" y="168"></rect>
|
||||
<rect strokeWidth="0" width="73" height="57" x="720" y="336"></rect>
|
||||
</svg>
|
||||
</svg>
|
||||
</div>
|
||||
{/* end background */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<FullLayout>{page}</FullLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
24
src/pages/messages/[pub].tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import BaseLayout from '@layouts/baseLayout';
|
||||
import UserLayout from '@layouts/userLayout';
|
||||
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<UserLayout>{page}</UserLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
239
src/pages/onboarding/create.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import BaseLayout from '@layouts/baseLayout';
|
||||
import OnboardingLayout from '@layouts/onboardingLayout';
|
||||
|
||||
import { currentUser } from '@stores/currentUser';
|
||||
|
||||
import EyeIcon from '@assets/icons/Eye';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { dateToUnix, useNostr } from 'nostr-react';
|
||||
import { generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent } from 'nostr-tools';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useState } from 'react';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
import { Config, names, uniqueNamesGenerator } from 'unique-names-generator';
|
||||
|
||||
const config: Config = {
|
||||
dictionaries: [names],
|
||||
};
|
||||
|
||||
const defaultAvatars = [
|
||||
'https://bafybeidfsbrzqbvontmucteomoz2rkrxugu462l5hyhh6uioslkfzzs4oq.ipfs.w3s.link/avatar-11.png',
|
||||
'https://bafybeid7mrvznbnd6r2ju2iu7lsxkcikufys6z6ssy5ldxrxq5qh3yqf4u.ipfs.w3s.link/avatar-12.png',
|
||||
'https://bafybeih5gpwu53ohui6p7scekjpxjk2d4lusq2jqohqhjsvhfkeu56ea4e.ipfs.w3s.link/avatar-13.png',
|
||||
'https://bafybeibpbvrpuphkerjygdbnh26av5brqggzunbbbmfl3ozlvcn2mj6zxa.ipfs.w3s.link/avatar-14.png',
|
||||
'https://bafybeia4ue4loinuflu7y5q3xu6hcvt653mzw5yorw25oarf2wqksig4ma.ipfs.w3s.link/avatar-15.png',
|
||||
'https://bafybeib3gzl6n2bebiru2cpkdljmlzbtqfsl6xcnqtabxt6jrpj7l7ltm4.ipfs.w3s.link/avatar-16.png',
|
||||
];
|
||||
|
||||
const defaultBanners = [
|
||||
'https://bafybeiacwit7hjmdefqggxqtgh6ht5dhth7ndptwn2msl5kpkodudsr7py.ipfs.w3s.link/banner-1.jpg',
|
||||
'https://bafybeiderllqadxsikh3envikobmyka3uwgojriwh6epctqartq2loswyi.ipfs.w3s.link/banner-2.jpg',
|
||||
'https://bafybeiba4tifde2kczvd26vxhbb5jpqi3wmgvccpkcrle4hse2cqrwlwiy.ipfs.w3s.link/banner-3.jpg',
|
||||
'https://bafybeifqpny2eom7ccvmaguxxxfajutmn5h3fotaasga7gce2xfx37p6oy.ipfs.w3s.link/banner-4.jpg',
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const { publish } = useNostr();
|
||||
|
||||
const [type, setType] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [privKey] = useState(() => generatePrivateKey());
|
||||
const [name] = useState(() => uniqueNamesGenerator(config).toString());
|
||||
const [avatar] = useState(
|
||||
() => defaultAvatars[Math.floor(Math.random() * defaultAvatars.length)]
|
||||
);
|
||||
const [banner] = useState(
|
||||
() => defaultBanners[Math.floor(Math.random() * defaultBanners.length)]
|
||||
);
|
||||
|
||||
const pubKey = getPublicKey(privKey);
|
||||
const npub = nip19.npubEncode(pubKey);
|
||||
const nsec = nip19.nsecEncode(pubKey);
|
||||
|
||||
// auto-generated profile
|
||||
const data = {
|
||||
display_name: name,
|
||||
name: name,
|
||||
username: name.toLowerCase(),
|
||||
picture: avatar,
|
||||
banner: banner,
|
||||
};
|
||||
|
||||
const createAccount = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// publish account to relays
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 0,
|
||||
pubkey: pubKey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privKey);
|
||||
publish(event);
|
||||
|
||||
// save account to database
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
await db.execute(
|
||||
`INSERT INTO accounts (privkey, pubkey, npub, nsec, metadata) VALUES ("${privKey}", "${pubKey}", "${npub}", "${nsec}", '${JSON.stringify(
|
||||
data
|
||||
)}')`
|
||||
);
|
||||
await db.close();
|
||||
|
||||
// set currentUser in global state
|
||||
currentUser.set({
|
||||
metadata: JSON.stringify(data),
|
||||
npub: npub,
|
||||
privkey: privKey,
|
||||
pubkey: pubKey,
|
||||
});
|
||||
|
||||
// redirect to following newsfeed
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.push('/onboarding/following');
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const showNsec = () => {
|
||||
if (type === 'password') {
|
||||
setType('text');
|
||||
} else {
|
||||
setType('password');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between px-8">
|
||||
<div>{/* spacer */}</div>
|
||||
<motion.div layoutId="form">
|
||||
<div className="mb-8 flex flex-col gap-3">
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent">
|
||||
Create new key
|
||||
</motion.h1>
|
||||
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
|
||||
Lume will generate key with default profile for you, you can edit it later, and please
|
||||
store your key safely so you can restore your account or use other client
|
||||
</motion.h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Public Key</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Private Key</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
type={type}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
<button
|
||||
onClick={() => showNsec()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700">
|
||||
<EyeIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">
|
||||
Default Profile (you can change it later)
|
||||
</label>
|
||||
<div className="relative max-w-sm shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<div className="relative max-w-sm rounded-lg border border-black/5 px-3.5 py-4 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600">
|
||||
<div className="flex space-x-4">
|
||||
<div className="relative h-10 w-10 rounded-full">
|
||||
<Image
|
||||
className="inline-block rounded-full"
|
||||
src={data.picture}
|
||||
alt=""
|
||||
fill={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold">{data.display_name}</p>
|
||||
<p className="text-zinc-400">@{data.username}</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 h-2 rounded bg-zinc-700"></div>
|
||||
<div className="col-span-1 h-2 rounded bg-zinc-700"></div>
|
||||
</div>
|
||||
<div className="h-2 rounded bg-zinc-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div layoutId="action" className="pb-5">
|
||||
<div className="flex h-10 items-center">
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<button
|
||||
onClick={() => createAccount()}
|
||||
className="transform rounded-lg border border-white/10 bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 px-3.5 py-2 font-medium shadow-input shadow-black/5 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-50 dark:shadow-black/10">
|
||||
<span className="drop-shadow-lg">Continue →</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<OnboardingLayout>{page}</OnboardingLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
161
src/pages/onboarding/following.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import BaseLayout from '@layouts/baseLayout';
|
||||
import OnboardingLayout from '@layouts/onboardingLayout';
|
||||
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { currentUser } from '@stores/currentUser';
|
||||
|
||||
import data from '@assets/directory.json';
|
||||
import CheckCircleIcon from '@assets/icons/CheckCircle';
|
||||
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useState } from 'react';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const shuffle = (arr) => [...arr].sort(() => Math.random() - 0.5);
|
||||
|
||||
const [follow, setFollow] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list] = useState(shuffle(data));
|
||||
const $currentUser: any = useStore(currentUser);
|
||||
|
||||
const followUser = (e) => {
|
||||
const npub = e.currentTarget.getAttribute('data-npub');
|
||||
setFollow((arr) => [...arr, npub]);
|
||||
};
|
||||
|
||||
const insertDB = async () => {
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
await db.execute(
|
||||
`INSERT INTO followings (pubkey, account) VALUES ("${$currentUser.pubkey}", "${$currentUser.pubkey}")`
|
||||
);
|
||||
follow.forEach(async (npub) => {
|
||||
const { data } = nip19.decode(npub);
|
||||
await db.execute(
|
||||
`INSERT INTO followings (pubkey, account) VALUES ("${data}", "${$currentUser.pubkey}")`
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const createFollowing = async () => {
|
||||
setLoading(true);
|
||||
|
||||
insertDB().then(() =>
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.push('/');
|
||||
}, 1500)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between px-8">
|
||||
<div>{/* spacer */}</div>
|
||||
<motion.div layoutId="form" className="flex flex-col">
|
||||
<div className="mb-8 flex flex-col gap-3">
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent">
|
||||
Choose 10 people you want to following
|
||||
</motion.h1>
|
||||
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
|
||||
For better experiences, you should follow the people you care about to personalize your
|
||||
newsfeed, otherwise you will be very bored
|
||||
</motion.h2>
|
||||
</div>
|
||||
<div className="h-full w-full shrink">
|
||||
<div className="scrollbar-hide grid grid-cols-3 gap-4 overflow-y-auto">
|
||||
{list.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={(e) => followUser(e)}
|
||||
data-npub={item.npub}
|
||||
className={`col-span-1 inline-flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-zinc-700 ${
|
||||
follow.includes(item.npub) ? 'bg-zinc-800' : ''
|
||||
}`}>
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
className="rounded-full object-cover"
|
||||
src={item.avatar}
|
||||
alt={item.name}
|
||||
fill={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="inline-flex flex-1 items-center justify-between">
|
||||
<div>
|
||||
<p className="truncate text-sm font-medium text-zinc-200">{item.name}</p>
|
||||
<p className="text-sm leading-tight text-zinc-500">
|
||||
{truncate(item.npub, 16, ' .... ')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{follow.includes(item.npub) ? (
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div layoutId="action" className="pb-5">
|
||||
<div className="flex h-10 items-center">
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<button
|
||||
onClick={() => createFollowing()}
|
||||
disabled={follow.length < 10 ? true : false}
|
||||
className="transform rounded-lg border border-white/10 bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 px-3.5 py-2 font-medium shadow-input shadow-black/5 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-50 dark:shadow-black/10">
|
||||
<span className="drop-shadow-lg">Finish →</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<OnboardingLayout>{page}</OnboardingLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
139
src/pages/onboarding/import.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import BaseLayout from '@layouts/baseLayout';
|
||||
import OnboardingLayout from '@layouts/onboardingLayout';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
type FormValues = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
const resolver: Resolver<FormValues> = async (values) => {
|
||||
return {
|
||||
values: values.key ? values : {},
|
||||
errors: !values.key
|
||||
? {
|
||||
key: {
|
||||
type: 'required',
|
||||
message: 'This is required.',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
};
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
let privKey = data['key'];
|
||||
if (privKey.substring(0, 4) === 'nsec') {
|
||||
privKey = nip19.decode(privKey).data;
|
||||
}
|
||||
try {
|
||||
const pubKey = getPublicKey(privKey);
|
||||
if (pubKey) {
|
||||
const npub = nip19.npubEncode(pubKey);
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
|
||||
await db.execute(
|
||||
`INSERT INTO accounts (privkey, pubkey, npub) VALUES ("${privKey}", "${pubKey}", "${npub}")`
|
||||
);
|
||||
await db.close();
|
||||
|
||||
router.push('/');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('key', {
|
||||
type: 'custom',
|
||||
message: 'Private Key is invalid, please check again',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col justify-between">
|
||||
<div>{/* spacer */}</div>
|
||||
<motion.div layoutId="form">
|
||||
<div className="mb-8 flex flex-col gap-3">
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent">
|
||||
Import your private key
|
||||
</motion.h1>
|
||||
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
|
||||
You can import private key format as hex string or nsec. If you have installed Nostr
|
||||
Connect compality wallet in your mobile, you can connect by scan QR Code below
|
||||
</motion.h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
{...register('key', { required: true, minLength: 32 })}
|
||||
placeholder="Paste key here..."
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-900 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">{errors.key && <p>{errors.key.message}</p>}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div layoutId="action" className="pb-5">
|
||||
<div className="flex h-10 items-center">
|
||||
{isSubmitting ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="transform rounded-lg bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 px-3.5 py-2 font-medium active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30">
|
||||
<span className="drop-shadow-lg">Continue →</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<OnboardingLayout>{page}</OnboardingLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
60
src/pages/onboarding/index.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import BaseLayout from '@layouts/baseLayout';
|
||||
import OnboardingLayout from '@layouts/onboardingLayout';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between px-8">
|
||||
<div>{/* spacer */}</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent">
|
||||
Other social network require email/password
|
||||
<br />
|
||||
nostr use{' '}
|
||||
<span className="bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 bg-clip-text text-transparent">
|
||||
public/private key instead
|
||||
</span>
|
||||
</motion.h1>
|
||||
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
|
||||
If you have used nostr before, you can import your own private key. Otherwise, you can
|
||||
create a new key or use auto-generated account created by system.
|
||||
</motion.h2>
|
||||
<motion.div layoutId="form"></motion.div>
|
||||
<motion.div layoutId="action" className="mt-4 flex gap-2">
|
||||
<Link
|
||||
href="/onboarding/create"
|
||||
className="hover:bg-zinc-900/2.5 transform rounded-lg border border-black/5 bg-zinc-800 px-3.5 py-2 font-medium ring-1 ring-inset ring-zinc-900/10 hover:text-zinc-900 active:translate-y-1 dark:text-zinc-300 dark:ring-white/10 dark:hover:bg-zinc-700 dark:hover:text-white">
|
||||
Create new key
|
||||
</Link>
|
||||
<Link
|
||||
href="/onboarding/import"
|
||||
className="hover:bg-zinc-900/2.5 transform rounded-lg border border-black/5 bg-zinc-800 px-3.5 py-2 font-medium ring-1 ring-inset ring-zinc-900/10 hover:text-zinc-900 active:translate-y-1 dark:text-zinc-300 dark:ring-white/10 dark:hover:bg-zinc-700 dark:hover:text-white">
|
||||
Import key
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div>{/* spacer */}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<OnboardingLayout>{page}</OnboardingLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
10
src/stores/currentUser.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
|
||||
export const currentUser = persistentAtom(
|
||||
'currentUser',
|
||||
{},
|
||||
{
|
||||
encode: JSON.stringify,
|
||||
decode: JSON.parse,
|
||||
}
|
||||
);
|
10
src/stores/follows.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
|
||||
export const follows = persistentAtom('follows', [], {
|
||||
encode(value) {
|
||||
return JSON.stringify(value);
|
||||
},
|
||||
decode(value) {
|
||||
return JSON.parse(value);
|
||||
},
|
||||
});
|
25
src/stores/relays.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
|
||||
export const relays = persistentAtom(
|
||||
'relays',
|
||||
[
|
||||
'wss://relay.uselume.xyz',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://nostr.bongbong.com',
|
||||
'wss://nostr.zebedee.cloud',
|
||||
'wss://nostr.fmt.wiz.biz',
|
||||
'wss://nostr.walletofsatoshi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://offchain.pub',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.damus.io',
|
||||
],
|
||||
{
|
||||
encode(value) {
|
||||
return JSON.stringify(value);
|
||||
},
|
||||
decode(value) {
|
||||
return JSON.parse(value);
|
||||
},
|
||||
}
|
||||
);
|
13
src/utils/getDate.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
export const daysAgo = (numOfDays, date = new Date()) => {
|
||||
const daysAgo = new Date(date.getTime());
|
||||
daysAgo.setDate(date.getDate() - numOfDays);
|
||||
|
||||
return daysAgo;
|
||||
};
|
||||
|
||||
export const hoursAgo = (numOfHours, date = new Date()) => {
|
||||
const hoursAgo = new Date(date.getTime());
|
||||
hoursAgo.setHours(date.getHours() - numOfHours);
|
||||
|
||||
return hoursAgo;
|
||||
};
|