diff --git a/public/healthz.json b/public/healthz.json
index 5be6279..119d3cb 100644
--- a/public/healthz.json
+++ b/public/healthz.json
@@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.1",
- "buildTime": "2026-02-05T18:24:55.668Z",
+ "buildTime": "2026-02-05T22:42:01.031Z",
"gitCommit": "unknown",
- "timestamp": 1770315895668
+ "timestamp": 1770331321031
}
\ No newline at end of file
diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte
index 901b9b9..472d2dc 100644
--- a/src/lib/components/content/MarkdownRenderer.svelte
+++ b/src/lib/components/content/MarkdownRenderer.svelte
@@ -331,26 +331,42 @@
}
// Convert greentext (>text with no space) to styled spans
+ // Groups consecutive greentext lines into a single block, preserving line breaks
function convertGreentext(text: string): string {
- // Split by lines and process each line
+ // Split by lines and process
const lines = text.split('\n');
- const processedLines = lines.map(line => {
- // Check if line starts with > followed immediately by non-whitespace (greentext)
- // Must match: >text (no space after >)
- // Must NOT match: > text (space after >, normal blockquote)
- // Also handle HTML-escaped > (>)
- const greentextPattern = /^(>|>)([^\s>].*)$/;
+ const processedLines: string[] = [];
+ let greentextBlock: string[] = [];
+
+ const greentextPattern = /^(>|>)([^\s>].*)$/;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
const match = line.match(greentextPattern);
if (match) {
- // This is greentext - wrap in span with greentext class
- // Use > character (not >) since we're inserting HTML
+ // This is greentext - add to current block
const greentextContent = escapeHtml(match[2]);
- return `>${greentextContent}`;
+ greentextBlock.push(greentextContent);
+ } else {
+ // Not greentext - flush any accumulated greentext block
+ if (greentextBlock.length > 0) {
+ // Join with
to preserve line breaks, no extra spacing
+ const blockContent = greentextBlock.map(content => `>${content}`).join('
');
+ processedLines.push(`${blockContent}`);
+ greentextBlock = [];
+ }
+ // Add the non-greentext line as-is
+ processedLines.push(line);
}
-
- return line;
- });
+ }
+
+ // Flush any remaining greentext block at the end
+ if (greentextBlock.length > 0) {
+ // Join with
to preserve line breaks, no extra spacing
+ const blockContent = greentextBlock.map(content => `>${content}`).join('
');
+ processedLines.push(`${blockContent}`);
+ }
return processedLines.join('\n');
}
@@ -507,16 +523,30 @@
});
// Post-process HTML to convert blockquotes that are actually greentext
+ // Merges consecutive greentext blockquotes into a single block, preserving line breaks
function postProcessGreentext(html: string): string {
- // Find blockquotes that match greentext pattern (>text with no space)
- // These are blockquotes that markdown created from greentext lines
- // Pattern:
where there's no space after > - const greentextBlockquotePattern = />text
]*>\s*]*>>([^\s<].*?)<\/p>\s*<\/blockquote>/g; + // Pattern to match one or more consecutive greentext blockquotes + // Matches:
(with optional whitespace between) + // where there's no space after > (greentext pattern) + // The (?:...) part matches zero or more additional consecutive blockquotes + const consecutiveGreentextPattern = /(>text
]*>\s*]*>>([^\s<].*?)<\/p>\s*<\/blockquote>)(\s*
]*>\s*]*>>([^\s<].*?)<\/p>\s*<\/blockquote>)*/g; - return html.replace(greentextBlockquotePattern, (match, content) => { - // Convert to greentext span - const escapedContent = escapeHtml(content); - return `>${escapedContent}`; + return html.replace(consecutiveGreentextPattern, (match) => { + // Extract all greentext contents from the match + const contentPattern = /
]*>\s*]*>>([^\s<].*?)<\/p>\s*<\/blockquote>/g; + const contents: string[] = []; + let contentMatch; + while ((contentMatch = contentPattern.exec(match)) !== null) { + contents.push(contentMatch[1]); + } + + if (contents.length === 0) { + return match; // Shouldn't happen, but safety check + } + + // Join all contents with
to preserve line breaks, no extra spacing + const combinedContent = contents.map(c => escapeHtml(c)).map(content => `>${content}`).join('
'); + return `${combinedContent}`; }); } diff --git a/src/lib/components/content/MediaViewer.svelte b/src/lib/components/content/MediaViewer.svelte index 801d1e9..668081f 100644 --- a/src/lib/components/content/MediaViewer.svelte +++ b/src/lib/components/content/MediaViewer.svelte @@ -45,9 +45,11 @@ {#if mediaType === 'image'}{:else if mediaType === 'video'} - + {:else if mediaType === 'audio'} - + {:else}
Unsupported media type
diff --git a/src/lib/components/content/MentionsAutocomplete.svelte b/src/lib/components/content/MentionsAutocomplete.svelte new file mode 100644 index 0000000..8c15bef --- /dev/null +++ b/src/lib/components/content/MentionsAutocomplete.svelte @@ -0,0 +1,302 @@ + + +{#if showSuggestions && suggestions.length > 0} +++{/if} + + diff --git a/src/lib/components/profile/ProfileMenu.svelte b/src/lib/components/profile/ProfileMenu.svelte index 9f47e21..2d7d2df 100644 --- a/src/lib/components/profile/ProfileMenu.svelte +++ b/src/lib/components/profile/ProfileMenu.svelte @@ -90,6 +90,23 @@ }); } + // Reposition menu on window resize + $effect(() => { + if (!menuOpen) return; + + function handleResize() { + positionMenu(); + } + + window.addEventListener('resize', handleResize); + window.addEventListener('scroll', handleResize, true); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', handleResize, true); + }; + }); + function closeMenu() { menuOpen = false; } @@ -98,26 +115,63 @@ if (!menuButtonElement || !menuDropdownElement) return; const buttonRect = menuButtonElement.getBoundingClientRect(); - const top = buttonRect.bottom + 4; - const right = window.innerWidth - buttonRect.right; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const padding = 8; // Padding from viewport edges + + // Initial position: below button, aligned to right edge + let top = buttonRect.bottom + 4; + let right = viewportWidth - buttonRect.right; menuPosition = { top, right }; requestAnimationFrame(() => { if (!menuDropdownElement) return; const dropdownRect = menuDropdownElement.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const viewportWidth = window.innerWidth; - - // Adjust if menu goes off bottom of screen - if (top + dropdownRect.height > viewportHeight) { - menuPosition.top = buttonRect.top - dropdownRect.height - 4; + const dropdownWidth = dropdownRect.width; + const dropdownHeight = dropdownRect.height; + + // Calculate left position from right + const left = viewportWidth - right - dropdownWidth; + + let adjustedTop = top; + let adjustedRight = right; + + // Check bottom overflow + if (top + dropdownHeight + padding > viewportHeight) { + // Try positioning above button + const spaceAbove = buttonRect.top; + const spaceBelow = viewportHeight - buttonRect.bottom; + if (spaceAbove >= dropdownHeight + padding || spaceAbove > spaceBelow) { + adjustedTop = buttonRect.top - dropdownHeight - 4; + } else { + // Not enough space above, position at bottom of viewport + adjustedTop = viewportHeight - dropdownHeight - padding; + } } - - // Adjust if menu goes off right side of screen - if (right - dropdownRect.width < 0) { - menuPosition.right = window.innerWidth - buttonRect.left; + + // Check top overflow + if (adjustedTop < padding) { + adjustedTop = padding; + } + + // Check right overflow (menu goes off right edge) + if (left < padding) { + adjustedRight = viewportWidth - padding - dropdownWidth; } + + // Check left overflow (menu goes off left edge) + const adjustedLeft = viewportWidth - adjustedRight - dropdownWidth; + if (adjustedLeft < padding) { + adjustedRight = viewportWidth - padding - dropdownWidth; + } + + // Ensure menu doesn't go off right edge + if (adjustedRight < padding) { + adjustedRight = padding; + } + + menuPosition = { top: adjustedTop, right: adjustedRight }; }); } @@ -383,7 +437,11 @@ border-radius: 0.375rem; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); min-width: 180px; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); padding: 0.25rem; + overflow-y: auto; + overflow-x: hidden; } :global(.dark) .menu-dropdown { diff --git a/src/lib/components/write/CreateEventForm.svelte b/src/lib/components/write/CreateEventForm.svelte index e984790..3263366 100644 --- a/src/lib/components/write/CreateEventForm.svelte +++ b/src/lib/components/write/CreateEventForm.svelte @@ -15,6 +15,8 @@ import { goto } from '$app/navigation'; import { KIND } from '../../types/kind-lookup.js'; import type { NostrEvent } from '../../types/nostr.js'; + import MentionsAutocomplete from '../content/MentionsAutocomplete.svelte'; + import { extractMentions, getMentionPubkeys } from '../../services/mentions.js'; const SUPPORTED_KINDS = [ { value: 1, label: '1 - Short Text Note' }, @@ -115,6 +117,7 @@ let fileInputRef: HTMLInputElement | null = $state(null); let uploading = $state(false); let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); + let eventJson = $state('{}'); // Sync selectedKind when initialKind prop changes $effect(() => { @@ -490,7 +493,7 @@ tags = newTags; } - function getEventJson(): string { + async function getEventJson(): Promise+ {#each suggestions as suggestion, index} + + {/each} ++{ const session = sessionManager.getSession(); if (!session) return '{}'; @@ -512,6 +515,13 @@ } } + // Extract mentions and add p tags + const mentions = await extractMentions(contentWithUrls); + const mentionPubkeys = getMentionPubkeys(mentions); + for (const pubkey of mentionPubkeys) { + allTags.push(['p', pubkey]); + } + if (shouldIncludeClientTag()) { allTags.push(['client', 'aitherboard']); } @@ -653,6 +663,13 @@ } } + // Extract mentions and add p tags + const mentions = await extractMentions(contentWithUrls); + const mentionPubkeys = getMentionPubkeys(mentions); + for (const pubkey of mentionPubkeys) { + allTags.push(['p', pubkey]); + } + if (shouldIncludeClientTag()) { allTags.push(['client', 'aitherboard']); } @@ -833,6 +850,10 @@ disabled={publishing} > + {#if textareaRef} + + {/if} + -@@ -142,9 +202,62 @@ color: var(--fog-dark-accent, #94a3b8); } + .bookmarks-info { + margin-bottom: 1rem; + padding: 0.5rem 0; + } + .bookmarks-posts { display: flex; flex-direction: column; gap: 1rem; } + + .pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-top: 2rem; + padding: 1rem 0; + } + + .pagination-button { + padding: 0.5rem 1rem; + background-color: var(--fog-bg, #ffffff); + color: var(--fog-text, #1e293b); + border: 1px solid var(--fog-border, #e2e8f0); + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s; + } + + .pagination-button:hover:not(:disabled) { + background-color: var(--fog-accent, #64748b); + color: var(--fog-bg, #ffffff); + border-color: var(--fog-accent, #64748b); + } + + .pagination-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + :global(.dark) .pagination-button { + background-color: var(--fog-dark-bg, #0f172a); + color: var(--fog-dark-text, #f1f5f9); + border-color: var(--fog-dark-border, #334155); + } + + :global(.dark) .pagination-button:hover:not(:disabled) { + background-color: var(--fog-dark-accent, #94a3b8); + color: var(--fog-dark-bg, #0f172a); + border-color: var(--fog-dark-accent, #94a3b8); + } + + .pagination-info { + min-width: 100px; + text-align: center; + }{getEventJson()}+{eventJson}
Wall Posts
+ {#if loadingWall} +Loading wall...
+ {:else if wallComments.length === 0} +No wall posts yet. Be the first to write on the wall!
+ {:else} +