chore: clean up

This commit is contained in:
2024-01-03 11:03:56 +07:00
parent 698f5a5d6d
commit 9f27d68533
29 changed files with 322 additions and 1944 deletions

View File

@@ -14,7 +14,7 @@
}
.prose :where(iframe):not(:where([class~='not-prose'] *)) {
@apply mx-auto aspect-video h-auto w-full;
@apply w-full h-auto mx-auto aspect-video;
}
}
@@ -42,11 +42,3 @@ input::-ms-clear {
.border {
background-clip: padding-box;
}
.ProseMirror p.is-empty::before {
@apply pointer-events-none float-left h-0 text-neutral-600 content-[attr(data-placeholder)] dark:text-neutral-400;
}
.ProseMirror img.ProseMirror-selectednode {
@apply outline-blue-500;
}

View File

@@ -1,12 +1,6 @@
import { useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import {
AppLayout,
AuthLayout,
ComposerLayout,
HomeLayout,
SettingsLayout,
} from "@lume/ui";
import { AppLayout, AuthLayout, HomeLayout, SettingsLayout } from "@lume/ui";
import { fetch } from "@tauri-apps/plugin-http";
import {
RouterProvider,
@@ -70,44 +64,6 @@ export default function App() {
return { Component: RelayScreen };
},
},
{
path: "new",
element: <ComposerLayout />,
children: [
{
index: true,
async lazy() {
const { NewPostScreen } = await import("./routes/new/post");
return { Component: NewPostScreen };
},
},
{
path: "article",
async lazy() {
const { NewArticleScreen } = await import(
"./routes/new/article"
);
return { Component: NewArticleScreen };
},
},
{
path: "file",
async lazy() {
const { NewFileScreen } = await import("./routes/new/file");
return { Component: NewFileScreen };
},
},
{
path: "privkey",
async lazy() {
const { NewPrivkeyScreen } = await import(
"./routes/new/privkey"
);
return { Component: NewPrivkeyScreen };
},
},
],
},
{
path: "settings",
element: <SettingsLayout />,
@@ -304,8 +260,8 @@ export default function App() {
<RouterProvider
router={router}
fallbackElement={
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-6 w-6 animate-spin" />
<div className="flex items-center justify-center w-full h-full">
<LoaderIcon className="w-6 h-6 animate-spin" />
</div>
}
future={{ v7_startTransition: true }}

View File

@@ -84,7 +84,7 @@ export function HomeScreen() {
{columns.map((column) => renderItem(column))}
</VList>
<div className="absolute bottom-3 right-3">
<div className="flex items-center gap-1 p-1 bg-black/50 backdrop-blur-xl rounded-xl">
<div className="flex items-center gap-1 p-1 bg-black/50 dark:bg-white/30 backdrop-blur-xl rounded-xl">
<button
type="button"
onClick={() => {

View File

@@ -1,314 +0,0 @@
import { useArk } from "@lume/ark";
import {
BoldIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ItalicIcon,
LoaderIcon,
ThreadsIcon,
} from "@lume/icons";
import { NDKKind, NDKTag } from "@nostr-dev-kit/ndk";
import CharacterCount from "@tiptap/extension-character-count";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, FloatingMenu, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { twMerge } from "tailwind-merge";
import { Markdown } from "tiptap-markdown";
import {
ArticleCoverUploader,
MediaUploader,
MentionPopup,
} from "./components";
export function NewArticleScreen() {
const ark = useArk();
const [height, setHeight] = useState(0);
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
const [summary, setSummary] = useState({ open: false, content: "" });
const [cover, setCover] = useState("");
const navigate = useNavigate();
const containerRef = useRef(null);
const ident = useMemo(() => String(Date.now()), []);
const editor = useEditor({
extensions: [
StarterKit.configure(),
Placeholder.configure({ placeholder: "Type something..." }),
Image.configure({
HTMLAttributes: {
class:
"rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700",
},
}),
CharacterCount.configure(),
Markdown.configure({
html: false,
tightLists: true,
linkify: true,
transformPastedText: true,
}),
],
content: JSON.parse(localStorage.getItem("editor-post") || "{}"),
editorProps: {
attributes: {
class:
"outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500",
},
},
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem("editor-article", jsonContent);
},
});
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setLoading(true);
// get markdown content
const content = editor.storage.markdown.getMarkdown();
// define tags
const tags: NDKTag[] = [
["d", ident],
["title", title],
["image", cover],
["summary", summary.content],
["published_at", String(Math.floor(Date.now() / 1000))],
];
// add hashtag to tags if present
const hashtags = content
.split(/\s/gm)
.filter((s: string) => s.startsWith("#"));
if (hashtags) {
for (const tag of hashtags) {
tags.push(["t", tag.replace("#", "")]);
}
}
// publish
const publish = await ark.createEvent({
content,
tags,
kind: NDKKind.Article,
});
if (publish) {
toast.success(
`Broadcasted to ${publish.seens.length} relays successfully.`,
);
// update state
setLoading(false);
// reset editor
editor.commands.clearContent();
localStorage.setItem("editor-article", "{}");
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
return (
<div className="flex flex-1 flex-col justify-between">
<div className="flex-1 overflow-y-auto">
<div
className="flex flex-col gap-4"
ref={containerRef}
style={{ height: `${height}px` }}
>
{cover ? (
<img
src={cover}
alt="post cover"
className="h-72 w-full rounded-lg object-cover"
/>
) : null}
<div className="group flex justify-between gap-2">
<input
name="title"
className="h-9 flex-1 border-none bg-transparent px-0 text-2xl font-semibold text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 focus:border-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="Untitled"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<div
className={twMerge(
"inline-flex shrink-0 gap-2 group-hover:inline-flex",
title.length > 0 ? "" : "hidden",
)}
>
<ArticleCoverUploader setCover={setCover} />
<button
type="button"
onClick={() =>
setSummary((prev) => ({ ...prev, open: !prev.open }))
}
className="inline-flex h-9 w-max items-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
<ThreadsIcon className="h-4 w-4" />
Add summary
</button>
</div>
</div>
{summary.open ? (
<div className="flex gap-3">
<div className="h-16 w-1 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
<div className="flex-1">
<textarea
className="h-16 w-full border-none bg-transparent px-1 py-1 text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="A brief summary of your article"
value={summary.content}
onChange={(e) =>
setSummary((prev) => ({ ...prev, content: e.target.value }))
}
/>
</div>
</div>
) : null}
<div>
{editor && (
<FloatingMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="ml-36 inline-flex h-10 items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-100 px-px dark:border-neutral-800 dark:bg-neutral-900"
>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("heading", { level: 1 })
? "bg-white shadow dark:bg-black"
: "",
)}
>
<Heading1Icon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("heading", { level: 2 })
? "bg-white shadow dark:bg-black"
: "",
)}
>
<Heading2Icon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("heading", { level: 3 })
? "bg-white shadow dark:bg-black"
: "",
)}
>
<Heading3Icon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("bold")
? "bg-white shadow dark:bg-black"
: "",
)}
>
<BoldIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("italic")
? "bg-white shadow dark:bg-black"
: "",
)}
>
<ItalicIcon className="h-5 w-5" />
</button>
</FloatingMenu>
)}
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</div>
</div>
</div>
<div>
<div className="mb-3 flex h-12 w-full items-center rounded-lg bg-yellow-100 px-3 text-yellow-700">
<p className="text-sm">
Article editor is still in beta. If you need a stable and more
reliable feature, you can use <b>Habla (habla.news)</b> instead.
</p>
</div>
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
<div className="inline-flex items-center gap-3">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} characters
</span>
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
-
</span>
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
<b>Identifier:</b>
{ident}
</span>
</div>
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={() => submit()}
disabled={editor?.isEmpty}
className="inline-flex h-9 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading === true ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
"Publish article"
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,86 +0,0 @@
import { ImageIcon, LoaderIcon } from "@lume/icons";
import { message, open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { useState } from "react";
export function ArticleCoverUploader({ setCover }) {
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: "Media",
extensions: [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
],
},
],
});
if (!selected) {
setLoading(false);
return;
}
const file = await readFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append("fileToUpload", blob);
data.append("submit", "Upload Image");
const res = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: data,
});
if (res.ok) {
const json = await res.json();
const content = json.data[0];
setCover(content.url);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
return (
<button
type="button"
onClick={uploadToNostrBuild}
className="inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ImageIcon className="h-4 w-4" />
Add cover
</>
)}
</button>
);
}

