28 changed files with 1907 additions and 129 deletions
@ -0,0 +1,277 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
import { stripMarkdown } from '../../services/text-utils.js'; |
||||||
|
import { KIND, getKindInfo } from '../../types/kind-lookup.js'; |
||||||
|
import { getEventLink } from '../../services/event-links.js'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import Icon from '../ui/Icon.svelte'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
eventId: string; // Bech32 encoded event ID (note1, nevent1, or naddr1) |
||||||
|
} |
||||||
|
|
||||||
|
let { eventId }: Props = $props(); |
||||||
|
|
||||||
|
let event = $state<NostrEvent | null>(null); |
||||||
|
let loading = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let loadAttempted = $state(false); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (!eventId || loadAttempted) return; |
||||||
|
loadEvent(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadEvent() { |
||||||
|
if (loadAttempted || loading) return; |
||||||
|
|
||||||
|
loading = true; |
||||||
|
loadAttempted = true; |
||||||
|
|
||||||
|
try { |
||||||
|
// Decode bech32 to get event info |
||||||
|
const decoded = nip19.decode(eventId); |
||||||
|
|
||||||
|
let loadedEvent: NostrEvent | null = null; |
||||||
|
|
||||||
|
if (decoded.type === 'note') { |
||||||
|
// Simple note - fetch by ID |
||||||
|
const relays = relayManager.getFeedReadRelays(); |
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[{ ids: [String(decoded.data)], limit: 1 }], |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
if (events.length > 0) { |
||||||
|
loadedEvent = events[0]; |
||||||
|
} |
||||||
|
} else if (decoded.type === 'nevent') { |
||||||
|
// Nevent with optional relays |
||||||
|
const neventData = decoded.data as { id: string; relays?: string[] }; |
||||||
|
const relays = neventData.relays && neventData.relays.length > 0 |
||||||
|
? neventData.relays |
||||||
|
: relayManager.getFeedReadRelays(); |
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[{ ids: [neventData.id], limit: 1 }], |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
if (events.length > 0) { |
||||||
|
loadedEvent = events[0]; |
||||||
|
} |
||||||
|
} else if (decoded.type === 'naddr') { |
||||||
|
// Naddr for parameterized replaceable events |
||||||
|
const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] }; |
||||||
|
const relays = naddrData.relays && naddrData.relays.length > 0 |
||||||
|
? naddrData.relays |
||||||
|
: relayManager.getProfileReadRelays(); |
||||||
|
|
||||||
|
const filter: any = { |
||||||
|
kinds: [naddrData.kind], |
||||||
|
authors: [naddrData.pubkey], |
||||||
|
limit: 1 |
||||||
|
}; |
||||||
|
|
||||||
|
if (naddrData.identifier) { |
||||||
|
filter['#d'] = [naddrData.identifier]; |
||||||
|
} |
||||||
|
|
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[filter], |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
if (events.length > 0) { |
||||||
|
loadedEvent = events[0]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (loadedEvent) { |
||||||
|
event = loadedEvent; |
||||||
|
} else { |
||||||
|
error = 'Event not found'; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Error loading embedded event:', err); |
||||||
|
error = 'Failed to load event'; |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getEventPreview(): string { |
||||||
|
if (!event) { |
||||||
|
return loading ? 'Loading...' : error || 'Event not found'; |
||||||
|
} |
||||||
|
|
||||||
|
// If content exists, use it |
||||||
|
if (event.content && event.content.trim()) { |
||||||
|
let plaintext = stripMarkdown(event.content); |
||||||
|
// Remove nostr: links from preview (match full nostr: URI format, with optional spaces) |
||||||
|
plaintext = plaintext.replace(/\s*nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})\s*/gi, ' ').trim(); |
||||||
|
// Clean up multiple spaces |
||||||
|
plaintext = plaintext.replace(/\s+/g, ' ').trim(); |
||||||
|
return plaintext.slice(0, 200) + (plaintext.length > 200 ? '...' : ''); |
||||||
|
} |
||||||
|
|
||||||
|
// Otherwise, check for title, summary, description, or alt tag (in that order) |
||||||
|
const titleTag = event.tags.find(t => t[0] === 'title' && t[1])?.[1]; |
||||||
|
if (titleTag && titleTag.trim()) { |
||||||
|
return titleTag.trim(); |
||||||
|
} |
||||||
|
|
||||||
|
const summaryTag = event.tags.find(t => t[0] === 'summary' && t[1])?.[1]; |
||||||
|
if (summaryTag && summaryTag.trim()) { |
||||||
|
return summaryTag.trim(); |
||||||
|
} |
||||||
|
|
||||||
|
const descriptionTag = event.tags.find(t => t[0] === 'description' && t[1])?.[1]; |
||||||
|
if (descriptionTag && descriptionTag.trim()) { |
||||||
|
return descriptionTag.trim(); |
||||||
|
} |
||||||
|
|
||||||
|
const altTag = event.tags.find(t => t[0] === 'alt' && t[1])?.[1]; |
||||||
|
if (altTag && altTag.trim()) { |
||||||
|
return altTag.trim(); |
||||||
|
} |
||||||
|
|
||||||
|
// Fallback: show kind |
||||||
|
return `Kind ${event.kind}`; |
||||||
|
} |
||||||
|
|
||||||
|
function getEventKindInfo(): string { |
||||||
|
if (!event) return ''; |
||||||
|
const kindInfo = getKindInfo(event.kind); |
||||||
|
return `Kind ${kindInfo.number}: ${kindInfo.description}`; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="embedded-event-blurb"> |
||||||
|
<div class="embedded-blurb-content"> |
||||||
|
<span class="font-semibold">Referenced:</span> |
||||||
|
{#if loading} |
||||||
|
<span class="opacity-70">Loading...</span> |
||||||
|
{:else if event} |
||||||
|
<div class="embedded-preview-container"> |
||||||
|
<span class="embedded-preview">{getEventPreview()}</span> |
||||||
|
<span class="embedded-kind-info">{getEventKindInfo()}</span> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<span class="opacity-70">{error || 'Event not found'}</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{#if event} |
||||||
|
<div class="embedded-blurb-actions"> |
||||||
|
<button |
||||||
|
class="view-button" |
||||||
|
onclick={() => { |
||||||
|
goto(getEventLink(event!)); |
||||||
|
}} |
||||||
|
aria-label="View referenced post" |
||||||
|
title="View referenced post" |
||||||
|
> |
||||||
|
<Icon name="eye" size={14} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.embedded-event-blurb { |
||||||
|
border-left: 2px solid var(--fog-accent, #64748b); |
||||||
|
border-radius: 0.25rem; |
||||||
|
padding: 0.75rem; |
||||||
|
margin: 0.75rem 0; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
gap: 0.75rem; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .embedded-event-blurb { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
border-left-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-blurb-content { |
||||||
|
flex: 1; |
||||||
|
min-width: 0; |
||||||
|
display: flex; |
||||||
|
align-items: flex-start; |
||||||
|
gap: 0.5rem; |
||||||
|
flex-wrap: wrap; |
||||||
|
color: var(--fog-text-light, #52667a); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .embedded-blurb-content { |
||||||
|
color: var(--fog-dark-text-light, #a8b8d0); |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-preview-container { |
||||||
|
flex: 1; |
||||||
|
min-width: 0; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-preview { |
||||||
|
overflow: hidden; |
||||||
|
text-overflow: ellipsis; |
||||||
|
white-space: normal; |
||||||
|
display: -webkit-box; |
||||||
|
-webkit-line-clamp: 3; |
||||||
|
-webkit-box-orient: vertical; |
||||||
|
line-height: 1.4; |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-kind-info { |
||||||
|
font-size: 0.75rem; |
||||||
|
opacity: 0.7; |
||||||
|
font-style: italic; |
||||||
|
} |
||||||
|
|
||||||
|
.embedded-blurb-actions { |
||||||
|
flex-shrink: 0; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
|
||||||
|
.view-button { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
padding: 0.25rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
background: transparent; |
||||||
|
color: var(--fog-text-light, #52667a); |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
min-width: 1.75rem; |
||||||
|
min-height: 1.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.view-button:hover { |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .view-button { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
color: var(--fog-dark-text-light, #a8b8d0); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .view-button:hover { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,232 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import { page } from '$app/stores'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
totalItems: number; |
||||||
|
itemsPerPage: number; |
||||||
|
currentPage?: number; |
||||||
|
onPageChange?: (page: number) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { totalItems, itemsPerPage, currentPage: providedCurrentPage, onPageChange }: Props = $props(); |
||||||
|
|
||||||
|
// Get current page from URL query param or use provided |
||||||
|
const currentPage = $derived(providedCurrentPage ?? parseInt($page.url.searchParams.get('page') || '1', 10)); |
||||||
|
|
||||||
|
const totalPages = $derived(Math.ceil(totalItems / itemsPerPage)); |
||||||
|
const startItem = $derived((currentPage - 1) * itemsPerPage + 1); |
||||||
|
const endItem = $derived(Math.min(currentPage * itemsPerPage, totalItems)); |
||||||
|
|
||||||
|
function goToPage(pageNum: number) { |
||||||
|
if (pageNum < 1 || pageNum > totalPages) return; |
||||||
|
|
||||||
|
if (onPageChange) { |
||||||
|
onPageChange(pageNum); |
||||||
|
} else { |
||||||
|
// Update URL query param |
||||||
|
const url = new URL($page.url); |
||||||
|
if (pageNum === 1) { |
||||||
|
url.searchParams.delete('page'); |
||||||
|
} else { |
||||||
|
url.searchParams.set('page', pageNum.toString()); |
||||||
|
} |
||||||
|
goto(url.pathname + url.search, { replaceState: true, noScroll: false }); |
||||||
|
} |
||||||
|
|
||||||
|
// Scroll to top of page |
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' }); |
||||||
|
} |
||||||
|
|
||||||
|
function getPageNumbers(): number[] { |
||||||
|
const pages: number[] = []; |
||||||
|
const maxVisible = 7; |
||||||
|
|
||||||
|
if (totalPages <= maxVisible) { |
||||||
|
// Show all pages |
||||||
|
for (let i = 1; i <= totalPages; i++) { |
||||||
|
pages.push(i); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Show first page, last page, current page, and pages around current |
||||||
|
pages.push(1); |
||||||
|
|
||||||
|
if (currentPage > 3) { |
||||||
|
pages.push(-1); // Ellipsis marker |
||||||
|
} |
||||||
|
|
||||||
|
const start = Math.max(2, currentPage - 1); |
||||||
|
const end = Math.min(totalPages - 1, currentPage + 1); |
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) { |
||||||
|
pages.push(i); |
||||||
|
} |
||||||
|
|
||||||
|
if (currentPage < totalPages - 2) { |
||||||
|
pages.push(-1); // Ellipsis marker |
||||||
|
} |
||||||
|
|
||||||
|
pages.push(totalPages); |
||||||
|
} |
||||||
|
|
||||||
|
return pages; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if totalPages > 1} |
||||||
|
<div class="pagination"> |
||||||
|
<div class="pagination-info"> |
||||||
|
Showing {startItem}-{endItem} of {totalItems} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="pagination-controls"> |
||||||
|
<button |
||||||
|
onclick={() => goToPage(currentPage - 1)} |
||||||
|
disabled={currentPage === 1} |
||||||
|
class="pagination-btn" |
||||||
|
aria-label="Previous page" |
||||||
|
> |
||||||
|
Previous |
||||||
|
</button> |
||||||
|
|
||||||
|
<div class="pagination-numbers"> |
||||||
|
{#each getPageNumbers() as pageNum} |
||||||
|
{#if pageNum === -1} |
||||||
|
<span class="pagination-ellipsis">...</span> |
||||||
|
{:else} |
||||||
|
<button |
||||||
|
onclick={() => goToPage(pageNum)} |
||||||
|
class="pagination-btn" |
||||||
|
class:active={pageNum === currentPage} |
||||||
|
aria-label="Go to page {pageNum}" |
||||||
|
aria-current={pageNum === currentPage ? 'page' : undefined} |
||||||
|
> |
||||||
|
{pageNum} |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
|
||||||
|
<button |
||||||
|
onclick={() => goToPage(currentPage + 1)} |
||||||
|
disabled={currentPage === totalPages} |
||||||
|
class="pagination-btn" |
||||||
|
aria-label="Next page" |
||||||
|
> |
||||||
|
Next |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.pagination { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: center; |
||||||
|
gap: 1rem; |
||||||
|
padding: 1.5rem 0; |
||||||
|
margin-top: 2rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .pagination { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.pagination-info { |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--fog-text-light, #52667a); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .pagination-info { |
||||||
|
color: var(--fog-dark-text-light, #a8b8d0); |
||||||
|
} |
||||||
|
|
||||||
|
.pagination-controls { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.5rem; |
||||||
|
flex-wrap: wrap; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
|
||||||
|
.pagination-numbers { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pagination-btn { |
||||||
|
min-width: 2.5rem; |
||||||
|
height: 2.5rem; |
||||||
|
padding: 0.5rem 0.75rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.375rem; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
font-size: 0.875rem; |
||||||
|
font-weight: 500; |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
|
||||||
|
.pagination-btn:hover:not(:disabled) { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
.pagination-btn:disabled { |
||||||
|
opacity: 0.5; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
.pagination-btn.active { |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
color: white; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .pagination-btn { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .pagination-btn:hover:not(:disabled) { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .pagination-btn.active { |
||||||
|
background: var(--fog-dark-accent, #94a3b8); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
.pagination-ellipsis { |
||||||
|
padding: 0 0.5rem; |
||||||
|
color: var(--fog-text-light, #52667a); |
||||||
|
user-select: none; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .pagination-ellipsis { |
||||||
|
color: var(--fog-dark-text-light, #a8b8d0); |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 640px) { |
||||||
|
.pagination-controls { |
||||||
|
gap: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pagination-btn { |
||||||
|
min-width: 2rem; |
||||||
|
height: 2rem; |
||||||
|
padding: 0.25rem 0.5rem; |
||||||
|
font-size: 0.75rem; |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,30 @@ |
|||||||
|
/** |
||||||
|
* Pagination utility functions |
||||||
|
*/ |
||||||
|
|
||||||
|
export const ITEMS_PER_PAGE = 50; |
||||||
|
|
||||||
|
/** |
||||||
|
* Get paginated items for a given page |
||||||
|
*/ |
||||||
|
export function getPaginatedItems<T>(items: T[], page: number, itemsPerPage: number = ITEMS_PER_PAGE): T[] { |
||||||
|
const startIndex = (page - 1) * itemsPerPage; |
||||||
|
const endIndex = startIndex + itemsPerPage; |
||||||
|
return items.slice(startIndex, endIndex); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get current page from URL search params |
||||||
|
*/ |
||||||
|
export function getCurrentPage(searchParams: URLSearchParams): number { |
||||||
|
const pageParam = searchParams.get('page'); |
||||||
|
const page = pageParam ? parseInt(pageParam, 10) : 1; |
||||||
|
return isNaN(page) || page < 1 ? 1 : page; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if pagination should be shown (more than itemsPerPage items) |
||||||
|
*/ |
||||||
|
export function shouldShowPagination(totalItems: number, itemsPerPage: number = ITEMS_PER_PAGE): boolean { |
||||||
|
return totalItems > itemsPerPage; |
||||||
|
} |
||||||
Loading…
Reference in new issue