@ -1,11 +1,17 @@
@@ -1,11 +1,17 @@
< script lang = "ts" >
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { signAndPublish , signHttpAuth } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte';
import MarkdownRenderer from '../content/MarkdownRenderer.svelte';
import GifPicker from '../content/GifPicker.svelte';
import EmojiPicker from '../content/EmojiPicker.svelte';
import { insertTextAtCursor } from '../../services/text-utils.js';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
import { goto } from '$app/navigation';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
const SUPPORTED_KINDS = [
{ value : 1 , label : '1 - Short Text Note' } ,
@ -38,6 +44,14 @@
@@ -38,6 +44,14 @@
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state< { success : string []; failed : Array < { relay : string ; error : string } > } | null>(null);
let showJsonModal = $state(false);
let showPreviewModal = $state(false);
let showGifPicker = $state(false);
let showEmojiPicker = $state(false);
let textareaRef: HTMLTextAreaElement | null = $state(null);
let fileInputRef: HTMLInputElement | null = $state(null);
let uploading = $state(false);
let uploadedFiles: Array< { url : string ; imetaTag : string [] } > = $state([]);
// Sync selectedKind when initialKind prop changes
$effect(() => {
@ -386,6 +400,207 @@
@@ -386,6 +400,207 @@
tags = newTags;
}
function getEventJson(): string {
const session = sessionManager.getSession();
if (!session) return '{} ';
// Add file attachments as imeta tags (like jumble)
let contentWithUrls = content.trim();
const allTags = [...tags.filter(t => t[0] & & t[1])];
for (const file of uploadedFiles) {
// Use imeta tag from upload response (like jumble)
allTags.push(file.imetaTag);
// Add URL to content field
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${ file . url } \n`;
}
if (shouldIncludeClientTag()) {
allTags.push(['client', 'aitherboard']);
}
const event: Omit< NostrEvent , ' id ' | ' sig ' > = {
kind: effectiveKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: allTags,
content: contentWithUrls.trim()
};
return JSON.stringify(event, null, 2);
}
// Upload file to media server using NIP-96 discovery (like jumble)
async function uploadFileToServer(file: File): Promise< { url : string ; tags : string [][] } > {
const mediaServer = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build';
// Try NIP-96 discovery
let uploadUrl: string | null = null;
try {
const nip96Url = `${ mediaServer } /.well-known/nostr/nip96.json`;
const discoveryResponse = await fetch(nip96Url);
if (discoveryResponse.ok) {
const discoveryData = await discoveryResponse.json();
uploadUrl = discoveryData?.api_url;
}
} catch (error) {
console.log('[CreateEventForm] NIP-96 discovery failed, will try direct endpoints:', error);
}
const endpoints = uploadUrl
? [uploadUrl]
: [
`${ mediaServer } /api/upload`,
`${ mediaServer } /upload`,
`${ mediaServer } /api/v1/upload`,
`${ mediaServer } /api/v2/upload`,
mediaServer
];
const formData = new FormData();
formData.append('file', file);
for (const endpoint of endpoints) {
try {
let authHeader: string | null = null;
try {
const session = sessionManager.getSession();
if (session) {
authHeader = await signHttpAuth(endpoint, 'POST', 'Uploading media file');
}
} catch (authError) {
console.warn('[CreateEventForm] Failed to sign HTTP auth, trying without:', authError);
}
const result = await new Promise< { url : string ; tags : string [][] } >((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint);
xhr.responseType = 'json';
if (authHeader) {
xhr.setRequestHeader('Authorization', authHeader);
}
xhr.onerror = () => {
reject(new Error('Network error'));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300 ) {
const data = xhr.response;
try {
// Extract tags from NIP-94 response (like jumble)
const tags: string[][] = Array.isArray(data?.nip94_event?.tags)
? data.nip94_event.tags
: [];
const urlTag = tags.find((tag: string[]) => tag[0] === 'url');
const url = urlTag?.[1] || data?.url || (typeof data === 'string' ? data : null);
if (url) {
resolve({ url , tags } );
return;
} else {
reject(new Error('No url found'));
return;
}
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
} else {
reject(new Error(`HTTP ${ xhr . status } : ${ xhr . statusText } `));
}
};
xhr.send(formData);
});
return result;
} catch (error) {
console.log(`[CreateEventForm] Upload failed for ${ endpoint } :`, error);
if (endpoint === endpoints[endpoints.length - 1]) {
throw error;
}
}
}
throw new Error('All upload endpoints failed');
}
function handleGifSelect(gifUrl: string) {
if (!textareaRef) return;
// Insert GIF URL as plain text
insertTextAtCursor(textareaRef, gifUrl);
showGifPicker = false;
}
function handleEmojiSelect(emoji: string) {
if (!textareaRef) return;
insertTextAtCursor(textareaRef, emoji);
showEmojiPicker = false;
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Check file type
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
if (!isImage && !isVideo && !isAudio) {
alert('Please select an image, video, or audio file');
return;
}
if (!sessionManager.isLoggedIn()) {
alert('Please log in to upload files');
return;
}
uploading = true;
try {
// Upload file to media server (like jumble)
const { url , tags } = await uploadFileToServer(file);
console.log(`[CreateEventForm] Uploaded ${ file . name } to ${ url } `);
// Build imeta tag from upload response tags (like jumble)
// Format: ['imeta', 'url < url > ', 'm < mime > ', ...]
const imetaTag: string[] = ['imeta', ...tags.map(([n, v]) => `${ n } ${ v } `)];
// Store file with imeta tag
uploadedFiles.push({
url,
imetaTag
});
// Insert file URL into textarea
if (textareaRef) {
if (isImage) {
insertTextAtCursor(textareaRef, `\n`);
} else if (isVideo) {
insertTextAtCursor(textareaRef, `${ url } \n`);
} else if (isAudio) {
insertTextAtCursor(textareaRef, `${ url } \n`);
}
}
} catch (error) {
console.error('[CreateEventForm] File upload failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
alert(`Failed to upload file: ${ errorMessage } `);
} finally {
uploading = false;
// Reset file input
if (fileInputRef) {
fileInputRef.value = '';
}
}
}
async function publish() {
const session = sessionManager.getSession();
if (!session) {
@ -401,12 +616,31 @@
@@ -401,12 +616,31 @@
publishing = true;
try {
// Add file attachments as imeta tags (like jumble)
let contentWithUrls = content.trim();
const allTags = [...tags.filter(t => t[0] & & t[1])];
for (const file of uploadedFiles) {
// Use imeta tag from upload response (like jumble)
allTags.push(file.imetaTag);
// Add URL to content field
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${ file . url } \n`;
}
if (shouldIncludeClientTag()) {
allTags.push(['client', 'aitherboard']);
}
const eventTemplate = {
kind: effectiveKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] & & t[1]),
content
tags: allTags ,
content: contentWithUrls.trim()
};
const signedEvent = await session.signer(eventTemplate);
@ -422,6 +656,8 @@
@@ -422,6 +656,8 @@
publicationModalOpen = true;
if (results.success.length > 0) {
content = '';
uploadedFiles = []; // Clear uploaded files after successful publish
setTimeout(() => {
goto(`/event/${ signedEvent . id } `);
}, 5000);
@ -523,13 +759,80 @@
@@ -523,13 +759,80 @@
{ #if ! isKind30040 && ! isKind10895 }
< div class = "form-group" >
< label for = "content-textarea" class = "form-label" > Content< / label >
< textarea
id="content-textarea"
bind:value={ content }
class="content-input"
rows="10"
placeholder="Event content..."
>< / textarea >
< div class = "textarea-wrapper" >
< textarea
id="content-textarea"
bind:this={ textareaRef }
bind:value={ content }
class="content-input has-buttons"
rows="10"
placeholder="Event content..."
disabled={ publishing }
>< / textarea >
< div class = "textarea-buttons" >
< button
type="button"
onclick={() => {
showGifPicker = !showGifPicker;
showEmojiPicker = false;
}}
class="toolbar-button"
title="Insert GIF"
aria-label="Insert GIF"
disabled={ publishing }
>
GIF
< / button >
< button
type="button"
onclick={() => { showEmojiPicker = ! showEmojiPicker ; showGifPicker = false ; }}
class="toolbar-button"
title="Insert emoji"
aria-label="Insert emoji"
disabled={ publishing }
>
😀
< / button >
< / div >
< / div >
< div class = "content-buttons" >
< button
type="button"
onclick={() => showJsonModal = true }
class="content-button"
disabled={ publishing }
title="View JSON"
>
View JSON
< / button >
< button
type="button"
onclick={() => showPreviewModal = true }
class="content-button"
disabled={ publishing }
title="Preview"
>
Preview
< / button >
< input
type="file"
bind:this={ fileInputRef }
accept="image/*,video/*,audio/*"
onchange={ handleFileUpload }
class="hidden"
id="write-file-upload"
disabled={ publishing || uploading }
/>
< label
for="write-file-upload"
class="content-button upload-label"
title="Upload file (image, video, or audio)"
>
📤
< / label >
< / div >
< / div >
{ /if }
@ -584,6 +887,97 @@
@@ -584,6 +887,97 @@
< PublicationStatusModal bind:open = { publicationModalOpen } bind:results= { publicationResults } />
< GifPicker open = { showGifPicker } onSelect= { handleGifSelect } onClose = {() => showGifPicker = false } / >
< EmojiPicker open = { showEmojiPicker } onSelect= { handleEmojiSelect } onClose = {() => showEmojiPicker = false } / >
<!-- JSON View Modal -->
{ #if showJsonModal }
< div
class="modal-overlay"
onclick={() => showJsonModal = false }
onkeydown={( e ) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showJsonModal = false;
}
}}
role="dialog"
aria-modal="true"
tabindex="0"
>
< div
class="modal-content"
onclick={( e ) => e . stopPropagation ()}
onkeydown={( e ) => e . stopPropagation ()}
role="none"
>
< div class = "modal-header" >
< h2 > Event JSON< / h2 >
< button onclick = {() => showJsonModal = false } class="close-button" > ×</ button >
< / div >
< div class = "modal-body" >
< pre class = "json-preview" > { getEventJson ()} </ pre >
< / div >
< div class = "modal-footer" >
< button onclick = {() => {
navigator.clipboard.writeText(getEventJson());
alert('JSON copied to clipboard');
}}>Copy< / button >
< button onclick = {() => showJsonModal = false } > Close</button >
< / div >
< / div >
< / div >
{ /if }
<!-- Preview Modal -->
{ #if showPreviewModal }
< div
class="modal-overlay"
onclick={() => showPreviewModal = false }
onkeydown={( e ) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showPreviewModal = false;
}
}}
role="dialog"
aria-modal="true"
tabindex="0"
>
< div
class="modal-content preview-modal"
onclick={( e ) => e . stopPropagation ()}
onkeydown={( e ) => e . stopPropagation ()}
role="none"
>
< div class = "modal-header" >
< h2 > Preview< / h2 >
< button onclick = {() => showPreviewModal = false } class="close-button" > ×</ button >
< / div >
< div class = "modal-body preview-body" >
{ #if content . trim () || uploadedFiles . length > 0 }
{ @const previewContent = (() => {
let contentWithUrls = content.trim();
for (const file of uploadedFiles) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${ file . url } \n`;
}
return contentWithUrls.trim();
})()}
< MarkdownRenderer content = { previewContent } / >
{ : else }
< p class = "text-muted" > No content to preview< / p >
{ /if }
< / div >
< div class = "modal-footer" >
< button onclick = {() => showPreviewModal = false } > Close</button >
< / div >
< / div >
< / div >
{ /if }
{ #if publicationResults && publicationResults . success . length === 0 && publicationResults . failed . length > 0 }
< div class = "republish-section" >
< p class = "republish-text" > All relays failed. You can attempt to republish from cache.< / p >
@ -839,7 +1233,9 @@
@@ -839,7 +1233,9 @@
}
.content-input {
width: 100%;
padding: 0.75rem;
padding-bottom: 2.5rem; /* Always have padding for buttons */
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
@ -847,6 +1243,7 @@
@@ -847,6 +1243,7 @@
font-size: 0.875rem;
font-family: monospace;
resize: vertical;
box-sizing: border-box;
}
:global(.dark) .content-input {
@ -1016,4 +1413,242 @@
@@ -1016,4 +1413,242 @@
opacity: 0.6;
cursor: not-allowed;
}
.textarea-wrapper {
position: relative;
width: 100%;
}
.textarea-buttons {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
display: flex;
gap: 0.25rem;
z-index: 10;
}
.toolbar-button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
transition: all 0.2s;
}
.toolbar-button:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .toolbar-button {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .toolbar-button:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-accent, #64748b);
}
.content-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
justify-content: flex-start;
}
.content-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
:global(.dark) .content-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.content-button:hover:not(:disabled) {
background: var(--fog-border, #e5e7eb);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .content-button:hover:not(:disabled) {
background: var(--fog-dark-border, #475569);
border-color: var(--fog-dark-accent, #64748b);
}
.content-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.upload-label {
user-select: none;
filter: grayscale(100%);
}
.hidden {
display: none;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 8px;
max-width: 800px;
width: 90%;
max-height: 80vh;
overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
:global(.dark) .modal-content {
background: #1e293b;
border-color: #475569;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #cbd5e1;
}
:global(.dark) .modal-header {
border-bottom-color: #475569;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #64748b;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
}
.close-button:hover {
background: #e2e8f0;
color: #1e2937;
}
:global(.dark) .close-button {
color: #94a3b8;
}
:global(.dark) .close-button:hover {
background: #334155;
color: #f1f5f9;
}
.modal-body {
padding: 1rem;
max-height: 60vh;
overflow: auto;
}
.json-preview {
background: #1e293b;
color: #f1f5f9;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.preview-modal {
max-width: 900px;
}
.preview-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid #cbd5e1;
}
:global(.dark) .modal-footer {
border-top-color: #475569;
}
.modal-footer button {
padding: 0.5rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.375rem;
background: #ffffff;
color: #1e2937;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.modal-footer button:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
:global(.dark) .modal-footer button {
background: #334155;
border-color: #475569;
color: #f1f5f9;
}
:global(.dark) .modal-footer button:hover {
background: #475569;
border-color: #64748b;
}
< / style >