diff --git a/src/lib/components/modals/EventJsonModal.svelte b/src/lib/components/modals/EventJsonModal.svelte index f532b83..ca2895c 100644 --- a/src/lib/components/modals/EventJsonModal.svelte +++ b/src/lib/components/modals/EventJsonModal.svelte @@ -140,6 +140,7 @@ display: flex; flex-direction: column; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + margin: 0 auto; /* Center the modal */ } @media (max-width: 768px) { @@ -278,6 +279,8 @@ overflow-x: auto; max-height: 60vh; white-space: pre; + text-align: left; /* Ensure text aligns left */ + position: relative; /* Ensure proper stacking context */ } .json-preview.word-wrap { @@ -291,12 +294,13 @@ .json-preview code { display: block; padding: 0; - background: transparent !important; + background: #000000 !important; /* Match parent background to prevent any bleed-through */ /* Colors are defined by highlight.js vs2015 theme */ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; font-size: 0.875rem; line-height: 1.5; white-space: pre; + position: relative; /* Ensure proper stacking */ } .json-preview.word-wrap code { @@ -316,6 +320,20 @@ overflow-wrap: break-word !important; } + /* Ensure highlight.js doesn't override the background */ + .json-preview :global(code.hljs), + .json-preview :global(.hljs) { + background: #000000 !important; + padding: 0 !important; + margin: 0 !important; + } + + /* Override any highlight.js theme backgrounds that might be causing the offset */ + .json-preview :global(.hljs *), + .json-preview :global(code.hljs *) { + background: transparent !important; + } + @media (max-width: 768px) { .json-preview { padding: 0.75rem; diff --git a/src/lib/components/write/CreateEventForm.svelte b/src/lib/components/write/CreateEventForm.svelte index 0b11aff..c032a38 100644 --- a/src/lib/components/write/CreateEventForm.svelte +++ b/src/lib/components/write/CreateEventForm.svelte @@ -13,7 +13,7 @@ import AdvancedEditor from './AdvancedEditor.svelte'; import { shouldIncludeClientTag } from '../../services/client-tag-preference.js'; import { goto } from '$app/navigation'; - import { KIND, KIND_LOOKUP } from '../../types/kind-lookup.js'; + import { KIND_LOOKUP } from '../../types/kind-lookup.js'; import { getKindMetadata, getWritableKinds } from '../../types/kind-metadata.js'; import type { NostrEvent } from '../../types/nostr.js'; import { autoExtractTags, ensureDTagForParameterizedReplaceable } from '../../services/auto-tagging.js'; @@ -26,57 +26,111 @@ const SUPPORTED_KINDS = getWritableKinds(); interface Props { - initialKind?: number | null; - initialContent?: string | null; - initialTags?: string[][] | null; + initialEvent?: NostrEvent | null; } - let { initialKind = null, initialContent: propInitialContent = null, initialTags: propInitialTags = null }: Props = $props(); + let { initialEvent = null }: Props = $props(); const DRAFT_ID = 'write'; - let selectedKind = $state(1); - let customKindId = $state(''); - let content = $state(''); - let tags = $state([]); + // Extract initial values from event + const getInitialKind = (): number => { + if (initialEvent?.kind !== undefined) { + const isSupported = SUPPORTED_KINDS.some(k => k.value === initialEvent.kind); + return isSupported ? initialEvent.kind : -1; // -1 means "unknown kind" + } + return 1; // Default to kind 1 + }; + + const getInitialCustomKindId = (): string => { + if (initialEvent?.kind !== undefined) { + const isSupported = SUPPORTED_KINDS.some(k => k.value === initialEvent.kind); + return isSupported ? '' : String(initialEvent.kind); + } + return ''; + }; + + const getInitialContent = (): string => { + return initialEvent?.content || ''; + }; + + const getInitialTags = (): string[][] => { + return initialEvent?.tags ? [...initialEvent.tags] : []; + }; + + // Initialize state + let selectedKind = $state(getInitialKind()); + let customKindId = $state(getInitialCustomKindId()); + let content = $state(getInitialContent()); + let tags = $state(getInitialTags()); let publishing = $state(false); + let showAdvancedEditor = $state(false); + let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null); + let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); - // Restore draft from IndexedDB on mount (only if no initial props) + // Modal states + 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 showExampleModal = $state(false); + let mediaViewerOpen = $state(false); + let mediaViewerUrl = $state(null); + + // Preview/JSON refs + let jsonPreviewRef: HTMLElement | null = $state(null); + let exampleJsonPreviewRef: HTMLElement | null = $state(null); + let eventJson = $state('{}'); + let previewContent = $state(''); + let previewEvent = $state(null); + + // Sync when initialEvent changes $effect(() => { - if (typeof window === 'undefined') return; + if (initialEvent) { + const isSupported = SUPPORTED_KINDS.some(k => k.value === initialEvent.kind); + if (isSupported) { + selectedKind = initialEvent.kind; + customKindId = ''; + } else { + selectedKind = -1; + customKindId = String(initialEvent.kind); + } + content = initialEvent.content || ''; + tags = initialEvent.tags ? [...initialEvent.tags] : []; + } + }); + + // Restore draft from IndexedDB if no initial event + $effect(() => { + if (typeof window === 'undefined' || initialEvent) return; - // Only restore if no initial content/tags were provided (from highlight feature) - if (propInitialContent === null && propInitialTags === null) { - (async () => { - try { - const draft = await getDraft(DRAFT_ID); - if (draft) { - if (draft.content !== undefined && content === '') { - content = draft.content; - } - if (draft.tags && draft.tags.length > 0 && tags.length === 0) { - tags = draft.tags; - } - if (draft.selectedKind !== undefined && initialKind === null) { - selectedKind = draft.selectedKind; - } + (async () => { + try { + const draft = await getDraft(DRAFT_ID); + if (draft) { + if (draft.content !== undefined && content === '') { + content = draft.content; + } + if (draft.tags && draft.tags.length > 0 && tags.length === 0) { + tags = draft.tags; + } + if (draft.selectedKind !== undefined) { + selectedKind = draft.selectedKind; } - } catch (error) { - console.error('Error restoring draft:', error); } - })(); - } + } catch (error) { + console.error('Error restoring draft:', error); + } + })(); }); - - // Save draft to IndexedDB when content or tags change + + // Save draft to IndexedDB $effect(() => { if (typeof window === 'undefined') return; - if (publishing) return; // Don't save while publishing + if (publishing || initialEvent) return; // Don't save while publishing or when editing an event - // Debounce saves to avoid excessive IndexedDB writes const timeoutId = setTimeout(async () => { try { - // Only save if there's actual content if (content.trim() || tags.length > 0) { await saveDraft(DRAFT_ID, { content, @@ -84,7 +138,6 @@ selectedKind }); } else { - // Clear if empty await deleteDraft(DRAFT_ID); } } catch (error) { @@ -94,36 +147,7 @@ return () => clearTimeout(timeoutId); }); - 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 exampleJsonPreviewRef: HTMLElement | null = $state(null); - let previewContent = $state(''); - let previewEvent = $state(null); - let showExampleModal = $state(false); - - // Media viewer state for preview - let mediaViewerOpen = $state(false); - let mediaViewerUrl = $state(null); - - function handleMediaUrlClick(url: string, e: MouseEvent) { - e.stopPropagation(); - e.preventDefault(); - mediaViewerUrl = url; - mediaViewerOpen = true; - } - function closeMediaViewer() { - mediaViewerOpen = false; - mediaViewerUrl = null; - } - let showAdvancedEditor = $state(false); - let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null); - let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); - let eventJson = $state('{}'); - let jsonPreviewRef: HTMLElement | null = $state(null); - // Highlight JSON when it changes $effect(() => { if (jsonPreviewRef && eventJson && jsonPreviewRef instanceof HTMLElement) { @@ -132,54 +156,28 @@ jsonPreviewRef.innerHTML = highlighted; jsonPreviewRef.className = 'hljs language-json'; } catch (err) { - // Fallback to plain text if highlighting fails jsonPreviewRef.textContent = eventJson; jsonPreviewRef.className = 'language-json'; } } }); - + const isUnknownKind = $derived(selectedKind === -1); const effectiveKind = $derived(isUnknownKind ? (parseInt(customKindId) || 1) : selectedKind); - - // Determine editor mode based on selected kind const editorMode = $derived( effectiveKind === 30818 || effectiveKind === 30041 ? 'asciidoc' : 'markdown' ); - - // Show advanced editor button when editing (has initial content) or for AsciiDoc kinds const showAdvancedEditorButton = $derived( - (propInitialContent !== null && propInitialContent !== undefined) || + initialEvent !== null || effectiveKind === 30818 || effectiveKind === 30041 || - effectiveKind === 30023 || // Long-form note (markdown) - effectiveKind === 1 // Short text note (markdown) + effectiveKind === 30023 || + effectiveKind === 1 ); - - // Sync selectedKind when initialKind prop changes - $effect(() => { - if (initialKind !== null && initialKind !== undefined) { - selectedKind = initialKind; - } - }); - - // Track if we've already applied initial props to prevent re-applying after clear - let initialPropsApplied = $state(false); - let formCleared = $state(false); // Track if form was explicitly cleared - - // Sync content and tags when initial props change (only if form is empty and not yet applied) - $effect(() => { - if (initialPropsApplied || formCleared) return; // Don't re-apply after they've been used or after clear - - if (propInitialContent !== null && propInitialContent !== undefined && content === '') { - content = propInitialContent; - initialPropsApplied = true; - } - if (propInitialTags !== null && propInitialTags !== undefined && propInitialTags.length > 0 && tags.length === 0) { - tags = [...propInitialTags]; - initialPropsApplied = true; - } - }); + const kindMetadata = $derived(getKindMetadata(effectiveKind)); + const helpText = $derived(kindMetadata.helpText); + const isKind30040 = $derived(selectedKind === 30040); + const isKind10895 = $derived(selectedKind === 10895); // Clear content for metadata-only kinds $effect(() => { @@ -189,9 +187,6 @@ } }); - const kindMetadata = $derived(getKindMetadata(effectiveKind)); - const helpText = $derived(kindMetadata.helpText); - function getExampleJSON(): string { const examplePubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'; const exampleEventId = '67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446'; @@ -199,10 +194,10 @@ const timestamp = Math.floor(Date.now() / 1000); return JSON.stringify(kindMetadata.exampleJSON(examplePubkey, exampleEventId, exampleRelay, timestamp), null, 2); } - + const exampleJSON = $derived(getExampleJSON()); - - // Highlight example JSON when it changes + + // Highlight example JSON $effect(() => { if (exampleJsonPreviewRef && exampleJSON && exampleJsonPreviewRef instanceof HTMLElement) { try { @@ -210,15 +205,11 @@ exampleJsonPreviewRef.innerHTML = highlighted; exampleJsonPreviewRef.className = 'hljs language-json'; } catch (err) { - // Fallback to plain text if highlighting fails exampleJsonPreviewRef.textContent = exampleJSON; exampleJsonPreviewRef.className = 'language-json'; } } }); - - const isKind30040 = $derived(selectedKind === KIND.PUBLICATION_INDEX); - const isKind10895 = $derived(selectedKind === KIND.RSS_FEED); function addTag() { tags = [...tags, ['', '']]; @@ -245,16 +236,11 @@ 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 only if it's not already there - // (to avoid duplicates if URL was already inserted into textarea) if (!contentWithUrls.includes(file.url)) { if (contentWithUrls && !contentWithUrls.endsWith('\n')) { contentWithUrls += '\n'; @@ -263,7 +249,6 @@ } } - // Auto-extract tags from content (hashtags, mentions, nostr: links) const autoTagsResult = await autoExtractTags({ content: contentWithUrls, existingTags: allTags, @@ -271,7 +256,6 @@ }); allTags.push(...autoTagsResult.tags); - // For parameterized replaceable events, ensure d-tag exists (for preview) if (isParameterizedReplaceableKind(effectiveKind)) { const dTagResult = ensureDTagForParameterizedReplaceable(allTags, effectiveKind); if (dTagResult) { @@ -283,7 +267,6 @@ 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()); @@ -298,12 +281,10 @@ return JSON.stringify(event, null, 2); } - function handleFilesUploaded(files: Array<{ url: string; imetaTag: string[] }>) { uploadedFiles = files; } - async function publish() { const session = sessionManager.getSession(); if (!session) { @@ -319,21 +300,15 @@ publishing = true; try { - // Add file attachments as imeta tags (like jumble) let contentWithUrls = content.trim(); - // Create a plain array (not a Proxy) by mapping and filtering const allTags: string[][] = tags .filter(t => t[0] && t[1]) - .map(tag => [...tag]); // Create new array for each tag to avoid Proxy + .map(tag => [...tag]); for (const file of uploadedFiles) { - // Use imeta tag from upload response (like jumble) - // Ensure imetaTag is also a plain array const imetaTag = Array.isArray(file.imetaTag) ? [...file.imetaTag] : file.imetaTag; allTags.push(imetaTag); - // Add URL to content field only if it's not already there - // (to avoid duplicates if URL was already inserted into textarea) if (!contentWithUrls.includes(file.url)) { if (contentWithUrls && !contentWithUrls.endsWith('\n')) { contentWithUrls += '\n'; @@ -342,7 +317,6 @@ } } - // Auto-extract tags from content (hashtags, mentions, nostr: links) const autoTagsResult = await autoExtractTags({ content: contentWithUrls, existingTags: allTags, @@ -350,16 +324,13 @@ }); allTags.push(...autoTagsResult.tags); - // For parameterized replaceable events (30000-39999), ensure d-tag exists if (isParameterizedReplaceableKind(effectiveKind)) { const dTagResult = ensureDTagForParameterizedReplaceable(allTags, effectiveKind); if (dTagResult) { allTags.push(['d', dTagResult.dTag]); } else { - // Check if d-tag exists (it should after ensureDTagForParameterizedReplaceable if title exists) const existingDTag = allTags.find(t => t[0] === 'd' && t[1]); if (!existingDTag) { - // No d-tag and no title tag - alert user alert(`Parameterized replaceable events (kind ${effectiveKind}) require a d-tag. Please add a d-tag or a title tag that can be normalized to a d-tag.`); publishing = false; return; @@ -371,11 +342,9 @@ 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()); - // Create a plain object (not a Proxy) to avoid cloning issues const eventTemplate: Omit = { kind: effectiveKind, pubkey: session.pubkey, @@ -399,11 +368,10 @@ if (results.success.length > 0) { content = ''; tags = []; - uploadedFiles = []; // Clear uploaded files after successful publish + uploadedFiles = []; if (richTextEditorRef) { richTextEditorRef.clearUploadedFiles(); } - // Clear draft from IndexedDB after successful publish await deleteDraft(DRAFT_ID); setTimeout(() => { goto(`/event/${signedEvent.id}`); @@ -424,27 +392,15 @@ async function clearForm() { if (confirm('Are you sure you want to clear the form? This will delete all unsaved content.')) { try { - // Mark form as cleared to prevent initial props from re-applying - formCleared = true; - - // Clear state synchronously content = ''; tags = []; uploadedFiles = []; customKindId = ''; - selectedKind = 1; // Reset to default kind - - // Reset the initial props applied flag - initialPropsApplied = false; - - // Clear draft from IndexedDB after clearing state - // This prevents the save effect from running with old data + selectedKind = 1; + if (richTextEditorRef) { + richTextEditorRef.clearUploadedFiles(); + } await deleteDraft(DRAFT_ID); - - // Reset formCleared flag after a brief delay to allow effects to settle - setTimeout(() => { - formCleared = false; - }, 100); } catch (error) { console.error('Error clearing form:', error); alert('Failed to clear form. Please try again.'); @@ -465,12 +421,10 @@ const session = sessionManager.getSession(); if (!session) return; - // Create plain arrays/objects to avoid Proxy cloning issues const plainTags: string[][] = tags .filter(t => t[0] && t[1]) - .map(tag => [...tag]); // Create new array for each tag to avoid Proxy + .map(tag => [...tag]); - // Process content to add "nostr:" prefix to valid Nostr addresses const { processNostrLinks } = await import('../../utils/nostr-link-processor.js'); const processedContent = processNostrLinks(content); @@ -490,6 +444,73 @@ publishing = false; } } + + function handleMediaUrlClick(url: string, e: MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + mediaViewerUrl = url; + mediaViewerOpen = true; + } + + function closeMediaViewer() { + mediaViewerOpen = false; + mediaViewerUrl = null; + } + + async function showPreview() { + let contentWithUrls = content.trim(); + for (const file of uploadedFiles) { + if (!contentWithUrls.includes(file.url)) { + if (contentWithUrls && !contentWithUrls.endsWith('\n')) { + contentWithUrls += '\n'; + } + contentWithUrls += `${file.url}\n`; + } + } + + const { processNostrLinks } = await import('../../utils/nostr-link-processor.js'); + previewContent = processNostrLinks(contentWithUrls.trim()); + + const previewTags: string[][] = []; + for (const tag of tags) { + if (tag[0] && tag[1]) { + previewTags.push([...tag]); + } + } + for (const file of uploadedFiles) { + previewTags.push(file.imetaTag); + } + + const autoTagsResult = await autoExtractTags({ + content: contentWithUrls, + existingTags: previewTags, + kind: effectiveKind + }); + previewTags.push(...autoTagsResult.tags); + + if (isParameterizedReplaceableKind(effectiveKind)) { + const dTagResult = ensureDTagForParameterizedReplaceable(previewTags, effectiveKind); + if (dTagResult) { + previewTags.push(['d', dTagResult.dTag]); + } + } + + if (shouldIncludeClientTag()) { + previewTags.push(['client', 'aitherboard']); + } + + previewEvent = { + kind: effectiveKind, + pubkey: sessionManager.getCurrentPubkey() || '', + created_at: Math.floor(Date.now() / 1000), + tags: previewTags, + content: previewContent, + id: '', + sig: '' + } as NostrEvent; + + showPreviewModal = true; + }
@@ -527,11 +548,12 @@ {#each SUPPORTED_KINDS as kind} {/each} - {#if selectedKind !== -1 && !SUPPORTED_KINDS.find(k => k.value === selectedKind)} + {#if selectedKind !== -1 && selectedKind !== 1 && !SUPPORTED_KINDS.find(k => k.value === selectedKind)} {@const kindInfo = getKindMetadata(selectedKind)} {@const kindDescription = kindInfo?.description || KIND_LOOKUP[selectedKind]?.description || 'Unknown'} {/if} + {#if isUnknownKind}
@@ -578,96 +600,37 @@ />
- - - -
+ + + +
{/if} @@ -733,200 +696,199 @@ /> {/if} - - {#if showJsonModal} + +{#if showJsonModal} +