diff --git a/public/healthz.json b/public/healthz.json index c988467..fe4e0d3 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-04T11:59:22.072Z", + "buildTime": "2026-02-04T14:48:13.641Z", "gitCommit": "unknown", - "timestamp": 1770206362072 + "timestamp": 1770216493641 } \ No newline at end of file diff --git a/src/lib/components/EventMenu.svelte b/src/lib/components/EventMenu.svelte index bb20ea0..62caa7b 100644 --- a/src/lib/components/EventMenu.svelte +++ b/src/lib/components/EventMenu.svelte @@ -18,6 +18,7 @@ import { signAndPublish } from '../services/nostr/auth-handler.js'; import RelatedEventsModal from './modals/RelatedEventsModal.svelte'; import { KIND } from '../types/kind-lookup.js'; + import { goto } from '$app/navigation'; interface Props { event: NostrEvent; @@ -240,7 +241,29 @@ } function highlightNote() { - highlightedState = toggleHighlight(event.id); + // Extract content and e/a tags for highlight + const content = event.content || ''; + + // Find e-tag or a-tag (prefer a-tag if available) + let referenceTag: string[] | null = null; + const aTag = event.tags.find(tag => tag[0] === 'a'); + const eTag = event.tags.find(tag => tag[0] === 'e'); + + if (aTag) { + referenceTag = aTag; + } else if (eTag) { + referenceTag = eTag; + } + + // Store highlight data in sessionStorage + const highlightData = { + content, + tags: referenceTag ? [referenceTag] : [] + }; + sessionStorage.setItem('aitherboard_highlightData', JSON.stringify(highlightData)); + + // Navigate to write form with kind 9802 (highlight) + goto('/write?kind=9802'); closeMenu(); } diff --git a/src/lib/components/write/CreateEventForm.svelte b/src/lib/components/write/CreateEventForm.svelte index 2eadcae..5c4e318 100644 --- a/src/lib/components/write/CreateEventForm.svelte +++ b/src/lib/components/write/CreateEventForm.svelte @@ -33,19 +33,78 @@ interface Props { initialKind?: number | null; + initialContent?: string | null; + initialTags?: string[][] | null; } - let { initialKind = null }: Props = $props(); + let { initialKind = null, initialContent: propInitialContent = null, initialTags: propInitialTags = null }: Props = $props(); + const STORAGE_KEY = 'aitherboard_writeForm_draft'; + let selectedKind = $state(1); let customKindId = $state(''); let content = $state(''); let tags = $state([]); let publishing = $state(false); + + // Restore draft from localStorage on mount (only if no initial props) + $effect(() => { + if (typeof window === 'undefined') return; + + // Only restore if no initial content/tags were provided (from highlight feature) + if (propInitialContent === null && propInitialTags === null) { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const draft = JSON.parse(saved); + 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; + } + } + } catch (error) { + console.error('Error restoring draft:', error); + } + } + }); + + // Save draft to localStorage when content or tags change + $effect(() => { + if (typeof window === 'undefined') return; + if (publishing) return; // Don't save while publishing + + // Debounce saves to avoid excessive localStorage writes + const timeoutId = setTimeout(() => { + try { + const draft = { + content, + tags, + selectedKind + }; + // Only save if there's actual content + if (content.trim() || tags.length > 0) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(draft)); + } else { + // Clear if empty + localStorage.removeItem(STORAGE_KEY); + } + } catch (error) { + console.error('Error saving draft:', error); + } + }, 500); + + 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 showExampleModal = $state(false); let showGifPicker = $state(false); let showEmojiPicker = $state(false); let textareaRef: HTMLTextAreaElement | null = $state(null); @@ -60,6 +119,16 @@ } }); + // Sync content and tags when initial props change (only if form is empty) + $effect(() => { + if (propInitialContent !== null && propInitialContent !== undefined && content === '') { + content = propInitialContent; + } + if (propInitialTags !== null && propInitialTags !== undefined && propInitialTags.length > 0 && tags.length === 0) { + tags = [...propInitialTags]; + } + }); + // Clear content for metadata-only kinds $effect(() => { if (selectedKind === 30040 || selectedKind === 10895) { @@ -618,11 +687,16 @@ try { // Add file attachments as imeta tags (like jumble) let contentWithUrls = content.trim(); - const allTags = [...tags.filter(t => t[0] && t[1])]; + // 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 for (const file of uploadedFiles) { // Use imeta tag from upload response (like jumble) - allTags.push(file.imetaTag); + // 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 if (contentWithUrls && !contentWithUrls.endsWith('\n')) { @@ -635,7 +709,8 @@ allTags.push(['client', 'aitherboard']); } - const eventTemplate = { + // Create a plain object (not a Proxy) to avoid cloning issues + const eventTemplate: Omit = { kind: effectiveKind, pubkey: session.pubkey, created_at: Math.floor(Date.now() / 1000), @@ -657,7 +732,12 @@ if (results.success.length > 0) { content = ''; + tags = []; uploadedFiles = []; // Clear uploaded files after successful publish + // Clear draft from localStorage after successful publish + if (typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } setTimeout(() => { goto(`/event/${signedEvent.id}`); }, 5000); @@ -674,6 +754,19 @@ } } + function clearForm() { + if (confirm('Are you sure you want to clear the form? This will delete all unsaved content.')) { + content = ''; + tags = []; + uploadedFiles = []; + customKindId = ''; + // Clear draft from localStorage + if (typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } + } + } + async function republishFromCache() { if (!publicationResults) return; @@ -687,11 +780,16 @@ const session = sessionManager.getSession(); if (!session) return; - const eventTemplate = { + // 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 + + const eventTemplate: Omit = { kind: effectiveKind, pubkey: session.pubkey, created_at: Math.floor(Date.now() / 1000), - tags: tags.filter(t => t[0] && t[1]), + tags: plainTags, content }; @@ -713,11 +811,12 @@

