37 changed files with 7074 additions and 165 deletions
@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
|
||||
<script lang="ts"> |
||||
import ProfileBadge from '../layout/ProfileBadge.svelte'; |
||||
import ThreadDrawer from '../../modules/feed/ThreadDrawer.svelte'; |
||||
import type { Highlight } from '../../services/nostr/highlight-service.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
interface Props { |
||||
highlights: Array<{ start: number; end: number; highlight: Highlight }>; |
||||
content: string; |
||||
event: NostrEvent; |
||||
} |
||||
|
||||
let { highlights, content, event }: Props = $props(); |
||||
|
||||
let containerRef = $state<HTMLElement | null>(null); |
||||
let drawerOpen = $state(false); |
||||
let selectedHighlight = $state<Highlight | null>(null); |
||||
let hoveredHighlight = $state<Highlight | null>(null); |
||||
let tooltipPosition = $state({ top: 0, left: 0 }); |
||||
|
||||
function openHighlight(highlight: Highlight) { |
||||
selectedHighlight = highlight; |
||||
drawerOpen = true; |
||||
} |
||||
|
||||
function closeDrawer() { |
||||
drawerOpen = false; |
||||
selectedHighlight = null; |
||||
} |
||||
|
||||
// Apply highlights to rendered HTML content |
||||
// This runs after the HTML is rendered |
||||
$effect(() => { |
||||
if (!containerRef || highlights.length === 0) return; |
||||
|
||||
// Wait for content to be rendered |
||||
const timeoutId = setTimeout(() => { |
||||
if (!containerRef) return; |
||||
|
||||
// For each highlight, try to find and wrap the text in the rendered HTML |
||||
for (const { start, end, highlight } of highlights) { |
||||
const highlightText = content.substring(start, end); |
||||
if (!highlightText) continue; |
||||
|
||||
// Search for this text in the rendered HTML |
||||
const walker = document.createTreeWalker( |
||||
containerRef, |
||||
NodeFilter.SHOW_TEXT, |
||||
null |
||||
); |
||||
|
||||
let node; |
||||
while ((node = walker.nextNode())) { |
||||
const text = node.textContent || ''; |
||||
const index = text.indexOf(highlightText); |
||||
|
||||
if (index !== -1) { |
||||
// Found the text, wrap it |
||||
const span = document.createElement('span'); |
||||
span.className = 'highlight-span'; |
||||
span.setAttribute('data-highlight-id', highlight.event.id); |
||||
span.setAttribute('data-pubkey', highlight.pubkey); |
||||
span.textContent = highlightText; |
||||
|
||||
// Add event listeners |
||||
span.addEventListener('mouseenter', (e) => { |
||||
const rect = (e.target as HTMLElement).getBoundingClientRect(); |
||||
tooltipPosition = { top: rect.top - 40, left: rect.left }; |
||||
hoveredHighlight = highlight; |
||||
}); |
||||
|
||||
span.addEventListener('mouseleave', () => { |
||||
hoveredHighlight = null; |
||||
}); |
||||
|
||||
span.addEventListener('click', () => { |
||||
openHighlight(highlight); |
||||
}); |
||||
|
||||
// Replace text node |
||||
const beforeText = text.substring(0, index); |
||||
const afterText = text.substring(index + highlightText.length); |
||||
|
||||
if (beforeText) { |
||||
node.parentNode?.insertBefore(document.createTextNode(beforeText), node); |
||||
} |
||||
|
||||
node.parentNode?.insertBefore(span, node); |
||||
|
||||
if (afterText) { |
||||
node.parentNode?.insertBefore(document.createTextNode(afterText), node); |
||||
} |
||||
|
||||
node.remove(); |
||||
break; // Only wrap first occurrence |
||||
} |
||||
} |
||||
} |
||||
}, 500); |
||||
|
||||
return () => clearTimeout(timeoutId); |
||||
}); |
||||
</script> |
||||
|
||||
<div class="highlight-overlay" bind:this={containerRef}> |
||||
<slot /> |
||||
|
||||
{#if hoveredHighlight} |
||||
<div |
||||
class="highlight-tooltip" |
||||
style="top: {tooltipPosition.top}px; left: {tooltipPosition.left}px;" |
||||
> |
||||
<ProfileBadge pubkey={hoveredHighlight.pubkey} /> |
||||
<button class="view-highlight-button" onclick={() => openHighlight(hoveredHighlight)}> |
||||
View the highlight |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if drawerOpen && selectedHighlight} |
||||
<ThreadDrawer |
||||
opEvent={selectedHighlight.event} |
||||
isOpen={drawerOpen} |
||||
onClose={closeDrawer} |
||||
/> |
||||
{/if} |
||||
|
||||
<style> |
||||
:global(.highlight-span) { |
||||
background: rgba(255, 255, 0, 0.3); |
||||
cursor: pointer; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
:global(.highlight-span:hover) { |
||||
background: rgba(255, 255, 0, 0.5); |
||||
} |
||||
|
||||
.highlight-tooltip { |
||||
position: fixed; |
||||
background: var(--fog-post, #ffffff); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
padding: 0.75rem; |
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
||||
z-index: 1000; |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
min-width: 200px; |
||||
} |
||||
|
||||
:global(.dark) .highlight-tooltip { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
.view-highlight-button { |
||||
padding: 0.5rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .view-highlight-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.view-highlight-button:hover { |
||||
opacity: 0.9; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
|
||||
<script lang="ts"> |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import EventMenu from '../EventMenu.svelte'; |
||||
|
||||
interface Props { |
||||
event: NostrEvent; |
||||
showMenu?: boolean; |
||||
} |
||||
|
||||
let { event, showMenu = true }: Props = $props(); |
||||
|
||||
// Extract metadata tags (using $derived for reactivity) |
||||
const image = $derived(event.tags.find(t => t[0] === 'image' && t[1])?.[1]); |
||||
const description = $derived(event.tags.find(t => t[0] === 'description' && t[1])?.[1]); |
||||
const summary = $derived(event.tags.find(t => t[0] === 'summary' && t[1])?.[1]); |
||||
const author = $derived(event.tags.find(t => t[0] === 'author' && t[1])?.[1]); |
||||
const title = $derived( |
||||
event.tags.find(t => t[0] === 'title' && t[1])?.[1] || |
||||
(() => { |
||||
// Fallback to d-tag in Title Case |
||||
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1]; |
||||
if (dTag) { |
||||
return dTag.split('-').map(word => |
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() |
||||
).join(' '); |
||||
} |
||||
return null; |
||||
})() |
||||
); |
||||
|
||||
const hasMetadata = $derived(image || description || summary || author || title); |
||||
</script> |
||||
|
||||
{#if hasMetadata} |
||||
<div class="metadata-card"> |
||||
<div class="metadata-header"> |
||||
{#if title} |
||||
<h2 class="metadata-title">{title}</h2> |
||||
{/if} |
||||
{#if showMenu} |
||||
<EventMenu event={event} showContentActions={false} /> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if image} |
||||
<div class="metadata-image"> |
||||
<img |
||||
src={image} |
||||
alt={title || description || summary || 'Metadata image'} |
||||
loading="lazy" |
||||
/> |
||||
</div> |
||||
{/if} |
||||
|
||||
<div class="metadata-content"> |
||||
{#if description} |
||||
<p class="metadata-description">{description}</p> |
||||
{/if} |
||||
|
||||
{#if summary} |
||||
<p class="metadata-summary">{summary}</p> |
||||
{/if} |
||||
|
||||
{#if author} |
||||
<p class="metadata-author">Author: {author}</p> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.metadata-card { |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
padding: 1.5rem; |
||||
margin-bottom: 1.5rem; |
||||
background: var(--fog-post, #ffffff); |
||||
} |
||||
|
||||
:global(.dark) .metadata-card { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.metadata-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: flex-start; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.metadata-title { |
||||
margin: 0; |
||||
font-size: 1.5rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
flex: 1; |
||||
} |
||||
|
||||
:global(.dark) .metadata-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.metadata-image { |
||||
margin-bottom: 1rem; |
||||
border-radius: 0.5rem; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.metadata-image img { |
||||
width: 100%; |
||||
height: auto; |
||||
display: block; |
||||
} |
||||
|
||||
.metadata-content { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.75rem; |
||||
} |
||||
|
||||
.metadata-description, |
||||
.metadata-summary { |
||||
margin: 0; |
||||
color: var(--fog-text, #1f2937); |
||||
line-height: 1.6; |
||||
} |
||||
|
||||
:global(.dark) .metadata-description, |
||||
:global(.dark) .metadata-summary { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.metadata-author { |
||||
margin: 0; |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text-light, #6b7280); |
||||
} |
||||
|
||||
:global(.dark) .metadata-author { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
|
||||
<script lang="ts"> |
||||
import type { OpenGraphData } from '../../services/content/opengraph-fetcher.js'; |
||||
|
||||
interface Props { |
||||
data: OpenGraphData; |
||||
url: string; |
||||
} |
||||
|
||||
let { data, url }: Props = $props(); |
||||
</script> |
||||
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" class="opengraph-card"> |
||||
{#if data.image} |
||||
<div class="opengraph-image"> |
||||
<img src={data.image} alt={data.title || ''} loading="lazy" /> |
||||
</div> |
||||
{/if} |
||||
<div class="opengraph-content"> |
||||
{#if data.siteName} |
||||
<div class="opengraph-site">{data.siteName}</div> |
||||
{/if} |
||||
{#if data.title} |
||||
<div class="opengraph-title">{data.title}</div> |
||||
{/if} |
||||
{#if data.description} |
||||
<div class="opengraph-description">{data.description}</div> |
||||
{/if} |
||||
</div> |
||||
</a> |
||||
|
||||
<style> |
||||
.opengraph-card { |
||||
display: flex; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
overflow: hidden; |
||||
margin: 1rem 0; |
||||
text-decoration: none; |
||||
color: inherit; |
||||
transition: all 0.2s; |
||||
background: var(--fog-post, #ffffff); |
||||
} |
||||
|
||||
:global(.dark) .opengraph-card { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.opengraph-card:hover { |
||||
border-color: var(--fog-accent, #64748b); |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
:global(.dark) .opengraph-card:hover { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
.opengraph-image { |
||||
flex-shrink: 0; |
||||
width: 200px; |
||||
height: 150px; |
||||
overflow: hidden; |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .opengraph-image { |
||||
background: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.opengraph-image img { |
||||
width: 100%; |
||||
height: 100%; |
||||
object-fit: cover; |
||||
} |
||||
|
||||
.opengraph-content { |
||||
flex: 1; |
||||
padding: 1rem; |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.opengraph-site { |
||||
font-size: 0.75rem; |
||||
color: var(--fog-text-light, #6b7280); |
||||
text-transform: uppercase; |
||||
letter-spacing: 0.05em; |
||||
} |
||||
|
||||
:global(.dark) .opengraph-site { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.opengraph-title { |
||||
font-size: 1rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
line-height: 1.4; |
||||
} |
||||
|
||||
:global(.dark) .opengraph-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.opengraph-description { |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text-light, #6b7280); |
||||
line-height: 1.5; |
||||
display: -webkit-box; |
||||
-webkit-line-clamp: 2; |
||||
-webkit-box-orient: vertical; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .opengraph-description { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
@media (max-width: 640px) { |
||||
.opengraph-card { |
||||
flex-direction: column; |
||||
} |
||||
|
||||
.opengraph-image { |
||||
width: 100%; |
||||
height: 200px; |
||||
} |
||||
} |
||||
</style> |
||||
@ -0,0 +1,405 @@
@@ -0,0 +1,405 @@
|
||||
<script lang="ts"> |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { getEvent, getEventsByKind, getEventsByPubkey } from '../../services/cache/event-cache.js'; |
||||
import { cacheEvent } from '../../services/cache/event-cache.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { goto } from '$app/navigation'; |
||||
import { KIND } from '../../types/kind-lookup.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
let searchQuery = $state(''); |
||||
let searching = $state(false); |
||||
let searchResults = $state<Array<{ event: NostrEvent; matchType: string }>>([]); |
||||
let showResults = $state(false); |
||||
let searchInput: HTMLInputElement | null = $state(null); |
||||
|
||||
// Decode bech32 identifiers |
||||
function decodeIdentifier(input: string): { type: 'event' | 'profile' | null; id: string | null; pubkey: string | null } { |
||||
const trimmed = input.trim(); |
||||
|
||||
// Check if it's a hex event ID (64 hex characters) |
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) { |
||||
return { type: 'event', id: trimmed.toLowerCase(), pubkey: null }; |
||||
} |
||||
|
||||
// Check if it's a hex pubkey (64 hex characters) |
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) { |
||||
return { type: 'profile', id: null, pubkey: trimmed.toLowerCase() }; |
||||
} |
||||
|
||||
// Check if it's a bech32 encoded format |
||||
if (/^(note|nevent|naddr|npub|nprofile)1[a-z0-9]+$/i.test(trimmed)) { |
||||
try { |
||||
const decoded = nip19.decode(trimmed); |
||||
|
||||
if (decoded.type === 'note') { |
||||
return { type: 'event', id: String(decoded.data), pubkey: null }; |
||||
} else if (decoded.type === 'nevent') { |
||||
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
||||
return { type: 'event', id: String(decoded.data.id), pubkey: null }; |
||||
} |
||||
} else if (decoded.type === 'naddr') { |
||||
// naddr requires fetching by kind+pubkey+d, but we can try to find it |
||||
// For now, return null - we'll handle it in search |
||||
return { type: null, id: null, pubkey: null }; |
||||
} else if (decoded.type === 'npub') { |
||||
return { type: 'profile', id: null, pubkey: String(decoded.data) }; |
||||
} else if (decoded.type === 'nprofile') { |
||||
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
||||
return { type: 'profile', id: null, pubkey: String(decoded.data.pubkey) }; |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.error('Error decoding bech32:', error); |
||||
} |
||||
} |
||||
|
||||
return { type: null, id: null, pubkey: null }; |
||||
} |
||||
|
||||
async function performSearch() { |
||||
if (!searchQuery.trim()) { |
||||
searchResults = []; |
||||
showResults = false; |
||||
return; |
||||
} |
||||
|
||||
searching = true; |
||||
searchResults = []; |
||||
showResults = true; |
||||
|
||||
try { |
||||
const query = searchQuery.trim(); |
||||
|
||||
// First, try to decode as specific identifier |
||||
const decoded = decodeIdentifier(query); |
||||
|
||||
if (decoded.type === 'event' && decoded.id) { |
||||
// Search for specific event ID |
||||
let event = await getEvent(decoded.id); |
||||
|
||||
if (!event) { |
||||
// Not in cache, fetch from relays |
||||
const relays = relayManager.getFeedReadRelays(); |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ ids: [decoded.id] }], |
||||
relays, |
||||
{ useCache: false, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length > 0) { |
||||
event = events[0]; |
||||
await cacheEvent(event); |
||||
} |
||||
} |
||||
|
||||
if (event) { |
||||
searchResults = [{ event, matchType: 'Event ID' }]; |
||||
} |
||||
} else if (decoded.type === 'profile' && decoded.pubkey) { |
||||
// Search for profile - navigate directly to profile page |
||||
handleProfileClick(decoded.pubkey); |
||||
return; |
||||
} else { |
||||
// Text search in cached events |
||||
const allCached: NostrEvent[] = []; |
||||
|
||||
// Search kind 1 events |
||||
const kind1Events = await getEventsByKind(KIND.SHORT_TEXT_NOTE, 100); |
||||
allCached.push(...kind1Events); |
||||
|
||||
// Search kind 11 events |
||||
const kind11Events = await getEventsByKind(KIND.DISCUSSION_THREAD, 100); |
||||
allCached.push(...kind11Events); |
||||
|
||||
// Filter by search query |
||||
const queryLower = query.toLowerCase(); |
||||
const matches = allCached.filter(event => { |
||||
const contentMatch = event.content.toLowerCase().includes(queryLower); |
||||
const tagMatch = event.tags.some(tag => |
||||
tag.some(val => val && val.toLowerCase().includes(queryLower)) |
||||
); |
||||
return contentMatch || tagMatch; |
||||
}); |
||||
|
||||
// Sort by relevance (exact matches first, then by created_at) |
||||
const sorted = matches.sort((a, b) => { |
||||
const aExact = a.content.toLowerCase() === queryLower; |
||||
const bExact = b.content.toLowerCase() === queryLower; |
||||
if (aExact && !bExact) return -1; |
||||
if (!aExact && bExact) return 1; |
||||
return b.created_at - a.created_at; |
||||
}); |
||||
|
||||
searchResults = sorted.slice(0, 20).map(e => ({ event: e, matchType: 'Content' })); |
||||
} |
||||
} catch (error) { |
||||
console.error('Search error:', error); |
||||
} finally { |
||||
searching = false; |
||||
} |
||||
} |
||||
|
||||
function handleSearchInput(e: Event) { |
||||
const target = e.target as HTMLInputElement; |
||||
searchQuery = target.value; |
||||
|
||||
// Debounce search |
||||
if (searchQuery.trim()) { |
||||
performSearch(); |
||||
} else { |
||||
searchResults = []; |
||||
showResults = false; |
||||
} |
||||
} |
||||
|
||||
function handleKeyDown(e: KeyboardEvent) { |
||||
if (e.key === 'Enter') { |
||||
performSearch(); |
||||
} else if (e.key === 'Escape') { |
||||
showResults = false; |
||||
searchQuery = ''; |
||||
} |
||||
} |
||||
|
||||
function handleResultClick(event: NostrEvent) { |
||||
showResults = false; |
||||
searchQuery = ''; |
||||
goto(`/event/${event.id}`); |
||||
} |
||||
|
||||
function handleProfileClick(pubkey: string) { |
||||
showResults = false; |
||||
searchQuery = ''; |
||||
goto(`/profile/${pubkey}`); |
||||
} |
||||
|
||||
// Close results when clicking outside |
||||
$effect(() => { |
||||
if (showResults) { |
||||
const handleClickOutside = (e: MouseEvent) => { |
||||
const target = e.target as HTMLElement; |
||||
if (!target.closest('.search-box-container')) { |
||||
showResults = false; |
||||
} |
||||
}; |
||||
|
||||
document.addEventListener('click', handleClickOutside); |
||||
return () => { |
||||
document.removeEventListener('click', handleClickOutside); |
||||
}; |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<div class="search-box-container"> |
||||
<div class="search-input-wrapper"> |
||||
<input |
||||
bind:this={searchInput} |
||||
type="text" |
||||
placeholder="Search events, profiles, or enter event ID (hex, note, nevent, npub, nprofile)..." |
||||
value={searchQuery} |
||||
oninput={handleSearchInput} |
||||
onkeydown={handleKeyDown} |
||||
class="search-input" |
||||
aria-label="Search" |
||||
/> |
||||
{#if searching} |
||||
<span class="search-loading">⟳</span> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if showResults && searchResults.length > 0} |
||||
<div class="search-results"> |
||||
{#each searchResults as { event, matchType }} |
||||
<button |
||||
onclick={() => { |
||||
if (matchType === 'Profile' || event.kind === KIND.METADATA) { |
||||
handleProfileClick(event.pubkey); |
||||
} else { |
||||
handleResultClick(event); |
||||
} |
||||
}} |
||||
class="search-result-item" |
||||
> |
||||
<div class="search-result-header"> |
||||
<span class="search-result-type">{matchType}</span> |
||||
<span class="search-result-id">{event.id.substring(0, 16)}...</span> |
||||
</div> |
||||
<div class="search-result-content"> |
||||
{event.content.substring(0, 100)}{event.content.length > 100 ? '...' : ''} |
||||
</div> |
||||
<div class="search-result-meta"> |
||||
Kind {event.kind} • {new Date(event.created_at * 1000).toLocaleDateString()} |
||||
</div> |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{:else if showResults && !searching && searchQuery.trim()} |
||||
<div class="search-results"> |
||||
<div class="search-no-results">No results found</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.search-box-container { |
||||
position: relative; |
||||
width: 100%; |
||||
max-width: 600px; |
||||
} |
||||
|
||||
.search-input-wrapper { |
||||
position: relative; |
||||
display: flex; |
||||
align-items: center; |
||||
} |
||||
|
||||
.search-input { |
||||
width: 100%; |
||||
padding: 0.75rem 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.375rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.search-input:focus { |
||||
outline: none; |
||||
border-color: var(--fog-accent, #64748b); |
||||
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
||||
} |
||||
|
||||
:global(.dark) .search-input { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
:global(.dark) .search-input:focus { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
||||
} |
||||
|
||||
.search-loading { |
||||
position: absolute; |
||||
right: 1rem; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
animation: spin 1s linear infinite; |
||||
} |
||||
|
||||
@keyframes spin { |
||||
from { transform: rotate(0deg); } |
||||
to { transform: rotate(360deg); } |
||||
} |
||||
|
||||
.search-results { |
||||
position: absolute; |
||||
top: 100%; |
||||
left: 0; |
||||
right: 0; |
||||
margin-top: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.375rem; |
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
||||
max-height: 400px; |
||||
overflow-y: auto; |
||||
z-index: 1000; |
||||
} |
||||
|
||||
:global(.dark) .search-results { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
.search-result-item { |
||||
width: 100%; |
||||
padding: 0.75rem; |
||||
border: none; |
||||
background: transparent; |
||||
text-align: left; |
||||
cursor: pointer; |
||||
transition: background 0.2s; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
.search-result-item:last-child { |
||||
border-bottom: none; |
||||
} |
||||
|
||||
.search-result-item:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .search-result-item { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
:global(.dark) .search-result-item:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.search-result-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 0.25rem; |
||||
} |
||||
|
||||
.search-result-type { |
||||
font-size: 0.75rem; |
||||
font-weight: 600; |
||||
color: var(--fog-accent, #64748b); |
||||
text-transform: uppercase; |
||||
} |
||||
|
||||
:global(.dark) .search-result-type { |
||||
color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.search-result-id { |
||||
font-size: 0.75rem; |
||||
font-family: monospace; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
} |
||||
|
||||
:global(.dark) .search-result-id { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
|
||||
.search-result-content { |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text, #1f2937); |
||||
margin-bottom: 0.25rem; |
||||
line-height: 1.4; |
||||
} |
||||
|
||||
:global(.dark) .search-result-content { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.search-result-meta { |
||||
font-size: 0.75rem; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
} |
||||
|
||||
:global(.dark) .search-result-meta { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
|
||||
.search-no-results { |
||||
padding: 1rem; |
||||
text-align: center; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .search-no-results { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,649 @@
@@ -0,0 +1,649 @@
|
||||
<script lang="ts"> |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import { signAndPublish } from '../../services/nostr/auth-handler.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { cacheEvent } from '../../services/cache/event-cache.js'; |
||||
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte'; |
||||
import { goto } from '$app/navigation'; |
||||
import { KIND, isParameterizedReplaceableKind } from '../../types/kind-lookup.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
interface Props { |
||||
isOpen: boolean; |
||||
pubkey: string; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
let { isOpen, pubkey, onClose }: Props = $props(); |
||||
|
||||
const PROFILE_EVENT_KINDS = [ |
||||
{ kind: 0, name: 'Metadata (Profile)' }, |
||||
{ kind: 3, name: 'Contacts' }, |
||||
{ kind: 30315, name: 'User Status' }, |
||||
{ kind: 10133, name: 'Payment Addresses' }, |
||||
{ kind: 10002, name: 'Relay List' }, |
||||
{ kind: 10432, name: 'Local Relays' }, |
||||
{ kind: 10001, name: 'Pin List' }, |
||||
{ kind: 10003, name: 'Bookmarks' }, |
||||
{ kind: 10895, name: 'RSS Feed' }, |
||||
{ kind: 10015, name: 'Interest List' }, |
||||
{ kind: 10030, name: 'Emoji Set' }, |
||||
{ kind: 30030, name: 'Emoji Pack' }, |
||||
{ kind: 10000, name: 'Mute List' }, |
||||
{ kind: 30008, name: 'Badges' }, |
||||
{ kind: 30000, name: 'Follow Set' } |
||||
]; |
||||
|
||||
let selectedKind = $state<number | null>(null); |
||||
let editingEvent = $state<NostrEvent | null>(null); |
||||
let content = $state(''); |
||||
let tags = $state<string[][]>([]); |
||||
let publishing = $state(false); |
||||
let publicationModalOpen = $state(false); |
||||
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); |
||||
|
||||
async function selectKind(kind: number) { |
||||
selectedKind = kind; |
||||
|
||||
// Load existing event(s) |
||||
// For parameterized replaceable events (30000-39999), user can have multiple, so we load all |
||||
// For other kinds, typically one per user |
||||
try { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const limit = isParameterizedReplaceableKind(kind) ? 50 : 1; // Load multiple for parameterized replaceable events |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ kinds: [kind], authors: [pubkey], limit }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length > 0) { |
||||
// Get newest version (or first if multiple allowed) |
||||
editingEvent = events.sort((a, b) => b.created_at - a.created_at)[0]; |
||||
content = editingEvent.content || ''; |
||||
tags = [...editingEvent.tags]; |
||||
} else { |
||||
editingEvent = null; |
||||
content = ''; |
||||
tags = []; |
||||
} |
||||
} catch (error) { |
||||
console.error('Error loading event:', error); |
||||
editingEvent = null; |
||||
content = ''; |
||||
tags = []; |
||||
} |
||||
} |
||||
|
||||
function addTag() { |
||||
tags = [...tags, ['', '']]; |
||||
} |
||||
|
||||
function removeTag(index: number) { |
||||
tags = tags.filter((_, i) => i !== index); |
||||
} |
||||
|
||||
function updateTag(index: number, field: number, value: string) { |
||||
const newTags = [...tags]; |
||||
if (!newTags[index]) { |
||||
newTags[index] = ['', '']; |
||||
} |
||||
newTags[index] = [...newTags[index]]; |
||||
newTags[index][field] = value; |
||||
while (newTags[index].length <= field) { |
||||
newTags[index].push(''); |
||||
} |
||||
tags = newTags; |
||||
} |
||||
|
||||
async function publish() { |
||||
if (selectedKind === null) return; |
||||
|
||||
const session = sessionManager.getSession(); |
||||
if (!session || session.pubkey !== pubkey) { |
||||
alert('You can only edit your own profile events'); |
||||
return; |
||||
} |
||||
|
||||
publishing = true; |
||||
|
||||
try { |
||||
// Always use a new timestamp for newly-published events |
||||
// This ensures the event is considered "newer" and replaces older versions for replaceable events |
||||
const created_at = Math.floor(Date.now() / 1000); |
||||
|
||||
const eventTemplate = { |
||||
kind: selectedKind, |
||||
pubkey: session.pubkey, |
||||
created_at, |
||||
tags: tags.filter(t => t[0] && t[1]), |
||||
content |
||||
}; |
||||
|
||||
const signedEvent = await session.signer(eventTemplate); |
||||
await cacheEvent(signedEvent); |
||||
|
||||
const relays = relayManager.getPublishRelays( |
||||
relayManager.getProfileReadRelays(), |
||||
true |
||||
); |
||||
|
||||
const results = await signAndPublish(eventTemplate, relays); |
||||
publicationResults = results; |
||||
publicationModalOpen = true; |
||||
|
||||
if (results.success.length > 0) { |
||||
setTimeout(() => { |
||||
goto(`/event/${signedEvent.id}`); |
||||
}, 5000); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error publishing event:', error); |
||||
publicationResults = { |
||||
success: [], |
||||
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }] |
||||
}; |
||||
publicationModalOpen = true; |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
|
||||
async function republishFromCache() { |
||||
if (!publicationResults || selectedKind === null) return; |
||||
|
||||
publishing = true; |
||||
try { |
||||
const session = sessionManager.getSession(); |
||||
if (!session) return; |
||||
|
||||
const relays = relayManager.getPublishRelays( |
||||
relayManager.getProfileReadRelays(), |
||||
true |
||||
); |
||||
|
||||
// Always use a new timestamp for newly-published events |
||||
const eventTemplate = { |
||||
kind: selectedKind, |
||||
pubkey: session.pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: tags.filter(t => t[0] && t[1]), |
||||
content |
||||
}; |
||||
|
||||
const results = await signAndPublish(eventTemplate, relays); |
||||
publicationResults = results; |
||||
} catch (error) { |
||||
console.error('Error republishing:', error); |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
|
||||
function closeForm() { |
||||
selectedKind = null; |
||||
editingEvent = null; |
||||
content = ''; |
||||
tags = []; |
||||
} |
||||
</script> |
||||
|
||||
{#if isOpen} |
||||
<div |
||||
class="panel-backdrop" |
||||
onclick={onClose} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Enter' || e.key === ' ') { |
||||
e.preventDefault(); |
||||
onClose(); |
||||
} |
||||
}} |
||||
role="button" |
||||
tabindex="0" |
||||
aria-label="Close panel" |
||||
></div> |
||||
<div class="profile-events-panel"> |
||||
<div class="panel-header"> |
||||
<h2 class="panel-title">Adjust Profile Events</h2> |
||||
<button class="panel-close" onclick={onClose}>×</button> |
||||
</div> |
||||
|
||||
<div class="panel-content"> |
||||
{#if selectedKind === null} |
||||
<div class="kind-list"> |
||||
{#each PROFILE_EVENT_KINDS as { kind, name }} |
||||
<button class="kind-button" onclick={() => selectKind(kind)}> |
||||
{name} (Kind {kind}) |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{:else} |
||||
<div class="edit-form"> |
||||
<button class="back-button" onclick={closeForm}>← Back</button> |
||||
<h3 class="form-title">Edit Kind {selectedKind}</h3> |
||||
|
||||
<div class="form-group"> |
||||
<label for="content-textarea" class="form-label">Content</label> |
||||
<textarea |
||||
id="content-textarea" |
||||
bind:value={content} |
||||
class="content-input" |
||||
rows="10" |
||||
placeholder="Event content..." |
||||
></textarea> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<fieldset> |
||||
<legend class="form-label">Tags</legend> |
||||
<div class="tags-list"> |
||||
{#each tags as tag, index (index)} |
||||
<div class="tag-row"> |
||||
<input |
||||
type="text" |
||||
value={tag[0] || ''} |
||||
oninput={(e) => updateTag(index, 0, e.currentTarget.value)} |
||||
placeholder="Tag name" |
||||
class="tag-input" |
||||
/> |
||||
{#each tag.slice(1) as value, valueIndex} |
||||
<input |
||||
type="text" |
||||
value={value || ''} |
||||
oninput={(e) => updateTag(index, valueIndex + 1, e.currentTarget.value)} |
||||
placeholder="Tag value" |
||||
class="tag-input" |
||||
/> |
||||
{/each} |
||||
<button class="tag-add-value" onclick={() => { |
||||
const newTags = [...tags]; |
||||
newTags[index] = [...newTags[index], '']; |
||||
tags = newTags; |
||||
}}>+</button> |
||||
<button class="tag-remove" onclick={() => removeTag(index)}>×</button> |
||||
</div> |
||||
{/each} |
||||
<button class="add-tag-button" onclick={addTag}>Add Tag</button> |
||||
</div> |
||||
</fieldset> |
||||
</div> |
||||
|
||||
<div class="form-actions"> |
||||
<button |
||||
class="publish-button" |
||||
onclick={publish} |
||||
disabled={publishing} |
||||
> |
||||
{publishing ? 'Publishing...' : 'Publish'} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> |
||||
|
||||
{#if publicationResults && publicationResults.success.length === 0 && publicationResults.failed.length > 0} |
||||
<div class="republish-section"> |
||||
<p class="republish-text">All relays failed. You can attempt to republish from cache.</p> |
||||
<button class="republish-button" onclick={republishFromCache} disabled={publishing}> |
||||
Republish from Cache |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.panel-backdrop { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
z-index: 999; |
||||
} |
||||
|
||||
.profile-events-panel { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
bottom: 0; |
||||
width: min(500px, 90vw); |
||||
background: var(--fog-post, #ffffff); |
||||
border-right: 2px solid var(--fog-border, #cbd5e1); |
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2); |
||||
z-index: 1000; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .profile-events-panel { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-right-color: var(--fog-dark-border, #475569); |
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5); |
||||
} |
||||
|
||||
.panel-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 1rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
:global(.dark) .panel-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.panel-title { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .panel-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.panel-close { |
||||
background: transparent; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
} |
||||
|
||||
.panel-close:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .panel-close { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
|
||||
:global(.dark) .panel-close:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.panel-content { |
||||
overflow-y: auto; |
||||
flex: 1; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.kind-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.kind-button { |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
cursor: pointer; |
||||
text-align: left; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
:global(.dark) .kind-button { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.kind-button:hover { |
||||
border-color: var(--fog-accent, #64748b); |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .kind-button:hover { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.edit-form { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.back-button { |
||||
padding: 0.5rem 1rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
align-self: flex-start; |
||||
} |
||||
|
||||
:global(.dark) .back-button { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.back-button:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .back-button:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.form-title { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .form-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.form-group { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.form-label { |
||||
font-weight: 500; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .form-label { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.content-input { |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
font-family: monospace; |
||||
resize: vertical; |
||||
} |
||||
|
||||
:global(.dark) .content-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.tags-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.tag-row { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
align-items: center; |
||||
} |
||||
|
||||
.tag-input { |
||||
flex: 1; |
||||
padding: 0.5rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .tag-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.tag-add-value, |
||||
.tag-remove { |
||||
padding: 0.5rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
min-width: 2rem; |
||||
} |
||||
|
||||
:global(.dark) .tag-add-value, |
||||
:global(.dark) .tag-remove { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.tag-add-value:hover, |
||||
.tag-remove:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .tag-add-value:hover, |
||||
:global(.dark) .tag-remove:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.add-tag-button { |
||||
padding: 0.5rem 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
align-self: flex-start; |
||||
} |
||||
|
||||
:global(.dark) .add-tag-button { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.add-tag-button:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .add-tag-button:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.form-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.publish-button { |
||||
padding: 0.75rem 1.5rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
:global(.dark) .publish-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.publish-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.publish-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.republish-section { |
||||
margin-top: 1rem; |
||||
padding: 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .republish-section { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.republish-text { |
||||
margin: 0 0 0.5rem 0; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .republish-text { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.republish-button { |
||||
padding: 0.5rem 1rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .republish-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.republish-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.republish-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,456 @@
@@ -0,0 +1,456 @@
|
||||
<script lang="ts"> |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import { signAndPublish } from '../../services/nostr/auth-handler.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { cacheEvent } from '../../services/cache/event-cache.js'; |
||||
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte'; |
||||
import { goto } from '$app/navigation'; |
||||
import { KIND } from '../../types/kind-lookup.js'; |
||||
|
||||
const SUPPORTED_KINDS = [ |
||||
{ value: 1, label: '1 - Short Text Note' }, |
||||
{ value: 11, label: '11 - Discussion Thread' }, |
||||
{ value: 9802, label: '9802 - Highlighted Article' }, |
||||
{ value: 1222, label: '1222 - Voice Note' }, |
||||
{ value: 20, label: '20 - Picture Note' }, |
||||
{ value: 21, label: '21 - Video Note' }, |
||||
{ value: 22, label: '22 - Short Video Note' }, |
||||
{ value: 30023, label: '30023 - Long-form Note' }, |
||||
{ value: 30818, label: '30818 - AsciiDoc' }, |
||||
{ value: 30817, label: '30817 - AsciiDoc' }, |
||||
{ value: 30041, label: '30041 - AsciiDoc' }, |
||||
{ value: 30040, label: '30040 - Event Index (metadata-only)' }, |
||||
{ value: 1068, label: '1068 - Poll' } |
||||
]; |
||||
|
||||
let selectedKind = $state<number>(1); |
||||
let content = $state(''); |
||||
let tags = $state<string[][]>([]); |
||||
let publishing = $state(false); |
||||
let publicationModalOpen = $state(false); |
||||
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); |
||||
|
||||
const isKind30040 = $derived(selectedKind === 30040); |
||||
|
||||
function addTag() { |
||||
tags = [...tags, ['', '']]; |
||||
} |
||||
|
||||
function removeTag(index: number) { |
||||
tags = tags.filter((_, i) => i !== index); |
||||
} |
||||
|
||||
function updateTag(index: number, field: number, value: string) { |
||||
const newTags = [...tags]; |
||||
if (!newTags[index]) { |
||||
newTags[index] = ['', '']; |
||||
} |
||||
newTags[index] = [...newTags[index]]; |
||||
newTags[index][field] = value; |
||||
while (newTags[index].length <= field) { |
||||
newTags[index].push(''); |
||||
} |
||||
tags = newTags; |
||||
} |
||||
|
||||
async function publish() { |
||||
const session = sessionManager.getSession(); |
||||
if (!session) { |
||||
alert('You must be logged in to publish events'); |
||||
return; |
||||
} |
||||
|
||||
publishing = true; |
||||
|
||||
try { |
||||
const eventTemplate = { |
||||
kind: selectedKind, |
||||
pubkey: session.pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: tags.filter(t => t[0] && t[1]), |
||||
content |
||||
}; |
||||
|
||||
const signedEvent = await session.signer(eventTemplate); |
||||
await cacheEvent(signedEvent); |
||||
|
||||
const relays = relayManager.getPublishRelays( |
||||
relayManager.getProfileReadRelays(), |
||||
true |
||||
); |
||||
|
||||
const results = await signAndPublish(eventTemplate, relays); |
||||
publicationResults = results; |
||||
publicationModalOpen = true; |
||||
|
||||
if (results.success.length > 0) { |
||||
setTimeout(() => { |
||||
goto(`/event/${signedEvent.id}`); |
||||
}, 5000); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error publishing event:', error); |
||||
publicationResults = { |
||||
success: [], |
||||
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }] |
||||
}; |
||||
publicationModalOpen = true; |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
|
||||
async function republishFromCache() { |
||||
if (!publicationResults) return; |
||||
|
||||
publishing = true; |
||||
try { |
||||
const relays = relayManager.getPublishRelays( |
||||
relayManager.getProfileReadRelays(), |
||||
true |
||||
); |
||||
|
||||
const session = sessionManager.getSession(); |
||||
if (!session) return; |
||||
|
||||
const eventTemplate = { |
||||
kind: selectedKind, |
||||
pubkey: session.pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: tags.filter(t => t[0] && t[1]), |
||||
content |
||||
}; |
||||
|
||||
const results = await signAndPublish(eventTemplate, relays); |
||||
publicationResults = results; |
||||
} catch (error) { |
||||
console.error('Error republishing:', error); |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="create-form"> |
||||
<h2 class="form-title">Create Event</h2> |
||||
|
||||
<div class="form-group"> |
||||
<label for="kind-select" class="form-label">Kind</label> |
||||
<select id="kind-select" bind:value={selectedKind} class="kind-select"> |
||||
{#each SUPPORTED_KINDS as kind} |
||||
<option value={kind.value}>{kind.label}</option> |
||||
{/each} |
||||
</select> |
||||
{#if isKind30040} |
||||
<p class="help-text">Note: Kind 30040 is metadata-only. Sections must be added manually using the edit function.</p> |
||||
{/if} |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="content-textarea" class="form-label">Content</label> |
||||
<textarea |
||||
id="content-textarea" |
||||
bind:value={content} |
||||
class="content-input" |
||||
rows="10" |
||||
placeholder="Event content..." |
||||
disabled={isKind30040} |
||||
></textarea> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<fieldset> |
||||
<legend class="form-label">Tags</legend> |
||||
<div class="tags-list"> |
||||
{#each tags as tag, index (index)} |
||||
<div class="tag-row"> |
||||
<input |
||||
type="text" |
||||
value={tag[0] || ''} |
||||
oninput={(e) => updateTag(index, 0, e.currentTarget.value)} |
||||
placeholder="Tag name" |
||||
class="tag-input" |
||||
/> |
||||
{#each tag.slice(1) as value, valueIndex} |
||||
<input |
||||
type="text" |
||||
value={value || ''} |
||||
oninput={(e) => updateTag(index, valueIndex + 1, e.currentTarget.value)} |
||||
placeholder="Tag value" |
||||
class="tag-input" |
||||
/> |
||||
{/each} |
||||
<button class="tag-add-value" onclick={() => { |
||||
const newTags = [...tags]; |
||||
newTags[index] = [...newTags[index], '']; |
||||
tags = newTags; |
||||
}}>+</button> |
||||
<button class="tag-remove" onclick={() => removeTag(index)}>×</button> |
||||
</div> |
||||
{/each} |
||||
<button class="add-tag-button" onclick={addTag}>Add Tag</button> |
||||
</div> |
||||
</fieldset> |
||||
</div> |
||||
|
||||
<div class="form-actions"> |
||||
<button |
||||
class="publish-button" |
||||
onclick={publish} |
||||
disabled={publishing} |
||||
> |
||||
{publishing ? 'Publishing...' : 'Publish'} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> |
||||
|
||||
{#if publicationResults && publicationResults.success.length === 0 && publicationResults.failed.length > 0} |
||||
<div class="republish-section"> |
||||
<p class="republish-text">All relays failed. You can attempt to republish from cache.</p> |
||||
<button class="republish-button" onclick={republishFromCache} disabled={publishing}> |
||||
Republish from Cache |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.create-form { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1.5rem; |
||||
} |
||||
|
||||
.form-title { |
||||
margin: 0; |
||||
font-size: 1.5rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .form-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.form-group { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.form-label { |
||||
font-weight: 500; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .form-label { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.kind-select { |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .kind-select { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.help-text { |
||||
margin: 0; |
||||
font-size: 0.75rem; |
||||
color: var(--fog-text-light, #6b7280); |
||||
font-style: italic; |
||||
} |
||||
|
||||
:global(.dark) .help-text { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.content-input { |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
font-family: monospace; |
||||
resize: vertical; |
||||
} |
||||
|
||||
:global(.dark) .content-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.content-input:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.tags-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.tag-row { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
align-items: center; |
||||
} |
||||
|
||||
.tag-input { |
||||
flex: 1; |
||||
padding: 0.5rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .tag-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.tag-add-value, |
||||
.tag-remove { |
||||
padding: 0.5rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
min-width: 2rem; |
||||
} |
||||
|
||||
:global(.dark) .tag-add-value, |
||||
:global(.dark) .tag-remove { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.tag-add-value:hover, |
||||
.tag-remove:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .tag-add-value:hover, |
||||
:global(.dark) .tag-remove:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.add-tag-button { |
||||
padding: 0.5rem 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
align-self: flex-start; |
||||
} |
||||
|
||||
:global(.dark) .add-tag-button { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.add-tag-button:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .add-tag-button:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.form-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.publish-button { |
||||
padding: 0.75rem 1.5rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
:global(.dark) .publish-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.publish-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.publish-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.republish-section { |
||||
margin-top: 1rem; |
||||
padding: 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .republish-section { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.republish-text { |
||||
margin: 0 0 0.5rem 0; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .republish-text { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.republish-button { |
||||
padding: 0.5rem 1rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .republish-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.republish-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.republish-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,416 @@
@@ -0,0 +1,416 @@
|
||||
<script lang="ts"> |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import { signAndPublish } from '../../services/nostr/auth-handler.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { cacheEvent } from '../../services/cache/event-cache.js'; |
||||
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte'; |
||||
import { goto } from '$app/navigation'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
interface Props { |
||||
event: NostrEvent; |
||||
} |
||||
|
||||
let { event }: Props = $props(); |
||||
|
||||
let content = $state(event.content || ''); |
||||
let tags = $state<string[][]>([...event.tags]); |
||||
let publishing = $state(false); |
||||
let publicationModalOpen = $state(false); |
||||
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); |
||||
|
||||
function addTag() { |
||||
tags = [...tags, ['', '']]; |
||||
} |
||||
|
||||
function removeTag(index: number) { |
||||
tags = tags.filter((_, i) => i !== index); |
||||
} |
||||
|
||||
function updateTag(index: number, field: number, value: string) { |
||||
const newTags = [...tags]; |
||||
if (!newTags[index]) { |
||||
newTags[index] = ['', '']; |
||||
} |
||||
newTags[index] = [...newTags[index]]; |
||||
newTags[index][field] = value; |
||||
// Ensure tag has enough elements |
||||
while (newTags[index].length <= field) { |
||||
newTags[index].push(''); |
||||
} |
||||
tags = newTags; |
||||
} |
||||
|
||||
async function publish() { |
||||
const session = sessionManager.getSession(); |
||||
if (!session) { |
||||
alert('You must be logged in to publish events'); |
||||
return; |
||||
} |
||||
|
||||
publishing = true; |
||||
|
||||
try { |
||||
// Create new event (id, sig, created_at will be generated) |
||||
const eventTemplate = { |
||||
kind: event.kind, |
||||
pubkey: session.pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: tags.filter(t => t[0] && t[1]), // Filter out empty tags |
||||
content |
||||
}; |
||||
|
||||
// Sign event |
||||
const signedEvent = await session.signer(eventTemplate); |
||||
|
||||
// Cache event |
||||
await cacheEvent(signedEvent); |
||||
|
||||
// Publish to write relays |
||||
const relays = relayManager.getPublishRelays( |
||||
relayManager.getProfileReadRelays(), |
||||
true |
||||
); |
||||
|
||||
const results = await signAndPublish(eventTemplate, relays); |
||||
publicationResults = results; |
||||
publicationModalOpen = true; |
||||
|
||||
// If successful, wait 5 seconds and navigate |
||||
if (results.success.length > 0) { |
||||
setTimeout(() => { |
||||
goto(`/event/${signedEvent.id}`); |
||||
}, 5000); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error publishing event:', error); |
||||
publicationResults = { |
||||
success: [], |
||||
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }] |
||||
}; |
||||
publicationModalOpen = true; |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
|
||||
async function republishFromCache() { |
||||
if (!publicationResults) return; |
||||
|
||||
publishing = true; |
||||
try { |
||||
const relays = relayManager.getPublishRelays( |
||||
relayManager.getProfileReadRelays(), |
||||
true |
||||
); |
||||
|
||||
const results = await signAndPublish( |
||||
{ |
||||
kind: event.kind, |
||||
pubkey: event.pubkey, |
||||
created_at: event.created_at, |
||||
tags: tags.filter(t => t[0] && t[1]), |
||||
content |
||||
}, |
||||
relays |
||||
); |
||||
|
||||
publicationResults = results; |
||||
} catch (error) { |
||||
console.error('Error republishing:', error); |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="edit-form"> |
||||
<h2 class="form-title">Edit Event</h2> |
||||
<p class="form-description">Edit the event content and tags. ID, kind, pubkey, sig, and created_at are generated on publish.</p> |
||||
|
||||
<div class="form-group"> |
||||
<label for="content-textarea" class="form-label">Content</label> |
||||
<textarea |
||||
id="content-textarea" |
||||
bind:value={content} |
||||
class="content-input" |
||||
rows="10" |
||||
placeholder="Event content..." |
||||
></textarea> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<fieldset> |
||||
<legend class="form-label">Tags</legend> |
||||
<div class="tags-list"> |
||||
{#each tags as tag, index (index)} |
||||
<div class="tag-row"> |
||||
<input |
||||
type="text" |
||||
value={tag[0] || ''} |
||||
oninput={(e) => updateTag(index, 0, e.currentTarget.value)} |
||||
placeholder="Tag name" |
||||
class="tag-input" |
||||
/> |
||||
{#each tag.slice(1) as value, valueIndex} |
||||
<input |
||||
type="text" |
||||
value={value || ''} |
||||
oninput={(e) => updateTag(index, valueIndex + 1, e.currentTarget.value)} |
||||
placeholder="Tag value" |
||||
class="tag-input" |
||||
/> |
||||
{/each} |
||||
<button class="tag-add-value" onclick={() => { |
||||
const newTags = [...tags]; |
||||
newTags[index] = [...newTags[index], '']; |
||||
tags = newTags; |
||||
}}>+</button> |
||||
<button class="tag-remove" onclick={() => removeTag(index)}>×</button> |
||||
</div> |
||||
{/each} |
||||
<button class="add-tag-button" onclick={addTag}>Add Tag</button> |
||||
</div> |
||||
</fieldset> |
||||
</div> |
||||
|
||||
<div class="form-actions"> |
||||
<button |
||||
class="publish-button" |
||||
onclick={publish} |
||||
disabled={publishing} |
||||
> |
||||
{publishing ? 'Publishing...' : 'Publish'} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> |
||||
|
||||
{#if publicationResults && publicationResults.success.length === 0 && publicationResults.failed.length > 0} |
||||
<div class="republish-section"> |
||||
<p class="republish-text">All relays failed. You can attempt to republish from cache.</p> |
||||
<button class="republish-button" onclick={republishFromCache} disabled={publishing}> |
||||
Republish from Cache |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.edit-form { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1.5rem; |
||||
} |
||||
|
||||
.form-title { |
||||
margin: 0; |
||||
font-size: 1.5rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .form-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.form-description { |
||||
margin: 0; |
||||
color: var(--fog-text-light, #6b7280); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .form-description { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.form-group { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.form-label { |
||||
font-weight: 500; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .form-label { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.content-input { |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
font-family: monospace; |
||||
resize: vertical; |
||||
} |
||||
|
||||
:global(.dark) .content-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.tags-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.tag-row { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
align-items: center; |
||||
} |
||||
|
||||
.tag-input { |
||||
flex: 1; |
||||
padding: 0.5rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .tag-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.tag-add-value, |
||||
.tag-remove { |
||||
padding: 0.5rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
min-width: 2rem; |
||||
} |
||||
|
||||
:global(.dark) .tag-add-value, |
||||
:global(.dark) .tag-remove { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.tag-add-value:hover, |
||||
.tag-remove:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .tag-add-value:hover, |
||||
:global(.dark) .tag-remove:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.add-tag-button { |
||||
padding: 0.5rem 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
align-self: flex-start; |
||||
} |
||||
|
||||
:global(.dark) .add-tag-button { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.add-tag-button:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .add-tag-button:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.form-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.publish-button { |
||||
padding: 0.75rem 1.5rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
:global(.dark) .publish-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.publish-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.publish-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.republish-section { |
||||
margin-top: 1rem; |
||||
padding: 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .republish-section { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.republish-text { |
||||
margin: 0 0 0.5rem 0; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .republish-text { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.republish-button { |
||||
padding: 0.5rem 1rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .republish-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.republish-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.republish-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,321 @@
@@ -0,0 +1,321 @@
|
||||
<script lang="ts"> |
||||
import EditEventForm from './EditEventForm.svelte'; |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { getEvent } from '../../services/cache/event-cache.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
let eventIdInput = $state(''); |
||||
let searching = $state(false); |
||||
let foundEvent = $state<NostrEvent | null>(null); |
||||
let error = $state<string | null>(null); |
||||
let showEdit = $state(false); |
||||
|
||||
async function findEvent() { |
||||
if (!eventIdInput.trim()) return; |
||||
|
||||
searching = true; |
||||
error = null; |
||||
foundEvent = null; |
||||
|
||||
try { |
||||
// Decode event ID |
||||
let eventId: string | null = null; |
||||
|
||||
// Check if it's already a hex event ID |
||||
if (/^[0-9a-f]{64}$/i.test(eventIdInput.trim())) { |
||||
eventId = eventIdInput.trim().toLowerCase(); |
||||
} else { |
||||
// Try to decode bech32 |
||||
try { |
||||
const decoded = nip19.decode(eventIdInput.trim()); |
||||
if (decoded.type === 'note') { |
||||
eventId = String(decoded.data); |
||||
} else if (decoded.type === 'nevent') { |
||||
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
||||
eventId = String(decoded.data.id); |
||||
} |
||||
} else if (decoded.type === 'naddr') { |
||||
// For naddr, we need to fetch by kind+pubkey+d |
||||
// This is more complex, for now just show error |
||||
error = 'naddr format requires fetching by kind+pubkey+d, not yet fully supported'; |
||||
searching = false; |
||||
return; |
||||
} |
||||
} catch { |
||||
error = 'Invalid event ID format'; |
||||
searching = false; |
||||
return; |
||||
} |
||||
} |
||||
|
||||
if (!eventId) { |
||||
error = 'Could not decode event ID'; |
||||
searching = false; |
||||
return; |
||||
} |
||||
|
||||
// Check cache first |
||||
const cached = await getEvent(eventId); |
||||
if (cached) { |
||||
foundEvent = cached; |
||||
searching = false; |
||||
return; |
||||
} |
||||
|
||||
// Fetch from relays |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ ids: [eventId], limit: 1 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length === 0) { |
||||
error = 'Event not found'; |
||||
} else { |
||||
foundEvent = events[0]; |
||||
} |
||||
} catch (err) { |
||||
console.error('Error finding event:', err); |
||||
error = 'Failed to find event'; |
||||
} finally { |
||||
searching = false; |
||||
} |
||||
} |
||||
|
||||
function startEdit() { |
||||
showEdit = true; |
||||
} |
||||
</script> |
||||
|
||||
<div class="find-form"> |
||||
<h2 class="form-title">Find Event</h2> |
||||
<p class="form-description">Enter an event ID (hex, note, nevent, or naddr)</p> |
||||
|
||||
<div class="input-group"> |
||||
<input |
||||
type="text" |
||||
bind:value={eventIdInput} |
||||
placeholder="Event ID..." |
||||
class="event-input" |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Enter') { |
||||
findEvent(); |
||||
} |
||||
}} |
||||
disabled={searching} |
||||
/> |
||||
<button |
||||
class="find-button" |
||||
onclick={findEvent} |
||||
disabled={searching || !eventIdInput.trim()} |
||||
> |
||||
{searching ? 'Searching...' : 'Find'} |
||||
</button> |
||||
</div> |
||||
|
||||
{#if error} |
||||
<div class="error-message">{error}</div> |
||||
{/if} |
||||
|
||||
{#if foundEvent} |
||||
<div class="found-event"> |
||||
<div class="event-header"> |
||||
<h3 class="event-title">Found Event</h3> |
||||
<a href="/event/{foundEvent.id}" class="view-link" target="_blank">View in /event page →</a> |
||||
</div> |
||||
|
||||
<div class="event-json"> |
||||
<pre>{JSON.stringify(foundEvent, null, 2)}</pre> |
||||
</div> |
||||
|
||||
<button class="edit-button" onclick={startEdit}> |
||||
Edit |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if showEdit && foundEvent} |
||||
<EditEventForm event={foundEvent} /> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.find-form { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.form-title { |
||||
margin: 0; |
||||
font-size: 1.5rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .form-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.form-description { |
||||
margin: 0; |
||||
color: var(--fog-text-light, #6b7280); |
||||
} |
||||
|
||||
:global(.dark) .form-description { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.input-group { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.event-input { |
||||
flex: 1; |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .event-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.event-input:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.find-button { |
||||
padding: 0.75rem 1.5rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
:global(.dark) .find-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.find-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.find-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.error-message { |
||||
padding: 0.75rem; |
||||
background: var(--fog-danger-light, #fee2e2); |
||||
color: var(--fog-danger, #dc2626); |
||||
border-radius: 0.25rem; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .error-message { |
||||
background: var(--fog-dark-danger-light, #7f1d1d); |
||||
color: var(--fog-dark-danger, #ef4444); |
||||
} |
||||
|
||||
.found-event { |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
padding: 1rem; |
||||
background: var(--fog-post, #ffffff); |
||||
} |
||||
|
||||
:global(.dark) .found-event { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.event-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.event-title { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .event-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.view-link { |
||||
color: var(--fog-accent, #64748b); |
||||
text-decoration: none; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .view-link { |
||||
color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.view-link:hover { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.event-json { |
||||
margin-bottom: 1rem; |
||||
padding: 1rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
border-radius: 0.25rem; |
||||
overflow-x: auto; |
||||
} |
||||
|
||||
:global(.dark) .event-json { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.event-json pre { |
||||
margin: 0; |
||||
font-size: 0.75rem; |
||||
color: var(--fog-text, #1f2937); |
||||
white-space: pre-wrap; |
||||
word-wrap: break-word; |
||||
} |
||||
|
||||
:global(.dark) .event-json pre { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.edit-button { |
||||
padding: 0.75rem 1.5rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
:global(.dark) .edit-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.edit-button:hover { |
||||
opacity: 0.9; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
|
||||
/** |
||||
* Asciidoctor renderer service |
||||
* Renders AsciiDoc content for kinds 30818 and 30041 |
||||
*/ |
||||
|
||||
import Asciidoctor from 'asciidoctor'; |
||||
|
||||
const asciidoctor = Asciidoctor(); |
||||
|
||||
/** |
||||
* Render AsciiDoc content to HTML |
||||
* @param content - AsciiDoc content string |
||||
* @returns HTML string |
||||
*/ |
||||
export function renderAsciiDoc(content: string): string { |
||||
try { |
||||
const html = asciidoctor.convert(content, { |
||||
safe: 'safe', |
||||
backend: 'html5', |
||||
attributes: { |
||||
'showtitle': true, |
||||
'icons': 'font', |
||||
'sectanchors': true, |
||||
'sectlinks': true, |
||||
'idprefix': '', |
||||
'idseparator': '-' |
||||
} |
||||
}); |
||||
return html as string; |
||||
} catch (error) { |
||||
console.error('Error rendering AsciiDoc:', error); |
||||
// Return escaped content as fallback
|
||||
return `<pre>${escapeHtml(content)}</pre>`; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Escape HTML to prevent XSS |
||||
*/ |
||||
function escapeHtml(text: string): string { |
||||
return text |
||||
.replace(/&/g, '&') |
||||
.replace(/</g, '<') |
||||
.replace(/>/g, '>') |
||||
.replace(/"/g, '"') |
||||
.replace(/'/g, '''); |
||||
} |
||||
@ -0,0 +1,217 @@
@@ -0,0 +1,217 @@
|
||||
/** |
||||
* OpenGraph metadata fetcher service |
||||
* Fetches OpenGraph metadata from URLs and caches results |
||||
*/ |
||||
|
||||
export interface OpenGraphData { |
||||
title?: string; |
||||
description?: string; |
||||
image?: string; |
||||
url?: string; |
||||
siteName?: string; |
||||
type?: string; |
||||
cachedAt: number; |
||||
} |
||||
|
||||
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const CACHE_KEY_PREFIX = 'opengraph_'; |
||||
|
||||
/** |
||||
* Fetch OpenGraph metadata from a URL |
||||
* Uses a CORS proxy if needed, caches results in localStorage |
||||
*/ |
||||
export async function fetchOpenGraph(url: string): Promise<OpenGraphData | null> { |
||||
// Check cache first
|
||||
const cached = getCachedOpenGraph(url); |
||||
if (cached && Date.now() - cached.cachedAt < CACHE_DURATION) { |
||||
return cached; |
||||
} |
||||
|
||||
try { |
||||
// Try to fetch the page HTML
|
||||
// Note: Direct fetch may fail due to CORS, so we'll use a simple approach
|
||||
// In production, you might want to use a backend proxy or service
|
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
||||
'User-Agent': 'Mozilla/5.0 (compatible; Aitherboard/1.0)' |
||||
}, |
||||
mode: 'cors', |
||||
cache: 'no-cache' |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
throw new Error(`HTTP ${response.status}`); |
||||
} |
||||
|
||||
const html = await response.text(); |
||||
const ogData = parseOpenGraph(html, url); |
||||
|
||||
// Cache the result
|
||||
if (ogData) { |
||||
cacheOpenGraph(url, ogData); |
||||
} |
||||
|
||||
return ogData; |
||||
} catch (error) { |
||||
console.warn('Failed to fetch OpenGraph data:', error); |
||||
// Return cached data even if expired, or null
|
||||
return cached || null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parse OpenGraph metadata from HTML |
||||
*/ |
||||
function parseOpenGraph(html: string, url: string): OpenGraphData | null { |
||||
const og: Partial<OpenGraphData> = { |
||||
cachedAt: Date.now() |
||||
}; |
||||
|
||||
// Extract OpenGraph meta tags
|
||||
const ogTitleMatch = html.match(/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i) || |
||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:title["']/i); |
||||
if (ogTitleMatch) { |
||||
og.title = decodeHtmlEntities(ogTitleMatch[1]); |
||||
} |
||||
|
||||
const ogDescriptionMatch = html.match(/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i) || |
||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:description["']/i); |
||||
if (ogDescriptionMatch) { |
||||
og.description = decodeHtmlEntities(ogDescriptionMatch[1]); |
||||
} |
||||
|
||||
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i) || |
||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:image["']/i); |
||||
if (ogImageMatch) { |
||||
og.image = ogImageMatch[1]; |
||||
// Make image URL absolute if relative
|
||||
if (og.image && !og.image.startsWith('http')) { |
||||
try { |
||||
const baseUrl = new URL(url); |
||||
og.image = new URL(og.image, baseUrl).href; |
||||
} catch { |
||||
// Invalid URL, keep as is
|
||||
} |
||||
} |
||||
} |
||||
|
||||
const ogUrlMatch = html.match(/<meta\s+property=["']og:url["']\s+content=["']([^"']+)["']/i) || |
||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:url["']/i); |
||||
if (ogUrlMatch) { |
||||
og.url = ogUrlMatch[1]; |
||||
} else { |
||||
og.url = url; |
||||
} |
||||
|
||||
const ogSiteNameMatch = html.match(/<meta\s+property=["']og:site_name["']\s+content=["']([^"']+)["']/i) || |
||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:site_name["']/i); |
||||
if (ogSiteNameMatch) { |
||||
og.siteName = decodeHtmlEntities(ogSiteNameMatch[1]); |
||||
} |
||||
|
||||
const ogTypeMatch = html.match(/<meta\s+property=["']og:type["']\s+content=["']([^"']+)["']/i) || |
||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:type["']/i); |
||||
if (ogTypeMatch) { |
||||
og.type = ogTypeMatch[1]; |
||||
} |
||||
|
||||
// Fallback to regular meta tags if OpenGraph not available
|
||||
if (!og.title) { |
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i); |
||||
if (titleMatch) { |
||||
og.title = decodeHtmlEntities(titleMatch[1].trim()); |
||||
} |
||||
} |
||||
|
||||
if (!og.description) { |
||||
const metaDescriptionMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i) || |
||||
html.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i); |
||||
if (metaDescriptionMatch) { |
||||
og.description = decodeHtmlEntities(metaDescriptionMatch[1]); |
||||
} |
||||
} |
||||
|
||||
// Return null if we have no useful data
|
||||
if (!og.title && !og.description && !og.image) { |
||||
return null; |
||||
} |
||||
|
||||
return og as OpenGraphData; |
||||
} |
||||
|
||||
/** |
||||
* Decode HTML entities |
||||
*/ |
||||
function decodeHtmlEntities(text: string): string { |
||||
const textarea = document.createElement('textarea'); |
||||
textarea.innerHTML = text; |
||||
return textarea.value; |
||||
} |
||||
|
||||
/** |
||||
* Get cached OpenGraph data |
||||
*/ |
||||
function getCachedOpenGraph(url: string): OpenGraphData | null { |
||||
if (typeof window === 'undefined') return null; |
||||
|
||||
try { |
||||
const key = CACHE_KEY_PREFIX + url; |
||||
const cached = localStorage.getItem(key); |
||||
if (cached) { |
||||
return JSON.parse(cached) as OpenGraphData; |
||||
} |
||||
} catch (error) { |
||||
console.warn('Error reading cached OpenGraph data:', error); |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* Cache OpenGraph data |
||||
*/ |
||||
function cacheOpenGraph(url: string, data: OpenGraphData): void { |
||||
if (typeof window === 'undefined') return; |
||||
|
||||
const key = CACHE_KEY_PREFIX + url; |
||||
try { |
||||
localStorage.setItem(key, JSON.stringify(data)); |
||||
} catch (error) { |
||||
console.warn('Error caching OpenGraph data:', error); |
||||
// If storage is full, try to clear old entries
|
||||
try { |
||||
clearOldCacheEntries(); |
||||
localStorage.setItem(key, JSON.stringify(data)); |
||||
} catch { |
||||
// Give up
|
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Clear old cache entries to free up space |
||||
*/ |
||||
function clearOldCacheEntries(): void { |
||||
if (typeof window === 'undefined') return; |
||||
|
||||
const now = Date.now(); |
||||
const keysToRemove: string[] = []; |
||||
|
||||
for (let i = 0; i < localStorage.length; i++) { |
||||
const key = localStorage.key(i); |
||||
if (key && key.startsWith(CACHE_KEY_PREFIX)) { |
||||
try { |
||||
const data = JSON.parse(localStorage.getItem(key) || '{}') as OpenGraphData; |
||||
if (now - data.cachedAt > CACHE_DURATION) { |
||||
keysToRemove.push(key); |
||||
} |
||||
} catch { |
||||
keysToRemove.push(key); |
||||
} |
||||
} |
||||
} |
||||
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key)); |
||||
} |
||||
@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
|
||||
/** |
||||
* Event hierarchy builder |
||||
* Builds full reply chain by following e-tags, q-tags, and a-tags |
||||
*/ |
||||
|
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { nostrClient } from './nostr-client.js'; |
||||
import { relayManager } from './relay-manager.js'; |
||||
import { getEvent } from '../cache/event-cache.js'; |
||||
|
||||
export interface EventHierarchy { |
||||
event: NostrEvent; |
||||
parent?: EventHierarchy; |
||||
children: EventHierarchy[]; |
||||
} |
||||
|
||||
/** |
||||
* Build full event hierarchy starting from a given event |
||||
* Recursively fetches parent events until reaching root (no references) |
||||
*/ |
||||
export async function buildEventHierarchy(event: NostrEvent): Promise<EventHierarchy> { |
||||
const hierarchy: EventHierarchy = { |
||||
event, |
||||
children: [] |
||||
}; |
||||
|
||||
// Build parent chain
|
||||
const parent = await findParentEvent(event); |
||||
if (parent) { |
||||
hierarchy.parent = await buildEventHierarchy(parent); |
||||
} |
||||
|
||||
return hierarchy; |
||||
} |
||||
|
||||
/** |
||||
* Get all events in hierarchy as a flat array (root to leaf) |
||||
*/ |
||||
export function getHierarchyChain(hierarchy: EventHierarchy): NostrEvent[] { |
||||
const chain: NostrEvent[] = []; |
||||
|
||||
// Build chain from root to leaf
|
||||
let current: EventHierarchy | undefined = hierarchy; |
||||
const parents: EventHierarchy[] = []; |
||||
|
||||
// Collect all parents
|
||||
while (current) { |
||||
parents.unshift(current); |
||||
current = current.parent; |
||||
} |
||||
|
||||
// Return chain from root to leaf
|
||||
return parents.map(h => h.event); |
||||
} |
||||
|
||||
/** |
||||
* Find parent event by following e-tags, q-tags, or a-tags |
||||
*/ |
||||
async function findParentEvent(event: NostrEvent): Promise<NostrEvent | null> { |
||||
// Check for e-tag (reply to event)
|
||||
const eTag = event.tags.find(t => t[0] === 'e' && t[1]); |
||||
if (eTag && eTag[1]) { |
||||
const parent = await fetchEventById(eTag[1]); |
||||
if (parent) return parent; |
||||
} |
||||
|
||||
// Check for q-tag (quoted event)
|
||||
const qTag = event.tags.find(t => t[0] === 'q' && t[1]); |
||||
if (qTag && qTag[1]) { |
||||
const parent = await fetchEventById(qTag[1]); |
||||
if (parent) return parent; |
||||
} |
||||
|
||||
// Check for a-tag (reply to replaceable event)
|
||||
const aTag = event.tags.find(t => t[0] === 'a' && t[1]); |
||||
if (aTag && aTag[1]) { |
||||
// Parse a-tag: kind:pubkey:d-tag
|
||||
const parts = aTag[1].split(':'); |
||||
if (parts.length === 3) { |
||||
const kind = parseInt(parts[0], 10); |
||||
const pubkey = parts[1]; |
||||
const dTag = parts[2]; |
||||
|
||||
if (!isNaN(kind) && pubkey && dTag) { |
||||
const parent = await fetchReplaceableEvent(kind, pubkey, dTag); |
||||
if (parent) return parent; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// No parent found
|
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* Fetch event by ID (check cache first, then relays) |
||||
*/ |
||||
async function fetchEventById(eventId: string): Promise<NostrEvent | null> { |
||||
// Check cache first
|
||||
const cached = await getEvent(eventId); |
||||
if (cached) { |
||||
return cached; |
||||
} |
||||
|
||||
// Fetch from relays
|
||||
try { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ ids: [eventId], limit: 1 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
return events.length > 0 ? events[0] : null; |
||||
} catch (error) { |
||||
console.warn('Error fetching event by ID:', error); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fetch replaceable event by kind, pubkey, and d-tag |
||||
*/ |
||||
async function fetchReplaceableEvent(kind: number, pubkey: string, dTag: string): Promise<NostrEvent | null> { |
||||
try { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
// Return newest (replaceable events can have multiple versions)
|
||||
if (events.length > 0) { |
||||
return events.sort((a, b) => b.created_at - a.created_at)[0]; |
||||
} |
||||
|
||||
return null; |
||||
} catch (error) { |
||||
console.warn('Error fetching replaceable event:', error); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Check if event is a root (has no parent references) |
||||
*/ |
||||
export function isRootEvent(event: NostrEvent): boolean { |
||||
const hasETag = event.tags.some(t => t[0] === 'e' && t[1]); |
||||
const hasQTag = event.tags.some(t => t[0] === 'q' && t[1]); |
||||
const hasATag = event.tags.some(t => t[0] === 'a' && t[1]); |
||||
|
||||
return !hasETag && !hasQTag && !hasATag; |
||||
} |
||||
@ -0,0 +1,166 @@
@@ -0,0 +1,166 @@
|
||||
/** |
||||
* Event index loader for kind 30040 |
||||
* Handles lazy-loading of event-index hierarchy with a-tags and e-tags |
||||
*/ |
||||
|
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { nostrClient } from './nostr-client.js'; |
||||
import { relayManager } from './relay-manager.js'; |
||||
import { getEvent } from '../cache/event-cache.js'; |
||||
|
||||
export interface EventIndexItem { |
||||
event: NostrEvent; |
||||
order: number; // Original order in index
|
||||
} |
||||
|
||||
/** |
||||
* Load entire event-index hierarchy for a kind 30040 event |
||||
* Handles both a-tags and e-tags, maintains original order |
||||
*/ |
||||
export async function loadEventIndex(opEvent: NostrEvent): Promise<EventIndexItem[]> { |
||||
if (opEvent.kind !== 30040) { |
||||
throw new Error('Event is not kind 30040'); |
||||
} |
||||
|
||||
const items: EventIndexItem[] = []; |
||||
const loadedEventIds = new Set<string>(); |
||||
const loadedAddresses = new Set<string>(); |
||||
const missingIds: string[] = []; |
||||
const missingAddresses: string[] = []; |
||||
|
||||
// Parse a-tags and e-tags from OP event
|
||||
const aTags: string[] = []; |
||||
const eTags: string[] = []; |
||||
|
||||
for (const tag of opEvent.tags) { |
||||
if (tag[0] === 'a' && tag[1]) { |
||||
aTags.push(tag[1]); |
||||
} else if (tag[0] === 'e' && tag[1]) { |
||||
eTags.push(tag[1]); |
||||
} |
||||
} |
||||
|
||||
// First pass: try to load all events from cache and relays
|
||||
const relays = relayManager.getProfileReadRelays(); |
||||
|
||||
// Load events by ID (e-tags)
|
||||
if (eTags.length > 0) { |
||||
const eventsById = await nostrClient.fetchEvents( |
||||
[{ ids: eTags, limit: eTags.length }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
for (let i = 0; i < eTags.length; i++) { |
||||
const eventId = eTags[i]; |
||||
const event = eventsById.find(e => e.id === eventId); |
||||
|
||||
if (event) { |
||||
items.push({ event, order: i }); |
||||
loadedEventIds.add(eventId); |
||||
} else { |
||||
missingIds.push(eventId); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Load events by address (a-tags)
|
||||
if (aTags.length > 0) { |
||||
for (let i = 0; i < aTags.length; i++) { |
||||
const aTag = aTags[i]; |
||||
const parts = aTag.split(':'); |
||||
|
||||
if (parts.length === 3) { |
||||
const kind = parseInt(parts[0], 10); |
||||
const pubkey = parts[1]; |
||||
const dTag = parts[2]; |
||||
|
||||
if (!isNaN(kind) && pubkey && dTag) { |
||||
// Check cache first
|
||||
const cached = await getEvent(aTag); |
||||
if (cached) { |
||||
items.push({ event: cached, order: eTags.length + i }); |
||||
loadedAddresses.add(aTag); |
||||
continue; |
||||
} |
||||
|
||||
// Fetch from relays
|
||||
const events = await nostrClient.fetchEvents( |
||||
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length > 0) { |
||||
// Get newest version
|
||||
const event = events.sort((a, b) => b.created_at - a.created_at)[0]; |
||||
items.push({ event, order: eTags.length + i }); |
||||
loadedAddresses.add(aTag); |
||||
} else { |
||||
missingAddresses.push(aTag); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Second pass: retry missing events (but don't loop infinitely)
|
||||
if (missingIds.length > 0 || missingAddresses.length > 0) { |
||||
// Wait a bit before retry
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); |
||||
|
||||
// Retry missing IDs
|
||||
if (missingIds.length > 0) { |
||||
const retryEvents = await nostrClient.fetchEvents( |
||||
[{ ids: missingIds, limit: missingIds.length }], |
||||
relays, |
||||
{ useCache: false, cacheResults: true } // Force relay query
|
||||
); |
||||
|
||||
for (const eventId of missingIds) { |
||||
const event = retryEvents.find(e => e.id === eventId); |
||||
if (event) { |
||||
const originalIndex = eTags.indexOf(eventId); |
||||
if (originalIndex >= 0) { |
||||
items.push({ event, order: originalIndex }); |
||||
loadedEventIds.add(eventId); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Retry missing addresses
|
||||
if (missingAddresses.length > 0) { |
||||
for (const aTag of missingAddresses) { |
||||
const parts = aTag.split(':'); |
||||
if (parts.length === 3) { |
||||
const kind = parseInt(parts[0], 10); |
||||
const pubkey = parts[1]; |
||||
const dTag = parts[2]; |
||||
|
||||
if (!isNaN(kind) && pubkey && dTag) { |
||||
const retryEvents = await nostrClient.fetchEvents( |
||||
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], |
||||
relays, |
||||
{ useCache: false, cacheResults: true } // Force relay query
|
||||
); |
||||
|
||||
if (retryEvents.length > 0) { |
||||
const event = retryEvents.sort((a, b) => b.created_at - a.created_at)[0]; |
||||
const originalIndex = aTags.indexOf(aTag); |
||||
if (originalIndex >= 0) { |
||||
items.push({ event, order: eTags.length + originalIndex }); |
||||
loadedAddresses.add(aTag); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Sort by original order
|
||||
items.sort((a, b) => a.order - b.order); |
||||
|
||||
return items; |
||||
} |
||||
@ -0,0 +1,195 @@
@@ -0,0 +1,195 @@
|
||||
/** |
||||
* Highlight service (NIP-84) |
||||
* Handles kind 9802 highlight events and matches them to source events |
||||
*/ |
||||
|
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { nostrClient } from './nostr-client.js'; |
||||
import { relayManager } from './relay-manager.js'; |
||||
import { KIND } from '../../types/kind-lookup.js'; |
||||
|
||||
export interface Highlight { |
||||
event: NostrEvent; // The highlight event (kind 9802)
|
||||
pubkey: string; // Who created the highlight
|
||||
content: string; // Highlight content
|
||||
sourceEventId?: string; // e-tag source event ID
|
||||
sourceAddress?: string; // a-tag source address (kind:pubkey:d-tag)
|
||||
context?: string; // Context from context tag
|
||||
url?: string; // URL if source is a URL
|
||||
} |
||||
|
||||
/** |
||||
* Get highlights for a specific event |
||||
* Matches by e-tag (source) or a-tag |
||||
*/ |
||||
export async function getHighlightsForEvent(eventId: string, eventKind?: number, eventPubkey?: string, eventDTag?: string): Promise<Highlight[]> { |
||||
const highlights: Highlight[] = []; |
||||
|
||||
try { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
|
||||
// Fetch highlight events that reference this event
|
||||
const highlightEvents = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], '#e': [eventId], limit: 100 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
// Also fetch by a-tag if we have kind, pubkey, and d-tag
|
||||
if (eventKind && eventPubkey && eventDTag) { |
||||
const aTag = `${eventKind}:${eventPubkey}:${eventDTag}`; |
||||
const aTagHighlights = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], '#a': [aTag], limit: 100 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
// Merge and deduplicate
|
||||
const existingIds = new Set(highlightEvents.map(e => e.id)); |
||||
for (const h of aTagHighlights) { |
||||
if (!existingIds.has(h.id)) { |
||||
highlightEvents.push(h); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Parse highlights
|
||||
for (const highlightEvent of highlightEvents) { |
||||
const highlight = parseHighlight(highlightEvent); |
||||
if (highlight) { |
||||
highlights.push(highlight); |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.error('Error fetching highlights:', error); |
||||
} |
||||
|
||||
return highlights; |
||||
} |
||||
|
||||
/** |
||||
* Get highlights for a URL |
||||
*/ |
||||
export async function getHighlightsForUrl(url: string): Promise<Highlight[]> { |
||||
const highlights: Highlight[] = []; |
||||
|
||||
try { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
|
||||
// Fetch highlight events that reference this URL
|
||||
// We'll search for highlights with the URL in content or tags
|
||||
const highlightEvents = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
// Filter highlights that reference this URL
|
||||
for (const highlightEvent of highlightEvents) { |
||||
// Check if URL is in content or tags
|
||||
const hasUrl = highlightEvent.content.includes(url) || |
||||
highlightEvent.tags.some(tag => tag[0] === 'url' && tag[1] === url) || |
||||
highlightEvent.tags.some(tag => tag[0] === 'r' && tag[1] === url); |
||||
|
||||
if (hasUrl) { |
||||
const highlight = parseHighlight(highlightEvent); |
||||
if (highlight) { |
||||
highlight.url = url; |
||||
highlights.push(highlight); |
||||
} |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.error('Error fetching highlights for URL:', error); |
||||
} |
||||
|
||||
return highlights; |
||||
} |
||||
|
||||
/** |
||||
* Parse a highlight event (kind 9802) |
||||
*/ |
||||
function parseHighlight(event: NostrEvent): Highlight | null { |
||||
if (event.kind !== KIND.HIGHLIGHTED_ARTICLE) { |
||||
return null; |
||||
} |
||||
|
||||
const highlight: Highlight = { |
||||
event, |
||||
pubkey: event.pubkey, |
||||
content: event.content |
||||
}; |
||||
|
||||
// Parse e-tag (source event)
|
||||
const eTag = event.tags.find(t => t[0] === 'e' && t[1]); |
||||
if (eTag && eTag[1]) { |
||||
highlight.sourceEventId = eTag[1]; |
||||
} |
||||
|
||||
// Parse a-tag (source address)
|
||||
const aTag = event.tags.find(t => t[0] === 'a' && t[1]); |
||||
if (aTag && aTag[1]) { |
||||
highlight.sourceAddress = aTag[1]; |
||||
} |
||||
|
||||
// Parse context tag
|
||||
const contextTag = event.tags.find(t => t[0] === 'context' && t[1]); |
||||
if (contextTag && contextTag[1]) { |
||||
highlight.context = contextTag[1]; |
||||
} |
||||
|
||||
// Parse URL tag
|
||||
const urlTag = event.tags.find(t => (t[0] === 'url' || t[0] === 'r') && t[1]); |
||||
if (urlTag && urlTag[1]) { |
||||
highlight.url = urlTag[1]; |
||||
} |
||||
|
||||
return highlight; |
||||
} |
||||
|
||||
/** |
||||
* Find text in content that matches highlight content |
||||
* Returns array of { start, end, highlight } for each match |
||||
*/ |
||||
export function findHighlightMatches(content: string, highlights: Highlight[]): Array<{ start: number; end: number; highlight: Highlight }> { |
||||
const matches: Array<{ start: number; end: number; highlight: Highlight }> = []; |
||||
|
||||
for (const highlight of highlights) { |
||||
const highlightText = highlight.content.trim(); |
||||
if (!highlightText) continue; |
||||
|
||||
// Find all occurrences of the highlight text in content
|
||||
let searchIndex = 0; |
||||
while (true) { |
||||
const index = content.indexOf(highlightText, searchIndex); |
||||
if (index === -1) break; |
||||
|
||||
matches.push({ |
||||
start: index, |
||||
end: index + highlightText.length, |
||||
highlight |
||||
}); |
||||
|
||||
searchIndex = index + 1; |
||||
} |
||||
} |
||||
|
||||
// Sort by start position
|
||||
matches.sort((a, b) => a.start - b.start); |
||||
|
||||
// Remove overlapping matches (keep first)
|
||||
const nonOverlapping: Array<{ start: number; end: number; highlight: Highlight }> = []; |
||||
for (const match of matches) { |
||||
const overlaps = nonOverlapping.some(existing =>
|
||||
(match.start >= existing.start && match.start < existing.end) || |
||||
(match.end > existing.start && match.end <= existing.end) || |
||||
(match.start <= existing.start && match.end >= existing.end) |
||||
); |
||||
|
||||
if (!overlaps) { |
||||
nonOverlapping.push(match); |
||||
} |
||||
} |
||||
|
||||
return nonOverlapping; |
||||
} |
||||
@ -0,0 +1,518 @@
@@ -0,0 +1,518 @@
|
||||
<script lang="ts"> |
||||
import Header from '../../../lib/components/layout/Header.svelte'; |
||||
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte'; |
||||
import MetadataCard from '../../../lib/components/content/MetadataCard.svelte'; |
||||
import MarkdownRenderer from '../../../lib/components/content/MarkdownRenderer.svelte'; |
||||
import EventMenu from '../../../lib/components/EventMenu.svelte'; |
||||
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../../lib/services/nostr/relay-manager.js'; |
||||
import { loadEventIndex, type EventIndexItem } from '../../../lib/services/nostr/event-index-loader.js'; |
||||
import { signAndPublish } from '../../../lib/services/nostr/auth-handler.js'; |
||||
import { sessionManager } from '../../../lib/services/auth/session-manager.js'; |
||||
import PublicationStatusModal from '../../../lib/components/modals/PublicationStatusModal.svelte'; |
||||
import { onMount } from 'svelte'; |
||||
import { page } from '$app/stores'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { goto } from '$app/navigation'; |
||||
import type { NostrEvent } from '../../../lib/types/nostr.js'; |
||||
import { getKindInfo, isReplaceableKind, isParameterizedReplaceableKind, KIND } from '../../../lib/types/kind-lookup.js'; |
||||
|
||||
let event = $state<NostrEvent | null>(null); |
||||
let loading = $state(true); |
||||
let error = $state<string | null>(null); |
||||
let indexItems = $state<EventIndexItem[]>([]); |
||||
let loadingIndex = $state(false); |
||||
let labeling = $state(false); |
||||
let publicationModalOpen = $state(false); |
||||
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); |
||||
|
||||
onMount(async () => { |
||||
await nostrClient.initialize(); |
||||
await loadEvent(); |
||||
}); |
||||
|
||||
$effect(() => { |
||||
if ($page.params.id) { |
||||
loadEvent(); |
||||
} |
||||
}); |
||||
|
||||
async function loadEvent() { |
||||
if (!$page.params.id) return; |
||||
|
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const eventId = decodeEventId($page.params.id); |
||||
if (!eventId) { |
||||
error = 'Invalid event ID format'; |
||||
loading = false; |
||||
return; |
||||
} |
||||
|
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ ids: [eventId], limit: 1 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length === 0) { |
||||
error = 'Event not found'; |
||||
} else { |
||||
event = events[0]; |
||||
|
||||
// If kind 30040, load event index |
||||
if (event.kind === 30040) { |
||||
await loadIndex(); |
||||
} |
||||
} |
||||
} catch (err) { |
||||
console.error('Error loading event:', err); |
||||
error = 'Failed to load event'; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
function decodeEventId(param: string): string | null { |
||||
if (!param) return null; |
||||
|
||||
// Check if it's already a hex event ID |
||||
if (/^[0-9a-f]{64}$/i.test(param)) { |
||||
return param.toLowerCase(); |
||||
} |
||||
|
||||
// Check if it's a bech32 encoded format |
||||
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(param)) { |
||||
try { |
||||
const decoded = nip19.decode(param); |
||||
if (decoded.type === 'note') { |
||||
return String(decoded.data); |
||||
} else if (decoded.type === 'nevent') { |
||||
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
||||
return String(decoded.data.id); |
||||
} |
||||
} else if (decoded.type === 'naddr') { |
||||
// For naddr, we need to fetch by kind+pubkey+d |
||||
// This is handled separately |
||||
return null; |
||||
} |
||||
} catch (err) { |
||||
console.error('Error decoding bech32:', err); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
async function loadIndex() { |
||||
if (!event || event.kind !== 30040) return; |
||||
|
||||
loadingIndex = true; |
||||
try { |
||||
indexItems = await loadEventIndex(event); |
||||
} catch (err) { |
||||
console.error('Error loading event index:', err); |
||||
} finally { |
||||
loadingIndex = false; |
||||
} |
||||
} |
||||
|
||||
function getSectionTitle(item: EventIndexItem): string { |
||||
const titleTag = item.event.tags.find(t => t[0] === 'title' && t[1]); |
||||
if (titleTag) { |
||||
return titleTag[1]; |
||||
} |
||||
|
||||
// Fallback to d-tag in Title Case |
||||
const dTag = item.event.tags.find(t => t[0] === 'd' && t[1])?.[1]; |
||||
if (dTag) { |
||||
return dTag.split('-').map(word => |
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() |
||||
).join(' '); |
||||
} |
||||
|
||||
return `Section ${item.order + 1}`; |
||||
} |
||||
|
||||
function openSectionInNewWindow(item: EventIndexItem) { |
||||
window.open(`/event/${item.event.id}`, '_blank'); |
||||
} |
||||
|
||||
async function labelAsBook() { |
||||
if (!event) return; |
||||
|
||||
const session = sessionManager.getSession(); |
||||
if (!session) { |
||||
alert('You must be logged in to label events'); |
||||
return; |
||||
} |
||||
|
||||
labeling = true; |
||||
publicationResults = null; |
||||
|
||||
try { |
||||
const tags: string[][] = [ |
||||
['L', 'ugc'], // Namespace tag |
||||
['l', 'booklist', 'ugc'], // Label tag |
||||
]; |
||||
|
||||
// Add reference to the event |
||||
// For kind 30040 (replaceable), use 'a' tag with kind:pubkey:d-tag |
||||
// For other events, use 'e' tag with event ID |
||||
if (event.kind === 30040) { |
||||
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1]; |
||||
if (dTag) { |
||||
const aTag = `${event.kind}:${event.pubkey}:${dTag}`; |
||||
// Add relay hint if available |
||||
const relayUrl = relayManager.getFeedReadRelays()[0]; |
||||
if (relayUrl) { |
||||
tags.push(['a', aTag, relayUrl]); |
||||
} else { |
||||
tags.push(['a', aTag]); |
||||
} |
||||
} else { |
||||
// Fallback to 'e' tag if no d-tag found |
||||
const relayUrl = relayManager.getFeedReadRelays()[0]; |
||||
if (relayUrl) { |
||||
tags.push(['e', event.id, relayUrl]); |
||||
} else { |
||||
tags.push(['e', event.id]); |
||||
} |
||||
} |
||||
} else { |
||||
// Regular event - use 'e' tag |
||||
const relayUrl = relayManager.getFeedReadRelays()[0]; |
||||
if (relayUrl) { |
||||
tags.push(['e', event.id, relayUrl]); |
||||
} else { |
||||
tags.push(['e', event.id]); |
||||
} |
||||
} |
||||
|
||||
// Add client tag (NIP-89) |
||||
tags.push(['client', 'Aitherboard']); |
||||
|
||||
const eventTemplate = { |
||||
kind: KIND.LABEL, // 1985 |
||||
content: '', |
||||
tags, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
pubkey: session.pubkey, |
||||
}; |
||||
|
||||
const config = nostrClient.getConfig(); |
||||
const relays = relayManager.getPublishRelays(config.defaultRelays); |
||||
const results = await signAndPublish(eventTemplate, relays); |
||||
publicationResults = results; |
||||
publicationModalOpen = true; |
||||
|
||||
if (results.success.length > 0) { |
||||
// Success - could show a success message |
||||
console.log('Event labeled as book successfully'); |
||||
} else { |
||||
console.error('Failed to publish label event'); |
||||
} |
||||
} catch (err) { |
||||
console.error('Error labeling event as book:', err); |
||||
alert('Failed to label event as book. Please try again.'); |
||||
} finally { |
||||
labeling = false; |
||||
} |
||||
} |
||||
|
||||
const isReplaceable = $derived(event ? isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind) : false); |
||||
const hasContent = $derived(event ? !!event.content : false); |
||||
const isKind30040 = $derived(event?.kind === 30040); |
||||
const isLoggedIn = $derived(sessionManager.isLoggedIn()); |
||||
</script> |
||||
|
||||
<Header /> |
||||
|
||||
<main class="container mx-auto px-4 py-8"> |
||||
{#if loading} |
||||
<div class="loading-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">Loading event...</p> |
||||
</div> |
||||
{:else if error} |
||||
<div class="error-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">{error}</p> |
||||
</div> |
||||
{:else if event} |
||||
<div class="event-page"> |
||||
{#if isReplaceable} |
||||
<div class="metadata-wrapper"> |
||||
<MetadataCard event={event} /> |
||||
{#if isKind30040 && isLoggedIn} |
||||
<div class="metadata-actions"> |
||||
<EventMenu event={event} showContentActions={false} /> |
||||
<button class="btn-label-book" onclick={labelAsBook} disabled={labeling}> |
||||
{labeling ? 'Labeling...' : 'Label as book'} |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if isKind30040} |
||||
{#if loadingIndex} |
||||
<div class="loading-index"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">Loading publication...</p> |
||||
</div> |
||||
{:else if indexItems.length > 0} |
||||
{#each indexItems as item (item.event.id)} |
||||
<div class="section-item"> |
||||
<div class="section-header"> |
||||
<h3 class="section-title">{getSectionTitle(item)}</h3> |
||||
<div class="section-actions"> |
||||
<EventMenu event={item.event} showContentActions={false} /> |
||||
<button class="btn-open-window" onclick={() => openSectionInNewWindow(item)}> |
||||
Open in new window |
||||
</button> |
||||
</div> |
||||
</div> |
||||
<div class="section-content"> |
||||
<MarkdownRenderer content={item.event.content} event={item.event} /> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
{/if} |
||||
{:else if hasContent} |
||||
<div class="event-content"> |
||||
<MarkdownRenderer content={event.content} event={event} /> |
||||
</div> |
||||
{:else} |
||||
<div class="event-tags"> |
||||
<h3 class="tags-title">Tags</h3> |
||||
<div class="tags-list"> |
||||
{#each event.tags as tag} |
||||
<div class="tag-item"> |
||||
<span class="tag-name">{tag[0]}</span> |
||||
{#each tag.slice(1) as value} |
||||
<span class="tag-value">{value}</span> |
||||
{/each} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<div class="event-details"> |
||||
<FeedPost post={event} /> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</main> |
||||
|
||||
<style> |
||||
main { |
||||
max-width: var(--content-width); |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.loading-state, |
||||
.error-state { |
||||
padding: 2rem; |
||||
text-align: center; |
||||
} |
||||
|
||||
.event-page { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1.5rem; |
||||
} |
||||
|
||||
.event-content { |
||||
padding: 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
background: var(--fog-post, #ffffff); |
||||
} |
||||
|
||||
:global(.dark) .event-content { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.event-tags { |
||||
padding: 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
background: var(--fog-post, #ffffff); |
||||
} |
||||
|
||||
:global(.dark) .event-tags { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.tags-title { |
||||
margin: 0 0 1rem 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .tags-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.tags-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.tag-item { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
padding: 0.5rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
border-radius: 0.25rem; |
||||
} |
||||
|
||||
:global(.dark) .tag-item { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.tag-name { |
||||
font-weight: 600; |
||||
color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .tag-name { |
||||
color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.tag-value { |
||||
color: var(--fog-text, #1f2937); |
||||
word-break: break-all; |
||||
} |
||||
|
||||
:global(.dark) .tag-value { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.event-details { |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.metadata-wrapper { |
||||
position: relative; |
||||
} |
||||
|
||||
.metadata-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
align-items: center; |
||||
margin-top: 0.5rem; |
||||
} |
||||
|
||||
.btn-label-book { |
||||
padding: 0.5rem 1rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .btn-label-book { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.btn-label-book:hover { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.loading-index { |
||||
padding: 2rem; |
||||
text-align: center; |
||||
} |
||||
|
||||
.section-item { |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
padding: 1rem; |
||||
margin-bottom: 1rem; |
||||
background: var(--fog-post, #ffffff); |
||||
} |
||||
|
||||
:global(.dark) .section-item { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.section-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 1rem; |
||||
padding-bottom: 0.5rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .section-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.section-title { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .section-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.section-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
align-items: center; |
||||
} |
||||
|
||||
.btn-open-window { |
||||
padding: 0.25rem 0.75rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .btn-open-window { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.btn-open-window:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .btn-open-window:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.section-content { |
||||
padding: 0.5rem 0; |
||||
} |
||||
|
||||
.btn-label-book:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.btn-label-book:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
</style> |
||||
|
||||
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> |
||||
@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
|
||||
<script lang="ts"> |
||||
import Header from '../../../lib/components/layout/Header.svelte'; |
||||
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte'; |
||||
import ThreadDrawer from '../../../lib/modules/feed/ThreadDrawer.svelte'; |
||||
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../../lib/services/nostr/relay-manager.js'; |
||||
import { onMount } from 'svelte'; |
||||
import { page } from '$app/stores'; |
||||
import type { NostrEvent } from '../../../lib/types/nostr.js'; |
||||
import { isReplaceableKind, isParameterizedReplaceableKind } from '../../../lib/types/kind-lookup.js'; |
||||
|
||||
let events = $state<NostrEvent[]>([]); |
||||
let loading = $state(true); |
||||
let dTag = $derived($page.params.d_tag); |
||||
let drawerOpen = $state(false); |
||||
let drawerEvent = $state<NostrEvent | null>(null); |
||||
|
||||
onMount(async () => { |
||||
await nostrClient.initialize(); |
||||
await loadReplaceableEvents(); |
||||
}); |
||||
|
||||
$effect(() => { |
||||
if ($page.params.d_tag) { |
||||
loadReplaceableEvents(); |
||||
} |
||||
}); |
||||
|
||||
async function loadReplaceableEvents() { |
||||
if (!dTag) return; |
||||
|
||||
loading = true; |
||||
try { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
|
||||
// Fetch all replaceable events with matching d-tag |
||||
const allEvents: NostrEvent[] = []; |
||||
|
||||
// Build list of replaceable kinds to check: |
||||
// - Replaceable: 0, 3, and 10000-19999 |
||||
// - Parameterized replaceable: 30000-39999 |
||||
const kindsToCheck: number[] = [0, 3]; // Basic replaceable kinds |
||||
|
||||
// Add replaceable range (10000-19999) |
||||
for (let kind = 10000; kind < 20000; kind++) { |
||||
kindsToCheck.push(kind); |
||||
} |
||||
|
||||
// Add parameterized replaceable range (30000-39999) |
||||
for (let kind = 30000; kind < 40000; kind++) { |
||||
kindsToCheck.push(kind); |
||||
} |
||||
|
||||
for (const kind of kindsToCheck) { |
||||
const kindEvents = await nostrClient.fetchEvents( |
||||
[{ kinds: [kind], '#d': [dTag], limit: 100 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
// For replaceable events, get the newest version of each (by pubkey) |
||||
const eventsByPubkey = new Map<string, NostrEvent>(); |
||||
for (const event of kindEvents) { |
||||
const existing = eventsByPubkey.get(event.pubkey); |
||||
if (!existing || event.created_at > existing.created_at) { |
||||
eventsByPubkey.set(event.pubkey, event); |
||||
} |
||||
} |
||||
|
||||
allEvents.push(...Array.from(eventsByPubkey.values())); |
||||
} |
||||
|
||||
// Sort by created_at descending |
||||
events = allEvents.sort((a, b) => b.created_at - a.created_at); |
||||
} catch (error) { |
||||
console.error('Error loading replaceable events:', error); |
||||
events = []; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
function openInDrawer(event: NostrEvent) { |
||||
drawerEvent = event; |
||||
drawerOpen = true; |
||||
} |
||||
|
||||
function closeDrawer() { |
||||
drawerOpen = false; |
||||
drawerEvent = null; |
||||
} |
||||
</script> |
||||
|
||||
<Header /> |
||||
|
||||
<main class="container mx-auto px-4 py-8"> |
||||
<div class="replaceable-header mb-6"> |
||||
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text"> |
||||
Replaceable Events: {dTag} |
||||
</h1> |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2"> |
||||
{events.length} {events.length === 1 ? 'event' : 'events'} found |
||||
</p> |
||||
</div> |
||||
|
||||
{#if loading} |
||||
<div class="loading-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">Loading events...</p> |
||||
</div> |
||||
{:else if events.length === 0} |
||||
<div class="empty-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">No replaceable events found with this d-tag.</p> |
||||
</div> |
||||
{:else} |
||||
<div class="events-list"> |
||||
{#each events as event (event.id)} |
||||
<div |
||||
class="event-item" |
||||
onclick={() => openInDrawer(event)} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Enter' || e.key === ' ') { |
||||
e.preventDefault(); |
||||
openInDrawer(event); |
||||
} |
||||
}} |
||||
role="button" |
||||
tabindex="0" |
||||
> |
||||
<FeedPost post={event} /> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</main> |
||||
|
||||
{#if drawerOpen && drawerEvent} |
||||
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} /> |
||||
{/if} |
||||
|
||||
<style> |
||||
main { |
||||
max-width: var(--content-width); |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.replaceable-header { |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
padding-bottom: 1rem; |
||||
} |
||||
|
||||
:global(.dark) .replaceable-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.loading-state, |
||||
.empty-state { |
||||
padding: 2rem; |
||||
text-align: center; |
||||
} |
||||
|
||||
.events-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.event-item { |
||||
padding: 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
background: var(--fog-post, #ffffff); |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
:global(.dark) .event-item { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.event-item:hover { |
||||
border-color: var(--fog-accent, #64748b); |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
:global(.dark) .event-item:hover { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
|
||||
<script lang="ts"> |
||||
import Header from '../../../lib/components/layout/Header.svelte'; |
||||
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte'; |
||||
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../../lib/services/nostr/relay-manager.js'; |
||||
import { onMount } from 'svelte'; |
||||
import { page } from '$app/stores'; |
||||
import type { NostrEvent } from '../../../lib/types/nostr.js'; |
||||
import { KIND } from '../../../lib/types/kind-lookup.js'; |
||||
|
||||
let events = $state<NostrEvent[]>([]); |
||||
let loading = $state(true); |
||||
let topicName = $derived($page.params.name); |
||||
|
||||
onMount(async () => { |
||||
await nostrClient.initialize(); |
||||
await loadTopicEvents(); |
||||
}); |
||||
|
||||
$effect(() => { |
||||
if ($page.params.name) { |
||||
loadTopicEvents(); |
||||
} |
||||
}); |
||||
|
||||
async function loadTopicEvents() { |
||||
if (!topicName) return; |
||||
|
||||
loading = true; |
||||
try { |
||||
const relays = relayManager.getFeedReadRelays(); |
||||
|
||||
// Fetch events with matching hashtag in content or t-tag |
||||
// We'll search for events that contain the hashtag in content or have a matching t-tag |
||||
const allEvents: NostrEvent[] = []; |
||||
|
||||
// Search for hashtag in content (kind 1 posts) |
||||
const contentEvents = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 100 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
// Filter events that contain the hashtag |
||||
const hashtagPattern = new RegExp(`#${topicName}\\b`, 'i'); |
||||
for (const event of contentEvents) { |
||||
if (hashtagPattern.test(event.content)) { |
||||
allEvents.push(event); |
||||
} |
||||
} |
||||
|
||||
// Search for events with matching t-tag |
||||
const tTagEvents = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], '#t': [topicName], limit: 100 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
// Merge and deduplicate |
||||
const eventMap = new Map<string, NostrEvent>(); |
||||
for (const event of allEvents) { |
||||
eventMap.set(event.id, event); |
||||
} |
||||
for (const event of tTagEvents) { |
||||
eventMap.set(event.id, event); |
||||
} |
||||
|
||||
events = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at); |
||||
} catch (error) { |
||||
console.error('Error loading topic events:', error); |
||||
events = []; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<Header /> |
||||
|
||||
<main class="container mx-auto px-4 py-8"> |
||||
<div class="topic-header mb-6"> |
||||
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text"> |
||||
Topic: #{topicName} |
||||
</h1> |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2"> |
||||
{events.length} {events.length === 1 ? 'event' : 'events'} found |
||||
</p> |
||||
</div> |
||||
|
||||
{#if loading} |
||||
<div class="loading-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">Loading events...</p> |
||||
</div> |
||||
{:else if events.length === 0} |
||||
<div class="empty-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">No events found for this topic.</p> |
||||
</div> |
||||
{:else} |
||||
<div class="events-list"> |
||||
{#each events as event (event.id)} |
||||
<div class="event-item"> |
||||
<FeedPost post={event} /> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</main> |
||||
|
||||
<style> |
||||
main { |
||||
max-width: var(--content-width); |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.topic-header { |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
padding-bottom: 1rem; |
||||
} |
||||
|
||||
:global(.dark) .topic-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.loading-state, |
||||
.empty-state { |
||||
padding: 2rem; |
||||
text-align: center; |
||||
} |
||||
|
||||
.events-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.event-item { |
||||
padding: 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
background: var(--fog-post, #ffffff); |
||||
} |
||||
|
||||
:global(.dark) .event-item { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
|
||||
<script lang="ts"> |
||||
import Header from '../../lib/components/layout/Header.svelte'; |
||||
import FindEventForm from '../../lib/components/write/FindEventForm.svelte'; |
||||
import CreateEventForm from '../../lib/components/write/CreateEventForm.svelte'; |
||||
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; |
||||
import { sessionManager } from '../../lib/services/auth/session-manager.js'; |
||||
import { onMount } from 'svelte'; |
||||
|
||||
let mode = $state<'select' | 'find' | 'create'>('select'); |
||||
const isLoggedIn = $derived(sessionManager.isLoggedIn()); |
||||
|
||||
onMount(async () => { |
||||
await nostrClient.initialize(); |
||||
}); |
||||
</script> |
||||
|
||||
<Header /> |
||||
|
||||
<main class="container mx-auto px-4 py-8"> |
||||
<div class="write-page"> |
||||
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text">Write</h1> |
||||
|
||||
{#if !isLoggedIn} |
||||
<div class="login-prompt"> |
||||
<p class="text-fog-text dark:text-fog-dark-text mb-4">You must be logged in to write or edit events.</p> |
||||
<a href="/login" class="text-fog-accent dark:text-fog-dark-accent hover:underline">Login here</a> |
||||
</div> |
||||
{:else if mode === 'select'} |
||||
<div class="mode-selector"> |
||||
<button |
||||
class="mode-button" |
||||
onclick={() => mode = 'find'} |
||||
> |
||||
Find an existing event to edit |
||||
</button> |
||||
<button |
||||
class="mode-button" |
||||
onclick={() => mode = 'create'} |
||||
> |
||||
Create a new event |
||||
</button> |
||||
</div> |
||||
{:else if mode === 'find'} |
||||
<div class="form-container"> |
||||
<button class="back-button" onclick={() => mode = 'select'}>← Back</button> |
||||
<FindEventForm /> |
||||
</div> |
||||
{:else if mode === 'create'} |
||||
<div class="form-container"> |
||||
<button class="back-button" onclick={() => mode = 'select'}>← Back</button> |
||||
<CreateEventForm /> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</main> |
||||
|
||||
<style> |
||||
main { |
||||
max-width: var(--content-width); |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.write-page { |
||||
max-width: 800px; |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.mode-selector { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.mode-button { |
||||
padding: 1.5rem; |
||||
border: 2px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 1rem; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
text-align: left; |
||||
} |
||||
|
||||
:global(.dark) .mode-button { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.mode-button:hover { |
||||
border-color: var(--fog-accent, #64748b); |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
:global(.dark) .mode-button:hover { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
.form-container { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.back-button { |
||||
padding: 0.5rem 1rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
align-self: flex-start; |
||||
} |
||||
|
||||
:global(.dark) .back-button { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.back-button:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .back-button:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue