diff --git a/public/healthz.json b/public/healthz.json index 59dd86e..b191b11 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.2.0", - "buildTime": "2026-02-06T17:22:41.111Z", + "buildTime": "2026-02-06T17:29:48.880Z", "gitCommit": "unknown", - "timestamp": 1770398561111 + "timestamp": 1770398988880 } \ No newline at end of file diff --git a/src/lib/components/write/AdvancedEditor.svelte b/src/lib/components/write/AdvancedEditor.svelte index b4cf284..a94ea4e 100644 --- a/src/lib/components/write/AdvancedEditor.svelte +++ b/src/lib/components/write/AdvancedEditor.svelte @@ -10,6 +10,13 @@ import { StreamLanguage } from '@codemirror/language'; import { asciidoc } from 'codemirror-asciidoc'; import { oneDark } from '@codemirror/theme-one-dark'; + import { sessionManager } from '../../services/auth/session-manager.js'; + import { uploadFileToServer, buildImetaTag } from '../../services/nostr/file-upload.js'; + import GifPicker from '../content/GifPicker.svelte'; + import EmojiPicker from '../content/EmojiPicker.svelte'; + import MarkdownRenderer from '../content/MarkdownRenderer.svelte'; + import MediaAttachments from '../content/MediaAttachments.svelte'; + import type { NostrEvent } from '../../types/nostr.js'; interface Props { value: string; @@ -24,6 +31,21 @@ let editorView: EditorView | null = $state(null); let isDark = $state(false); let initialized = $state(false); + + // Toolbar state + let showGifPicker = $state(false); + let showEmojiPicker = $state(false); + let showPreviewModal = $state(false); + let showJsonModal = $state(false); + let fileInputRef: HTMLInputElement | null = $state(null); + let uploading = $state(false); + let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); + let previewContent = $state(''); + let previewEvent = $state(null); + let eventJson = $state('{}'); + + // Generate unique ID for file input + const fileInputId = `advanced-editor-file-upload-${Math.random().toString(36).substring(7)}`; // Check for dark mode preference onMount(() => { @@ -145,6 +167,583 @@ onClose(); } } + + // Insert text at cursor in CodeMirror (replaces selection if any) + function insertTextAtCursor(text: string, cursorOffset?: number) { + if (!editorView) return; + + const state = editorView.state; + const selection = state.selection.main; + const from = selection.from; + const to = selection.to; + const offset = cursorOffset !== undefined ? cursorOffset : text.length; + + editorView.dispatch({ + changes: { from, to, insert: text }, + selection: { anchor: from + offset } + }); + + editorView.focus(); + } + + function handleGifSelect(gifUrl: string) { + insertTextAtCursor(gifUrl); + showGifPicker = false; + } + + function handleEmojiSelect(emoji: string) { + insertTextAtCursor(emoji); + showEmojiPicker = false; + } + + function handleTagInsert() { + const tag = prompt('Enter tag name (without #):'); + if (tag && tag.trim()) { + insertTextAtCursor(`#${tag.trim()}`); + } + } + + function insertNostrIdentifier() { + const type = prompt('Nostr identifier type (npub, nprofile, nevent, naddr, note):', 'npub'); + if (!type) return; + + const identifier = prompt('Enter identifier (with or without nostr: prefix):', ''); + if (identifier && identifier.trim()) { + // Add nostr: prefix if not present + const prefixed = identifier.trim().startsWith('nostr:') + ? identifier.trim() + : `nostr:${identifier.trim()}`; + insertTextAtCursor(prefixed); + } + } + + // Get selected text or placeholder + function getSelectedText(): { text: string; from: number; to: number } { + if (!editorView) return { text: '', from: 0, to: 0 }; + + const state = editorView.state; + const selection = state.selection.main; + const from = selection.from; + const to = selection.to; + const text = state.doc.sliceString(from, to); + + return { text, from, to }; + } + + // Formatting functions for Markdown + function insertMarkdownHeader(level: number) { + const { text, from, to } = getSelectedText(); + if (text) { + // Wrap selected text in header + const headerPrefix = '#'.repeat(level) + ' '; + insertTextAtCursor(`${headerPrefix}${text}`); + } else { + // Insert header template + insertTextAtCursor(`${'#'.repeat(level)} Heading ${level}\n`); + } + } + + function insertMarkdownBold() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`*${text}*`); + } else { + const template = '**bold text**'; + insertTextAtCursor(template); + // Select "bold text" for easy replacement + if (editorView) { + const state = editorView.state; + const pos = state.selection.main.anchor; + editorView.dispatch({ + selection: { anchor: pos - 11, head: pos - 1 } + }); + } + } + } + + function insertMarkdownItalic() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`_${text}_`); + } else { + insertTextAtCursor('_italic text_'); + if (editorView) { + const state = editorView.state; + const pos = state.selection.main.anchor; + editorView.dispatch({ + selection: { anchor: pos - 12, head: pos - 1 } + }); + } + } + } + + function insertMarkdownStrikethrough() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`~${text}~`); + } else { + insertTextAtCursor('~strikethrough text~'); + if (editorView) { + const state = editorView.state; + const pos = state.selection.main.anchor; + editorView.dispatch({ + selection: { anchor: pos - 18, head: pos - 1 } + }); + } + } + } + + function insertMarkdownBlockquote() { + const { text, from, to } = getSelectedText(); + if (text) { + const lines = text.split('\n'); + const quoted = lines.map(line => `> ${line}`).join('\n'); + insertTextAtCursor(quoted); + } else { + insertTextAtCursor('> quoted text\n'); + } + } + + function insertMarkdownList(ordered: boolean) { + const { text, from, to } = getSelectedText(); + if (text) { + const lines = text.split('\n'); + const listItems = lines.map((line, i) => + ordered ? `${i + 1}. ${line}` : `* ${line}` + ).join('\n'); + insertTextAtCursor(listItems); + } else { + insertTextAtCursor(ordered ? '1. List item\n' : '* List item\n'); + } + } + + function insertMarkdownLink() { + const text = prompt('Link text:') || 'link text'; + const url = prompt('URL:') || 'https://example.com'; + insertTextAtCursor(`[${text}](${url})`); + } + + function insertMarkdownImage() { + const alt = prompt('Alt text:') || 'image'; + const url = prompt('Image URL:') || 'https://example.com/image.jpg'; + insertTextAtCursor(`![${alt}](${url})`); + } + + function insertMarkdownCodeBlock() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`\`\`\`\n${text}\n\`\`\``); + } else { + insertTextAtCursor('```\ncode here\n```'); + if (editorView) { + const state = editorView.state; + const pos = state.selection.main.anchor; + editorView.dispatch({ + selection: { anchor: pos - 9, head: pos - 1 } + }); + } + } + } + + function insertMarkdownInlineCode() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`\`${text}\``); + } else { + insertTextAtCursor('`code`'); + if (editorView) { + const state = editorView.state; + const pos = state.selection.main.anchor; + editorView.dispatch({ + selection: { anchor: pos - 5, head: pos - 1 } + }); + } + } + } + + function insertMarkdownTable() { + const table = `| Header 1 | Header 2 | Header 3 | +|----------|----------|----------| +| Cell 1 | Cell 2 | Cell 3 | +| Cell 4 | Cell 5 | Cell 6 |`; + insertTextAtCursor(table); + } + + function insertMarkdownHorizontalRule() { + insertTextAtCursor('\n---\n'); + } + + // Formatting functions for AsciiDoc + function insertAsciiDocHeader(level: number) { + const { text, from, to } = getSelectedText(); + if (text) { + const headerPrefix = '='.repeat(level) + ' '; + insertTextAtCursor(`${headerPrefix}${text}`); + } else { + insertTextAtCursor(`${'='.repeat(level)} Heading ${level}\n`); + } + } + + function insertAsciiDocBold() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`*${text}*`); + } else { + insertTextAtCursor('*bold text*'); + if (editorView) { + const state = editorView.state; + const pos = state.selection.main.anchor; + editorView.dispatch({ + selection: { anchor: pos - 10, head: pos - 1 } + }); + } + } + } + + function insertAsciiDocItalic() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`_${text}_`); + } else { + insertTextAtCursor('_italic text_'); + if (editorView) { + const state = editorView.state; + const pos = state.selection.main.anchor; + editorView.dispatch({ + selection: { anchor: pos - 12, head: pos - 1 } + }); + } + } + } + + function insertAsciiDocStrikethrough() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`[.line-through]#${text}#`); + } else { + insertTextAtCursor('[.line-through]#strikethrough text#'); + if (editorView) { + const state = editorView.state; + const pos = state.selection.main.anchor; + editorView.dispatch({ + selection: { anchor: pos - 19, head: pos - 1 } + }); + } + } + } + + function insertAsciiDocBlockquote() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`[quote]\n____\n${text}\n____`); + } else { + insertTextAtCursor('[quote]\n____\nQuoted text\n____'); + } + } + + function insertAsciiDocList(ordered: boolean) { + const { text, from, to } = getSelectedText(); + if (text) { + const lines = text.split('\n'); + const listItems = lines.map(line => + ordered ? `. ${line}` : `* ${line}` + ).join('\n'); + insertTextAtCursor(listItems); + } else { + insertTextAtCursor(ordered ? '. List item\n' : '* List item\n'); + } + } + + function insertAsciiDocLink() { + const text = prompt('Link text:') || 'link text'; + const url = prompt('URL:') || 'https://example.com'; + insertTextAtCursor(`link:${url}[${text}]`); + } + + function insertAsciiDocImage() { + const alt = prompt('Alt text:') || 'image'; + const url = prompt('Image URL:') || 'https://example.com/image.jpg'; + insertTextAtCursor(`image::${url}[${alt}]`); + } + + function insertAsciiDocCodeBlock() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`[source]\n----\n${text}\n----`); + } else { + insertTextAtCursor('[source]\n----\ncode here\n----'); + if (editorView) { + const state = editorView.state; + const pos = state.selection.main.anchor; + editorView.dispatch({ + selection: { anchor: pos - 9, head: pos - 1 } + }); + } + } + } + + function insertAsciiDocInlineCode() { + const { text, from, to } = getSelectedText(); + if (text) { + insertTextAtCursor(`\`${text}\``); + } else { + insertTextAtCursor('`code`'); + if (editorView) { + const state = editorView.state; + const pos = state.selection.main.anchor; + editorView.dispatch({ + selection: { anchor: pos - 5, head: pos - 1 } + }); + } + } + } + + function insertAsciiDocTable() { + const table = `[cols="1,1,1"] +|=== +|Header 1 |Header 2 |Header 3 + +|Cell 1 |Cell 2 |Cell 3 +|Cell 4 |Cell 5 |Cell 6 +|===`; + insertTextAtCursor(table); + } + + function insertAsciiDocHorizontalRule() { + insertTextAtCursor('\n---\n'); + } + + // Wrapper functions that choose based on mode + function insertHeader(level: number) { + if (mode === 'asciidoc') { + insertAsciiDocHeader(level); + } else { + insertMarkdownHeader(level); + } + } + + function insertBold() { + if (mode === 'asciidoc') { + insertAsciiDocBold(); + } else { + insertMarkdownBold(); + } + } + + function insertItalic() { + if (mode === 'asciidoc') { + insertAsciiDocItalic(); + } else { + insertMarkdownItalic(); + } + } + + function insertStrikethrough() { + if (mode === 'asciidoc') { + insertAsciiDocStrikethrough(); + } else { + insertMarkdownStrikethrough(); + } + } + + function insertBlockquote() { + if (mode === 'asciidoc') { + insertAsciiDocBlockquote(); + } else { + insertMarkdownBlockquote(); + } + } + + function insertList(ordered: boolean) { + if (mode === 'asciidoc') { + insertAsciiDocList(ordered); + } else { + insertMarkdownList(ordered); + } + } + + function insertLink() { + if (mode === 'asciidoc') { + insertAsciiDocLink(); + } else { + insertMarkdownLink(); + } + } + + function insertImage() { + if (mode === 'asciidoc') { + insertAsciiDocImage(); + } else { + insertMarkdownImage(); + } + } + + function insertCodeBlock() { + if (mode === 'asciidoc') { + insertAsciiDocCodeBlock(); + } else { + insertMarkdownCodeBlock(); + } + } + + function insertInlineCode() { + if (mode === 'asciidoc') { + insertAsciiDocInlineCode(); + } else { + insertMarkdownInlineCode(); + } + } + + function insertTable() { + if (mode === 'asciidoc') { + insertAsciiDocTable(); + } else { + insertMarkdownTable(); + } + } + + function insertHorizontalRule() { + if (mode === 'asciidoc') { + insertAsciiDocHorizontalRule(); + } else { + insertMarkdownHorizontalRule(); + } + } + + async function handleFileUpload(event: Event) { + const input = event.target as HTMLInputElement; + const files = input.files; + if (!files || files.length === 0) return; + + // Filter valid file types + const validFiles: File[] = []; + for (const file of Array.from(files)) { + const isImage = file.type.startsWith('image/'); + const isVideo = file.type.startsWith('video/'); + const isAudio = file.type.startsWith('audio/'); + + if (!isImage && !isVideo && !isAudio) { + alert(`${file.name} is not an image, video, or audio file`); + continue; + } + validFiles.push(file); + } + + if (validFiles.length === 0) { + return; + } + + if (!sessionManager.isLoggedIn()) { + alert('Please log in to upload files'); + return; + } + + uploading = true; + const uploadPromises: Promise[] = []; + + // Process all files + for (const file of validFiles) { + const uploadPromise = (async () => { + try { + // Upload file to media server + const uploadResult = await uploadFileToServer(file, 'AdvancedEditor'); + console.log(`[AdvancedEditor] Uploaded ${file.name} to ${uploadResult.url}`); + + // Build imeta tag from upload response (NIP-92 format) + const imetaTag = buildImetaTag(file, uploadResult); + + // Store file with imeta tag + uploadedFiles.push({ + url: uploadResult.url, + imetaTag + }); + + // Insert file URL into editor + insertTextAtCursor(`${uploadResult.url}\n`); + } catch (error) { + console.error(`[AdvancedEditor] File upload failed for ${file.name}:`, error); + const errorMessage = error instanceof Error ? error.message : String(error); + alert(`Failed to upload ${file.name}: ${errorMessage}`); + } + })(); + + uploadPromises.push(uploadPromise); + } + + // Wait for all uploads to complete + try { + await Promise.all(uploadPromises); + } finally { + uploading = false; + // Reset file input + if (fileInputRef) { + fileInputRef.value = ''; + } + } + } + + async function handlePreview() { + if (!editorView) return; + + const content = editorView.state.doc.toString(); + let contentWithUrls = content.trim(); + + // Add uploaded file URLs if not already in content + for (const file of uploadedFiles) { + if (!contentWithUrls.includes(file.url)) { + if (contentWithUrls && !contentWithUrls.endsWith('\n')) { + contentWithUrls += '\n'; + } + contentWithUrls += `${file.url}\n`; + } + } + + // Process content to add "nostr:" prefix + const { processNostrLinks } = await import('../../utils/nostr-link-processor.js'); + previewContent = processNostrLinks(contentWithUrls.trim()); + + // Build preview event + const previewTags: string[][] = []; + for (const file of uploadedFiles) { + previewTags.push(file.imetaTag); + } + + previewEvent = { + kind: 1, + pubkey: sessionManager.getCurrentPubkey() || '', + created_at: Math.floor(Date.now() / 1000), + tags: previewTags, + content: previewContent, + id: '', + sig: '' + } as NostrEvent; + + showPreviewModal = true; + } + + async function handleJsonView() { + if (!editorView) return; + + const content = editorView.state.doc.toString(); + const tags: string[][] = []; + + for (const file of uploadedFiles) { + tags.push(file.imetaTag); + } + + const event = { + kind: 1, + pubkey: sessionManager.getCurrentPubkey() || '', + created_at: Math.floor(Date.now() / 1000), + tags, + content: content.trim(), + id: '', + sig: '' + }; + + eventJson = JSON.stringify(event, null, 2); + showJsonModal = true; + }
+
+ +
+ + + +
+ +
+ +
+ + + +
+ +
+ +
+ + + +
+ +
+ +
+ + + + + + +
+ +
+ + +
+ + + + + + +
+ +
+ + +
+ + +
+
@@ -177,6 +1020,90 @@ + showGifPicker = false} /> + showEmojiPicker = false} /> + + +{#if showPreviewModal} + +{/if} + + +{#if showJsonModal} + +{/if} + diff --git a/src/lib/components/write/CreateEventForm.svelte b/src/lib/components/write/CreateEventForm.svelte index b44d641..419d52d 100644 --- a/src/lib/components/write/CreateEventForm.svelte +++ b/src/lib/components/write/CreateEventForm.svelte @@ -93,6 +93,8 @@ let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); let showJsonModal = $state(false); let showPreviewModal = $state(false); + let previewContent = $state(''); + let previewEvent = $state(null); let showExampleModal = $state(false); let showAdvancedEditor = $state(false); let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null); @@ -228,12 +230,16 @@ allTags.push(['client', 'aitherboard']); } + // Process content to add "nostr:" prefix to valid Nostr addresses + const { processNostrLinks } = await import('../../utils/nostr-link-processor.js'); + const processedContent = processNostrLinks(contentWithUrls.trim()); + const event: Omit = { kind: effectiveKind, pubkey: session.pubkey, created_at: Math.floor(Date.now() / 1000), tags: allTags, - content: contentWithUrls.trim() + content: processedContent }; return JSON.stringify(event, null, 2); @@ -527,7 +533,66 @@