{helpText.description}

- -
-
Example of {effectiveKind} event
-
{exampleJSON}
-
+
{#if helpText.suggestedTags.length > 0} @@ -816,6 +915,15 @@ > Preview + {/if} + + {#if showExampleModal} + + {/if} + {#if publicationResults && publicationResults.success.length === 0 && publicationResults.failed.length > 0}

All relays failed. You can attempt to republish from cache.

@@ -992,6 +1139,14 @@ display: flex; gap: 2rem; max-width: 1200px; + width: 100%; + } + + @media (max-width: 768px) { + .create-form-container { + gap: 1rem; + padding: 0 0.5rem; + } } .create-form { @@ -1001,12 +1156,26 @@ gap: 1.5rem; } + @media (max-width: 768px) { + .create-form { + gap: 1rem; + } + } + .form-header { display: flex; + flex-direction: row; gap: 2rem; align-items: flex-start; } + @media (max-width: 768px) { + .form-header { + flex-direction: column; + gap: 1rem; + } + } + .form-title { margin: 0; font-size: 1.5rem; @@ -1028,6 +1197,13 @@ font-size: 0.875rem; } + @media (max-width: 768px) { + .help-text-panel { + padding: 0.75rem; + font-size: 0.8125rem; + } + } + :global(.dark) .help-text-panel { background: var(--fog-dark-highlight, #374151); border-color: var(--fog-dark-border, #475569); @@ -1052,7 +1228,6 @@ } .example-button-wrapper { - position: relative; flex-shrink: 0; } @@ -1090,49 +1265,21 @@ border-color: var(--fog-dark-accent, #94a3b8); } - .example-tooltip { - position: absolute; - top: 100%; - right: 0; - margin-top: 0.5rem; - background: var(--fog-post, #ffffff); - border: 1px solid var(--fog-border, #e5e7eb); - border-radius: 0.375rem; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - padding: 1rem; - min-width: 400px; - max-width: 600px; - max-height: 500px; - overflow: auto; - z-index: 1000; - opacity: 0; - pointer-events: none; - transition: opacity 0.2s; - } - - :global(.dark) .example-tooltip { - background: var(--fog-dark-post, #1f2937); - border-color: var(--fog-dark-border, #374151); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - } - - .example-button-wrapper:hover .example-tooltip { - opacity: 1; - pointer-events: auto; - } - - .example-tooltip-header { - font-weight: 600; - font-size: 0.875rem; - color: var(--fog-text, #1f2937); - margin-bottom: 0.75rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--fog-border, #e5e7eb); + .example-modal { + max-width: 90vw; + width: 100%; + max-width: 800px; } - :global(.dark) .example-tooltip-header { - color: var(--fog-dark-text, #f9fafb); - border-bottom-color: var(--fog-dark-border, #374151); + @media (max-width: 768px) { + .example-modal { + max-width: 95vw; + margin: 1rem; + } + + .modal-content { + max-height: 90vh; + } } .example-json { @@ -1235,7 +1382,8 @@ .content-input { width: 100%; padding: 0.75rem; - padding-bottom: 2.5rem; /* Always have padding for buttons */ + padding-bottom: 3.5rem; /* Extra padding for buttons at bottom */ + padding-left: 0.75rem; /* Ensure left padding */ border: 1px solid var(--fog-border, #e5e7eb); border-radius: 0.25rem; background: var(--fog-post, #ffffff); @@ -1245,6 +1393,11 @@ resize: vertical; box-sizing: border-box; } + + .content-input.has-buttons { + padding-bottom: 3.5rem; /* Extra padding when buttons are present */ + padding-left: 0.75rem; /* Ensure text doesn't overlap left-positioned buttons */ + } :global(.dark) .content-input { border-color: var(--fog-dark-border, #374151); @@ -1263,6 +1416,18 @@ display: flex; gap: 0.5rem; align-items: center; + flex-wrap: wrap; + } + + @media (max-width: 768px) { + .tag-row { + flex-direction: column; + align-items: stretch; + } + + .tag-row .tag-input { + width: 100%; + } } .tag-input { @@ -1344,6 +1509,17 @@ gap: 0.5rem; } + @media (max-width: 768px) { + .form-actions { + flex-direction: column; + } + + .publish-button { + width: 100%; + padding: 0.875rem 1.5rem; + } + } + .publish-button { padding: 0.75rem 1.5rem; background: var(--fog-accent, #64748b); @@ -1421,11 +1597,12 @@ .textarea-buttons { position: absolute; - bottom: 0.5rem; - left: 0.5rem; + bottom: 0.75rem; + left: 0.75rem; display: flex; gap: 0.25rem; z-index: 10; + pointer-events: auto; } .toolbar-button { @@ -1437,6 +1614,7 @@ color: var(--fog-text, #1f2937); cursor: pointer; transition: all 0.2s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .toolbar-button:hover:not(:disabled) { @@ -1453,6 +1631,7 @@ background: var(--fog-dark-post, #1f2937); border-color: var(--fog-dark-border, #374151); color: var(--fog-dark-text, #f9fafb); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } :global(.dark) .toolbar-button:hover:not(:disabled) { @@ -1465,6 +1644,18 @@ gap: 0.5rem; margin-top: 0.5rem; justify-content: flex-start; + flex-wrap: wrap; + } + + @media (max-width: 768px) { + .content-buttons { + gap: 0.375rem; + } + + .content-button { + font-size: 0.8125rem; + padding: 0.375rem 0.75rem; + } } .content-button { diff --git a/src/lib/modules/comments/CommentForm.svelte b/src/lib/modules/comments/CommentForm.svelte index 9c31e3c..262d5df 100644 --- a/src/lib/modules/comments/CommentForm.svelte +++ b/src/lib/modules/comments/CommentForm.svelte @@ -24,8 +24,51 @@ let { threadId, rootEvent, parentEvent, onPublished, onCancel }: Props = $props(); + // Create unique storage key based on thread and parent + const STORAGE_KEY = $derived(`aitherboard_commentForm_${threadId}_${parentEvent?.id || 'root'}`); + let content = $state(''); let publishing = $state(false); + + // Restore draft from localStorage on mount + $effect(() => { + if (typeof window === 'undefined') return; + + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const draft = JSON.parse(saved); + if (draft.content !== undefined && content === '') { + content = draft.content; + } + } + } catch (error) { + console.error('Error restoring comment draft:', error); + } + }); + + // Save draft to localStorage when content changes + $effect(() => { + if (typeof window === 'undefined') return; + if (publishing) return; // Don't save while publishing + + // Debounce saves to avoid excessive localStorage writes + const timeoutId = setTimeout(() => { + try { + // Only save if there's actual content + if (content.trim()) { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ content })); + } else { + // Clear if empty + localStorage.removeItem(STORAGE_KEY); + } + } catch (error) { + console.error('Error saving comment draft:', error); + } + }, 500); + + return () => clearTimeout(timeoutId); + }); let showStatusModal = $state(false); let publicationResults: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = $state(null); let showGifPicker = $state(false); @@ -183,6 +226,10 @@ if (result.success.length > 0) { content = ''; uploadedFiles = []; // Clear uploaded files after successful publish + // Clear draft from localStorage after successful publish + if (typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } onPublished?.(); } } catch (error) { @@ -199,6 +246,17 @@ } } + function clearForm() { + if (confirm('Are you sure you want to clear the comment? This will delete all unsaved content.')) { + content = ''; + uploadedFiles = []; + // Clear draft from localStorage + if (typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } + } + } + function handleGifSelect(gifUrl: string) { if (!textareaRef) return; // Insert GIF URL as plain text @@ -452,8 +510,8 @@ {/if}
-
-
+
+
+
-
+
{#if onCancel}
{:else}
- {#each posts as post (post.id)} - + {#each [...posts, ...highlights].sort((a, b) => b.created_at - a.created_at) as event (event.id)} + {#if event.kind === KIND.HIGHLIGHTED_ARTICLE} + + {:else} + + {/if} {/each}
diff --git a/src/lib/modules/feed/HighlightCard.svelte b/src/lib/modules/feed/HighlightCard.svelte new file mode 100644 index 0000000..e1c6eb7 --- /dev/null +++ b/src/lib/modules/feed/HighlightCard.svelte @@ -0,0 +1,302 @@ + + + + + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b7d5303..f118611 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,8 +2,24 @@ import '../app.css'; import { sessionManager } from '../lib/services/auth/session-manager.js'; import { onMount } from 'svelte'; + import { browser } from '$app/environment'; - // Restore session on app load (only if no session exists) + // Restore session immediately if in browser (before onMount) + if (browser) { + // Try to restore session synchronously if possible + // This ensures session is restored before any components render + (async () => { + try { + if (!sessionManager.isLoggedIn()) { + await sessionManager.restoreSession(); + } + } catch (error) { + console.error('Failed to restore session:', error); + } + })(); + } + + // Also restore in onMount as fallback onMount(async () => { try { // Only restore if there's no active session diff --git a/src/routes/find/+page.svelte b/src/routes/find/+page.svelte index 0b2c46e..a910333 100644 --- a/src/routes/find/+page.svelte +++ b/src/routes/find/+page.svelte @@ -111,8 +111,6 @@
-

Find Event

-

Enter an event ID (hex, note, nevent, or naddr)

diff --git a/src/routes/write/+page.svelte b/src/routes/write/+page.svelte index 859729b..01409d2 100644 --- a/src/routes/write/+page.svelte +++ b/src/routes/write/+page.svelte @@ -6,14 +6,12 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; + // Read kind from URL synchronously so it's available on first render + const kindParam = $derived($page.url.searchParams.get('kind')); let initialKind = $state(null); - const isLoggedIn = $derived(sessionManager.isLoggedIn()); - - onMount(async () => { - await nostrClient.initialize(); - - // Check for kind parameter in URL - const kindParam = $page.url.searchParams.get('kind'); + + // Set initial kind from URL if available + $effect(() => { if (kindParam) { const kind = parseInt(kindParam, 10); if (!isNaN(kind)) { @@ -21,6 +19,47 @@ } } }); + let initialContent = $state(null); + let initialTags = $state(null); + + // Subscribe to session changes to reactively update login status + let currentSession = $state(sessionManager.session.value); + const isLoggedIn = $derived(currentSession !== null); + + // Subscribe to session changes + $effect(() => { + const unsubscribe = sessionManager.session.subscribe((session) => { + currentSession = session; + }); + return unsubscribe; + }); + + onMount(async () => { + await nostrClient.initialize(); + + // Ensure session is restored (fallback in case layout restoration didn't complete) + if (!sessionManager.isLoggedIn()) { + try { + await sessionManager.restoreSession(); + } catch (error) { + console.error('Failed to restore session in write page:', error); + } + } + + // Check for highlight data in sessionStorage + const highlightDataStr = sessionStorage.getItem('aitherboard_highlightData'); + if (highlightDataStr) { + try { + const highlightData = JSON.parse(highlightDataStr); + initialContent = highlightData.content || null; + initialTags = highlightData.tags || null; + // Clear sessionStorage after reading + sessionStorage.removeItem('aitherboard_highlightData'); + } catch (error) { + console.error('Error parsing highlight data:', error); + } + } + });
@@ -36,7 +75,11 @@
{:else}
- +
{/if}
@@ -53,68 +96,9 @@ margin: 0 auto; } - .mode-selector { - display: flex; - flex-direction: column; - gap: 1rem; - } - - .mode-button { - padding: 1.5rem; - border: 2px solid var(--fog-border, #e5e7eb); - border-radius: 0.5rem; - background: var(--fog-post, #ffffff); - color: var(--fog-text, #1f2937); - font-size: 1rem; - cursor: pointer; - transition: all 0.2s; - text-align: left; - } - - :global(.dark) .mode-button { - border-color: var(--fog-dark-border, #374151); - background: var(--fog-dark-post, #1f2937); - color: var(--fog-dark-text, #f9fafb); - } - - .mode-button:hover { - border-color: var(--fog-accent, #64748b); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - } - - :global(.dark) .mode-button:hover { - border-color: var(--fog-dark-accent, #94a3b8); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - } - .form-container { display: flex; flex-direction: column; gap: 1rem; } - - .back-button { - padding: 0.5rem 1rem; - background: var(--fog-highlight, #f3f4f6); - color: var(--fog-text, #1f2937); - border: 1px solid var(--fog-border, #e5e7eb); - border-radius: 0.25rem; - cursor: pointer; - font-size: 0.875rem; - align-self: flex-start; - } - - :global(.dark) .back-button { - background: var(--fog-dark-highlight, #374151); - color: var(--fog-dark-text, #f9fafb); - border-color: var(--fog-dark-border, #475569); - } - - .back-button:hover { - background: var(--fog-border, #e5e7eb); - } - - :global(.dark) .back-button:hover { - background: var(--fog-dark-border, #475569); - }