28 changed files with 1907 additions and 129 deletions
@ -0,0 +1,277 @@
@@ -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 @@
@@ -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 @@
@@ -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