Browse Source

new features

master
Silberengel 1 month ago
parent
commit
15a6991d29
  1. 911
      package-lock.json
  2. 1
      package.json
  3. 4
      public/healthz.json
  4. 213
      src/lib/components/EventMenu.svelte
  5. 311
      src/lib/components/content/EmojiPicker.svelte
  6. 187
      src/lib/components/content/GifPicker.svelte
  7. 177
      src/lib/components/content/HighlightOverlay.svelte
  8. 194
      src/lib/components/content/MarkdownRenderer.svelte
  9. 143
      src/lib/components/content/MetadataCard.svelte
  10. 131
      src/lib/components/content/OpenGraphCard.svelte
  11. 3
      src/lib/components/layout/Header.svelte
  12. 405
      src/lib/components/layout/SearchBox.svelte
  13. 649
      src/lib/components/profile/ProfileEventsPanel.svelte
  14. 456
      src/lib/components/write/CreateEventForm.svelte
  15. 416
      src/lib/components/write/EditEventForm.svelte
  16. 321
      src/lib/components/write/FindEventForm.svelte
  17. 4
      src/lib/modules/comments/CommentForm.svelte
  18. 245
      src/lib/modules/feed/FeedPage.svelte
  19. 91
      src/lib/modules/feed/ThreadDrawer.svelte
  20. 60
      src/lib/modules/profiles/ProfilePage.svelte
  21. 3
      src/lib/modules/reactions/FeedReactionButtons.svelte
  22. 3
      src/lib/modules/reactions/ReactionButtons.svelte
  23. 74
      src/lib/modules/threads/ThreadList.svelte
  24. 1
      src/lib/services/auth/activity-tracker.ts
  25. 47
      src/lib/services/content/asciidoctor-renderer.ts
  26. 217
      src/lib/services/content/opengraph-fetcher.ts
  27. 154
      src/lib/services/nostr/event-hierarchy.ts
  28. 166
      src/lib/services/nostr/event-index-loader.ts
  29. 195
      src/lib/services/nostr/highlight-service.ts
  30. 241
      src/lib/services/user-actions.ts
  31. 122
      src/lib/types/kind-lookup.ts
  32. 5
      src/routes/+page.svelte
  33. 518
      src/routes/event/[id]/+page.svelte
  34. 4
      src/routes/feed/+page.svelte
  35. 190
      src/routes/replaceable/[d_tag]/+page.svelte
  36. 147
      src/routes/topics/[name]/+page.svelte
  37. 132
      src/routes/write/+page.svelte

911
package-lock.json generated

File diff suppressed because it is too large Load Diff

1
package.json

@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
"dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1",
"idb": "^8.0.0",
"asciidoctor": "3.0.x",
"marked": "^11.1.1",
"nostr-tools": "^2.22.1",
"svelte": "^5.0.0",

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-04T07:23:20.145Z",
"buildTime": "2026-02-04T08:31:34.379Z",
"gitCommit": "unknown",
"timestamp": 1770189800145
"timestamp": 1770193894379
}

213
src/lib/components/EventMenu.svelte

@ -15,6 +15,7 @@ @@ -15,6 +15,7 @@
} from '../services/user-actions.js';
import { eventMenuStore } from '../services/event-menu-store.js';
import { sessionManager } from '../services/auth/session-manager.js';
import { signAndPublish } from '../services/nostr/auth-handler.js';
import RelatedEventsModal from './modals/RelatedEventsModal.svelte';
import { KIND } from '../types/kind-lookup.js';
@ -31,6 +32,8 @@ @@ -31,6 +32,8 @@
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let broadcasting = $state(false);
let deleting = $state(false);
let deleteConfirmOpen = $state(false);
let copied = $state<string | null>(null);
let menuButtonElement: HTMLButtonElement | null = $state(null);
let menuDropdownElement: HTMLDivElement | null = $state(null);
@ -44,6 +47,8 @@ @@ -44,6 +47,8 @@
// Check if user is logged in
let isLoggedIn = $derived(sessionManager.isLoggedIn());
let currentUserPubkey = $derived(sessionManager.getCurrentPubkey());
let isOwnEvent = $derived(isLoggedIn && currentUserPubkey === event.pubkey);
// Track pin/bookmark/highlight state
let pinnedState = $state(false);
@ -224,13 +229,13 @@ @@ -224,13 +229,13 @@
}
}
function pinNote() {
pinnedState = togglePin(event.id);
async function pinNote() {
pinnedState = await togglePin(event.id);
closeMenu();
}
function bookmarkNote() {
bookmarkedState = toggleBookmark(event.id);
async function bookmarkNote() {
bookmarkedState = await toggleBookmark(event.id);
closeMenu();
}
@ -238,6 +243,49 @@ @@ -238,6 +243,49 @@
highlightedState = toggleHighlight(event.id);
closeMenu();
}
function confirmDelete() {
deleteConfirmOpen = true;
closeMenu();
}
async function deleteEvent() {
if (!isLoggedIn) return;
deleting = true;
deleteConfirmOpen = false;
try {
// Create kind 5 deletion event
const deletionEvent: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.EVENT_DELETION,
pubkey: currentUserPubkey!, // Use the current user's pubkey (the person deleting)
created_at: Math.floor(Date.now() / 1000),
tags: [['e', event.id]], // Reference the deleted event
content: ''
};
// Get all available relays for publishing
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
// Sign and publish
const results = await signAndPublish(deletionEvent, relays);
publicationResults = results;
publicationModalOpen = true;
} catch (error) {
console.error('Error deleting event:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
deleting = false;
}
}
</script>
<div class="event-menu-container">
@ -287,7 +335,7 @@ @@ -287,7 +335,7 @@
{/if}
</button>
{#if showContentActions && isContentNote}
{#if isLoggedIn && showContentActions && isContentNote}
<div class="menu-divider"></div>
<button class="menu-item" onclick={pinNote} class:active={pinnedState}>
Pin note
@ -308,6 +356,13 @@ @@ -308,6 +356,13 @@
{/if}
</button>
{/if}
{#if isLoggedIn && isOwnEvent}
<div class="menu-divider"></div>
<button class="menu-item menu-item-danger" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete event'}
</button>
{/if}
</div>
{/if}
</div>
@ -316,6 +371,37 @@ @@ -316,6 +371,37 @@
<RelatedEventsModal bind:open={relatedEventsModalOpen} event={event} />
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
{#if deleteConfirmOpen}
<div
class="delete-confirm-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="delete-dialog-title"
onclick={(e) => {
if (e.target === e.currentTarget) {
deleteConfirmOpen = false;
}
}}
onkeydown={(e) => {
if (e.key === 'Escape') {
deleteConfirmOpen = false;
}
}}
tabindex="-1"
>
<div class="delete-confirm-dialog">
<h3 id="delete-dialog-title">Delete Event?</h3>
<p>Are you sure you want to delete this event? This action cannot be undone.</p>
<div class="delete-confirm-buttons">
<button class="btn-cancel" onclick={() => deleteConfirmOpen = false}>Cancel</button>
<button class="btn-delete" onclick={deleteEvent} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
{/if}
<style>
.event-menu-container {
position: relative;
@ -446,4 +532,121 @@ @@ -446,4 +532,121 @@
:global(.dark) .copied-indicator {
color: var(--fog-dark-accent, #94a3b8);
}
.menu-item-danger {
color: var(--fog-danger, #dc2626);
}
:global(.dark) .menu-item-danger {
color: var(--fog-dark-danger, #ef4444);
}
.menu-item-danger:hover:not(:disabled) {
background: var(--fog-danger-light, #fee2e2);
}
:global(.dark) .menu-item-danger:hover:not(:disabled) {
background: var(--fog-dark-danger-light, #7f1d1d);
}
.delete-confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.delete-confirm-dialog {
background: var(--fog-post, #ffffff);
border-radius: 0.5rem;
padding: 1.5rem;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
:global(.dark) .delete-confirm-dialog {
background: var(--fog-dark-post, #1f2937);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.delete-confirm-dialog h3 {
margin: 0 0 0.5rem 0;
color: var(--fog-text, #1f2937);
font-size: 1.25rem;
}
:global(.dark) .delete-confirm-dialog h3 {
color: var(--fog-dark-text, #f9fafb);
}
.delete-confirm-dialog p {
margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .delete-confirm-dialog p {
color: var(--fog-dark-text-light, #9ca3af);
}
.delete-confirm-buttons {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.btn-cancel,
.btn-delete {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
border: none;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-cancel {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .btn-cancel {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.btn-cancel:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .btn-cancel:hover {
background: var(--fog-dark-border, #475569);
}
.btn-delete {
background: var(--fog-danger, #dc2626);
color: white;
}
:global(.dark) .btn-delete {
background: var(--fog-dark-danger, #ef4444);
}
.btn-delete:hover:not(:disabled) {
background: var(--fog-danger-dark, #b91c1c);
}
:global(.dark) .btn-delete:hover:not(:disabled) {
background: var(--fog-dark-danger-dark, #dc2626);
}
.btn-delete:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

311
src/lib/components/content/EmojiPicker.svelte

@ -3,6 +3,12 @@ @@ -3,6 +3,12 @@
import emojiNames from 'unicode-emoji-json/data-by-emoji.json';
import EmojiDrawer from './EmojiDrawer.svelte';
import { loadAllEmojiPacks, getAllCustomEmojis } from '../../services/nostr/nip30-emoji.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
open: boolean;
@ -16,6 +22,14 @@ @@ -16,6 +22,14 @@
let customEmojis = $state<Array<{ shortcode: string; url: string }>>([]);
let searchQuery = $state('');
let loadingCustomEmojis = $state(false);
let uploading = $state(false);
let uploadError: string | null = $state(null);
let fileInput: HTMLInputElement | null = $state(null);
let shortcodeInput: HTMLInputElement | null = $state(null);
let showUploadForm = $state(false);
// Check if user is logged in
let isLoggedIn = $derived(sessionManager.isLoggedIn());
// Common emojis to show first
const commonEmojis = ['❤', '🫂','😀', '😂', '😍', '🥰', '😎', '🤔', '👍', '🔥', '✨', '🎉', '💯', '👏', '🙏', '😊', '😢', '😮', '😴', '🤗', '😋'];
@ -118,6 +132,148 @@ @@ -118,6 +132,148 @@
onClose();
}
// Convert file to data URL
function fileToDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// Handle emoji file upload
async function handleEmojiUpload(e: Event) {
const target = e.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) return;
const session = sessionManager.getSession();
if (!session) {
uploadError = 'Please log in to upload emojis';
return;
}
uploading = true;
uploadError = null;
try {
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
// Fetch existing emoji set to merge with new emojis
const existingRelays = relayManager.getProfileReadRelays();
const existingEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.EMOJI_SET], authors: [session.pubkey], limit: 1 }],
existingRelays,
{ useCache: true, cacheResults: true }
);
// Collect existing emoji tags
const existingEmojiTags: string[][] = [];
if (existingEvents.length > 0) {
for (const tag of existingEvents[0].tags) {
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
existingEmojiTags.push(tag);
}
}
}
// Process each selected file
const newEmojiTags: string[][] = [];
const fileArray = Array.from(files);
for (let i = 0; i < fileArray.length; i++) {
const file = fileArray[i];
// Verify it's an image
if (!file.type.startsWith('image/')) {
uploadError = `${file.name} is not an image file`;
continue;
}
// Get shortcode: use input if single file, otherwise use filename
let shortcode = '';
if (fileArray.length === 1 && shortcodeInput && shortcodeInput.value.trim()) {
shortcode = shortcodeInput.value.trim().toLowerCase().replace(/[^a-z0-9_+-]/g, '_');
} else {
// Use filename without extension for each file
shortcode = file.name.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9_+-]/g, '_');
}
if (!shortcode) {
uploadError = `Please provide a shortcode for ${file.name}`;
continue;
}
// Check if shortcode already exists
if (existingEmojiTags.some(tag => tag[1] === shortcode) ||
newEmojiTags.some(tag => tag[1] === shortcode)) {
// Append number if duplicate
let counter = 1;
let uniqueShortcode = `${shortcode}_${counter}`;
while (existingEmojiTags.some(tag => tag[1] === uniqueShortcode) ||
newEmojiTags.some(tag => tag[1] === uniqueShortcode)) {
counter++;
uniqueShortcode = `${shortcode}_${counter}`;
}
shortcode = uniqueShortcode;
}
// Convert to data URL
const dataUrl = await fileToDataURL(file);
// Add emoji tag
newEmojiTags.push(['emoji', shortcode, dataUrl]);
}
if (newEmojiTags.length === 0) {
uploadError = 'No valid emojis to upload';
return;
}
// Merge existing and new emoji tags
const allEmojiTags = [...existingEmojiTags, ...newEmojiTags];
// Create or update kind 10030 emoji set event
const event: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.EMOJI_SET,
pubkey: session.pubkey,
created_at: existingEvents.length > 0
? existingEvents[0].created_at
: Math.floor(Date.now() / 1000),
tags: allEmojiTags,
content: ''
};
// Publish the event
await signAndPublish(event, relays);
// Reload custom emojis
await loadCustomEmojis();
// Reset form
if (fileInput) fileInput.value = '';
if (shortcodeInput) shortcodeInput.value = '';
showUploadForm = false;
} catch (error) {
console.error('Error uploading emoji:', error);
uploadError = error instanceof Error ? error.message : 'Failed to upload emoji';
} finally {
uploading = false;
}
}
function triggerEmojiUpload() {
if (showUploadForm) {
fileInput?.click();
} else {
showUploadForm = true;
}
}
// Load emojis when panel opens
$effect(() => {
if (open) {
@ -137,6 +293,7 @@ @@ -137,6 +293,7 @@
onSearchChange={handleSearchChange}
>
{#snippet children()}
<div class="emoji-picker-wrapper">
<div class="emoji-picker-content">
{#if emojis.length === 0 && (!searchQuery.trim() || customEmojis.length === 0)}
<div class="emoji-empty">No emojis found</div>
@ -177,14 +334,72 @@ @@ -177,14 +334,72 @@
{/if}
{/if}
</div>
<!-- Bottom option -->
{#if isLoggedIn}
<div class="emoji-picker-footer">
{#if showUploadForm}
<div class="emoji-upload-form">
<input
bind:this={shortcodeInput}
type="text"
placeholder="Shortcode (e.g., myemoji)"
class="emoji-shortcode-input"
/>
<button
onclick={() => fileInput?.click()}
class="emoji-footer-button"
disabled={uploading}
>
{uploading ? 'Uploading...' : 'Select Image Files'}
</button>
<button
onclick={() => { showUploadForm = false; uploadError = null; }}
class="emoji-footer-button-close"
>
Cancel
</button>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
onchange={handleEmojiUpload}
style="display: none;"
/>
{#if uploadError}
<div class="emoji-upload-error">{uploadError}</div>
{/if}
</div>
{:else}
<button
onclick={triggerEmojiUpload}
class="emoji-footer-button"
>
Add your own Emojis
</button>
{/if}
</div>
{/if}
</div>
{/snippet}
</EmojiDrawer>
<style>
.emoji-picker-wrapper {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.emoji-picker-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
flex: 1;
overflow-y: auto;
min-height: 0;
}
.custom-emojis-section {
@ -269,4 +484,100 @@ @@ -269,4 +484,100 @@
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #374151);
}
.emoji-picker-footer {
padding: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .emoji-picker-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.emoji-upload-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.emoji-shortcode-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-surface, #f8fafc);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
.emoji-shortcode-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
background: var(--fog-post, #ffffff);
}
:global(.dark) .emoji-shortcode-input {
background: var(--fog-dark-surface, #1e293b);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .emoji-shortcode-input:focus {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-accent, #64748b);
}
.emoji-footer-button {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
transition: opacity 0.2s;
}
.emoji-footer-button:hover:not(:disabled) {
opacity: 0.9;
}
.emoji-footer-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.emoji-footer-button-close {
padding: 0.5rem 1rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.emoji-footer-button-close:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .emoji-footer-button-close {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .emoji-footer-button-close:hover {
background: var(--fog-dark-border, #475569);
}
.emoji-upload-error {
color: var(--fog-error, #dc2626);
font-size: 0.75rem;
text-align: center;
}
:global(.dark) .emoji-upload-error {
color: var(--fog-dark-error, #ef4444);
}
</style>

187
src/lib/components/content/GifPicker.svelte

@ -1,6 +1,11 @@ @@ -1,6 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchGifs, searchGifs, type GifMetadata } from '../../services/nostr/gif-service.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
open: boolean;
@ -16,6 +21,12 @@ @@ -16,6 +21,12 @@
let searchInput: HTMLInputElement | null = $state(null);
let selectedGif: GifMetadata | null = $state(null);
let error: string | null = $state(null);
let uploading = $state(false);
let uploadError: string | null = $state(null);
let fileInput: HTMLInputElement | null = $state(null);
// Check if user is logged in
let isLoggedIn = $derived(sessionManager.isLoggedIn());
// Debounce search
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
@ -90,6 +101,84 @@ @@ -90,6 +101,84 @@
onClose();
}
}
// Convert file to data URL
function fileToDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// Handle file upload
async function handleFileUpload(e: Event) {
const target = e.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) return;
const session = sessionManager.getSession();
if (!session) {
uploadError = 'Please log in to upload GIFs';
return;
}
uploading = true;
uploadError = null;
try {
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
// Process each selected file
for (const file of Array.from(files)) {
// Verify it's a GIF
if (!file.type.includes('gif') && !file.name.toLowerCase().endsWith('.gif')) {
uploadError = `${file.name} is not a GIF file`;
continue;
}
// Convert to data URL
const dataUrl = await fileToDataURL(file);
// Create kind 1063 event (NIP-94 file metadata)
const event: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.FILE_METADATA,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['url', dataUrl],
['m', file.type || 'image/gif'],
['size', file.size.toString()]
],
content: ''
};
// Publish the event
await signAndPublish(event, relays);
}
// Reload GIFs to show the newly uploaded ones
await loadGifs(searchQuery);
// Reset file input
if (fileInput) {
fileInput.value = '';
}
} catch (error) {
console.error('Error uploading GIF:', error);
uploadError = error instanceof Error ? error.message : 'Failed to upload GIF';
} finally {
uploading = false;
}
}
function triggerFileUpload() {
fileInput?.click();
}
</script>
{#if open}
@ -171,6 +260,38 @@ @@ -171,6 +260,38 @@
</div>
{/if}
</div>
<!-- Bottom options -->
<div class="gif-picker-footer">
<a
href="https://www.gifbuddy.lol/"
target="_blank"
rel="noopener noreferrer"
class="gif-footer-link"
>
Search GifBuddy for more GIFs
</a>
{#if isLoggedIn}
<button
onclick={triggerFileUpload}
class="gif-footer-button"
disabled={uploading}
>
{uploading ? 'Uploading...' : 'Add your own GIFs'}
</button>
<input
bind:this={fileInput}
type="file"
accept=".gif,image/gif"
multiple
onchange={handleFileUpload}
style="display: none;"
/>
{#if uploadError}
<div class="gif-upload-error">{uploadError}</div>
{/if}
{/if}
</div>
</div>
{/if}
@ -406,4 +527,70 @@ @@ -406,4 +527,70 @@
object-fit: cover;
display: block;
}
.gif-picker-footer {
padding: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.75rem;
flex-shrink: 0;
}
:global(.dark) .gif-picker-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.gif-footer-link {
color: var(--fog-accent, #64748b);
text-decoration: none;
font-size: 0.875rem;
padding: 0.5rem;
border-radius: 0.375rem;
transition: all 0.2s;
text-align: center;
}
.gif-footer-link:hover {
background: var(--fog-highlight, #f3f4f6);
text-decoration: underline;
}
:global(.dark) .gif-footer-link {
color: var(--fog-dark-accent, #94a3b8);
}
:global(.dark) .gif-footer-link:hover {
background: var(--fog-dark-highlight, #374151);
}
.gif-footer-button {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
transition: opacity 0.2s;
}
.gif-footer-button:hover:not(:disabled) {
opacity: 0.9;
}
.gif-footer-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.gif-upload-error {
color: var(--fog-error, #dc2626);
font-size: 0.75rem;
text-align: center;
}
:global(.dark) .gif-upload-error {
color: var(--fog-dark-error, #ef4444);
}
</style>

177
src/lib/components/content/HighlightOverlay.svelte

@ -0,0 +1,177 @@ @@ -0,0 +1,177 @@
<script lang="ts">
import ProfileBadge from '../layout/ProfileBadge.svelte';
import ThreadDrawer from '../../modules/feed/ThreadDrawer.svelte';
import type { Highlight } from '../../services/nostr/highlight-service.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
highlights: Array<{ start: number; end: number; highlight: Highlight }>;
content: string;
event: NostrEvent;
}
let { highlights, content, event }: Props = $props();
let containerRef = $state<HTMLElement | null>(null);
let drawerOpen = $state(false);
let selectedHighlight = $state<Highlight | null>(null);
let hoveredHighlight = $state<Highlight | null>(null);
let tooltipPosition = $state({ top: 0, left: 0 });
function openHighlight(highlight: Highlight) {
selectedHighlight = highlight;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
selectedHighlight = null;
}
// Apply highlights to rendered HTML content
// This runs after the HTML is rendered
$effect(() => {
if (!containerRef || highlights.length === 0) return;
// Wait for content to be rendered
const timeoutId = setTimeout(() => {
if (!containerRef) return;
// For each highlight, try to find and wrap the text in the rendered HTML
for (const { start, end, highlight } of highlights) {
const highlightText = content.substring(start, end);
if (!highlightText) continue;
// Search for this text in the rendered HTML
const walker = document.createTreeWalker(
containerRef,
NodeFilter.SHOW_TEXT,
null
);
let node;
while ((node = walker.nextNode())) {
const text = node.textContent || '';
const index = text.indexOf(highlightText);
if (index !== -1) {
// Found the text, wrap it
const span = document.createElement('span');
span.className = 'highlight-span';
span.setAttribute('data-highlight-id', highlight.event.id);
span.setAttribute('data-pubkey', highlight.pubkey);
span.textContent = highlightText;
// Add event listeners
span.addEventListener('mouseenter', (e) => {
const rect = (e.target as HTMLElement).getBoundingClientRect();
tooltipPosition = { top: rect.top - 40, left: rect.left };
hoveredHighlight = highlight;
});
span.addEventListener('mouseleave', () => {
hoveredHighlight = null;
});
span.addEventListener('click', () => {
openHighlight(highlight);
});
// Replace text node
const beforeText = text.substring(0, index);
const afterText = text.substring(index + highlightText.length);
if (beforeText) {
node.parentNode?.insertBefore(document.createTextNode(beforeText), node);
}
node.parentNode?.insertBefore(span, node);
if (afterText) {
node.parentNode?.insertBefore(document.createTextNode(afterText), node);
}
node.remove();
break; // Only wrap first occurrence
}
}
}
}, 500);
return () => clearTimeout(timeoutId);
});
</script>
<div class="highlight-overlay" bind:this={containerRef}>
<slot />
{#if hoveredHighlight}
<div
class="highlight-tooltip"
style="top: {tooltipPosition.top}px; left: {tooltipPosition.left}px;"
>
<ProfileBadge pubkey={hoveredHighlight.pubkey} />
<button class="view-highlight-button" onclick={() => openHighlight(hoveredHighlight)}>
View the highlight
</button>
</div>
{/if}
</div>
{#if drawerOpen && selectedHighlight}
<ThreadDrawer
opEvent={selectedHighlight.event}
isOpen={drawerOpen}
onClose={closeDrawer}
/>
{/if}
<style>
:global(.highlight-span) {
background: rgba(255, 255, 0, 0.3);
cursor: pointer;
transition: background 0.2s;
}
:global(.highlight-span:hover) {
background: rgba(255, 255, 0, 0.5);
}
.highlight-tooltip {
position: fixed;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 0.75rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 200px;
}
:global(.dark) .highlight-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);
}
.view-highlight-button {
padding: 0.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .view-highlight-button {
background: var(--fog-dark-accent, #94a3b8);
}
.view-highlight-button:hover {
opacity: 0.9;
}
</style>

194
src/lib/components/content/MarkdownRenderer.svelte

@ -5,8 +5,13 @@ @@ -5,8 +5,13 @@
import { nip19 } from 'nostr-tools';
import ProfileBadge from '../layout/ProfileBadge.svelte';
import EmbeddedEvent from './EmbeddedEvent.svelte';
import { mountComponent } from './mount-component-action.js';
import { fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js';
import { renderAsciiDoc } from '../../services/content/asciidoctor-renderer.js';
import { fetchOpenGraph, type OpenGraphData } from '../../services/content/opengraph-fetcher.js';
import OpenGraphCard from './OpenGraphCard.svelte';
import HighlightOverlay from './HighlightOverlay.svelte';
import { getHighlightsForEvent, findHighlightMatches, type Highlight } from '../../services/nostr/highlight-service.js';
import { mountComponent } from './mount-component-action.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
@ -17,6 +22,10 @@ @@ -17,6 +22,10 @@
let { content, event }: Props = $props();
let containerRef = $state<HTMLElement | null>(null);
let emojiUrls = $state<Map<string, string>>(new Map());
let openGraphData = $state<Map<string, OpenGraphData>>(new Map());
let highlights = $state<Highlight[]>([]);
let highlightMatches = $state<Array<{ start: number; end: number; highlight: Highlight }>>([]);
let highlightsLoaded = $state(false);
// Extract pubkey from npub or nprofile
function getPubkeyFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null {
@ -192,11 +201,34 @@ @@ -192,11 +201,34 @@
return processed;
}
// Convert hashtags (#hashtag) to links
function convertHashtags(text: string): string {
// Match hashtags: # followed by alphanumeric characters, underscores, and hyphens
// Don't match if it's already in a link or markdown
const hashtagPattern = /(^|\s)(#[a-zA-Z0-9_-]+)/g;
return text.replace(hashtagPattern, (match, prefix, hashtag) => {
// Skip if it's already in a link or markdown
const before = text.substring(Math.max(0, text.indexOf(match) - 20), text.indexOf(match));
if (before.includes('<a') || before.includes('](') || before.includes('href=')) {
return match;
}
const tagName = hashtag.substring(1); // Remove #
const escapedTag = escapeHtml(tagName);
const escapedHashtag = escapeHtml(hashtag);
return `${prefix}<a href="/topics/${escapedTag}" class="hashtag-link">${escapedHashtag}</a>`;
});
}
// Process content: replace nostr URIs with HTML span elements and convert media URLs
function processContent(text: string): string {
// First, replace emoji shortcodes with images if resolved
let processed = replaceEmojis(text);
// Convert hashtags to links
processed = convertHashtags(processed);
// Then, convert plain media URLs (images, videos, audio) to HTML tags
processed = convertMediaUrls(processed);
@ -270,20 +302,97 @@ @@ -270,20 +302,97 @@
}
});
// Render markdown to HTML
// Check if event should use Asciidoctor (kinds 30818 and 30041)
const useAsciidoctor = $derived(event?.kind === 30818 || event?.kind === 30041);
const isKind30040 = $derived(event?.kind === 30040);
// Load highlights for event
async function loadHighlights(abortSignal: AbortSignal) {
if (!event || !content) return;
// Skip highlights for kind 30040 index events (they have no content)
// Highlights should be loaded for the indexed sections instead
if (isKind30040 && !content.trim()) {
if (!abortSignal.aborted) {
highlightsLoaded = true;
}
return;
}
// For kind 30040 sections, wait a bit for full publication load
if (isKind30040) {
await new Promise(resolve => setTimeout(resolve, 2000));
// Check if operation was aborted after delay
if (abortSignal.aborted) return;
}
try {
// Load highlights for this specific event (which could be a section event)
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1];
const eventHighlights = await getHighlightsForEvent(
event.id,
event.kind,
event.pubkey,
dTag
);
// Check if operation was aborted before updating state
if (abortSignal.aborted) return;
highlights = eventHighlights;
highlightMatches = findHighlightMatches(content, eventHighlights);
highlightsLoaded = true;
} catch (error) {
// Only update state if not aborted
if (abortSignal.aborted) return;
console.error('Error loading highlights:', error);
highlightsLoaded = true;
}
}
// Load highlights when content or event changes
$effect(() => {
if (content && event) {
// Create abort controller to track effect lifecycle
const abortController = new AbortController();
// Load highlights with abort signal
loadHighlights(abortController.signal);
// Cleanup: abort the async operation if effect re-runs or component unmounts
return () => {
abortController.abort();
};
}
});
// Render markdown or AsciiDoc to HTML
function renderMarkdown(text: string): string {
if (!content) return '';
const processed = processContent(content);
// Render markdown - this should convert ![alt](url) to <img src="url" alt="alt">
let html: string;
if (useAsciidoctor) {
// Use Asciidoctor for kinds 30818 and 30041
try {
html = renderAsciiDoc(processed);
} catch (error) {
console.error('Asciidoctor parsing error:', error);
return processed; // Fallback to raw text if parsing fails
}
} else {
// Use marked for all other kinds
try {
html = marked.parse(processed) as string;
} catch (error) {
console.error('Marked parsing error:', error);
return processed; // Fallback to raw text if parsing fails
}
}
// Sanitize HTML (but preserve our data attributes and image src)
const sanitized = sanitizeMarkdown(html);
@ -378,6 +487,61 @@ @@ -378,6 +487,61 @@
}
}
// Detect and mount OpenGraph cards for standalone URLs
async function mountOpenGraphCards() {
if (!containerRef) return;
// Find all links that look like standalone URLs (not in lists, not markdown images)
const links = containerRef.querySelectorAll('a[href^="http"]:not([data-opengraph-processed])');
for (const link of links) {
const href = link.getAttribute('href');
if (!href) continue;
// Skip if it's an image link or in a list
const parent = link.parentElement;
if (parent?.tagName === 'LI' || parent?.querySelector('img')) {
continue;
}
// Check if it's a standalone link (not part of a paragraph with other content)
const text = link.textContent?.trim() || '';
const parentText = parent?.textContent?.trim() || '';
if (parentText.length > text.length * 1.5) {
// Link is part of larger text, skip
continue;
}
link.setAttribute('data-opengraph-processed', 'true');
// Fetch OpenGraph data
try {
const ogData = await fetchOpenGraph(href);
if (ogData && (ogData.title || ogData.description || ogData.image)) {
// Create placeholder for OpenGraph card
const placeholder = document.createElement('div');
placeholder.setAttribute('data-opengraph-url', href);
placeholder.className = 'opengraph-placeholder';
// Insert card after the link
link.parentNode?.insertBefore(placeholder, link.nextSibling);
// Store data and mount component
openGraphData.set(href, ogData);
// Mount OpenGraphCard component
try {
mountComponent(placeholder, OpenGraphCard as any, { data: ogData, url: href });
} catch (error) {
console.error('Error mounting OpenGraphCard:', error);
}
}
} catch (error) {
console.debug('Error fetching OpenGraph data:', error);
}
}
}
$effect(() => {
if (!containerRef || !renderedHtml) return;
@ -386,6 +550,7 @@ @@ -386,6 +550,7 @@
const timeoutId = setTimeout(() => {
mountProfileBadges();
mountEmbeddedEvents();
mountOpenGraphCards();
}, 150);
return () => clearTimeout(timeoutId);
@ -401,6 +566,7 @@ @@ -401,6 +566,7 @@
const observer = new MutationObserver(() => {
mountProfileBadges();
mountEmbeddedEvents();
mountOpenGraphCards();
});
observer.observe(containerRef, {
@ -412,12 +578,14 @@ @@ -412,12 +578,14 @@
});
</script>
<HighlightOverlay highlights={highlightMatches} content={content} event={event!}>
<div
bind:this={containerRef}
class="markdown-content prose prose-sm dark:prose-invert max-w-none"
>
{@html renderedHtml}
</div>
</HighlightOverlay>
<style>
:global(.markdown-content) {
@ -469,6 +637,20 @@ @@ -469,6 +637,20 @@
text-decoration: none;
}
:global(.markdown-content a.hashtag-link) {
color: var(--fog-accent, #64748b);
text-decoration: none;
font-weight: 500;
}
:global(.markdown-content a.hashtag-link:hover) {
text-decoration: underline;
}
:global(.dark .markdown-content a.hashtag-link) {
color: var(--fog-dark-accent, #94a3b8);
}
:global(.markdown-content code) {
background: var(--fog-border, #e5e7eb);
padding: 0.125rem 0.25rem;
@ -523,6 +705,12 @@ @@ -523,6 +705,12 @@
margin: 1rem 0;
}
/* OpenGraph card placeholders */
:global(.markdown-content .opengraph-placeholder) {
display: block;
margin: 1rem 0;
}
/* Ensure normal Unicode emojis are displayed correctly */
:global(.markdown-content) {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif;

143
src/lib/components/content/MetadataCard.svelte

@ -0,0 +1,143 @@ @@ -0,0 +1,143 @@
<script lang="ts">
import type { NostrEvent } from '../../types/nostr.js';
import EventMenu from '../EventMenu.svelte';
interface Props {
event: NostrEvent;
showMenu?: boolean;
}
let { event, showMenu = true }: Props = $props();
// Extract metadata tags (using $derived for reactivity)
const image = $derived(event.tags.find(t => t[0] === 'image' && t[1])?.[1]);
const description = $derived(event.tags.find(t => t[0] === 'description' && t[1])?.[1]);
const summary = $derived(event.tags.find(t => t[0] === 'summary' && t[1])?.[1]);
const author = $derived(event.tags.find(t => t[0] === 'author' && t[1])?.[1]);
const title = $derived(
event.tags.find(t => t[0] === 'title' && t[1])?.[1] ||
(() => {
// Fallback to d-tag in Title Case
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1];
if (dTag) {
return dTag.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ');
}
return null;
})()
);
const hasMetadata = $derived(image || description || summary || author || title);
</script>
{#if hasMetadata}
<div class="metadata-card">
<div class="metadata-header">
{#if title}
<h2 class="metadata-title">{title}</h2>
{/if}
{#if showMenu}
<EventMenu event={event} showContentActions={false} />
{/if}
</div>
{#if image}
<div class="metadata-image">
<img
src={image}
alt={title || description || summary || 'Metadata image'}
loading="lazy"
/>
</div>
{/if}
<div class="metadata-content">
{#if description}
<p class="metadata-description">{description}</p>
{/if}
{#if summary}
<p class="metadata-summary">{summary}</p>
{/if}
{#if author}
<p class="metadata-author">Author: {author}</p>
{/if}
</div>
</div>
{/if}
<style>
.metadata-card {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .metadata-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.metadata-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.metadata-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
flex: 1;
}
:global(.dark) .metadata-title {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-image {
margin-bottom: 1rem;
border-radius: 0.5rem;
overflow: hidden;
}
.metadata-image img {
width: 100%;
height: auto;
display: block;
}
.metadata-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.metadata-description,
.metadata-summary {
margin: 0;
color: var(--fog-text, #1f2937);
line-height: 1.6;
}
:global(.dark) .metadata-description,
:global(.dark) .metadata-summary {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-author {
margin: 0;
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .metadata-author {
color: var(--fog-dark-text-light, #9ca3af);
}
</style>

131
src/lib/components/content/OpenGraphCard.svelte

@ -0,0 +1,131 @@ @@ -0,0 +1,131 @@
<script lang="ts">
import type { OpenGraphData } from '../../services/content/opengraph-fetcher.js';
interface Props {
data: OpenGraphData;
url: string;
}
let { data, url }: Props = $props();
</script>
<a href={url} target="_blank" rel="noopener noreferrer" class="opengraph-card">
{#if data.image}
<div class="opengraph-image">
<img src={data.image} alt={data.title || ''} loading="lazy" />
</div>
{/if}
<div class="opengraph-content">
{#if data.siteName}
<div class="opengraph-site">{data.siteName}</div>
{/if}
{#if data.title}
<div class="opengraph-title">{data.title}</div>
{/if}
{#if data.description}
<div class="opengraph-description">{data.description}</div>
{/if}
</div>
</a>
<style>
.opengraph-card {
display: flex;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
margin: 1rem 0;
text-decoration: none;
color: inherit;
transition: all 0.2s;
background: var(--fog-post, #ffffff);
}
:global(.dark) .opengraph-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.opengraph-card:hover {
border-color: var(--fog-accent, #64748b);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:global(.dark) .opengraph-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.opengraph-image {
flex-shrink: 0;
width: 200px;
height: 150px;
overflow: hidden;
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .opengraph-image {
background: var(--fog-dark-border, #374151);
}
.opengraph-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.opengraph-content {
flex: 1;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.opengraph-site {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
:global(.dark) .opengraph-site {
color: var(--fog-dark-text-light, #9ca3af);
}
.opengraph-title {
font-size: 1rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
line-height: 1.4;
}
:global(.dark) .opengraph-title {
color: var(--fog-dark-text, #f9fafb);
}
.opengraph-description {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
:global(.dark) .opengraph-description {
color: var(--fog-dark-text-light, #9ca3af);
}
@media (max-width: 640px) {
.opengraph-card {
flex-direction: column;
}
.opengraph-image {
width: 100%;
height: 200px;
}
}
</style>

3
src/lib/components/layout/Header.svelte

@ -39,6 +39,9 @@ @@ -39,6 +39,9 @@
<a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">Aitherboard</a>
<a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Threads</a>
<a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Feed</a>
{#if isLoggedIn}
<a href="/write" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Write</a>
{/if}
</div>
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm">
{#if isLoggedIn && currentPubkey}

405
src/lib/components/layout/SearchBox.svelte

@ -0,0 +1,405 @@ @@ -0,0 +1,405 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { getEvent, getEventsByKind, getEventsByPubkey } from '../../services/cache/event-cache.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
let searchQuery = $state('');
let searching = $state(false);
let searchResults = $state<Array<{ event: NostrEvent; matchType: string }>>([]);
let showResults = $state(false);
let searchInput: HTMLInputElement | null = $state(null);
// Decode bech32 identifiers
function decodeIdentifier(input: string): { type: 'event' | 'profile' | null; id: string | null; pubkey: string | null } {
const trimmed = input.trim();
// Check if it's a hex event ID (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return { type: 'event', id: trimmed.toLowerCase(), pubkey: null };
}
// Check if it's a hex pubkey (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return { type: 'profile', id: null, pubkey: trimmed.toLowerCase() };
}
// Check if it's a bech32 encoded format
if (/^(note|nevent|naddr|npub|nprofile)1[a-z0-9]+$/i.test(trimmed)) {
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'note') {
return { type: 'event', id: String(decoded.data), pubkey: null };
} else if (decoded.type === 'nevent') {
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
return { type: 'event', id: String(decoded.data.id), pubkey: null };
}
} else if (decoded.type === 'naddr') {
// naddr requires fetching by kind+pubkey+d, but we can try to find it
// For now, return null - we'll handle it in search
return { type: null, id: null, pubkey: null };
} else if (decoded.type === 'npub') {
return { type: 'profile', id: null, pubkey: String(decoded.data) };
} else if (decoded.type === 'nprofile') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
return { type: 'profile', id: null, pubkey: String(decoded.data.pubkey) };
}
}
} catch (error) {
console.error('Error decoding bech32:', error);
}
}
return { type: null, id: null, pubkey: null };
}
async function performSearch() {
if (!searchQuery.trim()) {
searchResults = [];
showResults = false;
return;
}
searching = true;
searchResults = [];
showResults = true;
try {
const query = searchQuery.trim();
// First, try to decode as specific identifier
const decoded = decodeIdentifier(query);
if (decoded.type === 'event' && decoded.id) {
// Search for specific event ID
let event = await getEvent(decoded.id);
if (!event) {
// Not in cache, fetch from relays
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [decoded.id] }],
relays,
{ useCache: false, cacheResults: true }
);
if (events.length > 0) {
event = events[0];
await cacheEvent(event);
}
}
if (event) {
searchResults = [{ event, matchType: 'Event ID' }];
}
} else if (decoded.type === 'profile' && decoded.pubkey) {
// Search for profile - navigate directly to profile page
handleProfileClick(decoded.pubkey);
return;
} else {
// Text search in cached events
const allCached: NostrEvent[] = [];
// Search kind 1 events
const kind1Events = await getEventsByKind(KIND.SHORT_TEXT_NOTE, 100);
allCached.push(...kind1Events);
// Search kind 11 events
const kind11Events = await getEventsByKind(KIND.DISCUSSION_THREAD, 100);
allCached.push(...kind11Events);
// Filter by search query
const queryLower = query.toLowerCase();
const matches = allCached.filter(event => {
const contentMatch = event.content.toLowerCase().includes(queryLower);
const tagMatch = event.tags.some(tag =>
tag.some(val => val && val.toLowerCase().includes(queryLower))
);
return contentMatch || tagMatch;
});
// Sort by relevance (exact matches first, then by created_at)
const sorted = matches.sort((a, b) => {
const aExact = a.content.toLowerCase() === queryLower;
const bExact = b.content.toLowerCase() === queryLower;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return b.created_at - a.created_at;
});
searchResults = sorted.slice(0, 20).map(e => ({ event: e, matchType: 'Content' }));
}
} catch (error) {
console.error('Search error:', error);
} finally {
searching = false;
}
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
searchQuery = target.value;
// Debounce search
if (searchQuery.trim()) {
performSearch();
} else {
searchResults = [];
showResults = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
performSearch();
} else if (e.key === 'Escape') {
showResults = false;
searchQuery = '';
}
}
function handleResultClick(event: NostrEvent) {
showResults = false;
searchQuery = '';
goto(`/event/${event.id}`);
}
function handleProfileClick(pubkey: string) {
showResults = false;
searchQuery = '';
goto(`/profile/${pubkey}`);
}
// Close results when clicking outside
$effect(() => {
if (showResults) {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.search-box-container')) {
showResults = false;
}
};
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}
});
</script>
<div class="search-box-container">
<div class="search-input-wrapper">
<input
bind:this={searchInput}
type="text"
placeholder="Search events, profiles, or enter event ID (hex, note, nevent, npub, nprofile)..."
value={searchQuery}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
class="search-input"
aria-label="Search"
/>
{#if searching}
<span class="search-loading"></span>
{/if}
</div>
{#if showResults && searchResults.length > 0}
<div class="search-results">
{#each searchResults as { event, matchType }}
<button
onclick={() => {
if (matchType === 'Profile' || event.kind === KIND.METADATA) {
handleProfileClick(event.pubkey);
} else {
handleResultClick(event);
}
}}
class="search-result-item"
>
<div class="search-result-header">
<span class="search-result-type">{matchType}</span>
<span class="search-result-id">{event.id.substring(0, 16)}...</span>
</div>
<div class="search-result-content">
{event.content.substring(0, 100)}{event.content.length > 100 ? '...' : ''}
</div>
<div class="search-result-meta">
Kind {event.kind}{new Date(event.created_at * 1000).toLocaleDateString()}
</div>
</button>
{/each}
</div>
{:else if showResults && !searching && searchQuery.trim()}
<div class="search-results">
<div class="search-no-results">No results found</div>
</div>
{/if}
</div>
<style>
.search-box-container {
position: relative;
width: 100%;
max-width: 600px;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .search-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .search-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.search-loading {
position: absolute;
right: 1rem;
color: var(--fog-text-light, #9ca3af);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
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);
max-height: 400px;
overflow-y: auto;
z-index: 1000;
}
:global(.dark) .search-results {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.search-result-item {
width: 100%;
padding: 0.75rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .search-result-item {
border-bottom-color: var(--fog-dark-border, #374151);
}
:global(.dark) .search-result-item:hover {
background: var(--fog-dark-highlight, #374151);
}
.search-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.search-result-type {
font-size: 0.75rem;
font-weight: 600;
color: var(--fog-accent, #64748b);
text-transform: uppercase;
}
:global(.dark) .search-result-type {
color: var(--fog-dark-accent, #94a3b8);
}
.search-result-id {
font-size: 0.75rem;
font-family: monospace;
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .search-result-id {
color: var(--fog-dark-text-light, #6b7280);
}
.search-result-content {
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
margin-bottom: 0.25rem;
line-height: 1.4;
}
:global(.dark) .search-result-content {
color: var(--fog-dark-text, #f9fafb);
}
.search-result-meta {
font-size: 0.75rem;
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .search-result-meta {
color: var(--fog-dark-text-light, #6b7280);
}
.search-no-results {
padding: 1rem;
text-align: center;
color: var(--fog-text-light, #9ca3af);
font-size: 0.875rem;
}
:global(.dark) .search-no-results {
color: var(--fog-dark-text-light, #6b7280);
}
</style>

649
src/lib/components/profile/ProfileEventsPanel.svelte

@ -0,0 +1,649 @@ @@ -0,0 +1,649 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte';
import { goto } from '$app/navigation';
import { KIND, isParameterizedReplaceableKind } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
isOpen: boolean;
pubkey: string;
onClose: () => void;
}
let { isOpen, pubkey, onClose }: Props = $props();
const PROFILE_EVENT_KINDS = [
{ kind: 0, name: 'Metadata (Profile)' },
{ kind: 3, name: 'Contacts' },
{ kind: 30315, name: 'User Status' },
{ kind: 10133, name: 'Payment Addresses' },
{ kind: 10002, name: 'Relay List' },
{ kind: 10432, name: 'Local Relays' },
{ kind: 10001, name: 'Pin List' },
{ kind: 10003, name: 'Bookmarks' },
{ kind: 10895, name: 'RSS Feed' },
{ kind: 10015, name: 'Interest List' },
{ kind: 10030, name: 'Emoji Set' },
{ kind: 30030, name: 'Emoji Pack' },
{ kind: 10000, name: 'Mute List' },
{ kind: 30008, name: 'Badges' },
{ kind: 30000, name: 'Follow Set' }
];
let selectedKind = $state<number | null>(null);
let editingEvent = $state<NostrEvent | null>(null);
let content = $state('');
let tags = $state<string[][]>([]);
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
async function selectKind(kind: number) {
selectedKind = kind;
// Load existing event(s)
// For parameterized replaceable events (30000-39999), user can have multiple, so we load all
// For other kinds, typically one per user
try {
const relays = relayManager.getProfileReadRelays();
const limit = isParameterizedReplaceableKind(kind) ? 50 : 1; // Load multiple for parameterized replaceable events
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], limit }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
// Get newest version (or first if multiple allowed)
editingEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
content = editingEvent.content || '';
tags = [...editingEvent.tags];
} else {
editingEvent = null;
content = '';
tags = [];
}
} catch (error) {
console.error('Error loading event:', error);
editingEvent = null;
content = '';
tags = [];
}
}
function addTag() {
tags = [...tags, ['', '']];
}
function removeTag(index: number) {
tags = tags.filter((_, i) => i !== index);
}
function updateTag(index: number, field: number, value: string) {
const newTags = [...tags];
if (!newTags[index]) {
newTags[index] = ['', ''];
}
newTags[index] = [...newTags[index]];
newTags[index][field] = value;
while (newTags[index].length <= field) {
newTags[index].push('');
}
tags = newTags;
}
async function publish() {
if (selectedKind === null) return;
const session = sessionManager.getSession();
if (!session || session.pubkey !== pubkey) {
alert('You can only edit your own profile events');
return;
}
publishing = true;
try {
// Always use a new timestamp for newly-published events
// This ensures the event is considered "newer" and replaces older versions for replaceable events
const created_at = Math.floor(Date.now() / 1000);
const eventTemplate = {
kind: selectedKind,
pubkey: session.pubkey,
created_at,
tags: tags.filter(t => t[0] && t[1]),
content
};
const signedEvent = await session.signer(eventTemplate);
await cacheEvent(signedEvent);
const relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
publicationModalOpen = true;
if (results.success.length > 0) {
setTimeout(() => {
goto(`/event/${signedEvent.id}`);
}, 5000);
}
} catch (error) {
console.error('Error publishing event:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
publishing = false;
}
}
async function republishFromCache() {
if (!publicationResults || selectedKind === null) return;
publishing = true;
try {
const session = sessionManager.getSession();
if (!session) return;
const relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
// Always use a new timestamp for newly-published events
const eventTemplate = {
kind: selectedKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] && t[1]),
content
};
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
} catch (error) {
console.error('Error republishing:', error);
} finally {
publishing = false;
}
}
function closeForm() {
selectedKind = null;
editingEvent = null;
content = '';
tags = [];
}
</script>
{#if isOpen}
<div
class="panel-backdrop"
onclick={onClose}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClose();
}
}}
role="button"
tabindex="0"
aria-label="Close panel"
></div>
<div class="profile-events-panel">
<div class="panel-header">
<h2 class="panel-title">Adjust Profile Events</h2>
<button class="panel-close" onclick={onClose}>×</button>
</div>
<div class="panel-content">
{#if selectedKind === null}
<div class="kind-list">
{#each PROFILE_EVENT_KINDS as { kind, name }}
<button class="kind-button" onclick={() => selectKind(kind)}>
{name} (Kind {kind})
</button>
{/each}
</div>
{:else}
<div class="edit-form">
<button class="back-button" onclick={closeForm}> Back</button>
<h3 class="form-title">Edit Kind {selectedKind}</h3>
<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>
<div class="form-group">
<fieldset>
<legend class="form-label">Tags</legend>
<div class="tags-list">
{#each tags as tag, index (index)}
<div class="tag-row">
<input
type="text"
value={tag[0] || ''}
oninput={(e) => updateTag(index, 0, e.currentTarget.value)}
placeholder="Tag name"
class="tag-input"
/>
{#each tag.slice(1) as value, valueIndex}
<input
type="text"
value={value || ''}
oninput={(e) => updateTag(index, valueIndex + 1, e.currentTarget.value)}
placeholder="Tag value"
class="tag-input"
/>
{/each}
<button class="tag-add-value" onclick={() => {
const newTags = [...tags];
newTags[index] = [...newTags[index], ''];
tags = newTags;
}}>+</button>
<button class="tag-remove" onclick={() => removeTag(index)}>×</button>
</div>
{/each}
<button class="add-tag-button" onclick={addTag}>Add Tag</button>
</div>
</fieldset>
</div>
<div class="form-actions">
<button
class="publish-button"
onclick={publish}
disabled={publishing}
>
{publishing ? 'Publishing...' : 'Publish'}
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
{#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>
<button class="republish-button" onclick={republishFromCache} disabled={publishing}>
Republish from Cache
</button>
</div>
{/if}
<style>
.panel-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.profile-events-panel {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(500px, 90vw);
background: var(--fog-post, #ffffff);
border-right: 2px solid var(--fog-border, #cbd5e1);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .profile-events-panel {
background: var(--fog-dark-post, #1f2937);
border-right-color: var(--fog-dark-border, #475569);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .panel-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.panel-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .panel-title {
color: var(--fog-dark-text, #f9fafb);
}
.panel-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.panel-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .panel-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .panel-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.panel-content {
overflow-y: auto;
flex: 1;
padding: 1rem;
}
.kind-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kind-button {
padding: 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;
text-align: left;
transition: all 0.2s;
}
:global(.dark) .kind-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.kind-button:hover {
border-color: var(--fog-accent, #64748b);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .kind-button:hover {
border-color: var(--fog-dark-accent, #94a3b8);
background: var(--fog-dark-highlight, #374151);
}
.edit-form {
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);
}
.form-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .form-title {
color: var(--fog-dark-text, #f9fafb);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 500;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .form-label {
color: var(--fog-dark-text, #f9fafb);
}
.content-input {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: monospace;
resize: vertical;
}
:global(.dark) .content-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.tags-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.tag-input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .tag-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.tag-add-value,
.tag-remove {
padding: 0.5rem;
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;
min-width: 2rem;
}
:global(.dark) .tag-add-value,
:global(.dark) .tag-remove {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.tag-add-value:hover,
.tag-remove:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .tag-add-value:hover,
:global(.dark) .tag-remove:hover {
background: var(--fog-dark-border, #475569);
}
.add-tag-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;
align-self: flex-start;
}
:global(.dark) .add-tag-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.add-tag-button:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .add-tag-button:hover {
background: var(--fog-dark-border, #475569);
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.publish-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
:global(.dark) .publish-button {
background: var(--fog-dark-accent, #94a3b8);
}
.publish-button:hover:not(:disabled) {
opacity: 0.9;
}
.publish-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.republish-section {
margin-top: 1rem;
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .republish-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.republish-text {
margin: 0 0 0.5rem 0;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .republish-text {
color: var(--fog-dark-text, #f9fafb);
}
.republish-button {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .republish-button {
background: var(--fog-dark-accent, #94a3b8);
}
.republish-button:hover:not(:disabled) {
opacity: 0.9;
}
.republish-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

456
src/lib/components/write/CreateEventForm.svelte

@ -0,0 +1,456 @@ @@ -0,0 +1,456 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } 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 { goto } from '$app/navigation';
import { KIND } from '../../types/kind-lookup.js';
const SUPPORTED_KINDS = [
{ value: 1, label: '1 - Short Text Note' },
{ value: 11, label: '11 - Discussion Thread' },
{ value: 9802, label: '9802 - Highlighted Article' },
{ value: 1222, label: '1222 - Voice Note' },
{ value: 20, label: '20 - Picture Note' },
{ value: 21, label: '21 - Video Note' },
{ value: 22, label: '22 - Short Video Note' },
{ value: 30023, label: '30023 - Long-form Note' },
{ value: 30818, label: '30818 - AsciiDoc' },
{ value: 30817, label: '30817 - AsciiDoc' },
{ value: 30041, label: '30041 - AsciiDoc' },
{ value: 30040, label: '30040 - Event Index (metadata-only)' },
{ value: 1068, label: '1068 - Poll' }
];
let selectedKind = $state<number>(1);
let content = $state('');
let tags = $state<string[][]>([]);
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
const isKind30040 = $derived(selectedKind === 30040);
function addTag() {
tags = [...tags, ['', '']];
}
function removeTag(index: number) {
tags = tags.filter((_, i) => i !== index);
}
function updateTag(index: number, field: number, value: string) {
const newTags = [...tags];
if (!newTags[index]) {
newTags[index] = ['', ''];
}
newTags[index] = [...newTags[index]];
newTags[index][field] = value;
while (newTags[index].length <= field) {
newTags[index].push('');
}
tags = newTags;
}
async function publish() {
const session = sessionManager.getSession();
if (!session) {
alert('You must be logged in to publish events');
return;
}
publishing = true;
try {
const eventTemplate = {
kind: selectedKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] && t[1]),
content
};
const signedEvent = await session.signer(eventTemplate);
await cacheEvent(signedEvent);
const relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
publicationModalOpen = true;
if (results.success.length > 0) {
setTimeout(() => {
goto(`/event/${signedEvent.id}`);
}, 5000);
}
} catch (error) {
console.error('Error publishing event:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
publishing = false;
}
}
async function republishFromCache() {
if (!publicationResults) return;
publishing = true;
try {
const relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
const session = sessionManager.getSession();
if (!session) return;
const eventTemplate = {
kind: selectedKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] && t[1]),
content
};
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
} catch (error) {
console.error('Error republishing:', error);
} finally {
publishing = false;
}
}
</script>
<div class="create-form">
<h2 class="form-title">Create Event</h2>
<div class="form-group">
<label for="kind-select" class="form-label">Kind</label>
<select id="kind-select" bind:value={selectedKind} class="kind-select">
{#each SUPPORTED_KINDS as kind}
<option value={kind.value}>{kind.label}</option>
{/each}
</select>
{#if isKind30040}
<p class="help-text">Note: Kind 30040 is metadata-only. Sections must be added manually using the edit function.</p>
{/if}
</div>
<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..."
disabled={isKind30040}
></textarea>
</div>
<div class="form-group">
<fieldset>
<legend class="form-label">Tags</legend>
<div class="tags-list">
{#each tags as tag, index (index)}
<div class="tag-row">
<input
type="text"
value={tag[0] || ''}
oninput={(e) => updateTag(index, 0, e.currentTarget.value)}
placeholder="Tag name"
class="tag-input"
/>
{#each tag.slice(1) as value, valueIndex}
<input
type="text"
value={value || ''}
oninput={(e) => updateTag(index, valueIndex + 1, e.currentTarget.value)}
placeholder="Tag value"
class="tag-input"
/>
{/each}
<button class="tag-add-value" onclick={() => {
const newTags = [...tags];
newTags[index] = [...newTags[index], ''];
tags = newTags;
}}>+</button>
<button class="tag-remove" onclick={() => removeTag(index)}>×</button>
</div>
{/each}
<button class="add-tag-button" onclick={addTag}>Add Tag</button>
</div>
</fieldset>
</div>
<div class="form-actions">
<button
class="publish-button"
onclick={publish}
disabled={publishing}
>
{publishing ? 'Publishing...' : 'Publish'}
</button>
</div>
</div>
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
{#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>
<button class="republish-button" onclick={republishFromCache} disabled={publishing}>
Republish from Cache
</button>
</div>
{/if}
<style>
.create-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .form-title {
color: var(--fog-dark-text, #f9fafb);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 500;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .form-label {
color: var(--fog-dark-text, #f9fafb);
}
.kind-select {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .kind-select {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.help-text {
margin: 0;
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
font-style: italic;
}
:global(.dark) .help-text {
color: var(--fog-dark-text-light, #9ca3af);
}
.content-input {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: monospace;
resize: vertical;
}
:global(.dark) .content-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.content-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tags-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.tag-input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .tag-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.tag-add-value,
.tag-remove {
padding: 0.5rem;
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;
min-width: 2rem;
}
:global(.dark) .tag-add-value,
:global(.dark) .tag-remove {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.tag-add-value:hover,
.tag-remove:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .tag-add-value:hover,
:global(.dark) .tag-remove:hover {
background: var(--fog-dark-border, #475569);
}
.add-tag-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;
align-self: flex-start;
}
:global(.dark) .add-tag-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.add-tag-button:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .add-tag-button:hover {
background: var(--fog-dark-border, #475569);
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.publish-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
:global(.dark) .publish-button {
background: var(--fog-dark-accent, #94a3b8);
}
.publish-button:hover:not(:disabled) {
opacity: 0.9;
}
.publish-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.republish-section {
margin-top: 1rem;
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .republish-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.republish-text {
margin: 0 0 0.5rem 0;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .republish-text {
color: var(--fog-dark-text, #f9fafb);
}
.republish-button {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .republish-button {
background: var(--fog-dark-accent, #94a3b8);
}
.republish-button:hover:not(:disabled) {
opacity: 0.9;
}
.republish-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

416
src/lib/components/write/EditEventForm.svelte

@ -0,0 +1,416 @@ @@ -0,0 +1,416 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } 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 { goto } from '$app/navigation';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
let content = $state(event.content || '');
let tags = $state<string[][]>([...event.tags]);
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
function addTag() {
tags = [...tags, ['', '']];
}
function removeTag(index: number) {
tags = tags.filter((_, i) => i !== index);
}
function updateTag(index: number, field: number, value: string) {
const newTags = [...tags];
if (!newTags[index]) {
newTags[index] = ['', ''];
}
newTags[index] = [...newTags[index]];
newTags[index][field] = value;
// Ensure tag has enough elements
while (newTags[index].length <= field) {
newTags[index].push('');
}
tags = newTags;
}
async function publish() {
const session = sessionManager.getSession();
if (!session) {
alert('You must be logged in to publish events');
return;
}
publishing = true;
try {
// Create new event (id, sig, created_at will be generated)
const eventTemplate = {
kind: event.kind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] && t[1]), // Filter out empty tags
content
};
// Sign event
const signedEvent = await session.signer(eventTemplate);
// Cache event
await cacheEvent(signedEvent);
// Publish to write relays
const relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
publicationModalOpen = true;
// If successful, wait 5 seconds and navigate
if (results.success.length > 0) {
setTimeout(() => {
goto(`/event/${signedEvent.id}`);
}, 5000);
}
} catch (error) {
console.error('Error publishing event:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
publishing = false;
}
}
async function republishFromCache() {
if (!publicationResults) return;
publishing = true;
try {
const relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
const results = await signAndPublish(
{
kind: event.kind,
pubkey: event.pubkey,
created_at: event.created_at,
tags: tags.filter(t => t[0] && t[1]),
content
},
relays
);
publicationResults = results;
} catch (error) {
console.error('Error republishing:', error);
} finally {
publishing = false;
}
}
</script>
<div class="edit-form">
<h2 class="form-title">Edit Event</h2>
<p class="form-description">Edit the event content and tags. ID, kind, pubkey, sig, and created_at are generated on publish.</p>
<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>
<div class="form-group">
<fieldset>
<legend class="form-label">Tags</legend>
<div class="tags-list">
{#each tags as tag, index (index)}
<div class="tag-row">
<input
type="text"
value={tag[0] || ''}
oninput={(e) => updateTag(index, 0, e.currentTarget.value)}
placeholder="Tag name"
class="tag-input"
/>
{#each tag.slice(1) as value, valueIndex}
<input
type="text"
value={value || ''}
oninput={(e) => updateTag(index, valueIndex + 1, e.currentTarget.value)}
placeholder="Tag value"
class="tag-input"
/>
{/each}
<button class="tag-add-value" onclick={() => {
const newTags = [...tags];
newTags[index] = [...newTags[index], ''];
tags = newTags;
}}>+</button>
<button class="tag-remove" onclick={() => removeTag(index)}>×</button>
</div>
{/each}
<button class="add-tag-button" onclick={addTag}>Add Tag</button>
</div>
</fieldset>
</div>
<div class="form-actions">
<button
class="publish-button"
onclick={publish}
disabled={publishing}
>
{publishing ? 'Publishing...' : 'Publish'}
</button>
</div>
</div>
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
{#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>
<button class="republish-button" onclick={republishFromCache} disabled={publishing}>
Republish from Cache
</button>
</div>
{/if}
<style>
.edit-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .form-title {
color: var(--fog-dark-text, #f9fafb);
}
.form-description {
margin: 0;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
}
:global(.dark) .form-description {
color: var(--fog-dark-text-light, #9ca3af);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 500;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .form-label {
color: var(--fog-dark-text, #f9fafb);
}
.content-input {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: monospace;
resize: vertical;
}
:global(.dark) .content-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.tags-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.tag-input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .tag-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.tag-add-value,
.tag-remove {
padding: 0.5rem;
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;
min-width: 2rem;
}
:global(.dark) .tag-add-value,
:global(.dark) .tag-remove {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.tag-add-value:hover,
.tag-remove:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .tag-add-value:hover,
:global(.dark) .tag-remove:hover {
background: var(--fog-dark-border, #475569);
}
.add-tag-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;
align-self: flex-start;
}
:global(.dark) .add-tag-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.add-tag-button:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .add-tag-button:hover {
background: var(--fog-dark-border, #475569);
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.publish-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
:global(.dark) .publish-button {
background: var(--fog-dark-accent, #94a3b8);
}
.publish-button:hover:not(:disabled) {
opacity: 0.9;
}
.publish-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.republish-section {
margin-top: 1rem;
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .republish-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.republish-text {
margin: 0 0 0.5rem 0;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .republish-text {
color: var(--fog-dark-text, #f9fafb);
}
.republish-button {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .republish-button {
background: var(--fog-dark-accent, #94a3b8);
}
.republish-button:hover:not(:disabled) {
opacity: 0.9;
}
.republish-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

321
src/lib/components/write/FindEventForm.svelte

@ -0,0 +1,321 @@ @@ -0,0 +1,321 @@
<script lang="ts">
import EditEventForm from './EditEventForm.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { getEvent } from '../../services/cache/event-cache.js';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
let eventIdInput = $state('');
let searching = $state(false);
let foundEvent = $state<NostrEvent | null>(null);
let error = $state<string | null>(null);
let showEdit = $state(false);
async function findEvent() {
if (!eventIdInput.trim()) return;
searching = true;
error = null;
foundEvent = null;
try {
// Decode event ID
let eventId: string | null = null;
// Check if it's already a hex event ID
if (/^[0-9a-f]{64}$/i.test(eventIdInput.trim())) {
eventId = eventIdInput.trim().toLowerCase();
} else {
// Try to decode bech32
try {
const decoded = nip19.decode(eventIdInput.trim());
if (decoded.type === 'note') {
eventId = String(decoded.data);
} else if (decoded.type === 'nevent') {
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
eventId = String(decoded.data.id);
}
} else if (decoded.type === 'naddr') {
// For naddr, we need to fetch by kind+pubkey+d
// This is more complex, for now just show error
error = 'naddr format requires fetching by kind+pubkey+d, not yet fully supported';
searching = false;
return;
}
} catch {
error = 'Invalid event ID format';
searching = false;
return;
}
}
if (!eventId) {
error = 'Could not decode event ID';
searching = false;
return;
}
// Check cache first
const cached = await getEvent(eventId);
if (cached) {
foundEvent = cached;
searching = false;
return;
}
// Fetch from relays
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [eventId], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length === 0) {
error = 'Event not found';
} else {
foundEvent = events[0];
}
} catch (err) {
console.error('Error finding event:', err);
error = 'Failed to find event';
} finally {
searching = false;
}
}
function startEdit() {
showEdit = true;
}
</script>
<div class="find-form">
<h2 class="form-title">Find Event</h2>
<p class="form-description">Enter an event ID (hex, note, nevent, or naddr)</p>
<div class="input-group">
<input
type="text"
bind:value={eventIdInput}
placeholder="Event ID..."
class="event-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
findEvent();
}
}}
disabled={searching}
/>
<button
class="find-button"
onclick={findEvent}
disabled={searching || !eventIdInput.trim()}
>
{searching ? 'Searching...' : 'Find'}
</button>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if foundEvent}
<div class="found-event">
<div class="event-header">
<h3 class="event-title">Found Event</h3>
<a href="/event/{foundEvent.id}" class="view-link" target="_blank">View in /event page →</a>
</div>
<div class="event-json">
<pre>{JSON.stringify(foundEvent, null, 2)}</pre>
</div>
<button class="edit-button" onclick={startEdit}>
Edit
</button>
</div>
{/if}
{#if showEdit && foundEvent}
<EditEventForm event={foundEvent} />
{/if}
</div>
<style>
.find-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .form-title {
color: var(--fog-dark-text, #f9fafb);
}
.form-description {
margin: 0;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .form-description {
color: var(--fog-dark-text-light, #9ca3af);
}
.input-group {
display: flex;
gap: 0.5rem;
}
.event-input {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .event-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.event-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.find-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
:global(.dark) .find-button {
background: var(--fog-dark-accent, #94a3b8);
}
.find-button:hover:not(:disabled) {
opacity: 0.9;
}
.find-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
padding: 0.75rem;
background: var(--fog-danger-light, #fee2e2);
color: var(--fog-danger, #dc2626);
border-radius: 0.25rem;
font-size: 0.875rem;
}
:global(.dark) .error-message {
background: var(--fog-dark-danger-light, #7f1d1d);
color: var(--fog-dark-danger, #ef4444);
}
.found-event {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .found-event {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.event-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .event-title {
color: var(--fog-dark-text, #f9fafb);
}
.view-link {
color: var(--fog-accent, #64748b);
text-decoration: none;
font-size: 0.875rem;
}
:global(.dark) .view-link {
color: var(--fog-dark-accent, #94a3b8);
}
.view-link:hover {
text-decoration: underline;
}
.event-json {
margin-bottom: 1rem;
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
overflow-x: auto;
}
:global(.dark) .event-json {
background: var(--fog-dark-highlight, #374151);
}
.event-json pre {
margin: 0;
font-size: 0.75rem;
color: var(--fog-text, #1f2937);
white-space: pre-wrap;
word-wrap: break-word;
}
:global(.dark) .event-json pre {
color: var(--fog-dark-text, #f9fafb);
}
.edit-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
:global(.dark) .edit-button {
background: var(--fog-dark-accent, #94a3b8);
}
.edit-button:hover {
opacity: 0.9;
}
</style>

4
src/lib/modules/comments/CommentForm.svelte

@ -9,6 +9,7 @@ @@ -9,6 +9,7 @@
import EmojiPicker from '../../components/content/EmojiPicker.svelte';
import { insertTextAtCursor } from '../../services/text-utils.js';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
interface Props {
threadId: string; // The root event ID
@ -28,6 +29,7 @@ @@ -28,6 +29,7 @@
let showGifPicker = $state(false);
let showEmojiPicker = $state(false);
let textareaRef: HTMLTextAreaElement | null = $state(null);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
// Only show GIF/emoji buttons for non-kind-11 events (kind 1 replies and kind 1111 comments)
const showGifButton = $derived.by(() => {
@ -176,6 +178,7 @@ @@ -176,6 +178,7 @@
}
</script>
{#if isLoggedIn}
<div class="comment-form">
<div class="textarea-wrapper">
<textarea
@ -253,6 +256,7 @@ @@ -253,6 +256,7 @@
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} />
{/if}
</div>
{/if}
<style>
.comment-form {

245
src/lib/modules/feed/FeedPage.svelte

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import FeedPost from './FeedPost.svelte';
import ThreadDrawer from './ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js';
@ -8,11 +9,17 @@ @@ -8,11 +9,17 @@
import { KIND } from '../../types/kind-lookup.js';
let posts = $state<NostrEvent[]>([]);
let allPosts = $state<NostrEvent[]>([]); // Store all posts before filtering
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let oldestTimestamp = $state<number | null>(null);
// List filter state
let availableLists = $state<Array<{ kind: number; name: string; event: NostrEvent }>>([]);
let selectedListId = $state<string | null>(null); // Format: "kind:eventId"
let listFilterIds = $state<Set<string>>(new Set()); // Event IDs or pubkeys to filter by
// Batch-loaded reactions: eventId -> reactions[]
let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map());
@ -42,6 +49,7 @@ @@ -42,6 +49,7 @@
onMount(async () => {
await nostrClient.initialize();
await loadUserLists();
await loadFeed();
// Set up persistent subscription for new events (only once)
if (!subscriptionSetup) {
@ -51,6 +59,131 @@ @@ -51,6 +59,131 @@
}
});
// Load user lists for filtering
async function loadUserLists() {
const session = sessionManager.getSession();
if (!session) return;
const listKinds = [
KIND.CONTACTS,
KIND.FAVORITE_RELAYS,
KIND.RELAY_LIST,
KIND.LOCAL_RELAYS,
KIND.PIN_LIST,
KIND.BOOKMARKS,
KIND.INTEREST_LIST,
KIND.FOLOW_SET
];
try {
const relays = relayManager.getProfileReadRelays();
const lists: Array<{ kind: number; name: string; event: NostrEvent }> = [];
// Fetch all list types
for (const kind of listKinds) {
const limit = kind === KIND.FOLOW_SET ? 50 : 1; // Multiple follow sets allowed
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [session.pubkey], limit }],
relays,
{ useCache: true, cacheResults: true }
);
const kindName = getKindName(kind);
for (const event of events) {
lists.push({
kind,
name: `${kindName}${kind === KIND.FOLOW_SET ? ` (${new Date(event.created_at * 1000).toLocaleDateString()})` : ''}`,
event
});
}
}
availableLists = lists;
} catch (error) {
console.error('Error loading user lists:', error);
}
}
function getKindName(kind: number): string {
const names: Record<number, string> = {
[KIND.CONTACTS]: 'Contacts',
[KIND.FAVORITE_RELAYS]: 'Favorite Relays',
[KIND.RELAY_LIST]: 'Relay List',
[KIND.LOCAL_RELAYS]: 'Local Relays',
[KIND.PIN_LIST]: 'Pin List',
[KIND.BOOKMARKS]: 'Bookmarks',
[KIND.INTEREST_LIST]: 'Interest List',
[KIND.FOLOW_SET]: 'Follow Set'
};
return names[kind] || `Kind ${kind}`;
}
function handleListFilterChange(listId: string | null) {
selectedListId = listId;
if (!listId) {
// No filter selected - show all posts
listFilterIds = new Set();
posts = [...allPosts];
return;
}
// Find the selected list
const [kindStr, eventId] = listId.split(':');
const kind = parseInt(kindStr, 10);
const list = availableLists.find(l => l.kind === kind && l.event.id === eventId);
if (!list) {
listFilterIds = new Set();
posts = [...allPosts];
return;
}
// Extract IDs from the list
const ids = new Set<string>();
// For contacts and follow sets, extract pubkeys from 'p' tags
if (kind === KIND.CONTACTS || kind === KIND.FOLOW_SET) {
for (const tag of list.event.tags) {
if (tag[0] === 'p' && tag[1]) {
ids.add(tag[1]);
}
}
} else {
// For other lists, extract event IDs from 'e' and 'a' tags
for (const tag of list.event.tags) {
if (tag[0] === 'e' && tag[1]) {
ids.add(tag[1]);
} else if (tag[0] === 'a' && tag[1]) {
// For 'a' tags, we'd need to resolve them to event IDs
// For now, we'll just use the 'a' tag value as-is
// This is a simplified approach - full implementation would resolve 'a' tags
ids.add(tag[1]);
}
}
}
listFilterIds = ids;
// Filter posts
if (kind === KIND.CONTACTS || kind === KIND.FOLOW_SET) {
// Filter by author pubkey
posts = allPosts.filter(post => ids.has(post.pubkey));
} else {
// Filter by event ID
posts = allPosts.filter(post => ids.has(post.id));
}
}
// Apply filter when allPosts changes
$effect(() => {
if (selectedListId) {
handleListFilterChange(selectedListId);
} else {
posts = [...allPosts];
}
});
// Cleanup subscription on unmount
$effect(() => {
return () => {
@ -251,7 +384,14 @@ @@ -251,7 +384,14 @@
}
const unique = Array.from(uniqueMap.values());
const sorted = unique.sort((a, b) => b.created_at - a.created_at);
posts = sorted;
allPosts = sorted;
// Apply filter if one is selected
if (selectedListId) {
handleListFilterChange(selectedListId);
} else {
posts = [...allPosts];
}
if (sorted.length > 0) {
oldestTimestamp = Math.min(...sorted.map(e => e.created_at));
@ -297,12 +437,19 @@ @@ -297,12 +437,19 @@
}
// Filter out duplicates
const existingIds = new Set(posts.map(p => p.id));
const existingIds = new Set(allPosts.map(p => p.id));
const newEvents = events.filter(e => !existingIds.has(e.id));
if (newEvents.length > 0) {
const sorted = newEvents.sort((a, b) => b.created_at - a.created_at);
posts = [...posts, ...sorted];
allPosts = [...allPosts, ...sorted];
// Apply filter if one is selected
if (selectedListId) {
handleListFilterChange(selectedListId);
} else {
posts = [...allPosts];
}
const oldest = Math.min(...newEvents.map(e => e.created_at));
if (oldest < (oldestTimestamp || Infinity)) {
@ -337,7 +484,7 @@ @@ -337,7 +484,7 @@
if (!updated || updated.length === 0) return;
// Deduplicate incoming updates before adding to pending
const existingIds = new Set(posts.map(p => p.id));
const existingIds = new Set(allPosts.map(p => p.id));
const newUpdates = updated.filter(e => e && e.id && !existingIds.has(e.id));
if (newUpdates.length === 0) {
@ -364,8 +511,8 @@ @@ -364,8 +511,8 @@
return;
}
// Final deduplication check against current posts (posts may have changed)
const currentIds = new Set(posts.map(p => p.id));
// Final deduplication check against current allPosts (allPosts may have changed)
const currentIds = new Set(allPosts.map(p => p.id));
const newEvents = pendingUpdates.filter(e => e && e.id && !currentIds.has(e.id));
if (newEvents.length === 0) {
@ -373,10 +520,10 @@ @@ -373,10 +520,10 @@
return;
}
console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${posts.length}`);
console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${allPosts.length}`);
// Merge and sort, then deduplicate by ID
const merged = [...posts, ...newEvents];
const merged = [...allPosts, ...newEvents];
// Deduplicate by ID (keep first occurrence)
const uniqueMap = new Map<string, NostrEvent>();
for (const event of merged) {
@ -388,8 +535,16 @@ @@ -388,8 +535,16 @@
const sorted = unique.sort((a, b) => b.created_at - a.created_at);
// Only update if we actually have new events to prevent loops
if (sorted.length > posts.length || sorted.some((e, i) => e.id !== posts[i]?.id)) {
posts = sorted;
if (sorted.length > allPosts.length || sorted.some((e, i) => e.id !== allPosts[i]?.id)) {
allPosts = sorted;
// Apply filter if one is selected
if (selectedListId) {
handleListFilterChange(selectedListId);
} else {
posts = [...allPosts];
}
console.debug(`[FeedPage] Updated posts to ${sorted.length} events`);
}
@ -446,13 +601,36 @@ @@ -446,13 +601,36 @@
</script>
<div class="feed-page">
{#if !loading && availableLists.length > 0}
<div class="feed-filter">
<label for="list-filter" class="filter-label">Filter by list:</label>
<select
id="list-filter"
bind:value={selectedListId}
onchange={(e) => handleListFilterChange((e.target as HTMLSelectElement).value || null)}
class="filter-select"
>
<option value="">All Posts</option>
{#each availableLists as list}
<option value="{list.kind}:{list.event.id}">{list.name}</option>
{/each}
</select>
</div>
{/if}
{#if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading feed...</p>
</div>
{:else if posts.length === 0}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No posts found. Check back later!</p>
<p class="text-fog-text dark:text-fog-dark-text">
{#if selectedListId}
No posts found in selected list.
{:else}
No posts found. Check back later!
{/if}
</p>
</div>
{:else}
<div class="feed-posts">
@ -482,6 +660,51 @@ @@ -482,6 +660,51 @@
max-width: 100%;
}
.feed-filter {
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.filter-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
:global(.dark) .filter-label {
color: var(--fog-dark-text, #f9fafb);
}
.filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
cursor: pointer;
min-width: 200px;
}
.filter-select:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .filter-select {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .filter-select:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.loading-state,
.empty-state {
padding: 2rem;

91
src/lib/modules/feed/ThreadDrawer.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import CommentThread from '../comments/CommentThread.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { buildEventHierarchy, getHierarchyChain } from '../../services/nostr/event-hierarchy.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
@ -18,6 +19,7 @@ @@ -18,6 +19,7 @@
let loading = $state(false);
let subscriptionId: string | null = $state(null);
let isInitialized = $state(false);
let hierarchyChain = $state<NostrEvent[]>([]);
// Initialize nostr client once
onMount(async () => {
@ -27,14 +29,51 @@ @@ -27,14 +29,51 @@
}
});
// Build event hierarchy when drawer opens
async function loadHierarchy(abortSignal: AbortSignal) {
if (!opEvent || !isInitialized) return;
loading = true;
try {
const hierarchy = await buildEventHierarchy(opEvent);
// Check if operation was aborted before updating state
if (abortSignal.aborted) return;
const chain = getHierarchyChain(hierarchy);
// Check again before final state update
if (abortSignal.aborted) return;
hierarchyChain = chain;
} catch (error) {
// Only update state if not aborted
if (abortSignal.aborted) return;
console.error('Error building event hierarchy:', error);
hierarchyChain = [opEvent]; // Fallback to just the event
} finally {
// Only update loading state if not aborted
if (!abortSignal.aborted) {
loading = false;
}
}
}
// Handle drawer open/close - only load when opening
$effect(() => {
if (isOpen && opEvent && isInitialized) {
// Drawer opened - reset loading state
loading = false;
// Create abort controller to track effect lifecycle
const abortController = new AbortController();
// Drawer opened - load hierarchy with abort signal
loadHierarchy(abortController.signal);
// Cleanup subscription when drawer closes
return () => {
// Abort the async operation
abortController.abort();
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
@ -46,6 +85,8 @@ @@ -46,6 +85,8 @@
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
hierarchyChain = [];
loading = false;
}
});
@ -111,10 +152,24 @@ @@ -111,10 +152,24 @@
</div>
{:else}
<div class="thread-content">
<!-- Display the OP event -->
<!-- Display full event hierarchy (root to leaf) -->
{#if hierarchyChain.length > 0}
{#each hierarchyChain as parentEvent, index (parentEvent.id)}
<div class="hierarchy-post">
{#if index > 0}
<div class="hierarchy-divider">
<span class="hierarchy-label">Replying to:</span>
</div>
{/if}
<FeedPost post={parentEvent} />
</div>
{/each}
{:else}
<!-- Fallback: just show the event -->
<div class="op-post">
<FeedPost post={opEvent} />
</div>
{/if}
<!-- Display comments/replies -->
<div class="comments-section">
@ -258,6 +313,36 @@ @@ -258,6 +313,36 @@
border-bottom-color: var(--fog-dark-border, #374151);
}
.hierarchy-post {
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .hierarchy-post {
border-bottom-color: var(--fog-dark-border, #374151);
}
.hierarchy-divider {
padding: 0.5rem 0;
margin-bottom: 0.5rem;
border-bottom: 1px dashed var(--fog-border, #e5e7eb);
}
:global(.dark) .hierarchy-divider {
border-bottom-color: var(--fog-dark-border, #374151);
}
.hierarchy-label {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
font-style: italic;
}
:global(.dark) .hierarchy-label {
color: var(--fog-dark-text-light, #9ca3af);
}
.comments-section {
padding: 1rem;
flex: 1;

60
src/lib/modules/profiles/ProfilePage.svelte

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
import PaymentAddresses from './PaymentAddresses.svelte';
import FeedPost from '../feed/FeedPost.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import ProfileEventsPanel from '../../components/profile/ProfileEventsPanel.svelte';
import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
@ -34,6 +35,9 @@ @@ -34,6 +35,9 @@
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
// Profile events panel state
let profileEventsPanelOpen = $state(false);
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
@ -44,6 +48,19 @@ @@ -44,6 +48,19 @@
drawerEvent = null;
}
function openProfileEventsPanel() {
profileEventsPanelOpen = true;
}
function closeProfileEventsPanel() {
profileEventsPanelOpen = false;
}
const isOwnProfile = $derived.by(() => {
const pubkey = decodePubkey($page.params.pubkey);
return currentUserPubkey && pubkey && currentUserPubkey === pubkey;
});
// Subscribe to session changes
$effect(() => {
const unsubscribe = sessionManager.session.subscribe((session) => {
@ -455,6 +472,17 @@ @@ -455,6 +472,17 @@
</div>
{/if}
<PaymentAddresses pubkey={decodePubkey($page.params.pubkey) || ''} />
{#if isOwnProfile}
<div class="profile-actions mt-4">
<button
class="adjust-profile-button"
onclick={openProfileEventsPanel}
>
Adjust profile events
</button>
</div>
{/if}
</div>
<div class="profile-posts">
@ -518,6 +546,14 @@ @@ -518,6 +546,14 @@
{/if}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
{#if isOwnProfile}
<ProfileEventsPanel
isOpen={profileEventsPanelOpen}
pubkey={decodePubkey($page.params.pubkey) || ''}
onClose={closeProfileEventsPanel}
/>
{/if}
</div>
<style>
@ -619,4 +655,28 @@ @@ -619,4 +655,28 @@
transform: rotate(360deg);
}
}
.profile-actions {
margin-top: 1rem;
}
.adjust-profile-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
:global(.dark) .adjust-profile-button {
background: var(--fog-dark-accent, #94a3b8);
}
.adjust-profile-button:hover {
opacity: 0.9;
}
</style>

3
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -633,8 +633,10 @@ @@ -633,8 +633,10 @@
});
let includeClientTag = $state(true);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
</script>
{#if isLoggedIn}
<div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap">
{#if event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote}
<!-- Kind 11 (Thread) or Kind 1111 (Reply to Thread): Only upvote and downvote buttons -->
@ -706,6 +708,7 @@ @@ -706,6 +708,7 @@
{/if}
{/if}
</div>
{/if}
<style>
.Feed-reaction-buttons {

3
src/lib/modules/reactions/ReactionButtons.svelte

@ -165,8 +165,10 @@ @@ -165,8 +165,10 @@
}
let includeClientTag = $state(true);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
</script>
{#if isLoggedIn}
<div class="reaction-buttons flex gap-2 items-center">
{#if event.kind === KIND.DISCUSSION_THREAD || event.kind === KIND.COMMENT}
<!-- Thread/Comment reactions: Only + and - -->
@ -199,6 +201,7 @@ @@ -199,6 +201,7 @@
<!-- Other reactions could go in a submenu -->
{/if}
</div>
{/if}
<style>
.reaction-buttons {

74
src/lib/modules/threads/ThreadList.svelte

@ -48,31 +48,81 @@ @@ -48,31 +48,81 @@
const reactionRelays = relayManager.getProfileReadRelays();
const zapRelays = relayManager.getZapReceiptReadRelays();
// Fetch all threads
const threadEvents = await nostrClient.fetchEvents(
// Step 1: Load from cache first (immediate display)
const cachedThreads = await nostrClient.fetchEvents(
[{ kinds: [KIND.DISCUSSION_THREAD], since, limit: 50 }],
threadRelays,
{
useCache: true,
cacheResults: false, // Don't cache again, we already have it
timeout: 100 // Quick timeout for cache-only fetch
}
);
// Build threads map from cache immediately
const newThreadsMap = new Map<string, NostrEvent>();
for (const event of cachedThreads) {
newThreadsMap.set(event.id, event);
}
threadsMap = newThreadsMap;
loading = false; // Show cached data immediately
// Step 2: Fetch from relays in background and update incrementally
// Use a Set to track which events we've already processed to avoid loops
const processedEventIds = new Set<string>(Array.from(newThreadsMap.keys()));
nostrClient.fetchEvents(
[{ kinds: [KIND.DISCUSSION_THREAD], since, limit: 50 }],
threadRelays,
{
useCache: false, // Force query relays
cacheResults: true,
onUpdate: async (updatedEvents) => {
// Update threads map when fresh data arrives
// Only add new events that aren't already in the map
let hasNewEvents = false;
for (const event of updatedEvents) {
threadsMap.set(event.id, event);
if (!processedEventIds.has(event.id)) {
newThreadsMap.set(event.id, event);
processedEventIds.add(event.id);
hasNewEvents = true;
} else {
// Update existing event if this one is newer
const existing = newThreadsMap.get(event.id);
if (existing && event.created_at > existing.created_at) {
newThreadsMap.set(event.id, event);
}
}
}
);
// Build threads map
const newThreadsMap = new Map<string, NostrEvent>();
for (const event of threadEvents) {
if (hasNewEvents) {
threadsMap = new Map(newThreadsMap); // Trigger reactivity
}
}
}
).then((relayThreads) => {
// Final update after relay fetch completes
let hasNewEvents = false;
for (const event of relayThreads) {
if (!processedEventIds.has(event.id)) {
newThreadsMap.set(event.id, event);
processedEventIds.add(event.id);
hasNewEvents = true;
} else {
// Update existing event if this one is newer
const existing = newThreadsMap.get(event.id);
if (existing && event.created_at > existing.created_at) {
newThreadsMap.set(event.id, event);
}
threadsMap = newThreadsMap;
}
}
if (hasNewEvents) {
threadsMap = new Map(newThreadsMap); // Trigger reactivity
}
}).catch((error) => {
console.debug('Background relay fetch error (non-critical):', error);
});
// Get all thread IDs
const threadIds = Array.from(newThreadsMap.keys());
// Get all thread IDs (use current threadsMap, not newThreadsMap, since it may have been updated)
const threadIds = Array.from(threadsMap.keys());
if (threadIds.length > 0) {
// Fetch all comments in parallel

1
src/lib/services/auth/activity-tracker.ts

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
import { nostrClient } from '../nostr/nostr-client.js';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
/**
* Get last activity timestamp for a pubkey

47
src/lib/services/content/asciidoctor-renderer.ts

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
/**
* Asciidoctor renderer service
* Renders AsciiDoc content for kinds 30818 and 30041
*/
import Asciidoctor from 'asciidoctor';
const asciidoctor = Asciidoctor();
/**
* Render AsciiDoc content to HTML
* @param content - AsciiDoc content string
* @returns HTML string
*/
export function renderAsciiDoc(content: string): string {
try {
const html = asciidoctor.convert(content, {
safe: 'safe',
backend: 'html5',
attributes: {
'showtitle': true,
'icons': 'font',
'sectanchors': true,
'sectlinks': true,
'idprefix': '',
'idseparator': '-'
}
});
return html as string;
} catch (error) {
console.error('Error rendering AsciiDoc:', error);
// Return escaped content as fallback
return `<pre>${escapeHtml(content)}</pre>`;
}
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

217
src/lib/services/content/opengraph-fetcher.ts

@ -0,0 +1,217 @@ @@ -0,0 +1,217 @@
/**
* OpenGraph metadata fetcher service
* Fetches OpenGraph metadata from URLs and caches results
*/
export interface OpenGraphData {
title?: string;
description?: string;
image?: string;
url?: string;
siteName?: string;
type?: string;
cachedAt: number;
}
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
const CACHE_KEY_PREFIX = 'opengraph_';
/**
* Fetch OpenGraph metadata from a URL
* Uses a CORS proxy if needed, caches results in localStorage
*/
export async function fetchOpenGraph(url: string): Promise<OpenGraphData | null> {
// Check cache first
const cached = getCachedOpenGraph(url);
if (cached && Date.now() - cached.cachedAt < CACHE_DURATION) {
return cached;
}
try {
// Try to fetch the page HTML
// Note: Direct fetch may fail due to CORS, so we'll use a simple approach
// In production, you might want to use a backend proxy or service
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': 'Mozilla/5.0 (compatible; Aitherboard/1.0)'
},
mode: 'cors',
cache: 'no-cache'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
const ogData = parseOpenGraph(html, url);
// Cache the result
if (ogData) {
cacheOpenGraph(url, ogData);
}
return ogData;
} catch (error) {
console.warn('Failed to fetch OpenGraph data:', error);
// Return cached data even if expired, or null
return cached || null;
}
}
/**
* Parse OpenGraph metadata from HTML
*/
function parseOpenGraph(html: string, url: string): OpenGraphData | null {
const og: Partial<OpenGraphData> = {
cachedAt: Date.now()
};
// Extract OpenGraph meta tags
const ogTitleMatch = html.match(/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:title["']/i);
if (ogTitleMatch) {
og.title = decodeHtmlEntities(ogTitleMatch[1]);
}
const ogDescriptionMatch = html.match(/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:description["']/i);
if (ogDescriptionMatch) {
og.description = decodeHtmlEntities(ogDescriptionMatch[1]);
}
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:image["']/i);
if (ogImageMatch) {
og.image = ogImageMatch[1];
// Make image URL absolute if relative
if (og.image && !og.image.startsWith('http')) {
try {
const baseUrl = new URL(url);
og.image = new URL(og.image, baseUrl).href;
} catch {
// Invalid URL, keep as is
}
}
}
const ogUrlMatch = html.match(/<meta\s+property=["']og:url["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:url["']/i);
if (ogUrlMatch) {
og.url = ogUrlMatch[1];
} else {
og.url = url;
}
const ogSiteNameMatch = html.match(/<meta\s+property=["']og:site_name["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:site_name["']/i);
if (ogSiteNameMatch) {
og.siteName = decodeHtmlEntities(ogSiteNameMatch[1]);
}
const ogTypeMatch = html.match(/<meta\s+property=["']og:type["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:type["']/i);
if (ogTypeMatch) {
og.type = ogTypeMatch[1];
}
// Fallback to regular meta tags if OpenGraph not available
if (!og.title) {
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (titleMatch) {
og.title = decodeHtmlEntities(titleMatch[1].trim());
}
}
if (!og.description) {
const metaDescriptionMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i) ||
html.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i);
if (metaDescriptionMatch) {
og.description = decodeHtmlEntities(metaDescriptionMatch[1]);
}
}
// Return null if we have no useful data
if (!og.title && !og.description && !og.image) {
return null;
}
return og as OpenGraphData;
}
/**
* Decode HTML entities
*/
function decodeHtmlEntities(text: string): string {
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
}
/**
* Get cached OpenGraph data
*/
function getCachedOpenGraph(url: string): OpenGraphData | null {
if (typeof window === 'undefined') return null;
try {
const key = CACHE_KEY_PREFIX + url;
const cached = localStorage.getItem(key);
if (cached) {
return JSON.parse(cached) as OpenGraphData;
}
} catch (error) {
console.warn('Error reading cached OpenGraph data:', error);
}
return null;
}
/**
* Cache OpenGraph data
*/
function cacheOpenGraph(url: string, data: OpenGraphData): void {
if (typeof window === 'undefined') return;
const key = CACHE_KEY_PREFIX + url;
try {
localStorage.setItem(key, JSON.stringify(data));
} catch (error) {
console.warn('Error caching OpenGraph data:', error);
// If storage is full, try to clear old entries
try {
clearOldCacheEntries();
localStorage.setItem(key, JSON.stringify(data));
} catch {
// Give up
}
}
}
/**
* Clear old cache entries to free up space
*/
function clearOldCacheEntries(): void {
if (typeof window === 'undefined') return;
const now = Date.now();
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(CACHE_KEY_PREFIX)) {
try {
const data = JSON.parse(localStorage.getItem(key) || '{}') as OpenGraphData;
if (now - data.cachedAt > CACHE_DURATION) {
keysToRemove.push(key);
}
} catch {
keysToRemove.push(key);
}
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
}

154
src/lib/services/nostr/event-hierarchy.ts

@ -0,0 +1,154 @@ @@ -0,0 +1,154 @@
/**
* Event hierarchy builder
* Builds full reply chain by following e-tags, q-tags, and a-tags
*/
import type { NostrEvent } from '../../types/nostr.js';
import { nostrClient } from './nostr-client.js';
import { relayManager } from './relay-manager.js';
import { getEvent } from '../cache/event-cache.js';
export interface EventHierarchy {
event: NostrEvent;
parent?: EventHierarchy;
children: EventHierarchy[];
}
/**
* Build full event hierarchy starting from a given event
* Recursively fetches parent events until reaching root (no references)
*/
export async function buildEventHierarchy(event: NostrEvent): Promise<EventHierarchy> {
const hierarchy: EventHierarchy = {
event,
children: []
};
// Build parent chain
const parent = await findParentEvent(event);
if (parent) {
hierarchy.parent = await buildEventHierarchy(parent);
}
return hierarchy;
}
/**
* Get all events in hierarchy as a flat array (root to leaf)
*/
export function getHierarchyChain(hierarchy: EventHierarchy): NostrEvent[] {
const chain: NostrEvent[] = [];
// Build chain from root to leaf
let current: EventHierarchy | undefined = hierarchy;
const parents: EventHierarchy[] = [];
// Collect all parents
while (current) {
parents.unshift(current);
current = current.parent;
}
// Return chain from root to leaf
return parents.map(h => h.event);
}
/**
* Find parent event by following e-tags, q-tags, or a-tags
*/
async function findParentEvent(event: NostrEvent): Promise<NostrEvent | null> {
// Check for e-tag (reply to event)
const eTag = event.tags.find(t => t[0] === 'e' && t[1]);
if (eTag && eTag[1]) {
const parent = await fetchEventById(eTag[1]);
if (parent) return parent;
}
// Check for q-tag (quoted event)
const qTag = event.tags.find(t => t[0] === 'q' && t[1]);
if (qTag && qTag[1]) {
const parent = await fetchEventById(qTag[1]);
if (parent) return parent;
}
// Check for a-tag (reply to replaceable event)
const aTag = event.tags.find(t => t[0] === 'a' && t[1]);
if (aTag && aTag[1]) {
// Parse a-tag: kind:pubkey:d-tag
const parts = aTag[1].split(':');
if (parts.length === 3) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const dTag = parts[2];
if (!isNaN(kind) && pubkey && dTag) {
const parent = await fetchReplaceableEvent(kind, pubkey, dTag);
if (parent) return parent;
}
}
}
// No parent found
return null;
}
/**
* Fetch event by ID (check cache first, then relays)
*/
async function fetchEventById(eventId: string): Promise<NostrEvent | null> {
// Check cache first
const cached = await getEvent(eventId);
if (cached) {
return cached;
}
// Fetch from relays
try {
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [eventId], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
return events.length > 0 ? events[0] : null;
} catch (error) {
console.warn('Error fetching event by ID:', error);
return null;
}
}
/**
* Fetch replaceable event by kind, pubkey, and d-tag
*/
async function fetchReplaceableEvent(kind: number, pubkey: string, dTag: string): Promise<NostrEvent | null> {
try {
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// Return newest (replaceable events can have multiple versions)
if (events.length > 0) {
return events.sort((a, b) => b.created_at - a.created_at)[0];
}
return null;
} catch (error) {
console.warn('Error fetching replaceable event:', error);
return null;
}
}
/**
* Check if event is a root (has no parent references)
*/
export function isRootEvent(event: NostrEvent): boolean {
const hasETag = event.tags.some(t => t[0] === 'e' && t[1]);
const hasQTag = event.tags.some(t => t[0] === 'q' && t[1]);
const hasATag = event.tags.some(t => t[0] === 'a' && t[1]);
return !hasETag && !hasQTag && !hasATag;
}

166
src/lib/services/nostr/event-index-loader.ts

@ -0,0 +1,166 @@ @@ -0,0 +1,166 @@
/**
* Event index loader for kind 30040
* Handles lazy-loading of event-index hierarchy with a-tags and e-tags
*/
import type { NostrEvent } from '../../types/nostr.js';
import { nostrClient } from './nostr-client.js';
import { relayManager } from './relay-manager.js';
import { getEvent } from '../cache/event-cache.js';
export interface EventIndexItem {
event: NostrEvent;
order: number; // Original order in index
}
/**
* Load entire event-index hierarchy for a kind 30040 event
* Handles both a-tags and e-tags, maintains original order
*/
export async function loadEventIndex(opEvent: NostrEvent): Promise<EventIndexItem[]> {
if (opEvent.kind !== 30040) {
throw new Error('Event is not kind 30040');
}
const items: EventIndexItem[] = [];
const loadedEventIds = new Set<string>();
const loadedAddresses = new Set<string>();
const missingIds: string[] = [];
const missingAddresses: string[] = [];
// Parse a-tags and e-tags from OP event
const aTags: string[] = [];
const eTags: string[] = [];
for (const tag of opEvent.tags) {
if (tag[0] === 'a' && tag[1]) {
aTags.push(tag[1]);
} else if (tag[0] === 'e' && tag[1]) {
eTags.push(tag[1]);
}
}
// First pass: try to load all events from cache and relays
const relays = relayManager.getProfileReadRelays();
// Load events by ID (e-tags)
if (eTags.length > 0) {
const eventsById = await nostrClient.fetchEvents(
[{ ids: eTags, limit: eTags.length }],
relays,
{ useCache: true, cacheResults: true }
);
for (let i = 0; i < eTags.length; i++) {
const eventId = eTags[i];
const event = eventsById.find(e => e.id === eventId);
if (event) {
items.push({ event, order: i });
loadedEventIds.add(eventId);
} else {
missingIds.push(eventId);
}
}
}
// Load events by address (a-tags)
if (aTags.length > 0) {
for (let i = 0; i < aTags.length; i++) {
const aTag = aTags[i];
const parts = aTag.split(':');
if (parts.length === 3) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const dTag = parts[2];
if (!isNaN(kind) && pubkey && dTag) {
// Check cache first
const cached = await getEvent(aTag);
if (cached) {
items.push({ event: cached, order: eTags.length + i });
loadedAddresses.add(aTag);
continue;
}
// Fetch from relays
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
// Get newest version
const event = events.sort((a, b) => b.created_at - a.created_at)[0];
items.push({ event, order: eTags.length + i });
loadedAddresses.add(aTag);
} else {
missingAddresses.push(aTag);
}
}
}
}
}
// Second pass: retry missing events (but don't loop infinitely)
if (missingIds.length > 0 || missingAddresses.length > 0) {
// Wait a bit before retry
await new Promise(resolve => setTimeout(resolve, 1000));
// Retry missing IDs
if (missingIds.length > 0) {
const retryEvents = await nostrClient.fetchEvents(
[{ ids: missingIds, limit: missingIds.length }],
relays,
{ useCache: false, cacheResults: true } // Force relay query
);
for (const eventId of missingIds) {
const event = retryEvents.find(e => e.id === eventId);
if (event) {
const originalIndex = eTags.indexOf(eventId);
if (originalIndex >= 0) {
items.push({ event, order: originalIndex });
loadedEventIds.add(eventId);
}
}
}
}
// Retry missing addresses
if (missingAddresses.length > 0) {
for (const aTag of missingAddresses) {
const parts = aTag.split(':');
if (parts.length === 3) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const dTag = parts[2];
if (!isNaN(kind) && pubkey && dTag) {
const retryEvents = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
{ useCache: false, cacheResults: true } // Force relay query
);
if (retryEvents.length > 0) {
const event = retryEvents.sort((a, b) => b.created_at - a.created_at)[0];
const originalIndex = aTags.indexOf(aTag);
if (originalIndex >= 0) {
items.push({ event, order: eTags.length + originalIndex });
loadedAddresses.add(aTag);
}
}
}
}
}
}
}
// Sort by original order
items.sort((a, b) => a.order - b.order);
return items;
}

195
src/lib/services/nostr/highlight-service.ts

@ -0,0 +1,195 @@ @@ -0,0 +1,195 @@
/**
* Highlight service (NIP-84)
* Handles kind 9802 highlight events and matches them to source events
*/
import type { NostrEvent } from '../../types/nostr.js';
import { nostrClient } from './nostr-client.js';
import { relayManager } from './relay-manager.js';
import { KIND } from '../../types/kind-lookup.js';
export interface Highlight {
event: NostrEvent; // The highlight event (kind 9802)
pubkey: string; // Who created the highlight
content: string; // Highlight content
sourceEventId?: string; // e-tag source event ID
sourceAddress?: string; // a-tag source address (kind:pubkey:d-tag)
context?: string; // Context from context tag
url?: string; // URL if source is a URL
}
/**
* Get highlights for a specific event
* Matches by e-tag (source) or a-tag
*/
export async function getHighlightsForEvent(eventId: string, eventKind?: number, eventPubkey?: string, eventDTag?: string): Promise<Highlight[]> {
const highlights: Highlight[] = [];
try {
const relays = relayManager.getProfileReadRelays();
// Fetch highlight events that reference this event
const highlightEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], '#e': [eventId], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
// Also fetch by a-tag if we have kind, pubkey, and d-tag
if (eventKind && eventPubkey && eventDTag) {
const aTag = `${eventKind}:${eventPubkey}:${eventDTag}`;
const aTagHighlights = await nostrClient.fetchEvents(
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], '#a': [aTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
// Merge and deduplicate
const existingIds = new Set(highlightEvents.map(e => e.id));
for (const h of aTagHighlights) {
if (!existingIds.has(h.id)) {
highlightEvents.push(h);
}
}
}
// Parse highlights
for (const highlightEvent of highlightEvents) {
const highlight = parseHighlight(highlightEvent);
if (highlight) {
highlights.push(highlight);
}
}
} catch (error) {
console.error('Error fetching highlights:', error);
}
return highlights;
}
/**
* Get highlights for a URL
*/
export async function getHighlightsForUrl(url: string): Promise<Highlight[]> {
const highlights: Highlight[] = [];
try {
const relays = relayManager.getProfileReadRelays();
// Fetch highlight events that reference this URL
// We'll search for highlights with the URL in content or tags
const highlightEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
// Filter highlights that reference this URL
for (const highlightEvent of highlightEvents) {
// Check if URL is in content or tags
const hasUrl = highlightEvent.content.includes(url) ||
highlightEvent.tags.some(tag => tag[0] === 'url' && tag[1] === url) ||
highlightEvent.tags.some(tag => tag[0] === 'r' && tag[1] === url);
if (hasUrl) {
const highlight = parseHighlight(highlightEvent);
if (highlight) {
highlight.url = url;
highlights.push(highlight);
}
}
}
} catch (error) {
console.error('Error fetching highlights for URL:', error);
}
return highlights;
}
/**
* Parse a highlight event (kind 9802)
*/
function parseHighlight(event: NostrEvent): Highlight | null {
if (event.kind !== KIND.HIGHLIGHTED_ARTICLE) {
return null;
}
const highlight: Highlight = {
event,
pubkey: event.pubkey,
content: event.content
};
// Parse e-tag (source event)
const eTag = event.tags.find(t => t[0] === 'e' && t[1]);
if (eTag && eTag[1]) {
highlight.sourceEventId = eTag[1];
}
// Parse a-tag (source address)
const aTag = event.tags.find(t => t[0] === 'a' && t[1]);
if (aTag && aTag[1]) {
highlight.sourceAddress = aTag[1];
}
// Parse context tag
const contextTag = event.tags.find(t => t[0] === 'context' && t[1]);
if (contextTag && contextTag[1]) {
highlight.context = contextTag[1];
}
// Parse URL tag
const urlTag = event.tags.find(t => (t[0] === 'url' || t[0] === 'r') && t[1]);
if (urlTag && urlTag[1]) {
highlight.url = urlTag[1];
}
return highlight;
}
/**
* Find text in content that matches highlight content
* Returns array of { start, end, highlight } for each match
*/
export function findHighlightMatches(content: string, highlights: Highlight[]): Array<{ start: number; end: number; highlight: Highlight }> {
const matches: Array<{ start: number; end: number; highlight: Highlight }> = [];
for (const highlight of highlights) {
const highlightText = highlight.content.trim();
if (!highlightText) continue;
// Find all occurrences of the highlight text in content
let searchIndex = 0;
while (true) {
const index = content.indexOf(highlightText, searchIndex);
if (index === -1) break;
matches.push({
start: index,
end: index + highlightText.length,
highlight
});
searchIndex = index + 1;
}
}
// Sort by start position
matches.sort((a, b) => a.start - b.start);
// Remove overlapping matches (keep first)
const nonOverlapping: Array<{ start: number; end: number; highlight: Highlight }> = [];
for (const match of matches) {
const overlaps = nonOverlapping.some(existing =>
(match.start >= existing.start && match.start < existing.end) ||
(match.end > existing.start && match.end <= existing.end) ||
(match.start <= existing.start && match.end >= existing.end)
);
if (!overlaps) {
nonOverlapping.push(match);
}
}
return nonOverlapping;
}

241
src/lib/services/user-actions.ts

@ -1,8 +1,15 @@ @@ -1,8 +1,15 @@
/**
* User actions service - manages pinned, bookmarked, and highlighted events
* Stores in localStorage for persistence
* Stores in localStorage for persistence and publishes list events
*/
import { sessionManager } from './auth/session-manager.js';
import { signAndPublish } from './nostr/auth-handler.js';
import { nostrClient } from './nostr/nostr-client.js';
import { relayManager } from './nostr/relay-manager.js';
import { KIND } from '../types/kind-lookup.js';
import type { NostrEvent } from '../types/nostr.js';
const STORAGE_KEY_PINNED = 'aitherboard_pinned_events';
const STORAGE_KEY_BOOKMARKED = 'aitherboard_bookmarked_events';
const STORAGE_KEY_HIGHLIGHTED = 'aitherboard_highlighted_events';
@ -75,8 +82,9 @@ export function isHighlighted(eventId: string): boolean { @@ -75,8 +82,9 @@ export function isHighlighted(eventId: string): boolean {
/**
* Toggle pin status of an event
* Updates localStorage and publishes kind 10001 list event
*/
export function togglePin(eventId: string): boolean {
export async function togglePin(eventId: string): Promise<boolean> {
const pinned = getPinnedEvents();
const isCurrentlyPinned = pinned.has(eventId);
@ -88,6 +96,13 @@ export function togglePin(eventId: string): boolean { @@ -88,6 +96,13 @@ export function togglePin(eventId: string): boolean {
try {
localStorage.setItem(STORAGE_KEY_PINNED, JSON.stringify(Array.from(pinned)));
// Publish list event if user is logged in
const session = sessionManager.getSession();
if (session) {
await publishPinList(Array.from(pinned));
}
return !isCurrentlyPinned;
} catch (error) {
console.error('Failed to save pinned events:', error);
@ -95,10 +110,117 @@ export function togglePin(eventId: string): boolean { @@ -95,10 +110,117 @@ export function togglePin(eventId: string): boolean {
}
}
/**
* Publish pin list event (kind 10001)
*/
async function publishPinList(eventIds: string[]): Promise<void> {
try {
const session = sessionManager.getSession();
if (!session) return;
// Deduplicate input eventIds first
const deduplicatedEventIds = Array.from(new Set(eventIds));
// Fetch existing pin list to merge with new entries
const relays = relayManager.getProfileReadRelays();
const existingLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.PIN_LIST], authors: [session.pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// Collect existing tags: both 'e' and 'a' tags
const existingETags = new Map<string, string[]>(); // eventId -> full tag
const existingATags: string[][] = []; // Store all a-tags
const existingEventIds = new Set<string>(); // Event IDs from e-tags only
if (existingLists.length > 0) {
const existingList = existingLists[0];
for (const tag of existingList.tags) {
if (tag[0] === 'e' && tag[1]) {
const eventId = tag[1];
existingEventIds.add(eventId);
existingETags.set(eventId, tag);
} else if (tag[0] === 'a' && tag[1]) {
// Store a-tags separately (format: a:<kind>:<pubkey>:<d-tag>)
existingATags.push(tag);
}
}
}
// Check if we have any changes
const newEventIds = deduplicatedEventIds.filter(id => !existingEventIds.has(id));
const removedEventIds = [...existingEventIds].filter(id => !deduplicatedEventIds.includes(id));
if (newEventIds.length === 0 && removedEventIds.length === 0 && existingLists.length > 0) {
return; // No changes, cancel operation
}
// Build final tags: preserve all a-tags, add/update e-tags
const tags: string[][] = [];
// First, add all existing a-tags (they take precedence)
for (const aTag of existingATags) {
tags.push(aTag);
}
// Then, add e-tags for all event IDs in the final list
// Note: We can't easily check if an a-tag represents a specific event ID without resolving it
// So we'll add e-tags for all eventIds, and rely on clients to prefer a-tags when resolving
const seenETags = new Set<string>();
for (const eventId of deduplicatedEventIds) {
if (!seenETags.has(eventId)) {
tags.push(['e', eventId]);
seenETags.add(eventId);
}
}
// Final deduplication: if we somehow have duplicate e-tags, remove them
// (This shouldn't happen, but ensures clean output)
const finalTags: string[][] = [];
const seenEventIds = new Set<string>();
for (const tag of tags) {
if (tag[0] === 'a') {
// Always keep a-tags
finalTags.push(tag);
} else if (tag[0] === 'e' && tag[1]) {
// For e-tags, check if we already have this event ID
// If an a-tag represents this event, we'd ideally skip the e-tag,
// but we can't check that without resolving a-tags
// So we'll keep the e-tag and let clients handle the preference
if (!seenEventIds.has(tag[1])) {
finalTags.push(tag);
seenEventIds.add(tag[1]);
}
} else {
// Keep other tag types as-is
finalTags.push(tag);
}
}
// Create new list event with merged tags and new timestamp
const listEvent: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.PIN_LIST,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: finalTags,
content: ''
};
// Publish to write relays
const writeRelays = relayManager.getPublishRelays(relays, true);
await signAndPublish(listEvent, writeRelays);
} catch (error) {
console.error('Failed to publish pin list:', error);
}
}
/**
* Toggle bookmark status of an event
* Updates localStorage and publishes kind 10003 list event
*/
export function toggleBookmark(eventId: string): boolean {
export async function toggleBookmark(eventId: string): Promise<boolean> {
const bookmarked = getBookmarkedEvents();
const isCurrentlyBookmarked = bookmarked.has(eventId);
@ -110,6 +232,13 @@ export function toggleBookmark(eventId: string): boolean { @@ -110,6 +232,13 @@ export function toggleBookmark(eventId: string): boolean {
try {
localStorage.setItem(STORAGE_KEY_BOOKMARKED, JSON.stringify(Array.from(bookmarked)));
// Publish list event if user is logged in
const session = sessionManager.getSession();
if (session) {
await publishBookmarkList(Array.from(bookmarked));
}
return !isCurrentlyBookmarked;
} catch (error) {
console.error('Failed to save bookmarked events:', error);
@ -117,6 +246,112 @@ export function toggleBookmark(eventId: string): boolean { @@ -117,6 +246,112 @@ export function toggleBookmark(eventId: string): boolean {
}
}
/**
* Publish bookmark list event (kind 10003)
*/
async function publishBookmarkList(eventIds: string[]): Promise<void> {
try {
const session = sessionManager.getSession();
if (!session) return;
// Deduplicate input eventIds first
const deduplicatedEventIds = Array.from(new Set(eventIds));
// Fetch existing bookmark list to merge with new entries
const relays = relayManager.getProfileReadRelays();
const existingLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.BOOKMARKS], authors: [session.pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// Collect existing tags: both 'e' and 'a' tags
const existingETags = new Map<string, string[]>(); // eventId -> full tag
const existingATags: string[][] = []; // Store all a-tags
const existingEventIds = new Set<string>(); // Event IDs from e-tags only
if (existingLists.length > 0) {
const existingList = existingLists[0];
for (const tag of existingList.tags) {
if (tag[0] === 'e' && tag[1]) {
const eventId = tag[1];
existingEventIds.add(eventId);
existingETags.set(eventId, tag);
} else if (tag[0] === 'a' && tag[1]) {
// Store a-tags separately (format: a:<kind>:<pubkey>:<d-tag>)
existingATags.push(tag);
}
}
}
// Check if we have any changes
const newEventIds = deduplicatedEventIds.filter(id => !existingEventIds.has(id));
const removedEventIds = [...existingEventIds].filter(id => !deduplicatedEventIds.includes(id));
if (newEventIds.length === 0 && removedEventIds.length === 0 && existingLists.length > 0) {
return; // No changes, cancel operation
}
// Build final tags: preserve all a-tags, add/update e-tags
const tags: string[][] = [];
// First, add all existing a-tags (they take precedence)
for (const aTag of existingATags) {
tags.push(aTag);
}
// Then, add e-tags for all event IDs in the final list
// Note: We can't easily check if an a-tag represents a specific event ID without resolving it
// So we'll add e-tags for all eventIds, and rely on clients to prefer a-tags when resolving
const seenETags = new Set<string>();
for (const eventId of deduplicatedEventIds) {
if (!seenETags.has(eventId)) {
tags.push(['e', eventId]);
seenETags.add(eventId);
}
}
// Final deduplication: if we somehow have duplicate e-tags, remove them
// (This shouldn't happen, but ensures clean output)
const finalTags: string[][] = [];
const seenEventIds = new Set<string>();
for (const tag of tags) {
if (tag[0] === 'a') {
// Always keep a-tags
finalTags.push(tag);
} else if (tag[0] === 'e' && tag[1]) {
// For e-tags, check if we already have this event ID
// If an a-tag represents this event, we'd ideally skip the e-tag,
// but we can't check that without resolving a-tags
// So we'll keep the e-tag and let clients handle the preference
if (!seenEventIds.has(tag[1])) {
finalTags.push(tag);
seenEventIds.add(tag[1]);
}
} else {
// Keep other tag types as-is
finalTags.push(tag);
}
}
// Create new list event with merged tags and new timestamp
const listEvent: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.BOOKMARKS,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: finalTags,
content: ''
};
// Publish to write relays
const writeRelays = relayManager.getPublishRelays(relays, true);
await signAndPublish(listEvent, writeRelays);
} catch (error) {
console.error('Failed to publish bookmark list:', error);
}
}
/**
* Toggle highlight status of an event
*/

122
src/lib/types/kind-lookup.ts

@ -7,10 +7,52 @@ export interface KindInfo { @@ -7,10 +7,52 @@ export interface KindInfo {
number: number;
description: string;
showInFeed?: boolean; // Whether this kind should be displayed on the Feed page
isReplaceable?: boolean; // Whether this is a replaceable event (requires d-tag)
isSecondaryKind?: boolean; // Whether this is a secondary kind (used to display the main kind)
}
/**
* Kind categorization helpers based on NIP-01
* https://github.com/nostr-protocol/nips/blob/master/01.md
*/
/**
* Regular events: 1000 <= n < 10000 || 4 <= n < 45 || n == 1 || n == 2
* These are all expected to be stored by relays.
*/
export function isRegularKind(kind: number): boolean {
return (kind >= 1000 && kind < 10000) ||
(kind >= 4 && kind < 45) ||
kind === 1 ||
kind === 2;
}
/**
* Replaceable events: 10000 <= n < 20000 || n == 0 || n == 3
* For each combination of pubkey and kind, only the latest event MUST be stored by relays.
*/
export function isReplaceableKind(kind: number): boolean {
return (kind >= 10000 && kind < 20000) ||
kind === 0 ||
kind === 3;
}
/**
* Ephemeral events: 20000 <= n < 30000
* These are not expected to be stored by relays.
*/
export function isEphemeralKind(kind: number): boolean {
return kind >= 20000 && kind < 30000;
}
/**
* Parameterized replaceable events: 30000 <= n < 40000
* Addressable by their kind, pubkey and d tag value.
* For each combination of kind, pubkey and d tag value, only the latest event MUST be stored.
*/
export function isParameterizedReplaceableKind(kind: number): boolean {
return kind >= 30000 && kind < 40000;
}
// Kind number constants
export const KIND = {
METADATA: 0,
@ -47,60 +89,62 @@ export const KIND = { @@ -47,60 +89,62 @@ export const KIND = {
EMOJI_PACK: 30030,
MUTE_LIST: 10000,
BADGES: 30008,
FOLOW_SET: 30000
} as const;
export const KIND_LOOKUP: Record<number, KindInfo> = {
// Core kinds
[KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Short Text Note', showInFeed: true, isReplaceable: false },
[KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Public Message', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
[KIND.EVENT_DELETION]: { number: KIND.EVENT_DELETION, description: 'Event Deletion', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.REACTION]: { number: KIND.REACTION, description: 'Reaction', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
[KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Short Text Note', showInFeed: true },
[KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Public Message', showInFeed: true, isSecondaryKind: false },
[KIND.EVENT_DELETION]: { number: KIND.EVENT_DELETION, description: 'Event Deletion', showInFeed: false, isSecondaryKind: false },
[KIND.REACTION]: { number: KIND.REACTION, description: 'Reaction', showInFeed: false, isSecondaryKind: true },
// Articles
[KIND.LONG_FORM_NOTE]: { number: KIND.LONG_FORM_NOTE, description: 'Long-form Note', showInFeed: true, isReplaceable: true, isSecondaryKind: false },
[KIND.HIGHLIGHTED_ARTICLE]: { number: KIND.HIGHLIGHTED_ARTICLE, description: 'Highlighted Article', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
[KIND.LONG_FORM_NOTE]: { number: KIND.LONG_FORM_NOTE, description: 'Long-form Note', showInFeed: true, isSecondaryKind: false },
[KIND.HIGHLIGHTED_ARTICLE]: { number: KIND.HIGHLIGHTED_ARTICLE, description: 'Highlighted Article', showInFeed: true, isSecondaryKind: false },
// Threads and comments
[KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.COMMENT]: { number: KIND.COMMENT, description: 'Comment', showInFeed: true, isReplaceable: false, isSecondaryKind: true },
[KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: false, isSecondaryKind: false },
[KIND.COMMENT]: { number: KIND.COMMENT, description: 'Comment', showInFeed: true, isSecondaryKind: true },
// Media
[KIND.PICTURE_NOTE]: { number: KIND.PICTURE_NOTE, description: 'Picture Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
[KIND.VIDEO_NOTE]: { number: KIND.VIDEO_NOTE, description: 'Video Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
[KIND.SHORT_VIDEO_NOTE]: { number: KIND.SHORT_VIDEO_NOTE, description: 'Short Video Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
[KIND.VOICE_NOTE]: { number: KIND.VOICE_NOTE, description: 'Voice Note (Yak)', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
[KIND.VOICE_REPLY]: { number: KIND.VOICE_REPLY, description: 'Voice Reply (Yak Back)', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
[KIND.FILE_METADATA]: { number: KIND.FILE_METADATA, description: 'File Metadata (GIFs)', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.PICTURE_NOTE]: { number: KIND.PICTURE_NOTE, description: 'Picture Note', showInFeed: true, isSecondaryKind: false },
[KIND.VIDEO_NOTE]: { number: KIND.VIDEO_NOTE, description: 'Video Note', showInFeed: true, isSecondaryKind: false },
[KIND.SHORT_VIDEO_NOTE]: { number: KIND.SHORT_VIDEO_NOTE, description: 'Short Video Note', showInFeed: true, isSecondaryKind: false },
[KIND.VOICE_NOTE]: { number: KIND.VOICE_NOTE, description: 'Voice Note (Yak)', showInFeed: true, isSecondaryKind: false },
[KIND.VOICE_REPLY]: { number: KIND.VOICE_REPLY, description: 'Voice Reply (Yak Back)', showInFeed: false, isSecondaryKind: true },
[KIND.FILE_METADATA]: { number: KIND.FILE_METADATA, description: 'File Metadata (GIFs)', showInFeed: false, isSecondaryKind: false },
// Polls
[KIND.POLL]: { number: KIND.POLL, description: 'Poll', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
[KIND.POLL_RESPONSE]: { number: KIND.POLL_RESPONSE, description: 'Poll Response', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
[KIND.POLL]: { number: KIND.POLL, description: 'Poll', showInFeed: true, isSecondaryKind: false },
[KIND.POLL_RESPONSE]: { number: KIND.POLL_RESPONSE, description: 'Poll Response', showInFeed: false, isSecondaryKind: true },
// User events
[KIND.METADATA]: { number: KIND.METADATA, description: 'Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.USER_STATUS]: { number: KIND.USER_STATUS, description: 'User Status', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
[KIND.PAYMENT_ADDRESSES]: { number: KIND.PAYMENT_ADDRESSES, description: 'Payment Addresses', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.LABEL]: { number: KIND.LABEL, description: 'Label', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.REPORT]: { number: KIND.REPORT, description: 'Report', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.METADATA]: { number: KIND.METADATA, description: 'Metadata', showInFeed: false, isSecondaryKind: false },
[KIND.USER_STATUS]: { number: KIND.USER_STATUS, description: 'User Status', showInFeed: false, isSecondaryKind: true },
[KIND.PAYMENT_ADDRESSES]: { number: KIND.PAYMENT_ADDRESSES, description: 'Payment Addresses', showInFeed: false, isSecondaryKind: false },
[KIND.LABEL]: { number: KIND.LABEL, description: 'Label', showInFeed: false, isSecondaryKind: false },
[KIND.REPORT]: { number: KIND.REPORT, description: 'Report', showInFeed: false, isSecondaryKind: false },
// Zaps
[KIND.ZAP_RECEIPT]: { number: KIND.ZAP_RECEIPT, description: 'Zap Receipt', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
[KIND.ZAP_RECEIPT]: { number: KIND.ZAP_RECEIPT, description: 'Zap Receipt', showInFeed: false, isSecondaryKind: true },
// Relay lists
[KIND.RELAY_LIST]: { number: KIND.RELAY_LIST, description: 'Relay List Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.BLOCKED_RELAYS]: { number: KIND.BLOCKED_RELAYS, description: 'Blocked Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.FAVORITE_RELAYS]: { number: KIND.FAVORITE_RELAYS, description: 'Favorite Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.LOCAL_RELAYS]: { number: KIND.LOCAL_RELAYS, description: 'Local Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.RELAY_LIST]: { number: KIND.RELAY_LIST, description: 'Relay List Metadata', showInFeed: false, isSecondaryKind: false },
[KIND.BLOCKED_RELAYS]: { number: KIND.BLOCKED_RELAYS, description: 'Blocked Relays', showInFeed: false, isSecondaryKind: false },
[KIND.FAVORITE_RELAYS]: { number: KIND.FAVORITE_RELAYS, description: 'Favorite Relays', showInFeed: false, isSecondaryKind: false },
[KIND.LOCAL_RELAYS]: { number: KIND.LOCAL_RELAYS, description: 'Local Relays', showInFeed: false, isSecondaryKind: false },
// Personal lists
[KIND.PIN_LIST]: { number: KIND.PIN_LIST, description: 'Pin List', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.BOOKMARKS]: { number: KIND.BOOKMARKS, description: 'Bookmarks', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
[KIND.RSS_FEED]: { number: KIND.RSS_FEED, description: 'RSS Feed', showInFeed: false, isReplaceable: false },
[KIND.INTEREST_LIST]: { number: KIND.INTEREST_LIST, description: 'Interest List', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.EMOJI_SET]: { number: KIND.EMOJI_SET, description: 'Emoji Set', showInFeed: false, isReplaceable: true, isSecondaryKind: false },
[KIND.EMOJI_PACK]: { number: KIND.EMOJI_PACK, description: 'Emoji Pack', showInFeed: false, isReplaceable: true, isSecondaryKind: false },
[KIND.MUTE_LIST]: { number: KIND.MUTE_LIST, description: 'Mute List', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.BADGES]: { number: KIND.BADGES, description: 'Badges', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.PIN_LIST]: { number: KIND.PIN_LIST, description: 'Pin List', showInFeed: false, isSecondaryKind: false },
[KIND.BOOKMARKS]: { number: KIND.BOOKMARKS, description: 'Bookmarks', showInFeed: false, isSecondaryKind: true },
[KIND.RSS_FEED]: { number: KIND.RSS_FEED, description: 'RSS Feed', showInFeed: false },
[KIND.INTEREST_LIST]: { number: KIND.INTEREST_LIST, description: 'Interest List', showInFeed: false, isSecondaryKind: false },
[KIND.EMOJI_SET]: { number: KIND.EMOJI_SET, description: 'Emoji Set', showInFeed: false, isSecondaryKind: false },
[KIND.EMOJI_PACK]: { number: KIND.EMOJI_PACK, description: 'Emoji Pack', showInFeed: false, isSecondaryKind: false },
[KIND.MUTE_LIST]: { number: KIND.MUTE_LIST, description: 'Mute List', showInFeed: false, isSecondaryKind: false },
[KIND.BADGES]: { number: KIND.BADGES, description: 'Badges', showInFeed: false, isSecondaryKind: false },
[KIND.FOLOW_SET]: { number: KIND.FOLOW_SET, description: 'Follow Set', showInFeed: false, isSecondaryKind: false },
};
/**
@ -126,11 +170,3 @@ export function getFeedKinds(): number[] { @@ -126,11 +170,3 @@ export function getFeedKinds(): number[] {
.map(kind => kind.number);
}
/**
* Get all replaceable event kinds (that require d-tags)
*/
export function getReplaceableKinds(): number[] {
return Object.values(KIND_LOOKUP)
.filter(kind => kind.isReplaceable === true)
.map(kind => kind.number);
}

5
src/routes/+page.svelte

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import Header from '../lib/components/layout/Header.svelte';
import ThreadList from '../lib/modules/threads/ThreadList.svelte';
import SearchBox from '../lib/components/layout/SearchBox.svelte';
import { nostrClient } from '../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
@ -19,6 +20,10 @@ @@ -19,6 +20,10 @@
</div>
</div>
<div class="search-section mb-6">
<SearchBox />
</div>
<ThreadList />
</main>

518
src/routes/event/[id]/+page.svelte

@ -0,0 +1,518 @@ @@ -0,0 +1,518 @@
<script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte';
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte';
import MetadataCard from '../../../lib/components/content/MetadataCard.svelte';
import MarkdownRenderer from '../../../lib/components/content/MarkdownRenderer.svelte';
import EventMenu from '../../../lib/components/EventMenu.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { loadEventIndex, type EventIndexItem } from '../../../lib/services/nostr/event-index-loader.js';
import { signAndPublish } from '../../../lib/services/nostr/auth-handler.js';
import { sessionManager } from '../../../lib/services/auth/session-manager.js';
import PublicationStatusModal from '../../../lib/components/modals/PublicationStatusModal.svelte';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { getKindInfo, isReplaceableKind, isParameterizedReplaceableKind, KIND } from '../../../lib/types/kind-lookup.js';
let event = $state<NostrEvent | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let indexItems = $state<EventIndexItem[]>([]);
let loadingIndex = $state(false);
let labeling = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
onMount(async () => {
await nostrClient.initialize();
await loadEvent();
});
$effect(() => {
if ($page.params.id) {
loadEvent();
}
});
async function loadEvent() {
if (!$page.params.id) return;
loading = true;
error = null;
try {
const eventId = decodeEventId($page.params.id);
if (!eventId) {
error = 'Invalid event ID format';
loading = false;
return;
}
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [eventId], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length === 0) {
error = 'Event not found';
} else {
event = events[0];
// If kind 30040, load event index
if (event.kind === 30040) {
await loadIndex();
}
}
} catch (err) {
console.error('Error loading event:', err);
error = 'Failed to load event';
} finally {
loading = false;
}
}
function decodeEventId(param: string): string | null {
if (!param) return null;
// Check if it's already a hex event ID
if (/^[0-9a-f]{64}$/i.test(param)) {
return param.toLowerCase();
}
// Check if it's a bech32 encoded format
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(param)) {
try {
const decoded = nip19.decode(param);
if (decoded.type === 'note') {
return String(decoded.data);
} else if (decoded.type === 'nevent') {
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
return String(decoded.data.id);
}
} else if (decoded.type === 'naddr') {
// For naddr, we need to fetch by kind+pubkey+d
// This is handled separately
return null;
}
} catch (err) {
console.error('Error decoding bech32:', err);
return null;
}
}
return null;
}
async function loadIndex() {
if (!event || event.kind !== 30040) return;
loadingIndex = true;
try {
indexItems = await loadEventIndex(event);
} catch (err) {
console.error('Error loading event index:', err);
} finally {
loadingIndex = false;
}
}
function getSectionTitle(item: EventIndexItem): string {
const titleTag = item.event.tags.find(t => t[0] === 'title' && t[1]);
if (titleTag) {
return titleTag[1];
}
// Fallback to d-tag in Title Case
const dTag = item.event.tags.find(t => t[0] === 'd' && t[1])?.[1];
if (dTag) {
return dTag.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ');
}
return `Section ${item.order + 1}`;
}
function openSectionInNewWindow(item: EventIndexItem) {
window.open(`/event/${item.event.id}`, '_blank');
}
async function labelAsBook() {
if (!event) return;
const session = sessionManager.getSession();
if (!session) {
alert('You must be logged in to label events');
return;
}
labeling = true;
publicationResults = null;
try {
const tags: string[][] = [
['L', 'ugc'], // Namespace tag
['l', 'booklist', 'ugc'], // Label tag
];
// Add reference to the event
// For kind 30040 (replaceable), use 'a' tag with kind:pubkey:d-tag
// For other events, use 'e' tag with event ID
if (event.kind === 30040) {
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1];
if (dTag) {
const aTag = `${event.kind}:${event.pubkey}:${dTag}`;
// Add relay hint if available
const relayUrl = relayManager.getFeedReadRelays()[0];
if (relayUrl) {
tags.push(['a', aTag, relayUrl]);
} else {
tags.push(['a', aTag]);
}
} else {
// Fallback to 'e' tag if no d-tag found
const relayUrl = relayManager.getFeedReadRelays()[0];
if (relayUrl) {
tags.push(['e', event.id, relayUrl]);
} else {
tags.push(['e', event.id]);
}
}
} else {
// Regular event - use 'e' tag
const relayUrl = relayManager.getFeedReadRelays()[0];
if (relayUrl) {
tags.push(['e', event.id, relayUrl]);
} else {
tags.push(['e', event.id]);
}
}
// Add client tag (NIP-89)
tags.push(['client', 'Aitherboard']);
const eventTemplate = {
kind: KIND.LABEL, // 1985
content: '',
tags,
created_at: Math.floor(Date.now() / 1000),
pubkey: session.pubkey,
};
const config = nostrClient.getConfig();
const relays = relayManager.getPublishRelays(config.defaultRelays);
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
publicationModalOpen = true;
if (results.success.length > 0) {
// Success - could show a success message
console.log('Event labeled as book successfully');
} else {
console.error('Failed to publish label event');
}
} catch (err) {
console.error('Error labeling event as book:', err);
alert('Failed to label event as book. Please try again.');
} finally {
labeling = false;
}
}
const isReplaceable = $derived(event ? isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind) : false);
const hasContent = $derived(event ? !!event.content : false);
const isKind30040 = $derived(event?.kind === 30040);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
</script>
<Header />
<main class="container mx-auto px-4 py-8">
{#if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading event...</p>
</div>
{:else if error}
<div class="error-state">
<p class="text-fog-text dark:text-fog-dark-text">{error}</p>
</div>
{:else if event}
<div class="event-page">
{#if isReplaceable}
<div class="metadata-wrapper">
<MetadataCard event={event} />
{#if isKind30040 && isLoggedIn}
<div class="metadata-actions">
<EventMenu event={event} showContentActions={false} />
<button class="btn-label-book" onclick={labelAsBook} disabled={labeling}>
{labeling ? 'Labeling...' : 'Label as book'}
</button>
</div>
{/if}
</div>
{/if}
{#if isKind30040}
{#if loadingIndex}
<div class="loading-index">
<p class="text-fog-text dark:text-fog-dark-text">Loading publication...</p>
</div>
{:else if indexItems.length > 0}
{#each indexItems as item (item.event.id)}
<div class="section-item">
<div class="section-header">
<h3 class="section-title">{getSectionTitle(item)}</h3>
<div class="section-actions">
<EventMenu event={item.event} showContentActions={false} />
<button class="btn-open-window" onclick={() => openSectionInNewWindow(item)}>
Open in new window
</button>
</div>
</div>
<div class="section-content">
<MarkdownRenderer content={item.event.content} event={item.event} />
</div>
</div>
{/each}
{/if}
{:else if hasContent}
<div class="event-content">
<MarkdownRenderer content={event.content} event={event} />
</div>
{:else}
<div class="event-tags">
<h3 class="tags-title">Tags</h3>
<div class="tags-list">
{#each event.tags as tag}
<div class="tag-item">
<span class="tag-name">{tag[0]}</span>
{#each tag.slice(1) as value}
<span class="tag-value">{value}</span>
{/each}
</div>
{/each}
</div>
</div>
{/if}
<div class="event-details">
<FeedPost post={event} />
</div>
</div>
{/if}
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.loading-state,
.error-state {
padding: 2rem;
text-align: center;
}
.event-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.event-content {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .event-content {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-tags {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .event-tags {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.tags-title {
margin: 0 0 1rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .tags-title {
color: var(--fog-dark-text, #f9fafb);
}
.tags-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-item {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .tag-item {
background: var(--fog-dark-highlight, #374151);
}
.tag-name {
font-weight: 600;
color: var(--fog-accent, #64748b);
}
:global(.dark) .tag-name {
color: var(--fog-dark-accent, #94a3b8);
}
.tag-value {
color: var(--fog-text, #1f2937);
word-break: break-all;
}
:global(.dark) .tag-value {
color: var(--fog-dark-text, #f9fafb);
}
.event-details {
padding: 1rem;
}
.metadata-wrapper {
position: relative;
}
.metadata-actions {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.5rem;
}
.btn-label-book {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .btn-label-book {
background: var(--fog-dark-accent, #94a3b8);
}
.btn-label-book:hover {
opacity: 0.9;
}
.loading-index {
padding: 2rem;
text-align: center;
}
.section-item {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .section-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .section-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.section-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .section-title {
color: var(--fog-dark-text, #f9fafb);
}
.section-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-open-window {
padding: 0.25rem 0.75rem;
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;
}
:global(.dark) .btn-open-window {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
.btn-open-window:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .btn-open-window:hover {
background: var(--fog-dark-border, #475569);
}
.section-content {
padding: 0.5rem 0;
}
.btn-label-book:hover:not(:disabled) {
opacity: 0.9;
}
.btn-label-book:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />

4
src/routes/feed/+page.svelte

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import FeedPage from '../../lib/modules/feed/FeedPage.svelte';
import SearchBox from '../../lib/components/layout/SearchBox.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
@ -12,6 +13,9 @@ @@ -12,6 +13,9 @@
<Header />
<main class="container mx-auto px-4 py-8">
<div class="search-section mb-6">
<SearchBox />
</div>
<FeedPage />
</main>

190
src/routes/replaceable/[d_tag]/+page.svelte

@ -0,0 +1,190 @@ @@ -0,0 +1,190 @@
<script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte';
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte';
import ThreadDrawer from '../../../lib/modules/feed/ThreadDrawer.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { isReplaceableKind, isParameterizedReplaceableKind } from '../../../lib/types/kind-lookup.js';
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let dTag = $derived($page.params.d_tag);
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
onMount(async () => {
await nostrClient.initialize();
await loadReplaceableEvents();
});
$effect(() => {
if ($page.params.d_tag) {
loadReplaceableEvents();
}
});
async function loadReplaceableEvents() {
if (!dTag) return;
loading = true;
try {
const relays = relayManager.getProfileReadRelays();
// Fetch all replaceable events with matching d-tag
const allEvents: NostrEvent[] = [];
// Build list of replaceable kinds to check:
// - Replaceable: 0, 3, and 10000-19999
// - Parameterized replaceable: 30000-39999
const kindsToCheck: number[] = [0, 3]; // Basic replaceable kinds
// Add replaceable range (10000-19999)
for (let kind = 10000; kind < 20000; kind++) {
kindsToCheck.push(kind);
}
// Add parameterized replaceable range (30000-39999)
for (let kind = 30000; kind < 40000; kind++) {
kindsToCheck.push(kind);
}
for (const kind of kindsToCheck) {
const kindEvents = await nostrClient.fetchEvents(
[{ kinds: [kind], '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
// For replaceable events, get the newest version of each (by pubkey)
const eventsByPubkey = new Map<string, NostrEvent>();
for (const event of kindEvents) {
const existing = eventsByPubkey.get(event.pubkey);
if (!existing || event.created_at > existing.created_at) {
eventsByPubkey.set(event.pubkey, event);
}
}
allEvents.push(...Array.from(eventsByPubkey.values()));
}
// Sort by created_at descending
events = allEvents.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading replaceable events:', error);
events = [];
} finally {
loading = false;
}
}
function openInDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
}
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="replaceable-header mb-6">
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text">
Replaceable Events: {dTag}
</h1>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">
{events.length} {events.length === 1 ? 'event' : 'events'} found
</p>
</div>
{#if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading events...</p>
</div>
{:else if events.length === 0}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No replaceable events found with this d-tag.</p>
</div>
{:else}
<div class="events-list">
{#each events as event (event.id)}
<div
class="event-item"
onclick={() => openInDrawer(event)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openInDrawer(event);
}
}}
role="button"
tabindex="0"
>
<FeedPost post={event} />
</div>
{/each}
</div>
{/if}
</main>
{#if drawerOpen && drawerEvent}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
{/if}
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.replaceable-header {
border-bottom: 1px solid var(--fog-border, #e5e7eb);
padding-bottom: 1rem;
}
:global(.dark) .replaceable-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.loading-state,
.empty-state {
padding: 2rem;
text-align: center;
}
.events-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-item {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
cursor: pointer;
transition: all 0.2s;
}
:global(.dark) .event-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-item:hover {
border-color: var(--fog-accent, #64748b);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:global(.dark) .event-item:hover {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
</style>

147
src/routes/topics/[name]/+page.svelte

@ -0,0 +1,147 @@ @@ -0,0 +1,147 @@
<script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte';
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { KIND } from '../../../lib/types/kind-lookup.js';
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let topicName = $derived($page.params.name);
onMount(async () => {
await nostrClient.initialize();
await loadTopicEvents();
});
$effect(() => {
if ($page.params.name) {
loadTopicEvents();
}
});
async function loadTopicEvents() {
if (!topicName) return;
loading = true;
try {
const relays = relayManager.getFeedReadRelays();
// Fetch events with matching hashtag in content or t-tag
// We'll search for events that contain the hashtag in content or have a matching t-tag
const allEvents: NostrEvent[] = [];
// Search for hashtag in content (kind 1 posts)
const contentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
// Filter events that contain the hashtag
const hashtagPattern = new RegExp(`#${topicName}\\b`, 'i');
for (const event of contentEvents) {
if (hashtagPattern.test(event.content)) {
allEvents.push(event);
}
}
// Search for events with matching t-tag
const tTagEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], '#t': [topicName], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
// Merge and deduplicate
const eventMap = new Map<string, NostrEvent>();
for (const event of allEvents) {
eventMap.set(event.id, event);
}
for (const event of tTagEvents) {
eventMap.set(event.id, event);
}
events = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading topic events:', error);
events = [];
} finally {
loading = false;
}
}
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="topic-header mb-6">
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text">
Topic: #{topicName}
</h1>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">
{events.length} {events.length === 1 ? 'event' : 'events'} found
</p>
</div>
{#if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading events...</p>
</div>
{:else if events.length === 0}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No events found for this topic.</p>
</div>
{:else}
<div class="events-list">
{#each events as event (event.id)}
<div class="event-item">
<FeedPost post={event} />
</div>
{/each}
</div>
{/if}
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.topic-header {
border-bottom: 1px solid var(--fog-border, #e5e7eb);
padding-bottom: 1rem;
}
:global(.dark) .topic-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.loading-state,
.empty-state {
padding: 2rem;
text-align: center;
}
.events-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-item {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .event-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
</style>

132
src/routes/write/+page.svelte

@ -0,0 +1,132 @@ @@ -0,0 +1,132 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import FindEventForm from '../../lib/components/write/FindEventForm.svelte';
import CreateEventForm from '../../lib/components/write/CreateEventForm.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { onMount } from 'svelte';
let mode = $state<'select' | 'find' | 'create'>('select');
const isLoggedIn = $derived(sessionManager.isLoggedIn());
onMount(async () => {
await nostrClient.initialize();
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="write-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text">Write</h1>
{#if !isLoggedIn}
<div class="login-prompt">
<p class="text-fog-text dark:text-fog-dark-text mb-4">You must be logged in to write or edit events.</p>
<a href="/login" class="text-fog-accent dark:text-fog-dark-accent hover:underline">Login here</a>
</div>
{:else if mode === 'select'}
<div class="mode-selector">
<button
class="mode-button"
onclick={() => mode = 'find'}
>
Find an existing event to edit
</button>
<button
class="mode-button"
onclick={() => mode = 'create'}
>
Create a new event
</button>
</div>
{:else if mode === 'find'}
<div class="form-container">
<button class="back-button" onclick={() => mode = 'select'}> Back</button>
<FindEventForm />
</div>
{:else if mode === 'create'}
<div class="form-container">
<button class="back-button" onclick={() => mode = 'select'}> Back</button>
<CreateEventForm />
</div>
{/if}
</div>
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.write-page {
max-width: 800px;
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);
}
</style>
Loading…
Cancel
Save