Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
8c5c03e3bf
  1. 277
      src/lib/components/content/EmbeddedEventBlurb.svelte
  2. 70
      src/lib/components/content/MarkdownRenderer.svelte
  3. 114
      src/lib/components/content/MediaAttachments.svelte
  4. 68
      src/lib/components/content/MediaViewer.svelte
  5. 18
      src/lib/components/content/MetadataCard.svelte
  6. 175
      src/lib/components/content/QuotedContext.svelte
  7. 6
      src/lib/components/content/ReferencedEventPreview.svelte
  8. 124
      src/lib/components/content/ReplyContext.svelte
  9. 66
      src/lib/components/modals/EventJsonModal.svelte
  10. 232
      src/lib/components/ui/Pagination.svelte
  11. 113
      src/lib/components/write/AdvancedEditor.svelte
  12. 115
      src/lib/components/write/CreateEventForm.svelte
  13. 23
      src/lib/modules/comments/Comment.svelte
  14. 115
      src/lib/modules/comments/CommentForm.svelte
  15. 25
      src/lib/modules/discussions/DiscussionCard.svelte
  16. 54
      src/lib/modules/events/EventView.svelte
  17. 19
      src/lib/modules/feed/FeedPage.svelte
  18. 64
      src/lib/modules/feed/FeedPost.svelte
  19. 105
      src/lib/modules/rss/RSSCommentForm.svelte
  20. 30
      src/lib/utils/pagination.ts
  21. 13
      src/routes/bookmarks/+page.svelte
  22. 13
      src/routes/cache/+page.svelte
  23. 16
      src/routes/discussions/+page.svelte
  24. 26
      src/routes/find/+page.svelte
  25. 44
      src/routes/highlights/+page.svelte
  26. 16
      src/routes/lists/+page.svelte
  27. 15
      src/routes/replaceable/[d_tag]/+page.svelte
  28. 20
      src/routes/topics/+page.svelte

277
src/lib/components/content/EmbeddedEventBlurb.svelte

@ -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>

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

