37 changed files with 7074 additions and 165 deletions
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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, '&') |
||||||
|
.replace(/</g, '<') |
||||||
|
.replace(/>/g, '>') |
||||||
|
.replace(/"/g, '"') |
||||||
|
.replace(/'/g, '''); |
||||||
|
} |
||||||
@ -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)); |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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} /> |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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…
Reference in new issue