wip: new composer

This commit is contained in:
2023-10-22 15:48:06 +07:00
parent cade8c8b4c
commit b1a44f2cbf
15 changed files with 675 additions and 116 deletions

View File

@@ -0,0 +1,124 @@
import { NDKEvent, NDKKind } 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, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text';
import { useState } from 'react';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { MediaUploader, MentionPopup } from '@shared/composer';
import { LoaderIcon } from '@shared/icons';
export function ArticleEditor() {
const { ndk } = useNDK();
const [loading, setLoading] = useState(false);
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(),
],
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 {
setLoading(true);
// get plaintext content
const html = editor.getHTML();
const serializedContent = convert(html, {
selectors: [
{ selector: 'a', options: { linkBrackets: false } },
{ selector: 'img', options: { linkBrackets: false } },
],
});
// define tags
const tags: string[][] = [];
// add hashtag to tags if present
const hashtags = serializedContent
.split(/\s/gm)
.filter((s: string) => s.startsWith('#'));
hashtags?.forEach((tag: string) => {
tags.push(['t', tag.replace('#', '')]);
});
// publish message
const event = new NDKEvent(ndk);
event.content = serializedContent;
event.kind = NDKKind.Article;
event.tags = tags;
const publishedRelays = await event.publish();
if (publishedRelays) {
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
// update state
setLoading(false);
// reset editor
editor.commands.clearContent();
localStorage.setItem('editor-post', '{}');
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="flex h-full flex-col justify-between">
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.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
onClick={() => submit()}
disabled={editor && 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>
);
}

View File

@@ -0,0 +1,162 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { message, open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { useState } from 'react';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon } from '@shared/icons';
export function FileEditor() {
const { ndk } = useNDK();
const [loading, setLoading] = useState(false);
const [isPublish, setIsPublish] = useState(false);
const [metadata, setMetadata] = useState(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 readBinaryFile(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 {
setIsPublish(true);
const event = new NDKEvent(ndk);
event.content = caption;
event.kind = 1063;
event.tags = metadata;
const publishedRelays = await event.publish();
if (publishedRelays) {
setMetadata(null);
setIsPublish(false);
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
}
} catch (e) {
setIsPublish(false);
toast.error(e);
}
};
return (
<div className="h-full">
<div className="flex flex-col gap-4">
<button
type="button"
onClick={uploadFile}
className="flex h-72 w-full flex-col items-center justify-center rounded-xl border-2 border-dashed border-neutral-200 bg-neutral-100 hover:border-blue-500 hover:text-blue-500 dark:border-neutral-800 dark:bg-neutral-900"
>
{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="h-56 w-56 rounded-lg object-cover shadow-lg"
/>
</div>
)}
</button>
<div className="mx-auto w-full max-w-sm">
<div className="inline-flex w-full items-center 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 flex-1 rounded-lg bg-neutral-100 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-11 w-20 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>
</div>
</div>
);
}

View File

@@ -1,25 +1,39 @@
import { NDKEvent, NDKKind } 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, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text';
import { useState } from 'react';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { MediaUploader, MentionPopup } from '@shared/composer';
import { LoaderIcon } from '@shared/icons';
export function PostEditor() {
const { ndk } = useNDK();
const [loading, setLoading] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure(),
Placeholder.configure({ placeholder: 'Type something...' }),
Placeholder.configure({ placeholder: 'Sharing some thoughts...' }),
Image.configure({
HTMLAttributes: {
class:
'rounded-lg w-full h-auto border border-white/10 outline outline-2 outline-offset-0 outline-white/20 ml-1',
'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(),
],
content: JSON.parse(localStorage.getItem('editor-post') || '{}'),
editorProps: {
attributes: {
class:
'h-full 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',
'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 }) => {
@@ -28,13 +42,79 @@ export function PostEditor() {
},
});
const submit = async () => {
try {
setLoading(true);
// get plaintext content
const html = editor.getHTML();
const serializedContent = convert(html, {
selectors: [
{ selector: 'a', options: { linkBrackets: false } },
{ selector: 'img', options: { linkBrackets: false } },
],
});
// define tags
const tags: string[][] = [];
// add hashtag to tags if present
const hashtags = serializedContent
.split(/\s/gm)
.filter((s: string) => s.startsWith('#'));
hashtags?.forEach((tag: string) => {
tags.push(['t', tag.replace('#', '')]);
});
// publish message
const event = new NDKEvent(ndk);
event.content = serializedContent;
event.kind = NDKKind.Text;
event.tags = tags;
const publishedRelays = await event.publish();
if (publishedRelays) {
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
// update state
setLoading(false);
// reset editor
editor.commands.clearContent();
localStorage.setItem('editor-post', '{}');
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
<div className="flex h-full flex-col justify-between">
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.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
onClick={() => submit()}
disabled={editor && 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 +1,3 @@
export * from './editor/post';
export * from './editor/article';
export * from './editor/file';

View File

@@ -2,73 +2,84 @@ import { useState } from 'react';
import { Link } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { PostEditor } from '@app/notes/components';
import { ArticleEditor, FileEditor, PostEditor } from '@app/notes/components';
import { ArrowLeftIcon } from '@shared/icons';
export function NewNoteScreen() {
const [type, setType] = useState<'post' | 'article' | 'file' | 'raw'>('post');
const renderEditor = () => {
switch (type) {
case 'post':
return <PostEditor />;
case 'article':
return <ArticleEditor />;
case 'file':
return <FileEditor />;
default:
return null;
}
};
return (
<div className="h-full w-full pt-4">
<div className="container mx-auto grid grid-cols-8 px-4">
<div className="col-span-1">
<Link
to="/"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900"
>
<ArrowLeftIcon className="h-5 w-5" />
</Link>
</div>
<div className="col-span-6">
<div className="mb-8 flex items-center gap-3">
<div className="flex h-10 items-center gap-2 rounded-lg bg-neutral-100 px-0.5 dark:bg-neutral-800">
<button
type="button"
onClick={() => setType('post')}
className={twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
type === 'post' ? 'bg-white shadow' : 'bg-transparent'
)}
>
Post
</button>
<button
type="button"
onClick={() => setType('article')}
className={twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
type === 'article' ? 'bg-white shadow' : 'bg-transparent'
)}
>
Article
</button>
<button
type="button"
onClick={() => setType('file')}
className={twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
type === 'file' ? 'bg-white shadow' : 'bg-transparent'
)}
>
File
</button>
<button
type="button"
onClick={() => setType('raw')}
className={twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
type === 'file' ? 'bg-white shadow' : 'bg-transparent'
)}
>
Raw (advance)
</button>
</div>
</div>
{type === 'post' ? <PostEditor /> : null}
</div>
<div className="col-span-1" />
<div className="container mx-auto grid grid-cols-8 px-4">
<div className="col-span-1">
<Link
to="/"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900"
>
<ArrowLeftIcon className="h-5 w-5" />
</Link>
</div>
<div className="relative col-span-6 flex flex-col">
<div className="mb-8 flex h-10 shrink-0 items-center gap-3">
<div className="flex h-10 items-center gap-2 rounded-lg bg-neutral-100 px-0.5 dark:bg-neutral-800">
<button
type="button"
onClick={() => setType('post')}
className={twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
type === 'post' ? 'bg-white shadow' : 'bg-transparent'
)}
>
Post
</button>
<button
type="button"
onClick={() => setType('article')}
className={twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
type === 'article' ? 'bg-white shadow' : 'bg-transparent'
)}
>
Article
</button>
<button
type="button"
onClick={() => setType('file')}
className={twMerge(
'inline-flex h-9 w-28 items-center justify-center rounded-lg text-sm font-medium',
type === 'file' ? 'bg-white shadow' : 'bg-transparent'
)}
>
File Sharing
</button>
<button
type="button"
onClick={() => setType('raw')}
className={twMerge(
'inline-flex h-9 w-32 items-center justify-center rounded-lg text-sm font-medium',
type === 'raw' ? 'bg-white shadow' : 'bg-transparent'
)}
>
Raw (advance)
</button>
</div>
</div>
<div className="h-full min-h-0 w-full">{renderEditor()}</div>
</div>
<div className="col-span-1" />
</div>
);
}