View File

@@ -1,5 +0,0 @@
export * from './articleCoverUploader';
export * from './mediaUploader';
export * from './mentionPopup';
export * from './mentionPopupItem';
export * from './mentionList';

View File

@@ -1,46 +0,0 @@
import { useArk } from "@lume/ark";
import { MediaIcon } from "@lume/icons";
import { message } from "@tauri-apps/plugin-dialog";
import { Editor } from "@tiptap/react";
import { useState } from "react";
export function MediaUploader({ editor }: { editor: Editor }) {
const ark = useArk();
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const image = await ark.upload({
fileExts: ["mp4", "mp3", "webm", "mkv", "avi", "mov"],
});
if (image) {
editor.commands.setImage({ src: image });
editor.commands.createParagraphNear();
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
return (
<button
type="button"
onClick={() => uploadToNostrBuild()}
className="inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-sm font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<MediaIcon className="h-5 w-5" />
{loading ? "Uploading..." : "Add media"}
</button>
);
}

View File

@@ -1,115 +0,0 @@
import { NDKCacheUserProfile } from "@lume/types";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import {
Ref,
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { twMerge } from "tailwind-merge";
type MentionListRef = {
onKeyDown: (props: { event: Event }) => boolean;
};
const List = (
props: {
items: NDKCacheUserProfile[];
command: (arg0: { id: string }) => void;
},
ref: Ref<unknown>,
) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item.pubkey });
}
};
const upHandler = () => {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length,
);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex w-[200px] flex-col overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 shadow-lg shadow-neutral-500/20 dark:border-neutral-800 dark:bg-neutral-950 dark:shadow-neutral-300/50">
{props.items.length ? (
props.items.map((item, index) => (
<button
type="button"
key={item.pubkey}
onClick={() => selectItem(index)}
className={twMerge(
"inline-flex h-11 items-center gap-2 rounded-md px-2",
index === selectedIndex
? "bg-neutral-100 dark:bg-neutral-900"
: "",
)}
>
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={item.image}
alt={item.name}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md"
/>
<Avatar.Fallback delayMs={150}>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(item.name, 90, 50),
)}`}
alt={item.name}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<h5 className="max-w-[150px] truncate text-sm font-medium">
{item.name}
</h5>
</button>
))
) : (
<div className="text-center text-sm font-medium">No result</div>
)}
</div>
);
};
export const MentionList = forwardRef<MentionListRef>(List);

View File

@@ -1,51 +0,0 @@
import { useStorage } from "@lume/ark";
import { MentionIcon } from "@lume/icons";
import * as Popover from "@radix-ui/react-popover";
import { Editor } from "@tiptap/react";
import { nip19 } from "nostr-tools";
import { MentionPopupItem } from "./mentionPopupItem";
export function MentionPopup({ editor }: { editor: Editor }) {
const storage = useStorage();
const insertMention = (pubkey: string) => {
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-sm font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<MentionIcon className="h-5 w-5" />
Mention
</button>
</Popover.Trigger>
<Popover.Content
side="top"
sideOffset={5}
className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900"
>
<div className="flex flex-col gap-1 py-1">
{storage.account.contacts.length ? (
storage.account.contacts.map((item) => (
<button
key={item}
type="button"
onClick={() => insertMention(item)}
>
<MentionPopupItem pubkey={item} />
</button>
))
) : (
<div className="flex h-16 items-center justify-center">
Contact list is empty
</div>
)}
</div>
</Popover.Content>
</Popover.Root>
);
}

View File

@@ -1,57 +0,0 @@
import { useProfile } from "@lume/ark";
import { displayNpub } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import { useMemo } from "react";
export function MentionPopupItem({ pubkey }: { pubkey: string }) {
const { isLoading, user } = useProfile(pubkey);
const svgURI = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(pubkey, 90, 50),
)}`,
[pubkey],
);
if (isLoading) {
return (
<div className="flex items-center gap-2.5 px-2">
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
<span className="h-3 w-1/3 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
</div>
</div>
);
}
return (
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800">
<Avatar.Root className="shirnk-0 h-8 w-8">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col items-start gap-px">
<h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-neutral-900 dark:text-neutral-100">
{user?.display_name || user?.displayName || user?.name}
</h5>
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
{displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}

View File

@@ -1,184 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { message, open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function NewFileScreen() {
const ark = useArk();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [isPublish, setIsPublish] = useState(false);
const [metadata, setMetadata] = useState<string[][] | null>(null);
const [caption, setCaption] = useState("");
const uploadFile = async () => {
try {
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: "Media",
extensions: [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
],
},
],
});
if (!selected) {
setLoading(false);
return;
}
const file = await readFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append("fileToUpload", blob);
data.append("submit", "Upload Image");
const res = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: data,
});
if (res.ok) {
const json = await res.json();
const data = json.data[0];
setMetadata([
["url", data.url],
["m", data.mime ?? "application/octet-stream"],
["x", data.sha256 ?? ""],
["size", data.size.toString() ?? "0"],
["dim", `${data.dimensions.width}x${data.dimensions.height}` ?? "0"],
["blurhash", data.blurhash ?? ""],
["thumb", data.thumbnail ?? ""],
]);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setIsPublish(true);
const publish = await ark.createEvent({
kind: 1063,
tags: metadata,
content: caption,
});
if (publish) {
toast.success(
`Broadcasted to ${publish.seens.length} relays successfully.`,
);
setMetadata(null);
setIsPublish(false);
}
} catch (e) {
setIsPublish(false);
toast.error(e);
}
};
return (
<div className="h-full">
<div className="flex h-96 gap-4 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<button
type="button"
onClick={uploadFile}
className="flex h-full flex-1 flex-col items-center justify-center rounded-lg border border-dashed border-neutral-200 bg-neutral-50 p-2 hover:border-blue-500 hover:text-blue-500 dark:border-neutral-800 dark:bg-neutral-950"
>
{loading ? (
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
) : !metadata ? (
<div className="flex flex-col text-center">
<h5 className="text-lg font-semibold">
Click or drag a file to this area to upload
</h5>
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
Supports: jpg, png, webp, gif, mov, mp4 or mp3
</p>
</div>
) : (
<div>
<img
src={metadata[0][1]}
alt={metadata[1][1]}
className="aspect-square h-full w-full rounded-lg object-cover shadow-lg"
/>
</div>
)}
</button>
{metadata ? (
<div className="flex h-full flex-1 flex-col justify-between">
<div className="flex flex-col gap-2 py-2">
{metadata.map((item, index) => (
<div key={item[0] + index} className="flex min-w-0 gap-2">
<h5 className="w-24 shrink-0 truncate font-semibold capitalize text-neutral-600 dark:text-neutral-400">
{item[0]}
</h5>
<p className="w-72 truncate">{item[1]}</p>
</div>
))}
</div>
<div className="flex flex-col gap-2">
<input
name="caption"
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Caption (Optional)..."
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-900 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={submit}
disabled={!metadata}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{isPublish ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
"Share"
)}
</button>
</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -1,173 +0,0 @@
import { MentionNote, useArk, useSuggestion, useWidget } from "@lume/ark";
import { CancelIcon, LoaderIcon } from "@lume/icons";
import { COL_TYPES } from "@lume/utils";
import { NDKKind } from "@nostr-dev-kit/ndk";
import CharacterCount from "@tiptap/extension-character-count";
import Image from "@tiptap/extension-image";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { convert } from "html-to-text";
import { nip19 } from "nostr-tools";
import { useLayoutEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { MediaUploader, MentionPopup } from "./components";
export function NewPostScreen() {
const ark = useArk();
const { addWidget } = useWidget();
const { suggestion } = useSuggestion();
const [loading, setLoading] = useState(false);
const [height, setHeight] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const containerRef = useRef(null);
const editor = useEditor({
extensions: [
StarterKit.configure(),
Placeholder.configure({ placeholder: "Sharing some thoughts..." }),
Image.configure({
HTMLAttributes: {
class:
"rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700",
},
}),
CharacterCount.configure(),
Mention.configure({
suggestion,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
renderLabel({ options, node }) {
const npub = nip19.npubEncode(node.attrs.id);
return `nostr:${npub}`;
},
}),
],
content: JSON.parse(localStorage.getItem("editor-post") || "{}"),
editorProps: {
attributes: {
class:
"outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500",
},
},
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem("editor-post", jsonContent);
},
});
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setLoading(true);
// get plaintext content
const html = editor.getHTML();
const serializedContent = convert(html, {
selectors: [
{ selector: "a", options: { linkBrackets: false } },
{ selector: "img", options: { linkBrackets: false } },
],
});
// add reply to tags if present
const replyTo = searchParams.get("replyTo");
const rootReplyTo = searchParams.get("rootReplyTo");
// publish event
const publish = await ark.createEvent({
kind: NDKKind.Text,
tags: [],
content: serializedContent,
replyTo,
rootReplyTo,
});
if (publish) {
toast.success(
`Broadcasted to ${publish.seens.length} relays successfully.`,
);
// update state
setLoading(false);
setSearchParams({});
// open new widget with this event id
if (!replyTo) {
addWidget.mutate({
title: "Thread",
content: publish.id,
kind: COL_TYPES.thread,
});
}
// reset editor
editor.commands.clearContent();
localStorage.setItem("editor-post", "{}");
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
return (
<div className="flex h-[500px] flex-1 flex-col gap-4">
<div className="flex-1 overflow-y-auto">
<div ref={containerRef} style={{ height: `${height}px` }}>
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{searchParams.get("replyTo") && (
<div className="relative max-w-lg">
<MentionNote eventId={searchParams.get("replyTo")} />
<button
type="button"
onClick={() => setSearchParams({})}
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-200 px-2 dark:bg-neutral-800"
>
<CancelIcon className="h-5 w-5" />
</button>
</div>
)}
</div>
</div>
<div className="inline-flex h-16 w-full items-center justify-between border-t border-neutral-100 bg-neutral-50 dark:border-neutral-900 dark:bg-neutral-950">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} characters
</span>
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={() => submit()}
disabled={editor?.isEmpty}
className="inline-flex h-9 w-20 items-center justify-center rounded-lg bg-blue-500 px-2 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading === true ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
"Post"
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,81 +0,0 @@
import { useArk, useStorage } from "@lume/ark";
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { getPublicKey, nip19 } from "nostr-tools";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function NewPrivkeyScreen() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [nsec, setNsec] = useState("");
const submit = async (isSave?: boolean) => {
try {
if (!nsec.startsWith("nsec1"))
return toast.info("You must enter a private key starts with nsec");
const decoded = nip19.decode(nsec);
if (decoded.type !== "nsec")
return toast.info("You must enter a valid nsec");
const privkey = decoded.data;
const pubkey = getPublicKey(privkey);
if (pubkey !== storage.account.pubkey)
return toast.info(
"Your nsec is not match your current public key, please make sure you enter right nsec",
);
const signer = new NDKPrivateKeySigner(privkey);
ark.updateNostrSigner({ signer });
if (isSave) await storage.createPrivkey(storage.account.pubkey, privkey);
navigate(-1);
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mb-16 flex flex-col gap-3">
<h1 className="text-center font-semibold text-neutral-900 dark:text-neutral-100">
You need to provide private key to sign nostr event.
</h1>
<input
name="privkey"
placeholder="nsec..."
type="password"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<div className="mt-2 flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Submit
</button>
<button
type="button"
onClick={() => submit(true)}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Submit and Save
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { useArk, useStorage } from "@lume/ark";
import { CancelIcon, RefreshIcon } from "@lume/icons";
import { useRelay } from "@lume/utils";
import { NDKKind } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query";
import { RelayForm } from "./relayForm";
@@ -9,7 +8,6 @@ export function UserRelayList() {
const ark = useArk();
const storage = useStorage();
const { removeRelay } = useRelay();
const { status, data, refetch } = useQuery({
queryKey: ["relays", storage.account.pubkey],
queryFn: async () => {
@@ -32,21 +30,21 @@ export function UserRelayList() {
return (
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<div className="inline-flex items-center justify-between w-full h-16 px-3 border-b border-neutral-100 dark:border-neutral-900">
<h3 className="font-semibold">Connected relays</h3>
<button
type="button"
onClick={() => refetch()}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
className="inline-flex items-center justify-center w-6 h-6 rounded-md shrink-0 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<RefreshIcon className="h-4 w-4" />
<RefreshIcon className="w-4 h-4" />
</button>
</div>
<div className="mt-3 flex flex-col gap-2 px-3">
<div className="flex flex-col gap-2 px-3 mt-3">
{status === "pending" ? (
<p>Loading...</p>
) : !data.length ? (
<div className="flex h-20 w-full items-center justify-center rounded-xl bg-neutral-50 dark:bg-neutral-950">
<div className="flex items-center justify-center w-full h-20 rounded-xl bg-neutral-50 dark:bg-neutral-950">
<p className="text-sm font-medium">
You not have personal relay list yet
</p>
@@ -55,18 +53,18 @@ export function UserRelayList() {
data.map((item) => (
<div
key={item[1]}
className="group flex h-11 items-center justify-between rounded-lg bg-neutral-100 px-3 dark:bg-neutral-900"
className="flex items-center justify-between px-3 rounded-lg group h-11 bg-neutral-100 dark:bg-neutral-900"
>
<div className="inline-flex items-baseline gap-2">
{currentRelays.has(item[1]) ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500" />
<span className="relative flex w-2 h-2">
<span className="absolute inline-flex w-full h-full bg-green-400 rounded-full opacity-75 animate-ping" />
<span className="relative inline-flex w-2 h-2 bg-teal-500 rounded-full" />
</span>
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
<span className="relative flex w-2 h-2">
<span className="absolute inline-flex w-full h-full bg-red-400 rounded-full opacity-75 animate-ping" />
<span className="relative inline-flex w-2 h-2 bg-red-500 rounded-full" />
</span>
)}
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
@@ -75,16 +73,15 @@ export function UserRelayList() {
</div>
<div className="inline-flex items-center gap-2">
{item[2]?.length ? (
<div className="inline-flex h-6 w-max items-center justify-center rounded bg-neutral-200 px-2 text-xs font-medium capitalize dark:bg-neutral-800">
<div className="inline-flex items-center justify-center h-6 px-2 text-xs font-medium capitalize rounded w-max bg-neutral-200 dark:bg-neutral-800">
{item[2]}
</div>
) : null}
<button
type="button"
onClick={() => removeRelay.mutate(item[1])}
className="hidden h-6 w-6 items-center justify-center rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
className="items-center justify-center hidden w-6 h-6 rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<CancelIcon className="h-4 w-4 text-neutral-900 dark:text-neutral-100" />
<CancelIcon className="w-4 h-4 text-neutral-900 dark:text-neutral-100" />
</button>
</div>
</div>