@ -16,7 +16,9 @@
import { getCachedMarkdown, cacheMarkdown } from '../../services/cache/markdown-cache.js'; import { getCachedMarkdown, cacheMarkdown } from '../../services/cache/markdown-cache.js';
import EmbeddedEvent from './EmbeddedEvent.svelte'; import EmbeddedEvent from './EmbeddedEvent.svelte';
import EmbeddedEventBlurb from './EmbeddedEventBlurb.svelte';
let mountingEmbeddedEvents = $state(false); // Guard for mounting let mountingEmbeddedEvents = $state(false); // Guard for mounting
let mountingEmbeddedBlurbs = $state(false); // Guard for mounting blurbs
interface Props { interface Props {
content: string; content: string;
@ -425,7 +427,7 @@
link.parsed.type === 'note' || link.parsed.type === 'nevent' || link.parsed.type === 'naddr' link.parsed.type === 'note' || link.parsed.type === 'nevent' || link.parsed.type === 'naddr'
); );
// Replace event links with HTML div elements (for block-level display) // Replace event links with HTML div elements for embedded blurbs (block-level display)
// Process from end to start to preserve indices // Process from end to start to preserve indices
for (let i = eventLinks.length - 1; i >= 0; i--) { for (let i = eventLinks.length - 1; i >= 0; i--) {
const link = eventLinks[i]; const link = eventLinks[i];
@ -434,8 +436,8 @@
if (eventId && isValidNostrId(eventId)) { if (eventId && isValidNostrId(eventId)) {
// Escape event ID to prevent XSS // Escape event ID to prevent XSS
const escapedEventId = escapeHtml(eventId); const escapedEventId = escapeHtml(eventId);
// Create a div element for embedded event cards (block-level) // Create a div element for embedded event blurbs (block-level, styled differently)
const div = `<div data-nostr-event data-event-id="${escapedEventId}"></div>`; const div = `<div data-nostr-event-blurb data-event-id="${escapedEventId}"></div>`;
processed = processed =
processed.slice(0, link.start) + processed.slice(0, link.start) +
div + div +
@ -859,7 +861,65 @@
} }
} }
// Mount EmbeddedEvent components after rendering // Mount EmbeddedEventBlurb components after rendering (for nostr: links in content)
function mountEmbeddedBlurbs() {
if (!containerRef || mountingEmbeddedBlurbs) return;
// Find all event blurb placeholders and mount EmbeddedEventBlurb components
const placeholders = containerRef.querySelectorAll('[data-nostr-event-blurb]:not([data-mounted])');
if (placeholders.length > 0) {
mountingEmbeddedBlurbs = true;
try {
// Validate event IDs before mounting to prevent invalid fetches
const validPlaceholders: Element[] = [];
placeholders.forEach((placeholder) => {
const eventId = placeholder.getAttribute('data-event-id');
// Use strict validation to prevent invalid fetches
if (eventId && isValidNostrId(eventId)) {
validPlaceholders.push(placeholder);
} else if (eventId) {
// Invalid event ID - mark as mounted to prevent retries
placeholder.setAttribute('data-mounted', 'true');
placeholder.textContent = ''; // Don't show invalid IDs
console.debug('Skipping invalid event ID in MarkdownRenderer:', eventId);
}
});
if (validPlaceholders.length > 0) {
console.debug(`Mounting ${validPlaceholders.length} EmbeddedEventBlurb components`);
validPlaceholders.forEach((placeholder) => {
const eventId = placeholder.getAttribute('data-event-id');
if (eventId) {
placeholder.setAttribute('data-mounted', 'true');
try {
// Clear and mount component
placeholder.innerHTML = '';
// Mount EmbeddedEventBlurb component - it will decode and fetch the event
const instance = mountComponent(placeholder as HTMLElement, EmbeddedEventBlurb as any, { eventId });
if (!instance) {
console.warn('EmbeddedEventBlurb mount returned null', { eventId });
// Fallback: show the event ID
placeholder.textContent = eventId.slice(0, 20) + '...';
}
} catch (error) {
console.error('Error mounting EmbeddedEventBlurb:', error, { eventId });
// Show fallback
placeholder.textContent = eventId.slice(0, 20) + '...';
}
}
});
}
} finally {
mountingEmbeddedBlurbs = false;
}
}
}
// Mount EmbeddedEvent components after rendering (for other embedded events)
function mountEmbeddedEvents() { function mountEmbeddedEvents() {
if (!containerRef || mountingEmbeddedEvents) return; if (!containerRef || mountingEmbeddedEvents) return;
@ -985,6 +1045,7 @@
}); });
mountProfileBadges(); mountProfileBadges();
mountEmbeddedBlurbs();
mountEmbeddedEvents(); mountEmbeddedEvents();
}, 150); }, 150);
@ -1008,6 +1069,7 @@
} }
mutationDebounceTimeout = setTimeout(() => { mutationDebounceTimeout = setTimeout(() => {
mountProfileBadges(); mountProfileBadges();
mountEmbeddedBlurbs();
mountEmbeddedEvents(); mountEmbeddedEvents();
mutationDebounceTimeout = null; mutationDebounceTimeout = null;
}, 300); // 300ms debounce }, 300); // 300ms debounce

114
src/lib/components/content/MediaAttachments.svelte

@ -4,9 +4,17 @@
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
forceRender?: boolean; // If true, always render media even if URL is in content (for media kinds) forceRender?: boolean; // If true, always render media even if URL is in content (for media kinds)
onMediaClick?: (url: string, event: MouseEvent) => void; // Optional callback when media is clicked
} }
let { event, forceRender = false }: Props = $props(); let { event, forceRender = false, onMediaClick }: Props = $props();
function handleMediaClick(e: MouseEvent, url: string) {
e.stopPropagation(); // Don't trigger parent click handlers
if (onMediaClick) {
onMediaClick(url, e);
}
}
interface MediaItem { interface MediaItem {
url: string; url: string;
@ -15,6 +23,7 @@
width?: number; width?: number;
height?: number; height?: number;
size?: number; size?: number;
alt?: string; // Alt text for images
source: 'image-tag' | 'imeta' | 'file-tag' | 'content'; source: 'image-tag' | 'imeta' | 'file-tag' | 'content';
} }
@ -91,6 +100,7 @@
let mimeType: string | undefined; let mimeType: string | undefined;
let width: number | undefined; let width: number | undefined;
let height: number | undefined; let height: number | undefined;
let alt: string | undefined;
for (let i = 1; i < tag.length; i++) { for (let i = 1; i < tag.length; i++) {
const item = tag[i]; const item = tag[i];
@ -102,6 +112,8 @@
width = parseInt(item.substring(2).trim(), 10); width = parseInt(item.substring(2).trim(), 10);
} else if (item.startsWith('y ')) { } else if (item.startsWith('y ')) {
height = parseInt(item.substring(2).trim(), 10); height = parseInt(item.substring(2).trim(), 10);
} else if (item.startsWith('alt ')) {
alt = item.substring(4).trim();
} }
} }
@ -126,6 +138,7 @@
mimeType, mimeType,
width, width,
height, height,
alt,
source: 'imeta' source: 'imeta'
}); });
seen.add(normalized); seen.add(normalized);
@ -134,6 +147,13 @@
} }
} }
// Also check for standalone alt tag (fallback if not in imeta)
const altTag = event.tags.find(t => t[0] === 'alt' && t[1]);
if (altTag && altTag[1] && media.length > 0 && !media[0].alt) {
// Apply alt text to first media item if it doesn't have one
media[0].alt = altTag[1];
}
// 3. file tags (NIP-94) // 3. file tags (NIP-94)
for (const tag of event.tags) { for (const tag of event.tags) {
if (tag[0] === 'file' && tag[1]) { if (tag[0] === 'file' && tag[1]) {
@ -208,13 +228,33 @@
<div bind:this={containerRef}> <div bind:this={containerRef}>
{#if coverImage} {#if coverImage}
<div class="cover-image mb-4"> <div class="cover-image mb-4">
{#if onMediaClick}
<button
type="button"
class="cover-image-button"
onclick={(e) => handleMediaClick(e, coverImage.url)}
aria-label={coverImage.alt || 'View image'}
>
<img
src={coverImage.url}
alt={coverImage.alt || ''}
class="w-full max-h-96 object-cover rounded clickable-media"
loading="lazy"
decoding="async"
/>
</button>
{:else}
<img <img
src={coverImage.url} src={coverImage.url}
alt="" alt={coverImage.alt || ''}
class="w-full max-h-96 object-cover rounded" class="w-full max-h-96 object-cover rounded"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
{/if}
{#if coverImage.alt}
<div class="image-alt-text">{coverImage.alt}</div>
{/if}
</div> </div>
{/if} {/if}
@ -223,13 +263,33 @@
{#each otherMedia as item} {#each otherMedia as item}
{#if item.type === 'image'} {#if item.type === 'image'}
<div class="media-item"> <div class="media-item">
{#if onMediaClick}
<button
type="button"
class="media-image-button"
onclick={(e) => handleMediaClick(e, item.url)}
aria-label={item.alt || 'View image'}
>
<img
src={item.url}
alt={item.alt || ''}
class="max-w-full rounded clickable-media"
loading="lazy"
decoding="async"
/>
</button>
{:else}
<img <img
src={item.url} src={item.url}
alt="" alt={item.alt || ''}
class="max-w-full rounded" class="max-w-full rounded"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
{/if}
{#if item.alt}
<div class="image-alt-text">{item.alt}</div>
{/if}
</div> </div>
{:else if item.type === 'video'} {:else if item.type === 'video'}
<div class="media-item"> <div class="media-item">
@ -237,10 +297,16 @@
src={item.url} src={item.url}
controls controls
preload="metadata" preload="metadata"
class="max-w-full rounded" class="max-w-full rounded {onMediaClick ? 'clickable-media' : ''}"
style="max-height: 500px;" style="max-height: 500px;"
autoplay={false} autoplay={false}
muted={false} muted={false}
onclick={(e) => {
// Only open viewer if clicking the video element itself, not controls
if (onMediaClick && (e.target === e.currentTarget || (e.target as HTMLElement).tagName === 'VIDEO')) {
handleMediaClick(e, item.url);
}
}}
> >
<track kind="captions" /> <track kind="captions" />
Your browser does not support the video tag. Your browser does not support the video tag.
@ -252,8 +318,14 @@
src={item.url} src={item.url}
controls controls
preload="metadata" preload="metadata"
class="w-full" class="w-full {onMediaClick ? 'clickable-media' : ''}"
autoplay={false} autoplay={false}
onclick={(e) => {
// Only open viewer if clicking the audio element itself, not controls
if (onMediaClick && (e.target === e.currentTarget || (e.target as HTMLElement).tagName === 'AUDIO')) {
handleMediaClick(e, item.url);
}
}}
> >
Your browser does not support the audio tag. Your browser does not support the audio tag.
</audio> </audio>
@ -291,6 +363,18 @@
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
} }
.image-alt-text {
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--fog-text-light, #52667a);
font-style: italic;
line-height: 1.4;
}
:global(.dark) .image-alt-text {
color: var(--fog-dark-text-light, #a8b8d0);
}
.media-gallery { .media-gallery {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@ -346,4 +430,24 @@
text-decoration: underline; text-decoration: underline;
} }
.cover-image-button,
.media-image-button {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
display: block;
width: 100%;
}
.clickable-media {
cursor: pointer;
transition: opacity 0.2s;
}
.clickable-media:hover {
opacity: 0.9;
}
</style> </style>

68
src/lib/components/content/MediaViewer.svelte

@ -78,6 +78,18 @@
animation: fadeIn 0.2s ease-out; animation: fadeIn 0.2s ease-out;
} }
@media (max-width: 768px) {
.media-viewer-backdrop {
padding: 1rem;
}
}
@media (max-width: 640px) {
.media-viewer-backdrop {
padding: 0.5rem;
}
}
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
@ -90,6 +102,21 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%;
}
@media (max-width: 768px) {
.media-viewer-content {
max-width: 95vw;
max-height: 95vh;
}
}
@media (max-width: 640px) {
.media-viewer-content {
max-width: 100vw;
max-height: 100vh;
}
} }
.media-viewer-close { .media-viewer-close {
@ -111,6 +138,26 @@
transition: background 0.2s; transition: background 0.2s;
} }
@media (max-width: 768px) {
.media-viewer-close {
top: -2rem;
width: 2rem;
height: 2rem;
font-size: 1.5rem;
}
}
@media (max-width: 640px) {
.media-viewer-close {
top: 0.5rem;
right: 0.5rem;
width: 2rem;
height: 2rem;
font-size: 1.5rem;
background: rgba(0, 0, 0, 0.6);
}
}
.media-viewer-close:hover { .media-viewer-close:hover {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
@ -122,11 +169,32 @@
border-radius: 0.5rem; border-radius: 0.5rem;
} }
@media (max-width: 768px) {
.media-viewer-media {
max-height: 95vh;
border-radius: 0.25rem;
}
}
@media (max-width: 640px) {
.media-viewer-media {
max-height: 100vh;
border-radius: 0;
}
}
.media-viewer-audio { .media-viewer-audio {
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
} }
@media (max-width: 640px) {
.media-viewer-audio {
max-width: 100%;
padding: 0 1rem;
}
}
.media-viewer-unknown { .media-viewer-unknown {
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
padding: 2rem; padding: 2rem;

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

@ -38,11 +38,27 @@
const hasMetadata = $derived(description || summary || author || title); const hasMetadata = $derived(description || summary || author || title);
const hasContent = $derived(event.content && event.content.trim().length > 0); const hasContent = $derived(event.content && event.content.trim().length > 0);
const shouldShowMetadata = $derived(hasMetadata || !hasContent); // Show metadata if it exists OR if there's no content
// Media kinds check (for filtering tags display) // Media kinds check (for filtering tags display)
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY];
const isMediaKind = $derived(MEDIA_KINDS.includes(event.kind)); const isMediaKind = $derived(MEDIA_KINDS.includes(event.kind));
// Check if there are any tags to display (excluding filtered ones)
const hasDisplayableTags = $derived.by(() => {
if (hasContent || isMediaKind) return false;
for (const tag of event.tags) {
if (tag[0] !== 'image' && tag[0] !== 'description' && tag[0] !== 'summary' && tag[0] !== 'author' && tag[0] !== 'title' && tag[0] !== 'd' && tag[0] !== 'imeta' && tag[0] !== 'file' && tag[0] !== 'alt' && tag[0] !== 'x' && tag[0] !== 'm') {
// Check if tag has any non-empty values
if (tag.slice(1).some(value => value && value.trim())) {
return true;
}
}
}
return false;
});
// Only show metadata card if there's actually something to display
const shouldShowMetadata = $derived(hasMetadata || hasDisplayableTags);
</script> </script>
{#if shouldShowMetadata} {#if shouldShowMetadata}

175
src/lib/components/content/QuotedContext.svelte

@ -3,7 +3,10 @@
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { stripMarkdown } from '../../services/text-utils.js'; import { stripMarkdown } from '../../services/text-utils.js';
import { KIND } from '../../types/kind-lookup.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';
interface Props { interface Props {
quotedEvent?: NostrEvent; // Optional - if not provided, will load by quotedEventId quotedEvent?: NostrEvent; // Optional - if not provided, will load by quotedEventId
@ -17,28 +20,42 @@
let loadedQuotedEvent = $state<NostrEvent | null>(null); let loadedQuotedEvent = $state<NostrEvent | null>(null);
let loadingQuoted = $state(false); let loadingQuoted = $state(false);
let loadAttempted = $state(false); // Track if we've already attempted to load to prevent infinite loops
// Derive the effective quoted event: prefer provided, fall back to loaded // Derive the effective quoted event: prefer provided, fall back to loaded
let quotedEvent = $derived(providedQuotedEvent || loadedQuotedEvent); let quotedEvent = $derived(providedQuotedEvent || loadedQuotedEvent);
// Track last quotedEventId to detect changes
let lastQuotedEventId = $state<string | undefined>(undefined);
// Sync provided quoted event changes and load if needed // Sync provided quoted event changes and load if needed
$effect(() => { $effect(() => {
if (providedQuotedEvent) { if (providedQuotedEvent) {
// If provided quoted event is available, use it // If provided quoted event is available, use it and reset load attempt flag
loadAttempted = false;
lastQuotedEventId = undefined;
return; return;
} }
// If no provided quoted event and we have an ID, try to load it // If quotedEventId changed, reset the load attempt flag
if (!loadedQuotedEvent && quotedEventId && !loadingQuoted) { if (quotedEventId !== lastQuotedEventId) {
loadAttempted = false;
loadedQuotedEvent = null;
lastQuotedEventId = quotedEventId;
}
// If no provided quoted event and we have an ID, try to load it (only once per ID)
if (!loadedQuotedEvent && quotedEventId && !loadingQuoted && !loadAttempted) {
loadQuotedEvent(); loadQuotedEvent();
} }
}); });
async function loadQuotedEvent() { async function loadQuotedEvent() {
const eventId = quotedEventId || quotedEvent?.id; const eventId = quotedEventId || quotedEvent?.id;
if (!eventId || loadingQuoted) return; if (!eventId || loadingQuoted || loadAttempted) return;
loadingQuoted = true; loadingQuoted = true;
loadAttempted = true; // Mark that we've attempted to load
try { try {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
@ -53,8 +70,10 @@
onQuotedLoaded(loadedQuotedEvent); onQuotedLoaded(loadedQuotedEvent);
} }
} }
// If events.length === 0, we've attempted but found nothing - don't retry
} catch (error) { } catch (error) {
console.error('Error loading quoted event:', error); console.error('Error loading quoted event:', error);
// On error, we've still attempted - don't retry to prevent loops
} finally { } finally {
loadingQuoted = false; loadingQuoted = false;
} }
@ -64,9 +83,46 @@
if (!quotedEvent) { if (!quotedEvent) {
return loadingQuoted ? 'Loading...' : 'Quoted event not found'; return loadingQuoted ? 'Loading...' : 'Quoted event not found';
} }
// Create preview from quoted event (first 100 chars, plaintext with markdown stripped)
const plaintext = stripMarkdown(quotedEvent.content); // If content exists, use it
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); if (quotedEvent.content && quotedEvent.content.trim()) {
let plaintext = stripMarkdown(quotedEvent.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 = quotedEvent.tags.find(t => t[0] === 'title' && t[1])?.[1];
if (titleTag && titleTag.trim()) {
return titleTag.trim();
}
const summaryTag = quotedEvent.tags.find(t => t[0] === 'summary' && t[1])?.[1];
if (summaryTag && summaryTag.trim()) {
return summaryTag.trim();
}
const descriptionTag = quotedEvent.tags.find(t => t[0] === 'description' && t[1])?.[1];
if (descriptionTag && descriptionTag.trim()) {
return descriptionTag.trim();
}
const altTag = quotedEvent.tags.find(t => t[0] === 'alt' && t[1])?.[1];
if (altTag && altTag.trim()) {
return altTag.trim();
}
// Fallback: show kind
return `Kind ${quotedEvent.kind}`;
}
function getQuotedKindInfo(): string {
if (!quotedEvent) return '';
const kindInfo = getKindInfo(quotedEvent.kind);
return `Kind ${kindInfo.number}: ${kindInfo.description}`;
} }
</script> </script>
@ -74,21 +130,120 @@
<div <div
class="quoted-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light" class="quoted-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light"
> >
<span class="font-semibold">Quoting:</span> {getQuotedPreview()} <div class="quoted-context-content">
<span class="font-semibold">Quoting:</span>
{#if loadingQuoted} {#if loadingQuoted}
<span class="text-xs opacity-70"> (loading...)</span> <span class="opacity-70">Loading...</span>
{:else if quotedEvent}
<div class="quoted-preview-container">
<span class="quoted-preview">{getQuotedPreview()}</span>
<span class="quoted-kind-info">{getQuotedKindInfo()}</span>
</div>
{:else}
<span class="opacity-70">Quoted event not found</span>
{/if}
</div>
{#if quotedEvent}
<div class="quoted-context-actions">
<button
class="view-button"
onclick={() => {
goto(getEventLink(quotedEvent!));
}}
aria-label="View quoted post"
title="View quoted post"
>
<Icon name="eye" size={14} />
</button>
</div>
{/if} {/if}
</div> </div>
<style> <style>
.quoted-context { .quoted-context {
border-left: 2px solid var(--fog-accent, #64748b); border-left: 2px solid var(--fog-accent, #64748b);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
} }
:global(.dark) .quoted-context { :global(.dark) .quoted-context {
border-left-color: var(--fog-dark-accent, #64748b); border-left-color: var(--fog-dark-accent, #64748b);
} }
.quoted-context-content {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-start;
gap: 0.5rem;
flex-wrap: wrap;
}
.quoted-context-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-highlight, #f3f4f6);
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-highlight, #374151);
border-color: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #f9fafb);
}
.quoted-preview-container {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.quoted-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
line-height: 1.4;
}
.quoted-kind-info {
font-size: 0.75rem;
opacity: 0.7;
font-style: italic;
}
:global(.highlight-quoted) { :global(.highlight-quoted) {
outline: 2px solid var(--fog-accent, #64748b); outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px; outline-offset: 2px;

6
src/lib/components/content/ReferencedEventPreview.svelte

@ -148,7 +148,11 @@
} }
// Strip markdown and get plain text // Strip markdown and get plain text
const plaintext = stripMarkdown(event.content); 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, 250) + (plaintext.length > 250 ? '...' : ''); return plaintext.slice(0, 250) + (plaintext.length > 250 ? '...' : '');
} }

124
src/lib/components/content/ReplyContext.svelte

@ -3,10 +3,10 @@
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { stripMarkdown } from '../../services/text-utils.js'; import { stripMarkdown } from '../../services/text-utils.js';
import { KIND } from '../../types/kind-lookup.js'; import { KIND, getKindInfo } from '../../types/kind-lookup.js';
import { getEventLink } from '../../services/event-links.js'; import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import IconButton from '../ui/IconButton.svelte'; import Icon from '../ui/Icon.svelte';
interface Props { interface Props {
parentEvent?: NostrEvent; // Optional - if not provided, will load by parentEventId parentEvent?: NostrEvent; // Optional - if not provided, will load by parentEventId
@ -22,6 +22,7 @@
let loadedParentEvent = $state<NostrEvent | null>(null); let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false); let loadingParent = $state(false);
let lastLoadAttemptId = $state<string | null>(null); // Track which event ID we tried to load let lastLoadAttemptId = $state<string | null>(null); // Track which event ID we tried to load
let lastParentEventId = $state<string | undefined>(undefined); // Track last parentEventId to detect changes
// Derive the effective parent event: prefer provided, fall back to loaded // Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent); let parentEvent = $derived(providedParentEvent || loadedParentEvent);
@ -32,9 +33,17 @@
// If provided parent event is available, use it and clear loaded state // If provided parent event is available, use it and clear loaded state
loadedParentEvent = null; loadedParentEvent = null;
lastLoadAttemptId = null; lastLoadAttemptId = null;
lastParentEventId = undefined;
return; return;
} }
// If parentEventId changed, reset the load attempt flag and loaded event
if (parentEventId !== lastParentEventId) {
lastLoadAttemptId = null;
loadedParentEvent = null;
lastParentEventId = parentEventId;
}
// Only use parentEventId prop, not the derived parentEvent.id // Only use parentEventId prop, not the derived parentEvent.id
// This prevents reactive loops // This prevents reactive loops
const eventIdToLoad = parentEventId; const eventIdToLoad = parentEventId;
@ -121,9 +130,46 @@
if (!parentEvent) { if (!parentEvent) {
return loadingParent ? '' : 'Parent event not found'; return loadingParent ? '' : 'Parent event not found';
} }
// Create preview from parent (first 100 chars, plaintext with markdown stripped)
const plaintext = stripMarkdown(parentEvent.content); // If content exists, use it
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); if (parentEvent.content && parentEvent.content.trim()) {
let plaintext = stripMarkdown(parentEvent.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 = parentEvent.tags.find(t => t[0] === 'title' && t[1])?.[1];
if (titleTag && titleTag.trim()) {
return titleTag.trim();
}
const summaryTag = parentEvent.tags.find(t => t[0] === 'summary' && t[1])?.[1];
if (summaryTag && summaryTag.trim()) {
return summaryTag.trim();
}
const descriptionTag = parentEvent.tags.find(t => t[0] === 'description' && t[1])?.[1];
if (descriptionTag && descriptionTag.trim()) {
return descriptionTag.trim();
}
const altTag = parentEvent.tags.find(t => t[0] === 'alt' && t[1])?.[1];
if (altTag && altTag.trim()) {
return altTag.trim();
}
// Fallback: show kind
return `Kind ${parentEvent.kind}`;
}
function getParentKindInfo(): string {
if (!parentEvent) return '';
const kindInfo = getKindInfo(parentEvent.kind);
return `Kind ${kindInfo.number}: ${kindInfo.description}`;
} }
</script> </script>
@ -136,21 +182,26 @@
{#if loadingParent} {#if loadingParent}
<span class="opacity-70">Loading...</span> <span class="opacity-70">Loading...</span>
{:else if parentEvent} {:else if parentEvent}
<div class="reply-preview-container">
<span class="reply-preview">{getParentPreview()}</span> <span class="reply-preview">{getParentPreview()}</span>
<span class="reply-kind-info">{getParentKindInfo()}</span>
</div>
{:else} {:else}
<span class="opacity-70">Parent event not found</span> <span class="opacity-70">Parent event not found</span>
{/if} {/if}
</div> </div>
{#if parentEvent} {#if parentEvent}
<div class="reply-context-actions"> <div class="reply-context-actions">
<IconButton <button
icon="eye" class="view-button"
label="View original post"
size={14}
onclick={() => { onclick={() => {
goto(getEventLink(parentEvent!)); goto(getEventLink(parentEvent!));
}} }}
/> aria-label="View original post"
title="View original post"
>
<Icon name="eye" size={14} />
</button>
</div> </div>
{/if} {/if}
</div> </div>
@ -177,18 +228,67 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.reply-preview { .reply-preview-container {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.reply-preview {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
line-height: 1.4;
}
.reply-kind-info {
font-size: 0.75rem;
opacity: 0.7;
font-style: italic;
} }
.reply-context-actions { .reply-context-actions {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; 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-highlight, #f3f4f6);
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-highlight, #374151);
border-color: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #f9fafb);
} }
:global(.highlight-parent) { :global(.highlight-parent) {

66
src/lib/components/modals/EventJsonModal.svelte

@ -100,6 +100,14 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
padding: 1rem;
}
@media (max-width: 640px) {
.modal-overlay {
padding: 0;
align-items: flex-end;
}
} }
.modal-content { .modal-content {
@ -114,6 +122,23 @@
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
} }
@media (max-width: 768px) {
.modal-content {
width: 95%;
max-height: 85vh;
border-radius: 6px;
}
}
@media (max-width: 640px) {
.modal-content {
width: 100%;
max-height: 100vh;
border-radius: 0;
margin: 0;
}
}
:global(.dark) .modal-content { :global(.dark) .modal-content {
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
@ -126,6 +151,17 @@
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
@media (max-width: 640px) {
.modal-header {
padding: 0.75rem;
}
.modal-header h2 {
font-size: 1.125rem;
}
} }
:global(.dark) .modal-header { :global(.dark) .modal-header {
@ -179,6 +215,23 @@
resize: vertical; resize: vertical;
} }
@media (max-width: 768px) {
.json-textarea {
min-height: 300px;
font-size: 0.8125rem;
padding: 0.5rem;
}
}
@media (max-width: 640px) {
.json-textarea {
min-height: calc(100vh - 200px);
font-size: 0.75rem;
padding: 0.5rem;
border-radius: 0;
}
}
:global(.dark) .json-textarea { :global(.dark) .json-textarea {
background: var(--fog-dark-bg, #0f172a); background: var(--fog-dark-bg, #0f172a);
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
@ -191,6 +244,19 @@
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
@media (max-width: 640px) {
.modal-footer {
padding: 0.75rem;
flex-wrap: wrap;
}
.modal-footer button {
flex: 1;
min-width: 0;
}
} }
:global(.dark) .modal-footer { :global(.dark) .modal-footer {

232
src/lib/components/ui/Pagination.svelte

@ -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>

113
src/lib/components/write/AdvancedEditor.svelte

@ -16,6 +16,7 @@
import EmojiPicker from '../content/EmojiPicker.svelte'; import EmojiPicker from '../content/EmojiPicker.svelte';
import MarkdownRenderer from '../content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../content/MarkdownRenderer.svelte';
import MediaAttachments from '../content/MediaAttachments.svelte'; import MediaAttachments from '../content/MediaAttachments.svelte';
import MediaViewer from '../content/MediaViewer.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
@ -46,6 +47,22 @@
let previewEvent = $state<NostrEvent | null>(null); let previewEvent = $state<NostrEvent | null>(null);
let eventJson = $state('{}'); let eventJson = $state('{}');
// Media viewer state for preview
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state<string | null>(null);
function handleMediaUrlClick(url: string, e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
mediaViewerUrl = url;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
}
// Generate unique ID for file input // Generate unique ID for file input
const fileInputId = `advanced-editor-file-upload-${Math.random().toString(36).substring(7)}`; const fileInputId = `advanced-editor-file-upload-${Math.random().toString(36).substring(7)}`;
@ -1058,7 +1075,7 @@
</div> </div>
<div class="modal-body preview-body"> <div class="modal-body preview-body">
{#if previewEvent && previewContent} {#if previewEvent && previewContent}
<MediaAttachments event={previewEvent} /> <MediaAttachments event={previewEvent} onMediaClick={handleMediaUrlClick} />
<MarkdownRenderer content={previewContent} event={previewEvent} /> <MarkdownRenderer content={previewContent} event={previewEvent} />
{:else if editorView && editorView.state.doc.toString().trim()} {:else if editorView && editorView.state.doc.toString().trim()}
<p class="text-muted">Loading preview...</p> <p class="text-muted">Loading preview...</p>
@ -1123,6 +1140,10 @@
</div> </div>
{/if} {/if}
{#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if}
<style> <style>
.advanced-editor-modal { .advanced-editor-modal {
position: fixed; position: fixed;
@ -1139,6 +1160,13 @@
padding: 1rem; padding: 1rem;
} }
@media (max-width: 640px) {
.modal-overlay {
padding: 0;
align-items: flex-end;
}
}
.editor-container { .editor-container {
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
@ -1339,6 +1367,23 @@
overflow: hidden; overflow: hidden;
} }
@media (max-width: 768px) {
.modal-content {
max-width: 95vw;
max-height: 85vh;
border-radius: 6px;
}
}
@media (max-width: 640px) {
.modal-content {
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
margin: 0;
}
}
:global(.dark) .modal-content { :global(.dark) .modal-content {
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
@ -1351,6 +1396,17 @@
align-items: center; align-items: center;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
@media (max-width: 640px) {
.modal-header {
padding: 0.75rem;
}
.modal-header h2 {
font-size: 1.125rem;
}
} }
:global(.dark) .modal-header { :global(.dark) .modal-header {
@ -1374,12 +1430,32 @@
padding: 1.5rem; padding: 1.5rem;
} }
@media (max-width: 640px) {
.modal-body {
padding: 0.75rem;
max-height: calc(100vh - 200px);
}
}
.modal-footer { .modal-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 0.75rem; gap: 0.75rem;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb); border-top: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
@media (max-width: 640px) {
.modal-footer {
padding: 0.75rem;
flex-wrap: wrap;
}
.modal-footer button {
flex: 1;
min-width: 0;
}
} }
:global(.dark) .modal-footer { :global(.dark) .modal-footer {
@ -1424,6 +1500,22 @@
max-height: 60vh; max-height: 60vh;
} }
@media (max-width: 768px) {
.json-preview {
padding: 0.75rem;
font-size: 0.8125rem;
}
}
@media (max-width: 640px) {
.json-preview {
padding: 0.5rem;
font-size: 0.75rem;
border-radius: 0.25rem;
max-height: calc(100vh - 200px);
}
}
:global(.dark) .json-preview { :global(.dark) .json-preview {
background: var(--fog-dark-highlight, #374151); background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #f9fafb);
@ -1433,11 +1525,30 @@
max-width: 800px; max-width: 800px;
} }
@media (max-width: 768px) {
.preview-modal {
max-width: 95%;
}
}
@media (max-width: 640px) {
.preview-modal {
max-width: 100%;
}
}
.preview-body { .preview-body {
max-height: 70vh; max-height: 70vh;
overflow-y: auto; overflow-y: auto;
} }
@media (max-width: 640px) {
.preview-body {
max-height: calc(100vh - 200px);
padding: 1rem;
}
}
.text-muted { .text-muted {
color: var(--fog-text-light, #52667a); color: var(--fog-text-light, #52667a);
font-style: italic; font-style: italic;

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

@ -8,6 +8,7 @@
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte'; import PublicationStatusModal from '../modals/PublicationStatusModal.svelte';
import MarkdownRenderer from '../content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../content/MarkdownRenderer.svelte';
import MediaAttachments from '../content/MediaAttachments.svelte'; import MediaAttachments from '../content/MediaAttachments.svelte';
import MediaViewer from '../content/MediaViewer.svelte';
import RichTextEditor from '../content/RichTextEditor.svelte'; import RichTextEditor from '../content/RichTextEditor.svelte';
import AdvancedEditor from './AdvancedEditor.svelte'; import AdvancedEditor from './AdvancedEditor.svelte';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js'; import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
@ -97,6 +98,22 @@
let previewContent = $state<string>(''); let previewContent = $state<string>('');
let previewEvent = $state<NostrEvent | null>(null); let previewEvent = $state<NostrEvent | null>(null);
let showExampleModal = $state(false); let showExampleModal = $state(false);
// Media viewer state for preview
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state<string | null>(null);
function handleMediaUrlClick(url: string, e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
mediaViewerUrl = url;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
}
let showAdvancedEditor = $state(false); let showAdvancedEditor = $state(false);
let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null); let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
@ -802,7 +819,7 @@
</div> </div>
{/if} {/if}
{/if} {/if}
<MediaAttachments event={previewEvent} /> <MediaAttachments event={previewEvent} onMediaClick={handleMediaUrlClick} />
<MarkdownRenderer content={previewContent} event={previewEvent} /> <MarkdownRenderer content={previewContent} event={previewEvent} />
{:else if content.trim() || uploadedFiles.length > 0} {:else if content.trim() || uploadedFiles.length > 0}
<p class="text-muted">Loading preview...</p> <p class="text-muted">Loading preview...</p>
@ -820,6 +837,10 @@
</div> </div>
{/if} {/if}
{#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if}
<!-- Example JSON Modal --> <!-- Example JSON Modal -->
{#if showExampleModal} {#if showExampleModal}
<div <div
@ -1425,6 +1446,14 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
padding: 1rem;
}
@media (max-width: 640px) {
.modal-overlay {
padding: 0;
align-items: flex-end;
}
} }
.modal-content { .modal-content {
@ -1436,6 +1465,25 @@
max-height: 80vh; max-height: 80vh;
overflow: auto; overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
@media (max-width: 768px) {
.modal-content {
width: 95%;
max-height: 85vh;
border-radius: 6px;
}
}
@media (max-width: 640px) {
.modal-content {
width: 100%;
max-height: 100vh;
border-radius: 0;
margin: 0;
}
} }
:global(.dark) .modal-content { :global(.dark) .modal-content {
@ -1450,6 +1498,17 @@
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
border-bottom: 1px solid #cbd5e1; border-bottom: 1px solid #cbd5e1;
flex-shrink: 0;
}
@media (max-width: 640px) {
.modal-header {
padding: 0.75rem;
}
.modal-header h2 {
font-size: 1.125rem;
}
} }
:global(.dark) .modal-header { :global(.dark) .modal-header {
@ -1495,6 +1554,14 @@
padding: 1rem; padding: 1rem;
max-height: 60vh; max-height: 60vh;
overflow: auto; overflow: auto;
flex: 1;
}
@media (max-width: 640px) {
.modal-body {
padding: 0.75rem;
max-height: calc(100vh - 200px);
}
} }
.json-preview { .json-preview {
@ -1509,14 +1576,47 @@
margin: 0; margin: 0;
} }
@media (max-width: 768px) {
.json-preview {
padding: 0.75rem;
font-size: 0.8125rem;
}
}
@media (max-width: 640px) {
.json-preview {
padding: 0.5rem;
font-size: 0.75rem;
border-radius: 0.25rem;
}
}
.preview-modal { .preview-modal {
max-width: 900px; max-width: 900px;
} }
@media (max-width: 768px) {
.preview-modal {
max-width: 95%;
}
}
@media (max-width: 640px) {
.preview-modal {
max-width: 100%;
}
}
.preview-body { .preview-body {
padding: 1.5rem; padding: 1.5rem;
} }
@media (max-width: 640px) {
.preview-body {
padding: 1rem;
}
}
.preview-metadata { .preview-metadata {
padding: 1rem; padding: 1rem;
background: var(--fog-highlight, #f1f5f9); background: var(--fog-highlight, #f1f5f9);
@ -1599,6 +1699,19 @@
gap: 0.5rem; gap: 0.5rem;
padding: 1rem; padding: 1rem;
border-top: 1px solid #cbd5e1; border-top: 1px solid #cbd5e1;
flex-shrink: 0;
}
@media (max-width: 640px) {
.modal-footer {
padding: 0.75rem;
flex-wrap: wrap;
}
.modal-footer button {
flex: 1;
min-width: 0;
}
} }
:global(.dark) .modal-footer { :global(.dark) .modal-footer {

23
src/lib/modules/comments/Comment.svelte

@ -2,6 +2,7 @@
import CardHeader from '../../components/layout/CardHeader.svelte'; import CardHeader from '../../components/layout/CardHeader.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte'; import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import MediaViewer from '../../components/content/MediaViewer.svelte';
import ReferencedEventPreview from '../../components/content/ReferencedEventPreview.svelte'; import ReferencedEventPreview from '../../components/content/ReferencedEventPreview.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import DiscussionVoteButtons from '../discussions/DiscussionVoteButtons.svelte'; import DiscussionVoteButtons from '../discussions/DiscussionVoteButtons.svelte';
@ -35,6 +36,22 @@
let needsExpansion = $state(false); let needsExpansion = $state(false);
let showReplyForm = $state(false); let showReplyForm = $state(false);
// Media viewer state
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state<string | null>(null);
function handleMediaUrlClick(url: string, e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
mediaViewerUrl = url;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
}
// Media kinds that should auto-render media (except on /feed) // Media kinds that should auto-render media (except on /feed)
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY];
const isMediaKind = $derived(MEDIA_KINDS.includes(comment.kind)); const isMediaKind = $derived(MEDIA_KINDS.includes(comment.kind));
@ -165,7 +182,7 @@
<div class="comment-content mb-2"> <div class="comment-content mb-2">
{#if shouldAutoRenderMedia} {#if shouldAutoRenderMedia}
<MediaAttachments event={comment} forceRender={isMediaKind} /> <MediaAttachments event={comment} forceRender={isMediaKind} onMediaClick={handleMediaUrlClick} />
{/if} {/if}
<MarkdownRenderer content={comment.content} event={comment} /> <MarkdownRenderer content={comment.content} event={comment} />
</div> </div>
@ -211,6 +228,10 @@
{/if} {/if}
</article> </article>
{#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if}
<style> <style>
.comment { .comment {
padding: 1rem; padding: 1rem;

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

@ -9,6 +9,7 @@
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte'; import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte'; import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import MediaViewer from '../../components/content/MediaViewer.svelte';
import RichTextEditor from '../../components/content/RichTextEditor.svelte'; import RichTextEditor from '../../components/content/RichTextEditor.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js'; import { KIND } from '../../types/kind-lookup.js';
@ -77,6 +78,22 @@
let previewContent = $state<string>(''); let previewContent = $state<string>('');
let previewEvent = $state<NostrEvent | null>(null); let previewEvent = $state<NostrEvent | null>(null);
let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null); let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null);
// Media viewer state for preview
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state<string | null>(null);
function handleMediaUrlClick(url: string, e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
mediaViewerUrl = url;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
}
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
let eventJson = $state('{}'); let eventJson = $state('{}');
const isLoggedIn = $derived(sessionManager.isLoggedIn()); const isLoggedIn = $derived(sessionManager.isLoggedIn());
@ -541,7 +558,7 @@
</div> </div>
<div class="modal-body preview-body"> <div class="modal-body preview-body">
{#if previewEvent && previewContent} {#if previewEvent && previewContent}
<MediaAttachments event={previewEvent} /> <MediaAttachments event={previewEvent} onMediaClick={handleMediaUrlClick} />
<MarkdownRenderer content={previewContent} event={previewEvent} /> <MarkdownRenderer content={previewContent} event={previewEvent} />
{:else if content.trim() || uploadedFiles.length > 0} {:else if content.trim() || uploadedFiles.length > 0}
<p class="text-muted">Loading preview...</p> <p class="text-muted">Loading preview...</p>
@ -555,6 +572,10 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if}
</div> </div>
{/if} {/if}
@ -583,6 +604,14 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
padding: 1rem;
}
@media (max-width: 640px) {
.modal-overlay {
padding: 0;
align-items: flex-end;
}
} }
.modal-content { .modal-content {
@ -594,6 +623,25 @@
max-height: 80vh; max-height: 80vh;
overflow: auto; overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
@media (max-width: 768px) {
.modal-content {
width: 95%;
max-height: 85vh;
border-radius: 6px;
}
}
@media (max-width: 640px) {
.modal-content {
width: 100%;
max-height: 100vh;
border-radius: 0;
margin: 0;
}
} }
:global(.dark) .modal-content { :global(.dark) .modal-content {
@ -608,6 +656,17 @@
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
border-bottom: 1px solid #cbd5e1; border-bottom: 1px solid #cbd5e1;
flex-shrink: 0;
}
@media (max-width: 640px) {
.modal-header {
padding: 0.75rem;
}
.modal-header h2 {
font-size: 1.125rem;
}
} }
:global(.dark) .modal-header { :global(.dark) .modal-header {
@ -653,6 +712,14 @@
padding: 1rem; padding: 1rem;
max-height: 60vh; max-height: 60vh;
overflow: auto; overflow: auto;
flex: 1;
}
@media (max-width: 640px) {
.modal-body {
padding: 0.75rem;
max-height: calc(100vh - 200px);
}
} }
.json-preview { .json-preview {
@ -667,20 +734,66 @@
margin: 0; margin: 0;
} }
@media (max-width: 768px) {
.json-preview {
padding: 0.75rem;
font-size: 0.8125rem;
}
}
@media (max-width: 640px) {
.json-preview {
padding: 0.5rem;
font-size: 0.75rem;
border-radius: 0.25rem;
}
}
.preview-modal { .preview-modal {
max-width: 900px; max-width: 900px;
} }
@media (max-width: 768px) {
.preview-modal {
max-width: 95%;
}
}
@media (max-width: 640px) {
.preview-modal {
max-width: 100%;
}
}
.preview-body { .preview-body {
padding: 1.5rem; padding: 1.5rem;
} }
@media (max-width: 640px) {
.preview-body {
padding: 1rem;
}
}
.modal-footer { .modal-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;
padding: 1rem; padding: 1rem;
border-top: 1px solid #cbd5e1; border-top: 1px solid #cbd5e1;
flex-shrink: 0;
}
@media (max-width: 640px) {
.modal-footer {
padding: 0.75rem;
flex-wrap: wrap;
}
.modal-footer button {
flex: 1;
min-width: 0;
}
} }
:global(.dark) .modal-footer { :global(.dark) .modal-footer {

25
src/lib/modules/discussions/DiscussionCard.svelte

@ -4,6 +4,7 @@
import DiscussionVoteButtons from './DiscussionVoteButtons.svelte'; import DiscussionVoteButtons from './DiscussionVoteButtons.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte'; import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import MediaViewer from '../../components/content/MediaViewer.svelte';
import MetadataCard from '../../components/content/MetadataCard.svelte'; import MetadataCard from '../../components/content/MetadataCard.svelte';
import EventMenu from '../../components/EventMenu.svelte'; import EventMenu from '../../components/EventMenu.svelte';
import CommentForm from '../comments/CommentForm.svelte'; import CommentForm from '../comments/CommentForm.svelte';
@ -48,6 +49,22 @@
let loadingStats = $state(true); let loadingStats = $state(true);
let expanded = $state(false); let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null); let contentElement: HTMLElement | null = $state(null);
// Media viewer state
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state<string | null>(null);
function handleMediaUrlClick(url: string, e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
mediaViewerUrl = url;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
}
let needsExpansion = $state(false); let needsExpansion = $state(false);
let lastStatsLoadEventId = $state<string | null>(null); let lastStatsLoadEventId = $state<string | null>(null);
let showReplyForm = $state(false); let showReplyForm = $state(false);
@ -220,7 +237,7 @@
<div class="post-content mb-2"> <div class="post-content mb-2">
{#if shouldAutoRenderMedia} {#if shouldAutoRenderMedia}
<MediaAttachments event={thread} forceRender={isMediaKind} /> <MediaAttachments event={thread} forceRender={isMediaKind} onMediaClick={handleMediaUrlClick} />
{/if} {/if}
<MarkdownRenderer content={thread.content} event={thread} /> <MarkdownRenderer content={thread.content} event={thread} />
</div> </div>
@ -271,7 +288,7 @@
<MetadataCard event={thread} hideTitle={true} hideSummary={true} /> <MetadataCard event={thread} hideTitle={true} hideSummary={true} />
<div class="post-content mb-2"> <div class="post-content mb-2">
<MediaAttachments event={thread} forceRender={isMediaKind} /> <MediaAttachments event={thread} forceRender={isMediaKind} onMediaClick={handleMediaUrlClick} />
<MarkdownRenderer content={thread.content} event={thread} /> <MarkdownRenderer content={thread.content} event={thread} />
</div> </div>
@ -340,6 +357,10 @@
</div> </div>
{/if} {/if}
{#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if}
<style> <style>
.thread-card { .thread-card {
max-width: var(--content-width); max-width: var(--content-width);

54
src/lib/modules/events/EventView.svelte

@ -64,15 +64,53 @@
let totalItemCount = $derived(countTotalItems(eventIndexItems)); let totalItemCount = $derived(countTotalItems(eventIndexItems));
// Check if event is metadata-only (no content to display) // Check if event is metadata-only (no content to display)
// Note: kind 30040 (Publication Index) is excluded because it should render its children // These kinds should only show metadata, not content
const METADATA_ONLY_KINDS: number[] = [
KIND.METADATA, // 0 - Profile metadata
KIND.CONTACTS, // 3 - Contact list
KIND.RELAY_LIST, // 10002 - Relay list metadata
KIND.MUTE_LIST, // 10000 - Mute list
KIND.BLOCKED_RELAYS, // 10006 - Blocked relays
KIND.FAVORITE_RELAYS, // 10012 - Favorite relays
KIND.LOCAL_RELAYS, // 10432 - Local relays
KIND.PIN_LIST, // 10001 - Pin list
KIND.BOOKMARKS, // 10003 - Bookmarks
KIND.INTEREST_LIST, // 10015 - Interest list
KIND.EMOJI_SET, // 10030 - Emoji set
KIND.EMOJI_PACK, // 30030 - Emoji pack
KIND.BADGES, // 30008 - Badges
KIND.FOLLOW_SET, // 30000 - Follow set
KIND.PAYMENT_ADDRESSES, // 10133 - Payment addresses
KIND.LABEL, // 1985 - Label
KIND.REPORT, // 1984 - Report
KIND.HTTP_AUTH, // 27235 - HTTP Auth
KIND.RSS_FEED, // 10895 - RSS Feed
KIND.REPO_ANNOUNCEMENT, // 30617 - Repository announcement (metadata only)
KIND.USER_STATUS, // 30315 - User status (metadata only, shown in profile)
];
// Kinds that should render even with empty content (they have media or special rendering)
const ALWAYS_RENDER_CONTENT_KINDS: number[] = [
KIND.PICTURE_NOTE, // 20 - Has images in imeta tags
KIND.VIDEO_NOTE, // 21 - Has videos in imeta tags
KIND.SHORT_VIDEO_NOTE, // 22 - Has videos in imeta tags
KIND.VOICE_NOTE, // 1222 - Has audio in imeta tags
KIND.VOICE_REPLY, // 1244 - Has audio in imeta tags
KIND.FILE_METADATA, // 1063 - Has files in tags
KIND.POLL, // 1068 - Has poll rendering
KIND.PUBLICATION_INDEX, // 30040 - Has children to render
KIND.ISSUE, // 1621 - Repository issues (may have empty content but have tags)
KIND.STATUS_OPEN, // 1630 - Status events
KIND.STATUS_APPLIED, // 1631
KIND.STATUS_CLOSED, // 1632
KIND.STATUS_DRAFT, // 1633
];
let isMetadataOnly = $derived(rootEvent ? ( let isMetadataOnly = $derived(rootEvent ? (
rootEvent.kind === KIND.METADATA || METADATA_ONLY_KINDS.includes(rootEvent.kind) ||
rootEvent.kind === KIND.RELAY_LIST || (rootEvent.content === '' &&
rootEvent.kind === KIND.MUTE_LIST || rootEvent.tags.length > 0 &&
rootEvent.kind === KIND.BLOCKED_RELAYS || !ALWAYS_RENDER_CONTENT_KINDS.includes(rootEvent.kind))
rootEvent.kind === KIND.LOCAL_RELAYS ||
rootEvent.kind === KIND.USER_STATUS ||
(rootEvent.content === '' && rootEvent.tags.length > 0 && rootEvent.kind !== 30040)
) : false); ) : false);
onMount(async () => { onMount(async () => {

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

@ -9,6 +9,9 @@
import { getRecentFeedEvents, getCachedReactionsForEvents } from '../../services/cache/event-cache.js'; import { getRecentFeedEvents, getCachedReactionsForEvents } from '../../services/cache/event-cache.js';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js'; import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { page } from '$app/stores';
import Pagination from '../../components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../utils/pagination.js';
interface Props { interface Props {
singleRelay?: string; singleRelay?: string;
@ -74,6 +77,14 @@
// Use all events directly (no filtering) // Use all events directly (no filtering)
let events = $derived(allEvents); let events = $derived(allEvents);
// Pagination
let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedEvents = $derived(
events.length > ITEMS_PER_PAGE
? getPaginatedItems(events, currentPage, ITEMS_PER_PAGE)
: events
);
// Get preloaded referenced event for a post (from e, a, or q tag) // Get preloaded referenced event for a post (from e, a, or q tag)
function getReferencedEventForPost(event: NostrEvent): NostrEvent | null { function getReferencedEventForPost(event: NostrEvent): NostrEvent | null {
// Check q tag first // Check q tag first
@ -684,7 +695,7 @@
<!-- Note: Virtualizer is disabled due to compatibility issues --> <!-- Note: Virtualizer is disabled due to compatibility issues -->
<!-- Fallback to regular rendering --> <!-- Fallback to regular rendering -->
<div class="feed-posts"> <div class="feed-posts">
{#each events as event (event.id)} {#each paginatedEvents as event (event.id)}
{@const referencedEvent = getReferencedEventForPost(event)} {@const referencedEvent = getReferencedEventForPost(event)}
{@const preloadedReactions = preloadedReactionsMap.get(event.id) ?? []} {@const preloadedReactions = preloadedReactionsMap.get(event.id) ?? []}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} preloadedReactions={preloadedReactions} /> <FeedPost post={event} preloadedReferencedEvent={referencedEvent} preloadedReactions={preloadedReactions} />
@ -693,7 +704,7 @@
{:else} {:else}
<!-- Fallback to regular rendering for small feeds or when virtualizer not loaded --> <!-- Fallback to regular rendering for small feeds or when virtualizer not loaded -->
<div class="feed-posts"> <div class="feed-posts">
{#each events as event (event.id)} {#each paginatedEvents as event (event.id)}
{@const referencedEvent = getReferencedEventForPost(event)} {@const referencedEvent = getReferencedEventForPost(event)}
{@const preloadedReactions = preloadedReactionsMap.get(event.id) ?? []} {@const preloadedReactions = preloadedReactionsMap.get(event.id) ?? []}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} preloadedReactions={preloadedReactions} /> <FeedPost post={event} preloadedReferencedEvent={referencedEvent} preloadedReactions={preloadedReactions} />
@ -701,6 +712,9 @@
</div> </div>
{/if} {/if}
{#if events.length > ITEMS_PER_PAGE}
<Pagination totalItems={events.length} itemsPerPage={ITEMS_PER_PAGE} />
{:else}
<div class="load-more-section"> <div class="load-more-section">
<button <button
onclick={loadOlderEvents} onclick={loadOlderEvents}
@ -710,6 +724,7 @@
{loadingMore ? 'Loading...' : 'See more events'} {loadingMore ? 'Loading...' : 'See more events'}
</button> </button>
</div> </div>
{/if}
{/if} {/if}
</div> </div>

64
src/lib/modules/feed/FeedPost.svelte

@ -71,7 +71,7 @@
}); });
// Media kinds that should auto-render media (except on /feed) // Media kinds that should auto-render media (except on /feed)
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY, KIND.FILE_METADATA];
const isMediaKind = $derived(MEDIA_KINDS.includes(post.kind)); const isMediaKind = $derived(MEDIA_KINDS.includes(post.kind));
const isOnFeedPage = $derived($page.url.pathname === '/feed'); const isOnFeedPage = $derived($page.url.pathname === '/feed');
const isOnEventPage = $derived($page.url.pathname.startsWith('/event/')); const isOnEventPage = $derived($page.url.pathname.startsWith('/event/'));
@ -888,25 +888,6 @@
> >
{#if fullView} {#if fullView}
<!-- Full view: show complete content with markdown, media, profile pics, reactions --> <!-- Full view: show complete content with markdown, media, profile pics, reactions -->
{#if shouldShowReply()}
<ReplyContext
parentEvent={providedParentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
parentEventTagType={getReplyTagType() || undefined}
targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined}
/>
{/if}
{#if hasQuotedEvent()}
<QuotedContext
quotedEvent={providedQuotedEvent}
quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
/>
{/if}
<MetadataCard event={post} hideTitle={true} hideSummary={true} />
<CardHeader <CardHeader
pubkey={post.pubkey} pubkey={post.pubkey}
relativeTime={getRelativeTime()} relativeTime={getRelativeTime()}
@ -934,6 +915,25 @@
{/snippet} {/snippet}
</CardHeader> </CardHeader>
{#if shouldShowReply()}
<ReplyContext
parentEvent={providedParentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
parentEventTagType={getReplyTagType() || undefined}
targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined}
/>
{/if}
{#if hasQuotedEvent()}
<QuotedContext
quotedEvent={providedQuotedEvent}
quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
/>
{/if}
<MetadataCard event={post} hideTitle={true} hideSummary={true} />
{#if getTitle()} {#if getTitle()}
<div class="event-title-row"> <div class="event-title-row">
<h2 class="event-title">{getTitle()}</h2> <h2 class="event-title">{getTitle()}</h2>
@ -946,14 +946,17 @@
{/if} {/if}
<div class="post-content mb-2"> <div class="post-content mb-2">
{#if (shouldAutoRenderMedia || fullView) && (post.content && post.content.trim())} {#if (shouldAutoRenderMedia || fullView) && (post.content && post.content.trim() || isMediaKind)}
<MediaAttachments event={post} forceRender={isMediaKind} /> <MediaAttachments event={post} forceRender={isMediaKind} onMediaClick={(url, e) => handleMediaUrlClick(e, url)} />
{/if} {/if}
{#if post.kind === KIND.POLL && fullView} {#if post.kind === KIND.POLL && fullView}
<PollCard pollEvent={post} /> <PollCard pollEvent={post} />
{:else} {:else if post.content && post.content.trim()}
{@const mediaAttachmentUrls = getMediaAttachmentUrls()} {@const mediaAttachmentUrls = getMediaAttachmentUrls()}
<MarkdownRenderer content={post.content} event={post} excludeMediaUrls={mediaAttachmentUrls} /> <MarkdownRenderer content={post.content} event={post} excludeMediaUrls={mediaAttachmentUrls} />
{:else if !isMediaKind && post.kind !== KIND.POLL}
<!-- Show empty content message for non-media kinds without content -->
<p class="text-fog-text-light dark:text-fog-dark-text-light italic text-sm">No content</p>
{/if} {/if}
</div> </div>
@ -1014,7 +1017,7 @@
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}> <div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}>
{#if shouldAutoRenderMedia} {#if shouldAutoRenderMedia}
<MediaAttachments event={post} forceRender={isMediaKind} /> <MediaAttachments event={post} forceRender={isMediaKind} onMediaClick={(url, e) => handleMediaUrlClick(e, url)} />
{/if} {/if}
<p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap"> <p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap">
{#each parseContentWithNIP21Links() as segment} {#each parseContentWithNIP21Links() as segment}
@ -1180,9 +1183,22 @@
flex-direction: column; flex-direction: column;
} }
/* Ensure header and other non-content elements are always fully visible when collapsed */
.Feed-post.collapsed :global(.card-header),
.Feed-post.collapsed .event-title-row,
.Feed-post.collapsed .event-summary-row,
.Feed-post.collapsed :global(.reply-context),
.Feed-post.collapsed :global(.quoted-context),
.Feed-post.collapsed :global(.metadata-card) {
flex-shrink: 0;
min-height: fit-content;
overflow: visible;
}
.Feed-post.collapsed .post-content.collapsed-content { .Feed-post.collapsed .post-content.collapsed-content {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden;
} }
:global(.dark) .Feed-post { :global(.dark) .Feed-post {

105
src/lib/modules/rss/RSSCommentForm.svelte

@ -8,6 +8,7 @@
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte'; import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte'; import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import MediaViewer from '../../components/content/MediaViewer.svelte';
import RichTextEditor from '../../components/content/RichTextEditor.svelte'; import RichTextEditor from '../../components/content/RichTextEditor.svelte';
import { KIND } from '../../types/kind-lookup.js'; import { KIND } from '../../types/kind-lookup.js';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js'; import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
@ -76,6 +77,22 @@
let previewContent = $state<string>(''); let previewContent = $state<string>('');
let previewEvent = $state<NostrEvent | null>(null); let previewEvent = $state<NostrEvent | null>(null);
let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null); let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null);
// Media viewer state for preview
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state<string | null>(null);
function handleMediaUrlClick(url: string, e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
mediaViewerUrl = url;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
}
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
let eventJson = $state('{}'); let eventJson = $state('{}');
const isLoggedIn = $derived(sessionManager.isLoggedIn()); const isLoggedIn = $derived(sessionManager.isLoggedIn());
@ -415,7 +432,7 @@
</div> </div>
<div class="modal-body preview-body"> <div class="modal-body preview-body">
{#if previewEvent && previewContent} {#if previewEvent && previewContent}
<MediaAttachments event={previewEvent} /> <MediaAttachments event={previewEvent} onMediaClick={handleMediaUrlClick} />
<MarkdownRenderer content={previewContent} event={previewEvent} /> <MarkdownRenderer content={previewContent} event={previewEvent} />
{:else if content.trim() || uploadedFiles.length > 0} {:else if content.trim() || uploadedFiles.length > 0}
<p class="text-muted">Loading preview...</p> <p class="text-muted">Loading preview...</p>
@ -429,6 +446,10 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if}
</div> </div>
<style> <style>
@ -500,6 +521,13 @@
padding: 1rem; padding: 1rem;
} }
@media (max-width: 640px) {
.modal-overlay {
padding: 0;
align-items: flex-end;
}
}
.modal-content { .modal-content {
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
border-radius: 0.5rem; border-radius: 0.5rem;
@ -512,6 +540,23 @@
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
} }
@media (max-width: 768px) {
.modal-content {
max-width: 95vw;
max-height: 85vh;
border-radius: 6px;
}
}
@media (max-width: 640px) {
.modal-content {
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
margin: 0;
}
}
:global(.dark) .modal-content { :global(.dark) .modal-content {
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
} }
@ -520,12 +565,35 @@
max-width: 1000px; max-width: 1000px;
} }
@media (max-width: 768px) {
.preview-modal {
max-width: 95%;
}
}
@media (max-width: 640px) {
.preview-modal {
max-width: 100%;
}
}
.modal-header { .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
@media (max-width: 640px) {
.modal-header {
padding: 0.75rem;
}
.modal-header h2 {
font-size: 1.125rem;
}
} }
:global(.dark) .modal-header { :global(.dark) .modal-header {
@ -580,6 +648,13 @@
max-height: 60vh; max-height: 60vh;
} }
@media (max-width: 640px) {
.preview-body {
max-height: calc(100vh - 200px);
padding: 1rem;
}
}
.json-preview { .json-preview {
background: var(--fog-highlight, #f3f4f6); background: var(--fog-highlight, #f3f4f6);
padding: 1rem; padding: 1rem;
@ -591,6 +666,21 @@
word-wrap: break-word; word-wrap: break-word;
} }
@media (max-width: 768px) {
.json-preview {
padding: 0.75rem;
font-size: 0.8125rem;
}
}
@media (max-width: 640px) {
.json-preview {
padding: 0.5rem;
font-size: 0.75rem;
border-radius: 0.25rem;
}
}
:global(.dark) .json-preview { :global(.dark) .json-preview {
background: var(--fog-dark-highlight, #374151); background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #f9fafb);
@ -602,6 +692,19 @@
gap: 0.5rem; gap: 0.5rem;
padding: 1rem; padding: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb); border-top: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
@media (max-width: 640px) {
.modal-footer {
padding: 0.75rem;
flex-wrap: wrap;
}
.modal-footer button {
flex: 1;
min-width: 0;
}
} }
:global(.dark) .modal-footer { :global(.dark) .modal-footer {

30
src/lib/utils/pagination.ts

@ -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;
}

13
src/routes/bookmarks/+page.svelte

@ -13,7 +13,10 @@
import { KIND } from '../../lib/types/kind-lookup.js'; import { KIND } from '../../lib/types/kind-lookup.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Icon from '../../lib/components/ui/Icon.svelte'; import Icon from '../../lib/components/ui/Icon.svelte';
import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
interface BookmarkOrHighlight { interface BookmarkOrHighlight {
event: NostrEvent; event: NostrEvent;
@ -28,6 +31,14 @@
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null }); let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
let typeFilter = $state<'all' | 'bookmark' | 'highlight'>('all'); let typeFilter = $state<'all' | 'bookmark' | 'highlight'>('all');
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] }); let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
// Pagination for search results
let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedSearchEvents = $derived(
searchResults.events.length > ITEMS_PER_PAGE
? getPaginatedItems(searchResults.events, currentPage, ITEMS_PER_PAGE)
: searchResults.events
);
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null); let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
let seeMyOwn = $state(true); // Checked by default let seeMyOwn = $state(true); // Checked by default
let hasLoadedOnce = $state(false); // Track if we've loaded at least once let hasLoadedOnce = $state(false); // Track if we've loaded at least once
@ -576,7 +587,7 @@
<div class="results-group"> <div class="results-group">
<h3>Events ({searchResults.events.length})</h3> <h3>Events ({searchResults.events.length})</h3>
<div class="event-results"> <div class="event-results">
{#each searchResults.events as event} {#each paginatedSearchEvents as event}
{#if event.kind === KIND.HIGHLIGHTED_ARTICLE} {#if event.kind === KIND.HIGHLIGHTED_ARTICLE}
<div class="event-result-card"> <div class="event-result-card">
<HighlightCard highlight={event} onOpenEvent={(e) => goto(`/event/${e.id}`)} /> <HighlightCard highlight={event} onOpenEvent={(e) => goto(`/event/${e.id}`)} />

13
src/routes/cache/+page.svelte vendored

@ -18,6 +18,9 @@
import { triggerArchive } from '../../lib/services/cache/archive-scheduler.js'; import { triggerArchive } from '../../lib/services/cache/archive-scheduler.js';
import { KIND, getKindInfo } from '../../lib/types/kind-lookup.js'; import { KIND, getKindInfo } from '../../lib/types/kind-lookup.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { page } from '$app/stores';
import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
import { sessionManager } from '../../lib/services/auth/session-manager.js'; import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import type { NostrEvent } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js';
@ -25,6 +28,14 @@
let stats = $state<CacheStats | null>(null); let stats = $state<CacheStats | null>(null);
let archiveStats = $state<{ totalArchived: number; totalSize: number; oldestArchived: number | null } | null>(null); let archiveStats = $state<{ totalArchived: number; totalSize: number; oldestArchived: number | null } | null>(null);
let events = $state<CachedEvent[]>([]); let events = $state<CachedEvent[]>([]);
// Pagination
let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedEvents = $derived(
events.length > ITEMS_PER_PAGE
? getPaginatedItems(events, currentPage, ITEMS_PER_PAGE)
: events
);
let loading = $state(true); let loading = $state(true);
let loadingMore = $state(false); let loadingMore = $state(false);
let hasMore = $state(true); let hasMore = $state(true);
@ -669,7 +680,7 @@
</div> </div>
{:else} {:else}
<div class="events-list"> <div class="events-list">
{#each events as event (event.id)} {#each paginatedEvents as event (event.id)}
<div class="event-card"> <div class="event-card">
<div class="event-header"> <div class="event-header">
<div class="event-info"> <div class="event-info">

16
src/routes/discussions/+page.svelte

@ -8,13 +8,24 @@
import { KIND } from '../../lib/types/kind-lookup.js'; import { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores';
import Icon from '../../lib/components/ui/Icon.svelte'; import Icon from '../../lib/components/ui/Icon.svelte';
import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null }); let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] }); let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null); let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
let discussionListComponent: { sortBy: 'newest' | 'active' | 'upvoted'; showOlder: boolean } | null = $state(null); let discussionListComponent: { sortBy: 'newest' | 'active' | 'upvoted'; showOlder: boolean } | null = $state(null);
// Pagination for search results
let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedSearchEvents = $derived(
searchResults.events.length > ITEMS_PER_PAGE
? getPaginatedItems(searchResults.events, currentPage, ITEMS_PER_PAGE)
: searchResults.events
);
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) { function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result; filterResult = result;
} }
@ -108,12 +119,15 @@
<div class="results-group"> <div class="results-group">
<h3>Events ({searchResults.events.length})</h3> <h3>Events ({searchResults.events.length})</h3>
<div class="event-results"> <div class="event-results">
{#each searchResults.events as event} {#each paginatedSearchEvents as event}
<a href="/event/{event.id}" class="event-result-card"> <a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} /> <FeedPost post={event} fullView={false} />
</a> </a>
{/each} {/each}
</div> </div>
{#if searchResults.events.length > ITEMS_PER_PAGE}
<Pagination totalItems={searchResults.events.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
</div> </div>
{/if} {/if}
</div> </div>

26
src/routes/find/+page.svelte

@ -10,7 +10,10 @@
import { relayManager } from '../../lib/services/nostr/relay-manager.js'; import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import type { NostrEvent } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
let selectedKind = $state<number | null>(null); let selectedKind = $state<number | null>(null);
let selectedKindString = $state<string>(''); let selectedKindString = $state<string>('');
@ -19,6 +22,19 @@
let cacheResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] }); let cacheResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
// Map to track which relay each event came from // Map to track which relay each event came from
const eventRelayMap = new Map<string, string>(); const eventRelayMap = new Map<string, string>();
// Pagination
let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedCacheEvents = $derived(
cacheResults.events.length > ITEMS_PER_PAGE
? getPaginatedItems(cacheResults.events, currentPage, ITEMS_PER_PAGE)
: cacheResults.events
);
let paginatedSearchEvents = $derived(
searchResults.events.length > ITEMS_PER_PAGE
? getPaginatedItems(searchResults.events, currentPage, ITEMS_PER_PAGE)
: searchResults.events
);
let searching = $state(false); let searching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = $state(null); let searchTimeout: ReturnType<typeof setTimeout> | null = $state(null);
@ -969,7 +985,7 @@
{#if cacheResults.events.length > 0} {#if cacheResults.events.length > 0}
<div class="event-results"> <div class="event-results">
{#each cacheResults.events as event} {#each paginatedCacheEvents as event}
<div class="event-result-card"> <div class="event-result-card">
<a href="/event/{event.id}" class="event-result-link"> <a href="/event/{event.id}" class="event-result-link">
<FeedPost post={event} fullView={false} /> <FeedPost post={event} fullView={false} />
@ -980,6 +996,9 @@
</div> </div>
{/each} {/each}
</div> </div>
{#if cacheResults.events.length > ITEMS_PER_PAGE}
<Pagination totalItems={cacheResults.events.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
{/if} {/if}
</div> </div>
{/if} {/if}
@ -1004,7 +1023,7 @@
{#if searchResults.events.length > 0} {#if searchResults.events.length > 0}
<div class="event-results"> <div class="event-results">
{#each searchResults.events as event} {#each paginatedSearchEvents as event}
<div class="event-result-card"> <div class="event-result-card">
<a href="/event/{event.id}" class="event-result-link"> <a href="/event/{event.id}" class="event-result-link">
<FeedPost post={event} fullView={false} /> <FeedPost post={event} fullView={false} />
@ -1021,6 +1040,9 @@
</div> </div>
{/each} {/each}
</div> </div>
{#if searchResults.events.length > ITEMS_PER_PAGE}
<Pagination totalItems={searchResults.events.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
{/if} {/if}
</div> </div>
{/if} {/if}

44
src/routes/highlights/+page.svelte

@ -10,7 +10,10 @@
import type { NostrEvent } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js';
import { KIND } from '../../lib/types/kind-lookup.js'; import { KIND } from '../../lib/types/kind-lookup.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Icon from '../../lib/components/ui/Icon.svelte'; import Icon from '../../lib/components/ui/Icon.svelte';
import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
interface HighlightItem { interface HighlightItem {
event: NostrEvent; event: NostrEvent;
@ -20,9 +23,17 @@
let allItems = $state<HighlightItem[]>([]); let allItems = $state<HighlightItem[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let currentPage = $state(1); let highlightsCurrentPage = $state(1);
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null }); let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] }); let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
// Pagination for search results
let searchCurrentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedSearchEvents = $derived(
searchResults.events.length > ITEMS_PER_PAGE
? getPaginatedItems(searchResults.events, searchCurrentPage, ITEMS_PER_PAGE)
: searchResults.events
);
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null); let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
let hasLoadedOnce = $state(false); let hasLoadedOnce = $state(false);
@ -59,7 +70,7 @@
// Computed: get events for current page // Computed: get events for current page
let paginatedItems = $derived.by(() => { let paginatedItems = $derived.by(() => {
const start = (currentPage - 1) * itemsPerPage; const start = (highlightsCurrentPage - 1) * itemsPerPage;
const end = start + itemsPerPage; const end = start + itemsPerPage;
return filteredItems.slice(start, end); return filteredItems.slice(start, end);
}); });
@ -105,7 +116,7 @@
await processHighlightEvents(cachedHighlights); await processHighlightEvents(cachedHighlights);
loading = false; // Show cached content immediately loading = false; // Show cached content immediately
error = null; error = null;
currentPage = 1; highlightsCurrentPage = 1;
} else { } else {
loading = true; // Only show loading if no cache loading = true; // Only show loading if no cache
} }
@ -118,7 +129,7 @@
if (allItems.length === 0) { if (allItems.length === 0) {
allItems = []; allItems = [];
} }
currentPage = 1; highlightsCurrentPage = 1;
// Start fetching fresh data in the background (non-blocking) // Start fetching fresh data in the background (non-blocking)
// This enhances the cached content progressively // This enhances the cached content progressively
@ -198,7 +209,7 @@
// Reset to page 1 when filter changes // Reset to page 1 when filter changes
$effect(() => { $effect(() => {
filterResult; filterResult;
currentPage = 1; highlightsCurrentPage = 1;
}); });
onMount(async () => { onMount(async () => {
@ -245,9 +256,9 @@
<div class="pagination pagination-top"> <div class="pagination pagination-top">
<button <button
class="pagination-button" class="pagination-button"
disabled={currentPage === 1} disabled={highlightsCurrentPage === 1}
onclick={() => { onclick={() => {
if (currentPage > 1) currentPage--; if (highlightsCurrentPage > 1) highlightsCurrentPage--;
}} }}
aria-label="Previous page" aria-label="Previous page"
> >
@ -256,15 +267,15 @@
<div class="pagination-info"> <div class="pagination-info">
<span class="text-fog-text dark:text-fog-dark-text"> <span class="text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages} Page {highlightsCurrentPage} of {totalPages}
</span> </span>
</div> </div>
<button <button
class="pagination-button" class="pagination-button"
disabled={currentPage === totalPages} disabled={highlightsCurrentPage === totalPages}
onclick={() => { onclick={() => {
if (currentPage < totalPages) currentPage++; if (highlightsCurrentPage < totalPages) highlightsCurrentPage++;
}} }}
aria-label="Next page" aria-label="Next page"
> >
@ -303,6 +314,9 @@
{/if} {/if}
{/each} {/each}
</div> </div>
{#if searchResults.events.length > ITEMS_PER_PAGE}
<Pagination totalItems={searchResults.events.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@ -331,9 +345,9 @@
<div class="pagination pagination-bottom"> <div class="pagination pagination-bottom">
<button <button
class="pagination-button" class="pagination-button"
disabled={currentPage === 1} disabled={highlightsCurrentPage === 1}
onclick={() => { onclick={() => {
if (currentPage > 1) currentPage--; if (highlightsCurrentPage > 1) highlightsCurrentPage--;
}} }}
aria-label="Previous page" aria-label="Previous page"
> >
@ -342,15 +356,15 @@
<div class="pagination-info"> <div class="pagination-info">
<span class="text-fog-text dark:text-fog-dark-text"> <span class="text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages} Page {highlightsCurrentPage} of {totalPages}
</span> </span>
</div> </div>
<button <button
class="pagination-button" class="pagination-button"
disabled={currentPage === totalPages} disabled={highlightsCurrentPage === totalPages}
onclick={() => { onclick={() => {
if (currentPage < totalPages) currentPage++; if (highlightsCurrentPage < totalPages) highlightsCurrentPage++;
}} }}
aria-label="Next page" aria-label="Next page"
> >

16
src/routes/lists/+page.svelte

@ -9,6 +9,9 @@
import type { NostrEvent } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
interface ListInfo { interface ListInfo {
kind: number; kind: number;
@ -25,6 +28,14 @@
let loadingEvents = $state(false); let loadingEvents = $state(false);
let hasLists = $derived(lists.length > 0); let hasLists = $derived(lists.length > 0);
// Pagination
let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedEvents = $derived(
events.length > ITEMS_PER_PAGE
? getPaginatedItems(events, currentPage, ITEMS_PER_PAGE)
: events
);
// Subscribe to session changes to reactively update login status // Subscribe to session changes to reactively update login status
let currentSession = $state(sessionManager.session.value); let currentSession = $state(sessionManager.session.value);
const isLoggedIn = $derived(currentSession !== null); const isLoggedIn = $derived(currentSession !== null);
@ -377,10 +388,13 @@
</div> </div>
{:else if selectedList} {:else if selectedList}
<div class="events-list"> <div class="events-list">
{#each events as event (event.id)} {#each paginatedEvents as event (event.id)}
<FeedPost post={event} fullView={false} /> <FeedPost post={event} fullView={false} />
{/each} {/each}
</div> </div>
{#if events.length > ITEMS_PER_PAGE}
<Pagination totalItems={events.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
{/if} {/if}
{/if} {/if}
</main> </main>

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

@ -8,12 +8,22 @@
import type { NostrEvent } from '../../../lib/types/nostr.js'; import type { NostrEvent } from '../../../lib/types/nostr.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import Pagination from '../../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../../lib/utils/pagination.js';
let events = $state<NostrEvent[]>([]); let events = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let dTag = $derived($page.params.d_tag); let dTag = $derived($page.params.d_tag);
// Pagination
let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedEvents = $derived(
events.length > ITEMS_PER_PAGE
? getPaginatedItems(events, currentPage, ITEMS_PER_PAGE)
: events
);
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
await loadReplaceableEvents(); await loadReplaceableEvents();
@ -247,7 +257,7 @@
</div> </div>
{:else} {:else}
<div class="events-list"> <div class="events-list">
{#each events as event (event.id)} {#each paginatedEvents as event (event.id)}
<div <div
class="event-item" class="event-item"
onclick={() => navigateToEvent(event)} onclick={() => navigateToEvent(event)}
@ -264,6 +274,9 @@
</div> </div>
{/each} {/each}
</div> </div>
{#if events.length > ITEMS_PER_PAGE}
<Pagination totalItems={events.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
{/if} {/if}
</div> </div>
</main> </main>

20
src/routes/topics/+page.svelte

@ -8,9 +8,12 @@
import { getEventsByKind } from '../../lib/services/cache/event-cache.js'; import { getEventsByKind } from '../../lib/services/cache/event-cache.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { KIND } from '../../lib/types/kind-lookup.js'; import { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
interface TopicInfo { interface TopicInfo {
name: string; name: string;
@ -18,7 +21,7 @@
isInterest: boolean; isInterest: boolean;
} }
const ITEMS_PER_PAGE = 50; // Number of topics to render at once const TOPICS_ITEMS_PER_PAGE = 50; // Number of topics to render at once
const ITEM_HEIGHT = 60; // Approximate height of each topic item in pixels const ITEM_HEIGHT = 60; // Approximate height of each topic item in pixels
let allTopics = $state<TopicInfo[]>([]); let allTopics = $state<TopicInfo[]>([]);
@ -30,11 +33,19 @@
let interestList = $state<string[]>([]); let interestList = $state<string[]>([]);
let sentinelElement = $state<HTMLElement | null>(null); let sentinelElement = $state<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null; let observer: IntersectionObserver | null = null;
let renderedCount = $state(ITEMS_PER_PAGE); let renderedCount = $state(TOPICS_ITEMS_PER_PAGE);
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null }); let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] }); let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null); let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
// Pagination for search results
let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedSearchEvents = $derived(
searchResults.events.length > ITEMS_PER_PAGE
? getPaginatedItems(searchResults.events, currentPage, ITEMS_PER_PAGE)
: searchResults.events
);
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) { function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result; filterResult = result;
} }
@ -269,12 +280,15 @@
<div class="results-group"> <div class="results-group">
<h3>Events ({searchResults.events.length})</h3> <h3>Events ({searchResults.events.length})</h3>
<div class="event-results"> <div class="event-results">
{#each searchResults.events as event} {#each paginatedSearchEvents as event}
<a href="/event/{event.id}" class="event-result-card"> <a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} /> <FeedPost post={event} fullView={false} />
</a> </a>
{/each} {/each}
</div> </div>
{#if searchResults.events.length > ITEMS_PER_PAGE}
<Pagination totalItems={searchResults.events.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
</div> </div>
{/if} {/if}
</div> </div>

Loading…
Cancel
Save