Browse Source

universal search component

bug-fixes
master
Silberengel 1 month ago
parent
commit
9700e2d100
  1. 4
      public/healthz.json
  2. 8
      src/lib/components/layout/Header.svelte
  3. 494
      src/lib/components/layout/UnifiedSearch.svelte
  4. 2
      src/lib/modules/comments/Comment.svelte
  5. 22
      src/lib/modules/discussions/DiscussionList.svelte
  6. 45
      src/lib/modules/feed/FeedPage.svelte
  7. 19
      src/lib/modules/profiles/ProfilePage.svelte
  8. 12
      src/lib/services/nostr/relay-manager.ts
  9. 20
      src/lib/services/user-data.ts
  10. 33
      src/lib/types/kind-lookup.ts
  11. 386
      src/routes/bookmarks/+page.svelte
  12. 54
      src/routes/cache/+page.svelte
  13. 277
      src/routes/discussions/+page.svelte
  14. 277
      src/routes/feed/+page.svelte
  15. 30
      src/routes/find/+page.svelte
  16. 228
      src/routes/repos/+page.svelte
  17. 55
      src/routes/settings/+page.svelte
  18. 62
      src/routes/topics/+page.svelte

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.1.1", "version": "0.1.1",
"buildTime": "2026-02-06T07:27:23.473Z", "buildTime": "2026-02-06T08:55:58.492Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770362843473 "timestamp": 1770368158493
} }

8
src/lib/components/layout/Header.svelte

@ -47,7 +47,7 @@
</div> </div>
<!-- Navigation --> <!-- Navigation -->
<nav class="bg-fog-surface/95 dark:bg-fog-dark-surface/95 backdrop-blur-sm border-b border-fog-border dark:border-fog-dark-border px-2 sm:px-4 py-2 sm:py-3"> <nav class="bg-fog-surface dark:bg-fog-dark-surface border-b border-fog-border dark:border-fog-dark-border px-2 sm:px-4 py-2 sm:py-3">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 max-w-7xl mx-auto"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 max-w-7xl mx-auto">
<div class="flex flex-wrap gap-1.5 sm:gap-2 md:gap-4 items-center font-mono min-w-0 nav-links"> <div class="flex flex-wrap gap-1.5 sm:gap-2 md:gap-4 items-center font-mono min-w-0 nav-links">
<a href="/" class="font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors flex-shrink-0 nav-brand">aitherboard</a> <a href="/" class="font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors flex-shrink-0 nav-brand">aitherboard</a>
@ -112,6 +112,12 @@
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
background-color: var(--fog-surface, #f8fafc);
backdrop-filter: none;
}
:global(.dark) nav {
background-color: var(--fog-dark-surface, #1e293b);
} }
/* Responsive navigation links */ /* Responsive navigation links */

494
src/lib/components/layout/UnifiedSearch.svelte

@ -17,10 +17,14 @@
selectedKind?: number | null; // Selected kind for filtering selectedKind?: number | null; // Selected kind for filtering
onKindChange?: (kind: number | null) => void; // Callback when kind filter changes onKindChange?: (kind: number | null) => void; // Callback when kind filter changes
hideDropdownResults?: boolean; // If true, don't show dropdown results (for /find page) hideDropdownResults?: boolean; // If true, don't show dropdown results (for /find page)
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[] }) => void; // Callback for search results (events and profile pubkeys) onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[] }) => void; // Callback for search results (events and profile pubkeys, and relays used)
allowedKinds?: number[]; // Hard-coded list of kinds to search (hides kind dropdown)
} }
let { mode = 'search', placeholder = 'Search events, profiles, pubkeys, or enter event ID...', onFilterChange, showKindFilter = false, selectedKind = null, onKindChange, hideDropdownResults = false, onSearchResults }: Props = $props(); let { mode = 'search', placeholder = 'Search events, profiles, pubkeys, or enter event ID...', onFilterChange, showKindFilter = false, selectedKind = null, onKindChange, hideDropdownResults = false, onSearchResults, allowedKinds }: Props = $props();
// Use allowedKinds if provided, otherwise use selectedKind
let effectiveKinds = $derived(allowedKinds && allowedKinds.length > 0 ? allowedKinds : (selectedKind !== null ? [selectedKind] : null));
let searchQuery = $state(''); let searchQuery = $state('');
let searching = $state(false); let searching = $state(false);
@ -119,7 +123,7 @@
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
searchResults = []; searchResults = [];
showResults = false; showResults = false;
filterResult = { type: null, value: null, kind: selectedKind }; filterResult = { type: null, value: null, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult); if (onFilterChange) onFilterChange(filterResult);
return; return;
} }
@ -129,7 +133,10 @@
resolving = true; resolving = true;
searchResults = []; searchResults = [];
showResults = true; showResults = true;
filterResult = { type: null, value: null, kind: selectedKind }; filterResult = { type: null, value: null, kind: effectiveKinds?.[0] || selectedKind || null };
// Get relays that will be used for search (used for empty result messages)
const relaysUsed = relayManager.getAllAvailableRelays();
try { try {
const query = searchQuery.trim(); const query = searchQuery.trim();
@ -140,7 +147,7 @@
let event: NostrEvent | undefined = await getEvent(hexId); let event: NostrEvent | undefined = await getEvent(hexId);
if (!event) { if (!event) {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getAllAvailableRelays();
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ ids: [hexId] }], [{ ids: [hexId] }],
relays, relays,
@ -154,68 +161,72 @@
} }
if (event) { if (event) {
if (mode === 'search') { // If kinds are specified, filter by kind
if (hideDropdownResults && onSearchResults) { if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) {
foundEvents = [event]; // Event found but doesn't match allowed kinds, continue to next check
onSearchResults({ events: foundEvents, profiles: [] }); } else {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
} else {
searchResults = [{ event, matchType: 'Event ID' }];
showResults = true;
}
} else { } else {
searchResults = [{ event, matchType: 'Event ID' }]; filterResult = { type: 'event', value: event.id, kind: effectiveKinds?.[0] || selectedKind || null };
showResults = true; if (onFilterChange) onFilterChange(filterResult);
} }
} else { searching = false;
filterResult = { type: 'event', value: event.id }; resolving = false;
if (onFilterChange) onFilterChange(filterResult); return;
} }
searching = false;
resolving = false;
return;
} }
// Event not found, try as pubkey (step 2) // Event not found, try as pubkey (step 2)
const hexPubkey = hexId.toLowerCase(); const hexPubkey = hexId.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags // If kinds are specified, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays(); const relays = relayManager.getAllAvailableRelays();
const filters: any[] = [{
kinds: [selectedKind],
limit: 100
}];
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#p': [hexPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#q': [hexPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Also search by author
const eventsByAuthor = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, authors: [hexPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Combine and deduplicate
const allEvents = new Map<string, NostrEvent>(); const allEvents = new Map<string, NostrEvent>();
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) {
allEvents.set(event.id, event); // Search each allowed kind
for (const kind of effectiveKinds) {
// Search by author (most important for bookmarks and highlights)
const eventsByAuthor = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [hexPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
[{ kinds: [kind], '#p': [hexPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
[{ kinds: [kind], '#q': [hexPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Add to combined results
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) {
allEvents.set(event.id, event);
}
} }
foundEvents = Array.from(allEvents.values()); foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [] }); onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
searching = false; searching = false;
resolving = false; resolving = false;
return; return;
} else if (mode === 'search') { } else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) { if (hideDropdownResults && onSearchResults) {
foundProfiles = [hexPubkey]; foundProfiles = [hexPubkey];
onSearchResults({ events: [], profiles: foundProfiles }); onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else { } else {
// For search mode, navigate to profile // For search mode, navigate to profile
handleProfileClick(hexPubkey); handleProfileClick(hexPubkey);
@ -224,7 +235,7 @@
resolving = false; resolving = false;
return; return;
} else { } else {
filterResult = { type: 'pubkey', value: hexPubkey, kind: selectedKind }; filterResult = { type: 'pubkey', value: hexPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult); if (onFilterChange) onFilterChange(filterResult);
searching = false; searching = false;
resolving = false; resolving = false;
@ -248,49 +259,48 @@
if (pubkey) { if (pubkey) {
const normalizedPubkey = pubkey.toLowerCase(); const normalizedPubkey = pubkey.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags // If kinds are specified, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays(); const relays = relayManager.getAllAvailableRelays();
const filters: any[] = [{
kinds: [selectedKind],
limit: 100
}];
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Also search by author
const eventsByAuthor = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, authors: [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Combine and deduplicate
const allEvents = new Map<string, NostrEvent>(); const allEvents = new Map<string, NostrEvent>();
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) {
allEvents.set(event.id, event); // Search each allowed kind
for (const kind of effectiveKinds) {
// Search by author (most important for bookmarks and highlights)
const eventsByAuthor = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
[{ kinds: [kind], '#p': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
[{ kinds: [kind], '#q': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Add to combined results
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) {
allEvents.set(event.id, event);
}
} }
foundEvents = Array.from(allEvents.values()); foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [] }); onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
searching = false; searching = false;
resolving = false; resolving = false;
return; return;
} else if (mode === 'search') { } else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) { if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey]; foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles }); onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else { } else {
handleProfileClick(normalizedPubkey); handleProfileClick(normalizedPubkey);
} }
@ -298,7 +308,7 @@
resolving = false; resolving = false;
return; return;
} else { } else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind }; filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult); if (onFilterChange) onFilterChange(filterResult);
searching = false; searching = false;
resolving = false; resolving = false;
@ -318,49 +328,48 @@
const cachedPubkey = await searchCacheForNIP05(query); const cachedPubkey = await searchCacheForNIP05(query);
if (cachedPubkey) { if (cachedPubkey) {
const normalizedPubkey = cachedPubkey.toLowerCase(); const normalizedPubkey = cachedPubkey.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags // If kinds are specified, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays(); const relays = relayManager.getAllAvailableRelays();
const filters: any[] = [{
kinds: [selectedKind],
limit: 100
}];
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Also search by author
const eventsByAuthor = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, authors: [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Combine and deduplicate
const allEvents = new Map<string, NostrEvent>(); const allEvents = new Map<string, NostrEvent>();
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) {
allEvents.set(event.id, event); // Search each allowed kind
for (const kind of effectiveKinds) {
// Search by author (most important for bookmarks and highlights)
const eventsByAuthor = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
[{ kinds: [kind], '#p': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
[{ kinds: [kind], '#q': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Add to combined results
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) {
allEvents.set(event.id, event);
}
} }
foundEvents = Array.from(allEvents.values()); foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [] }); onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
searching = false; searching = false;
resolving = false; resolving = false;
return; return;
} else if (mode === 'search') { } else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) { if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey]; foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles }); onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else { } else {
handleProfileClick(normalizedPubkey); handleProfileClick(normalizedPubkey);
} }
@ -368,7 +377,7 @@
resolving = false; resolving = false;
return; return;
} else { } else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind }; filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult); if (onFilterChange) onFilterChange(filterResult);
searching = false; searching = false;
resolving = false; resolving = false;
@ -380,49 +389,48 @@
const wellKnownPubkey = await resolveNIP05FromWellKnown(query); const wellKnownPubkey = await resolveNIP05FromWellKnown(query);
if (wellKnownPubkey) { if (wellKnownPubkey) {
const normalizedPubkey = wellKnownPubkey.toLowerCase(); const normalizedPubkey = wellKnownPubkey.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags // If kinds are specified, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays(); const relays = relayManager.getAllAvailableRelays();
const filters: any[] = [{
kinds: [selectedKind],
limit: 100
}];
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Also search by author
const eventsByAuthor = await nostrClient.fetchEvents(
filters.map(f => ({ ...f, authors: [normalizedPubkey] })),
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Combine and deduplicate
const allEvents = new Map<string, NostrEvent>(); const allEvents = new Map<string, NostrEvent>();
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) {
allEvents.set(event.id, event); // Search each allowed kind
for (const kind of effectiveKinds) {
// Search by author (most important for bookmarks and highlights)
const eventsByAuthor = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
[{ kinds: [kind], '#p': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
[{ kinds: [kind], '#q': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Add to combined results
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) {
allEvents.set(event.id, event);
}
} }
foundEvents = Array.from(allEvents.values()); foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [] }); onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
searching = false; searching = false;
resolving = false; resolving = false;
return; return;
} else if (mode === 'search') { } else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) { if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey]; foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles }); onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else { } else {
handleProfileClick(normalizedPubkey); handleProfileClick(normalizedPubkey);
} }
@ -430,7 +438,7 @@
resolving = false; resolving = false;
return; return;
} else { } else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind }; filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult); if (onFilterChange) onFilterChange(filterResult);
searching = false; searching = false;
resolving = false; resolving = false;
@ -464,7 +472,7 @@
let event: NostrEvent | undefined = await getEvent(eventId); let event: NostrEvent | undefined = await getEvent(eventId);
if (!event) { if (!event) {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getAllAvailableRelays();
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ ids: [eventId] }], [{ ids: [eventId] }],
relays, relays,
@ -478,21 +486,26 @@
} }
if (event) { if (event) {
if (mode === 'search') { // If kinds are specified, filter by kind
if (hideDropdownResults && onSearchResults) { if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) {
foundEvents = [event]; // Event found but doesn't match allowed kinds, continue to next check
onSearchResults({ events: foundEvents, profiles: [] }); } else {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
} else {
searchResults = [{ event, matchType: 'Event ID' }];
showResults = true;
}
} else { } else {
searchResults = [{ event, matchType: 'Event ID' }]; filterResult = { type: 'event', value: event.id, kind: effectiveKinds?.[0] || selectedKind || null };
showResults = true; if (onFilterChange) onFilterChange(filterResult);
} }
} else { searching = false;
filterResult = { type: 'event', value: event.id, kind: selectedKind }; resolving = false;
if (onFilterChange) onFilterChange(filterResult); return;
} }
searching = false;
resolving = false;
return;
} }
} }
} catch (error) { } catch (error) {
@ -502,40 +515,74 @@
// 6. Anything else is a full-text search // 6. Anything else is a full-text search
if (mode === 'search') { if (mode === 'search') {
// Text search in cached events (title, summary, content) let allEvents: NostrEvent[] = [];
const allCached: NostrEvent[] = [];
// If kind filter is selected, only search that kind // If kinds are specified, search from relays
if (selectedKind !== null) { if (effectiveKinds && effectiveKinds.length > 0) {
const kindEvents = await getEventsByKind(selectedKind, 100); const relays = relayManager.getAllAvailableRelays();
allCached.push(...kindEvents); const queryLower = query.toLowerCase();
// Search each allowed kind
for (const kind of effectiveKinds) {
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Filter by text content
const matches = events.filter(event => {
const contentMatch = event.content.toLowerCase().includes(queryLower);
const titleTag = event.tags.find(t => t[0] === 'title');
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false;
const summaryTag = event.tags.find(t => t[0] === 'summary');
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false;
return contentMatch || titleMatch || summaryMatch;
});
allEvents.push(...matches);
}
} else { } else {
// Search all kinds we handle // Text search in cached events (title, summary, content)
const kindsToSearch = Object.keys(KIND_LOOKUP).map(k => parseInt(k)).filter(k => !KIND_LOOKUP[k].isSecondaryKind); const allCached: NostrEvent[] = [];
for (const kind of kindsToSearch) {
try { // If kind filter is selected, only search that kind
const kindEvents = await getEventsByKind(kind, 50); if (selectedKind !== null) {
allCached.push(...kindEvents); const kindEvents = await getEventsByKind(selectedKind, 100);
} catch (e) { allCached.push(...kindEvents);
// Skip kinds that fail } else {
// Search all kinds we handle
const kindsToSearch = Object.keys(KIND_LOOKUP).map(k => parseInt(k)).filter(k => !KIND_LOOKUP[k].isSecondaryKind);
for (const kind of kindsToSearch) {
try {
const kindEvents = await getEventsByKind(kind, 50);
allCached.push(...kindEvents);
} catch (e) {
// Skip kinds that fail
}
} }
} }
const queryLower = query.toLowerCase();
allEvents = allCached.filter(event => {
const contentMatch = event.content.toLowerCase().includes(queryLower);
const titleTag = event.tags.find(t => t[0] === 'title');
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false;
const summaryTag = event.tags.find(t => t[0] === 'summary');
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false;
return contentMatch || titleMatch || summaryMatch;
});
} }
// Sort and limit results
const queryLower = query.toLowerCase(); const queryLower = query.toLowerCase();
const matches = allCached.filter(event => { const sorted = allEvents.sort((a, b) => {
const contentMatch = event.content.toLowerCase().includes(queryLower);
const titleTag = event.tags.find(t => t[0] === 'title');
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false;
const summaryTag = event.tags.find(t => t[0] === 'summary');
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false;
return contentMatch || titleMatch || summaryMatch;
});
const sorted = matches.sort((a, b) => {
const aExact = a.content.toLowerCase() === queryLower; const aExact = a.content.toLowerCase() === queryLower;
const bExact = b.content.toLowerCase() === queryLower; const bExact = b.content.toLowerCase() === queryLower;
if (aExact && !bExact) return -1; if (aExact && !bExact) return -1;
@ -543,22 +590,32 @@
return b.created_at - a.created_at; return b.created_at - a.created_at;
}); });
const limitedResults = sorted.slice(0, 20); // Deduplicate by event ID
const uniqueEvents = new Map<string, NostrEvent>();
for (const event of sorted) {
uniqueEvents.set(event.id, event);
}
const limitedResults = Array.from(uniqueEvents.values()).slice(0, 100);
if (hideDropdownResults && onSearchResults) { if (hideDropdownResults && onSearchResults) {
foundEvents = limitedResults; foundEvents = limitedResults;
onSearchResults({ events: foundEvents, profiles: [] }); onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
} else { } else {
searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' })); searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' }));
showResults = true; showResults = true;
} }
} else { } else {
// Filter mode: treat as text search // Filter mode: treat as text search
filterResult = { type: 'text', value: query, kind: selectedKind }; filterResult = { type: 'text', value: query, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult); if (onFilterChange) onFilterChange(filterResult);
} }
} catch (error) { } catch (error) {
console.error('Search error:', error); console.error('Search error:', error);
// Ensure we reset state even on error
if (hideDropdownResults && onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: relaysUsed });
}
} finally { } finally {
searching = false; searching = false;
resolving = false; resolving = false;
@ -675,7 +732,7 @@
<div class="unified-search-container"> <div class="unified-search-container">
<div class="search-input-wrapper"> <div class="search-input-wrapper">
{#if showKindFilter} {#if showKindFilter && !allowedKinds}
<select <select
value={selectedKind?.toString() || ''} value={selectedKind?.toString() || ''}
onchange={handleKindChange} onchange={handleKindChange}
@ -757,11 +814,34 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem; border-radius: 0.375rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
background-color: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937); color: var(--fog-text, #1f2937);
font-size: 0.875rem; font-size: 0.875rem;
font-family: monospace; font-family: monospace;
cursor: pointer; cursor: pointer;
min-width: 150px; min-width: 150px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.kind-filter-select option {
background: var(--fog-post, #ffffff);
background-color: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
}
.kind-filter-select option:checked {
background: var(--fog-accent, #64748b) !important;
background-color: var(--fog-accent, #64748b) !important;
color: var(--fog-post, #ffffff) !important;
}
/* Ensure selected option in dropdown has proper contrast */
.kind-filter-select option:checked:not(:disabled) {
background: var(--fog-accent, #64748b) !important;
background-color: var(--fog-accent, #64748b) !important;
color: var(--fog-post, #ffffff) !important;
} }
.kind-filter-select:focus { .kind-filter-select:focus {
@ -773,9 +853,29 @@
:global(.dark) .kind-filter-select { :global(.dark) .kind-filter-select {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
background-color: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #f9fafb);
} }
:global(.dark) .kind-filter-select option {
background: var(--fog-dark-post, #1f2937);
background-color: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .kind-filter-select option:checked {
background: var(--fog-dark-accent, #94a3b8) !important;
background-color: var(--fog-dark-accent, #94a3b8) !important;
color: #ffffff !important;
}
/* Ensure selected option in dropdown has proper contrast in dark mode */
:global(.dark) .kind-filter-select option:checked:not(:disabled) {
background: var(--fog-dark-accent, #94a3b8) !important;
background-color: var(--fog-dark-accent, #94a3b8) !important;
color: #ffffff !important;
}
:global(.dark) .kind-filter-select:focus { :global(.dark) .kind-filter-select:focus {
border-color: var(--fog-dark-accent, #94a3b8); border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);

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

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte'; import ReplyContext from '../../components/content/ReplyContext.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';
@ -116,6 +117,7 @@
</div> </div>
<div class="comment-content mb-2"> <div class="comment-content mb-2">
<MediaAttachments event={comment} />
<MarkdownRenderer content={comment.content} event={comment} /> <MarkdownRenderer content={comment.content} event={comment} />
</div> </div>
</div> </div>

22
src/lib/modules/discussions/DiscussionList.svelte

@ -16,6 +16,9 @@
let { filterResult = { type: null, value: null } }: Props = $props(); let { filterResult = { type: null, value: null } }: Props = $props();
// Expose state for parent component
export { sortBy, showOlder };
// Resolved pubkey from filter (handled by parent component's PubkeyFilter) // Resolved pubkey from filter (handled by parent component's PubkeyFilter)
// For now, we'll do basic normalization here since we don't have access to the filter component // For now, we'll do basic normalization here since we don't have access to the filter component
// The parent component should resolve NIP-05 before passing it here // The parent component should resolve NIP-05 before passing it here
@ -623,25 +626,6 @@
</script> </script>
<div class="thread-list"> <div class="thread-list">
<!-- Top row: Sorting and Show Older checkbox -->
<div class="controls mb-4 flex gap-4 items-center flex-wrap">
<select
bind:value={sortBy}
class="border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text px-2 py-1 rounded"
>
<option value="newest">Newest</option>
<option value="active">Most Active</option>
<option value="upvoted">Most Upvoted</option>
</select>
<label class="text-fog-text dark:text-fog-dark-text flex items-center gap-2">
<input
type="checkbox"
bind:checked={showOlder}
/>
Show older posts (than 30 days)
</label>
</div>
<!-- Filter by topic buttons --> <!-- Filter by topic buttons -->
<div class="mb-6"> <div class="mb-6">

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

@ -14,6 +14,10 @@
} }
let { singleRelay, filterResult = { type: null, value: null } }: Props = $props(); let { singleRelay, filterResult = { type: null, value: null } }: Props = $props();
// Expose API for parent component via component reference
// Note: The warning about loadOlderEvents is a false positive - functions don't need to be reactive
export { loadOlderEvents, loadingMore, hasMoreEvents, waitingRoomEvents, loadWaitingRoomEvents };
// Core state // Core state
let allEvents = $state<NostrEvent[]>([]); let allEvents = $state<NostrEvent[]>([]);
@ -87,6 +91,7 @@
} }
// Load older events (pagination) // Load older events (pagination)
// svelte-ignore non_reactive_update
async function loadOlderEvents() { async function loadOlderEvents() {
if (loadingMore || !hasMoreEvents) return; if (loadingMore || !hasMoreEvents) return;
@ -304,24 +309,6 @@
<p class="text-fog-text dark:text-fog-dark-text">No posts found.</p> <p class="text-fog-text dark:text-fog-dark-text">No posts found.</p>
</div> </div>
{:else} {:else}
{#if waitingRoomEvents.length > 0}
<div class="waiting-room-banner">
<button onclick={loadWaitingRoomEvents} class="see-new-events-btn">
See {waitingRoomEvents.length} new event{waitingRoomEvents.length === 1 ? '' : 's'}
</button>
</div>
{/if}
<div class="load-more-section-top">
<button
onclick={loadOlderEvents}
disabled={loadingMore || !hasMoreEvents}
class="see-more-events-btn"
>
{loadingMore ? 'Loading...' : 'See more events'}
</button>
</div>
<div class="feed-posts"> <div class="feed-posts">
{#each events as event (event.id)} {#each events as event (event.id)}
<FeedPost post={event} /> <FeedPost post={event} />
@ -405,21 +392,6 @@
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
} }
.waiting-room-banner {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
text-align: center;
}
:global(.dark) .waiting-room-banner {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.see-new-events-btn,
.see-more-events-btn { .see-more-events-btn {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b); background: var(--fog-accent, #64748b);
@ -432,30 +404,25 @@
transition: all 0.2s; transition: all 0.2s;
} }
.see-new-events-btn:hover,
.see-more-events-btn:hover:not(:disabled) { .see-more-events-btn:hover:not(:disabled) {
background: var(--fog-text, #475569); background: var(--fog-text, #475569);
} }
.see-new-events-btn:disabled,
.see-more-events-btn:disabled { .see-more-events-btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
:global(.dark) .see-new-events-btn,
:global(.dark) .see-more-events-btn { :global(.dark) .see-more-events-btn {
background: var(--fog-dark-accent, #94a3b8); background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #f9fafb);
} }
:global(.dark) .see-new-events-btn:hover:not(:disabled),
:global(.dark) .see-more-events-btn:hover:not(:disabled) { :global(.dark) .see-more-events-btn:hover:not(:disabled) {
background: var(--fog-dark-text, #cbd5e1); background: var(--fog-dark-text, #cbd5e1);
} }
.load-more-section, .load-more-section {
.load-more-section-top {
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
} }

19
src/lib/modules/profiles/ProfilePage.svelte

@ -3,10 +3,11 @@
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import PaymentAddresses from './PaymentAddresses.svelte'; import PaymentAddresses from './PaymentAddresses.svelte';
import FeedPost from '../feed/FeedPost.svelte'; import FeedPost from '../feed/FeedPost.svelte';
import CommentComponent from '../comments/Comment.svelte';
import ProfileEventsPanel from '../../components/profile/ProfileEventsPanel.svelte'; import ProfileEventsPanel from '../../components/profile/ProfileEventsPanel.svelte';
import ProfileMenu from '../../components/profile/ProfileMenu.svelte'; import ProfileMenu from '../../components/profile/ProfileMenu.svelte';
import BookmarksPanel from '../../components/profile/BookmarksPanel.svelte'; import BookmarksPanel from '../../components/profile/BookmarksPanel.svelte';
import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js'; import { fetchProfile, fetchUserStatus, fetchUserStatusEvent, type ProfileData } from '../../services/user-data.js';
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 { config } from '../../services/nostr/config.js'; import { config } from '../../services/nostr/config.js';
@ -17,13 +18,13 @@
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';
import CommentForm from '../comments/CommentForm.svelte'; import CommentForm from '../comments/CommentForm.svelte';
import Comment from '../comments/Comment.svelte';
import { getProfile } from '../../services/cache/profile-cache.js'; import { getProfile } from '../../services/cache/profile-cache.js';
let profile = $state<ProfileData | null>(null); let profile = $state<ProfileData | null>(null);
let profileEvent = $state<NostrEvent | null>(null); // The kind 0 event let profileEvent = $state<NostrEvent | null>(null); // The kind 0 event
let userStatus = $state<string | null>(null); let userStatus = $state<string | null>(null);
let userStatusEvent = $state<NostrEvent | null>(null); // The kind 30315 event
let notifications = $state<NostrEvent[]>([]); let notifications = $state<NostrEvent[]>([]);
let interactionsWithMe = $state<NostrEvent[]>([]); let interactionsWithMe = $state<NostrEvent[]>([]);
let wallComments = $state<NostrEvent[]>([]); // Kind 1111 comments on the wall let wallComments = $state<NostrEvent[]>([]); // Kind 1111 comments on the wall
@ -577,11 +578,14 @@
// Step 1: Load profile and status first (fast from cache) - display immediately // Step 1: Load profile and status first (fast from cache) - display immediately
const profilePromise = fetchProfile(pubkey); const profilePromise = fetchProfile(pubkey);
const statusPromise = fetchUserStatus(pubkey); const statusPromise = fetchUserStatus(pubkey);
const statusEventPromise = fetchUserStatusEvent(pubkey);
activeFetchPromises.add(profilePromise); activeFetchPromises.add(profilePromise);
activeFetchPromises.add(statusPromise); activeFetchPromises.add(statusPromise);
const [profileData, status] = await Promise.all([profilePromise, statusPromise]); activeFetchPromises.add(statusEventPromise);
const [profileData, status, statusEvent] = await Promise.all([profilePromise, statusPromise, statusEventPromise]);
activeFetchPromises.delete(profilePromise); activeFetchPromises.delete(profilePromise);
activeFetchPromises.delete(statusPromise); activeFetchPromises.delete(statusPromise);
activeFetchPromises.delete(statusEventPromise);
// Check if this load was aborted or if pubkey changed // Check if this load was aborted or if pubkey changed
if (!isMounted || abortController.signal.aborted || currentLoadPubkey !== pubkey) { if (!isMounted || abortController.signal.aborted || currentLoadPubkey !== pubkey) {
@ -590,6 +594,7 @@
profile = profileData; profile = profileData;
userStatus = status; userStatus = status;
userStatusEvent = statusEvent;
loading = false; // Show profile immediately, even if posts are still loading loading = false; // Show profile immediately, even if posts are still loading
// Load the kind 0 profile event for the wall // Load the kind 0 profile event for the wall
@ -667,7 +672,11 @@
{#if profile.about} {#if profile.about}
<p class="text-fog-text dark:text-fog-dark-text mb-2">{profile.about}</p> <p class="text-fog-text dark:text-fog-dark-text mb-2">{profile.about}</p>
{/if} {/if}
{#if userStatus && userStatus.trim()} {#if userStatusEvent && userStatusEvent.content && userStatusEvent.content.trim()}
<div class="user-status-content mb-2">
<MarkdownRenderer content={userStatusEvent.content} event={userStatusEvent} />
</div>
{:else if userStatus && userStatus.trim()}
<p class="text-fog-text-light dark:text-fog-dark-text-light italic mb-2" style="font-size: 0.875em;"> <p class="text-fog-text-light dark:text-fog-dark-text-light italic mb-2" style="font-size: 0.875em;">
{userStatus} {userStatus}
</p> </p>
@ -826,7 +835,7 @@
{:else} {:else}
<div class="wall-comments"> <div class="wall-comments">
{#each wallComments as comment (comment.id)} {#each wallComments as comment (comment.id)}
<Comment <CommentComponent
comment={comment} comment={comment}
rootEventKind={KIND.METADATA} rootEventKind={KIND.METADATA}
/> />

12
src/lib/services/nostr/relay-manager.ts

@ -240,6 +240,18 @@ class RelayManager {
]); ]);
} }
/**
* Get all available relays (for comprehensive searches)
* Combines default relays, profile relays, and user relays
*/
getAllAvailableRelays(): string[] {
const allBaseRelays = [
...config.defaultRelays,
...config.profileRelays
];
return this.getReadRelays(allBaseRelays, true);
}
/** /**
* Get relays for reading payment targets (kind 10133) * Get relays for reading payment targets (kind 10133)
*/ */

20
src/lib/services/user-data.ts

@ -181,12 +181,12 @@ export function parseUserStatus(event: NostrEvent): string | null {
} }
/** /**
* Fetch user status for a pubkey * Fetch user status event for a pubkey
*/ */
export async function fetchUserStatus( export async function fetchUserStatusEvent(
pubkey: string, pubkey: string,
relays?: string[] relays?: string[]
): Promise<string | null> { ): Promise<NostrEvent | null> {
const relayList = relays || [ const relayList = relays || [
...config.defaultRelays, ...config.defaultRelays,
...config.profileRelays ...config.profileRelays
@ -207,7 +207,19 @@ export async function fetchUserStatus(
if (events.length === 0) return null; if (events.length === 0) return null;
return parseUserStatus(events[0]); return events[0];
}
/**
* Fetch user status text for a pubkey (for backwards compatibility)
*/
export async function fetchUserStatus(
pubkey: string,
relays?: string[]
): Promise<string | null> {
const event = await fetchUserStatusEvent(pubkey, relays);
if (!event) return null;
return parseUserStatus(event);
} }
export interface RelayInfo { export interface RelayInfo {

33
src/lib/types/kind-lookup.ts

@ -67,7 +67,12 @@ export const KIND = {
PICTURE_NOTE: 20, PICTURE_NOTE: 20,
VIDEO_NOTE: 21, VIDEO_NOTE: 21,
SHORT_VIDEO_NOTE: 22, SHORT_VIDEO_NOTE: 22,
PUBLIC_MESSAGE: 24,
LONG_FORM_NOTE: 30023, LONG_FORM_NOTE: 30023,
WIKI_MARKDOWN: 30817,
WIKI_ASCIIDOC: 30818,
PUBLICATION_INDEX: 30040,
PUBLICATION_CONTENT: 30041,
HIGHLIGHTED_ARTICLE: 9802, HIGHLIGHTED_ARTICLE: 9802,
FILE_METADATA: 1063, FILE_METADATA: 1063,
POLL: 1068, POLL: 1068,
@ -76,6 +81,7 @@ export const KIND = {
PAYMENT_ADDRESSES: 10133, PAYMENT_ADDRESSES: 10133,
LABEL: 1985, LABEL: 1985,
REPORT: 1984, REPORT: 1984,
ZAP_REQUEST: 9734, // NIP-57 Zap Request (not published to relays)
ZAP_RECEIPT: 9735, ZAP_RECEIPT: 9735,
RELAY_LIST: 10002, RELAY_LIST: 10002,
BLOCKED_RELAYS: 10006, BLOCKED_RELAYS: 10006,
@ -90,25 +96,30 @@ export const KIND = {
MUTE_LIST: 10000, MUTE_LIST: 10000,
BADGES: 30008, BADGES: 30008,
FOLOW_SET: 30000, FOLOW_SET: 30000,
HTTP_AUTH: 27235, // NIP-98 HTTP Auth (matches nostr-tools and jumble) HTTP_AUTH: 27235,
REPO_ANNOUNCEMENT: 30617, // NIP-34 Repository Announcement REPO_ANNOUNCEMENT: 30617,
ISSUE: 1621, // NIP-34 Issue ISSUE: 1621,
STATUS_OPEN: 1630, // NIP-34 Status: Open STATUS_OPEN: 1630,
STATUS_APPLIED: 1631, // NIP-34 Status: Applied/Merged/Resolved STATUS_APPLIED: 1631,
STATUS_CLOSED: 1632, // NIP-34 Status: Closed STATUS_CLOSED: 1632,
STATUS_DRAFT: 1633 // NIP-34 Status: Draft STATUS_DRAFT: 1633
} as const; } as const;
export const KIND_LOOKUP: Record<number, KindInfo> = { export const KIND_LOOKUP: Record<number, KindInfo> = {
// Core kinds // Core kinds
[KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Short Text Note', showInFeed: true, isSecondaryKind: false }, [KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Text Note', showInFeed: true, isSecondaryKind: false },
[KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Public Message', showInFeed: false, isSecondaryKind: false }, [KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Contact List', showInFeed: false, isSecondaryKind: false },
[KIND.EVENT_DELETION]: { number: KIND.EVENT_DELETION, description: 'Event Deletion', showInFeed: false, isSecondaryKind: false }, [KIND.EVENT_DELETION]: { number: KIND.EVENT_DELETION, description: 'Event Deletion', showInFeed: false, isSecondaryKind: false },
[KIND.REACTION]: { number: KIND.REACTION, description: 'Reaction', showInFeed: false, isSecondaryKind: true }, [KIND.REACTION]: { number: KIND.REACTION, description: 'Reaction', showInFeed: false, isSecondaryKind: true },
[KIND.PUBLIC_MESSAGE]: { number: KIND.PUBLIC_MESSAGE, description: 'Public Message', showInFeed: false, isSecondaryKind: false },
// Articles // Articles
[KIND.LONG_FORM_NOTE]: { number: KIND.LONG_FORM_NOTE, description: 'Long-form Note', showInFeed: true, isSecondaryKind: false }, [KIND.LONG_FORM_NOTE]: { number: KIND.LONG_FORM_NOTE, description: 'Long-form Note', showInFeed: true, isSecondaryKind: false },
[KIND.HIGHLIGHTED_ARTICLE]: { number: KIND.HIGHLIGHTED_ARTICLE, description: 'Highlighted Article', showInFeed: false, isSecondaryKind: false }, [KIND.HIGHLIGHTED_ARTICLE]: { number: KIND.HIGHLIGHTED_ARTICLE, description: 'Highlighted Article', showInFeed: false, isSecondaryKind: false },
[KIND.WIKI_MARKDOWN]: { number: KIND.WIKI_MARKDOWN, description: 'Wiki Markdown', showInFeed: false, isSecondaryKind: false },
[KIND.WIKI_ASCIIDOC]: { number: KIND.WIKI_ASCIIDOC, description: 'Wiki AsciiDoc', showInFeed: false, isSecondaryKind: false },
[KIND.PUBLICATION_INDEX]: { number: KIND.PUBLICATION_INDEX, description: 'Publication Index', showInFeed: false, isSecondaryKind: false },
[KIND.PUBLICATION_CONTENT]: { number: KIND.PUBLICATION_CONTENT, description: 'Publication Content', showInFeed: false, isSecondaryKind: false },
// Threads and comments // Threads and comments
[KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: false, isSecondaryKind: false }, // Only shown on /discussions page [KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: false, isSecondaryKind: false }, // Only shown on /discussions page
@ -127,13 +138,14 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
[KIND.POLL_RESPONSE]: { number: KIND.POLL_RESPONSE, description: 'Poll Response', showInFeed: false, isSecondaryKind: true }, [KIND.POLL_RESPONSE]: { number: KIND.POLL_RESPONSE, description: 'Poll Response', showInFeed: false, isSecondaryKind: true },
// User events // User events
[KIND.METADATA]: { number: KIND.METADATA, description: 'Metadata', showInFeed: false, isSecondaryKind: false }, [KIND.METADATA]: { number: KIND.METADATA, description: 'Profile', showInFeed: false, isSecondaryKind: false },
[KIND.USER_STATUS]: { number: KIND.USER_STATUS, description: 'User Status', showInFeed: false, isSecondaryKind: true }, [KIND.USER_STATUS]: { number: KIND.USER_STATUS, description: 'User Status', showInFeed: false, isSecondaryKind: true },
[KIND.PAYMENT_ADDRESSES]: { number: KIND.PAYMENT_ADDRESSES, description: 'Payment Addresses', showInFeed: false, isSecondaryKind: false }, [KIND.PAYMENT_ADDRESSES]: { number: KIND.PAYMENT_ADDRESSES, description: 'Payment Addresses', showInFeed: false, isSecondaryKind: false },
[KIND.LABEL]: { number: KIND.LABEL, description: 'Label', showInFeed: false, isSecondaryKind: false }, [KIND.LABEL]: { number: KIND.LABEL, description: 'Label', showInFeed: false, isSecondaryKind: false },
[KIND.REPORT]: { number: KIND.REPORT, description: 'Report', showInFeed: false, isSecondaryKind: false }, [KIND.REPORT]: { number: KIND.REPORT, description: 'Report', showInFeed: false, isSecondaryKind: false },
// Zaps // Zaps
[KIND.ZAP_REQUEST]: { number: KIND.ZAP_REQUEST, description: 'Zap Request', showInFeed: false, isSecondaryKind: false },
[KIND.ZAP_RECEIPT]: { number: KIND.ZAP_RECEIPT, description: 'Zap Receipt', showInFeed: false, isSecondaryKind: true }, [KIND.ZAP_RECEIPT]: { number: KIND.ZAP_RECEIPT, description: 'Zap Receipt', showInFeed: false, isSecondaryKind: true },
// Relay lists // Relay lists
@ -152,6 +164,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
[KIND.MUTE_LIST]: { number: KIND.MUTE_LIST, description: 'Mute List', showInFeed: false, isSecondaryKind: false }, [KIND.MUTE_LIST]: { number: KIND.MUTE_LIST, description: 'Mute List', showInFeed: false, isSecondaryKind: false },
[KIND.BADGES]: { number: KIND.BADGES, description: 'Badges', showInFeed: false, isSecondaryKind: false }, [KIND.BADGES]: { number: KIND.BADGES, description: 'Badges', showInFeed: false, isSecondaryKind: false },
[KIND.FOLOW_SET]: { number: KIND.FOLOW_SET, description: 'Follow Set', showInFeed: false, isSecondaryKind: false }, [KIND.FOLOW_SET]: { number: KIND.FOLOW_SET, description: 'Follow Set', showInFeed: false, isSecondaryKind: false },
[KIND.HTTP_AUTH]: { number: KIND.HTTP_AUTH, description: 'HTTP Auth', showInFeed: false, isSecondaryKind: false },
// Repository (NIP-34) // Repository (NIP-34)
[KIND.REPO_ANNOUNCEMENT]: { number: KIND.REPO_ANNOUNCEMENT, description: 'Repository Announcement', showInFeed: false, isSecondaryKind: false }, [KIND.REPO_ANNOUNCEMENT]: { number: KIND.REPO_ANNOUNCEMENT, description: 'Repository Announcement', showInFeed: false, isSecondaryKind: false },

386
src/routes/bookmarks/+page.svelte

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import Header from '../../lib/components/layout/Header.svelte'; import Header from '../../lib/components/layout/Header.svelte';
import FeedPost from '../../lib/modules/feed/FeedPost.svelte'; import FeedPost from '../../lib/modules/feed/FeedPost.svelte';
import HighlightCard from '../../lib/modules/feed/HighlightCard.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte'; import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js'; import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { config } from '../../lib/services/nostr/config.js'; import { config } from '../../lib/services/nostr/config.js';
@ -10,6 +12,7 @@
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 { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
interface BookmarkOrHighlight { interface BookmarkOrHighlight {
event: NostrEvent; event: NostrEvent;
@ -23,10 +26,22 @@
let currentPage = $state(1); let currentPage = $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 typeFilter = $state<'all' | 'bookmark' | 'highlight'>('all'); let typeFilter = $state<'all' | 'bookmark' | 'highlight'>('all');
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
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;
} }
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
const itemsPerPage = 100; const itemsPerPage = 100;
const maxTotalItems = 500; const maxTotalItems = 500;
@ -76,9 +91,9 @@
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const items: BookmarkOrHighlight[] = []; const items: BookmarkOrHighlight[] = [];
// 1. Fetch bookmark lists (kind 10003) // 1. Fetch bookmark lists (kind 10003) - limit 400
const fetchedBookmarkLists = await nostrClient.fetchEvents( const fetchedBookmarkLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.BOOKMARKS], limit: config.feedLimit }], [{ kinds: [KIND.BOOKMARKS], limit: 400 }],
relays, relays,
{ {
useCache: true, useCache: true,
@ -99,7 +114,7 @@
console.log(`[Bookmarks] Found ${bookmarkMap.size} unique bookmarked event IDs from ${fetchedBookmarkLists.length} bookmark lists`); console.log(`[Bookmarks] Found ${bookmarkMap.size} unique bookmarked event IDs from ${fetchedBookmarkLists.length} bookmark lists`);
// 2. Fetch highlight events (kind 9802) // 2. Fetch highlight events (kind 9802) - limit 100
// Use profile read relays for highlights (they might be on different relays) // Use profile read relays for highlights (they might be on different relays)
const profileRelays = relayManager.getProfileReadRelays(); const profileRelays = relayManager.getProfileReadRelays();
const allRelaysForHighlights = [...new Set([...relays, ...profileRelays])]; const allRelaysForHighlights = [...new Set([...relays, ...profileRelays])];
@ -107,7 +122,7 @@
// Fetch highlights - we want ALL highlights, not just from specific authors // Fetch highlights - we want ALL highlights, not just from specific authors
// This will show highlights from everyone, which is what we want for the bookmarks page // This will show highlights from everyone, which is what we want for the bookmarks page
const highlightEvents = await nostrClient.fetchEvents( const highlightEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: config.feedLimit }], [{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }],
allRelaysForHighlights, allRelaysForHighlights,
{ {
useCache: true, useCache: true,
@ -118,9 +133,11 @@
console.log(`[Bookmarks] Found ${highlightEvents.length} highlight events from ${allRelaysForHighlights.length} relays`); console.log(`[Bookmarks] Found ${highlightEvents.length} highlight events from ${allRelaysForHighlights.length} relays`);
// Extract event IDs from highlights (e-tags and a-tags) // For highlights, we store the highlight event itself, mapped by source event ID
const highlightMap = new Map<string, string>(); // eventId -> authorPubkey // sourceEventId -> highlight event
const highlightBySourceEvent = new Map<string, { highlight: NostrEvent; authorPubkey: string }>();
const aTagHighlights = new Map<string, { highlight: NostrEvent; pubkey: string }>(); // a-tag -> highlight info const aTagHighlights = new Map<string, { highlight: NostrEvent; pubkey: string }>(); // a-tag -> highlight info
const highlightsWithoutRefs: { highlight: NostrEvent; authorPubkey: string }[] = []; // Highlights without e-tag or a-tag
let highlightsWithETags = 0; let highlightsWithETags = 0;
let highlightsWithATags = 0; let highlightsWithATags = 0;
@ -133,7 +150,7 @@
// Extract e-tag (direct event reference) // Extract e-tag (direct event reference)
const eTag = highlight.tags.find(t => t[0] === 'e' && t[1]); const eTag = highlight.tags.find(t => t[0] === 'e' && t[1]);
if (eTag && eTag[1]) { if (eTag && eTag[1]) {
highlightMap.set(eTag[1], highlight.pubkey); highlightBySourceEvent.set(eTag[1], { highlight, authorPubkey: highlight.pubkey });
highlightsWithETags++; highlightsWithETags++;
hasRef = true; hasRef = true;
} }
@ -148,6 +165,8 @@
if (!hasRef) { if (!hasRef) {
highlightsWithNoRefs++; highlightsWithNoRefs++;
// Store highlights without refs to display them directly
highlightsWithoutRefs.push({ highlight, authorPubkey: highlight.pubkey });
// Log a sample of highlights without refs for debugging // Log a sample of highlights without refs for debugging
if (highlightsWithNoRefs <= 3) { if (highlightsWithNoRefs <= 3) {
console.debug(`[Bookmarks] Highlight ${highlight.id.substring(0, 16)}... has no e-tag or a-tag. Tags:`, highlight.tags.map(t => t[0]).join(', ')); console.debug(`[Bookmarks] Highlight ${highlight.id.substring(0, 16)}... has no e-tag or a-tag. Tags:`, highlight.tags.map(t => t[0]).join(', '));
@ -155,7 +174,7 @@
} }
} }
console.log(`[Bookmarks] Found ${highlightMap.size} e-tag references and ${aTagHighlights.size} a-tag references`); console.log(`[Bookmarks] Found ${highlightBySourceEvent.size} e-tag references and ${aTagHighlights.size} a-tag references`);
console.log(`[Bookmarks] Highlights breakdown: ${highlightsWithETags} with e-tags, ${highlightsWithATags} with a-tags only, ${highlightsWithNoRefs} with no event references`); console.log(`[Bookmarks] Highlights breakdown: ${highlightsWithETags} with e-tags, ${highlightsWithATags} with a-tags only, ${highlightsWithNoRefs} with no event references`);
// Second pass: fetch events for a-tags in batches // Second pass: fetch events for a-tags in batches
@ -213,12 +232,12 @@
if (dTag) { if (dTag) {
const eventDTag = event.tags.find(t => t[0] === 'd' && t[1]); const eventDTag = event.tags.find(t => t[0] === 'd' && t[1]);
if (eventDTag && eventDTag[1] === dTag) { if (eventDTag && eventDTag[1] === dTag) {
highlightMap.set(event.id, info.pubkey); highlightBySourceEvent.set(event.id, { highlight: info.highlight, authorPubkey: info.pubkey });
break; break;
} }
} else { } else {
// No d-tag, just match kind and pubkey // No d-tag, just match kind and pubkey
highlightMap.set(event.id, info.pubkey); highlightBySourceEvent.set(event.id, { highlight: info.highlight, authorPubkey: info.pubkey });
break; break;
} }
} }
@ -233,10 +252,12 @@
} }
} }
console.log(`[Bookmarks] Total extracted ${highlightMap.size} event IDs from ${highlightEvents.length} highlight events`); // Get source event IDs for highlights (to fetch them for sorting/display)
const highlightSourceEventIds = Array.from(highlightBySourceEvent.keys());
console.log(`[Bookmarks] Total extracted ${highlightSourceEventIds.length} source event IDs from ${highlightEvents.length} highlight events`);
// Combine all event IDs // Combine all event IDs (bookmarks + highlight source events)
const allEventIds = new Set([...bookmarkMap.keys(), ...highlightMap.keys()]); const allEventIds = new Set([...bookmarkMap.keys(), ...highlightSourceEventIds]);
if (allEventIds.size === 0) { if (allEventIds.size === 0) {
loading = false; loading = false;
@ -249,8 +270,8 @@
console.log(`[Bookmarks] Limiting to ${maxTotalItems} items (found ${allEventIds.size})`); console.log(`[Bookmarks] Limiting to ${maxTotalItems} items (found ${allEventIds.size})`);
} }
// Fetch the actual events - batch to avoid relay limits // Fetch the actual events - batch to avoid relay limits (use smaller batch size to avoid "arr too big" errors)
const batchSize = config.veryLargeBatchLimit; const batchSize = 100; // Reduced from 500 to avoid relay limits
const allFetchedEvents: NostrEvent[] = []; const allFetchedEvents: NostrEvent[] = [];
console.log(`[Bookmarks] Fetching ${eventIds.length} events in batches of ${batchSize}`); console.log(`[Bookmarks] Fetching ${eventIds.length} events in batches of ${batchSize}`);
@ -277,10 +298,13 @@
console.log(`[Bookmarks] Total fetched: ${allFetchedEvents.length} events`); console.log(`[Bookmarks] Total fetched: ${allFetchedEvents.length} events`);
// Track which highlights we've already added (to avoid duplicates)
const addedHighlightIds = new Set<string>();
// Create BookmarkOrHighlight items // Create BookmarkOrHighlight items
for (const event of allFetchedEvents) { for (const event of allFetchedEvents) {
const isBookmark = bookmarkMap.has(event.id); const isBookmark = bookmarkMap.has(event.id);
const isHighlight = highlightMap.has(event.id); const highlightInfo = highlightBySourceEvent.get(event.id);
if (isBookmark) { if (isBookmark) {
items.push({ items.push({
@ -288,12 +312,40 @@
type: 'bookmark', type: 'bookmark',
authorPubkey: bookmarkMap.get(event.id)! authorPubkey: bookmarkMap.get(event.id)!
}); });
} else if (isHighlight) { } else if (highlightInfo) {
// For highlights, use the highlight event itself, not the source event
if (!addedHighlightIds.has(highlightInfo.highlight.id)) {
items.push({
event: highlightInfo.highlight,
type: 'highlight',
authorPubkey: highlightInfo.authorPubkey
});
addedHighlightIds.add(highlightInfo.highlight.id);
}
}
}
// Add ALL highlights with e-tag or a-tag references, even if source event wasn't found
for (const [sourceEventId, highlightInfo] of highlightBySourceEvent.entries()) {
if (!addedHighlightIds.has(highlightInfo.highlight.id)) {
items.push({ items.push({
event, event: highlightInfo.highlight,
type: 'highlight', type: 'highlight',
authorPubkey: highlightMap.get(event.id)! authorPubkey: highlightInfo.authorPubkey
}); });
addedHighlightIds.add(highlightInfo.highlight.id);
}
}
// Add highlights without e-tag or a-tag references (URL-only highlights, etc.)
for (const highlightInfo of highlightsWithoutRefs) {
if (!addedHighlightIds.has(highlightInfo.highlight.id)) {
items.push({
event: highlightInfo.highlight,
type: 'highlight',
authorPubkey: highlightInfo.authorPubkey
});
addedHighlightIds.add(highlightInfo.highlight.id);
} }
} }
@ -339,50 +391,133 @@
<p class="text-fog-text dark:text-fog-dark-text">No bookmarks or highlights found.</p> <p class="text-fog-text dark:text-fog-dark-text">No bookmarks or highlights found.</p>
</div> </div>
{:else} {:else}
<div class="filters-section mb-4"> <div class="filters-section-sticky mb-4">
<div class="type-filter-section"> <div class="filters-row">
<label for="type-filter" class="type-filter-label">Filter:</label> <div class="type-filter-section">
<select <label for="type-filter" class="type-filter-label">Filter:</label>
id="type-filter" <select
bind:value={typeFilter} id="type-filter"
class="type-filter-select" bind:value={typeFilter}
aria-label="Filter by type" class="type-filter-select"
> aria-label="Filter by type"
<option value="all">Bookmarks and highlights</option> >
<option value="bookmark">Bookmarks</option> <option value="all">Bookmarks and highlights</option>
<option value="highlight">Highlights</option> <option value="bookmark">Bookmarks</option>
</select> <option value="highlight">Highlights</option>
</div> </select>
<div class="search-filter-section"> </div>
<UnifiedSearch mode="filter" onFilterChange={handleFilterChange} placeholder="Filter by pubkey..." /> <div class="search-filter-section">
<UnifiedSearch
mode="search"
bind:this={unifiedSearchComponent}
allowedKinds={[KIND.BOOKMARKS, KIND.HIGHLIGHTED_ARTICLE]}
hideDropdownResults={true}
onSearchResults={handleSearchResults}
onFilterChange={handleFilterChange}
placeholder="Search bookmarks and highlights by pubkey, event ID, or content..."
/>
</div>
</div> </div>
{#if totalPages > 1 && !searchResults.events.length && !searchResults.profiles.length}
<div class="pagination pagination-top">
<button
class="pagination-button"
disabled={currentPage === 1}
onclick={() => {
if (currentPage > 1) currentPage--;
}}
aria-label="Previous page"
>
Previous
</button>
<div class="pagination-info">
<span class="text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages}
</span>
</div>
<button
class="pagination-button"
disabled={currentPage === totalPages}
onclick={() => {
if (currentPage < totalPages) currentPage++;
}}
aria-label="Next page"
>
Next
</button>
</div>
{/if}
</div> </div>
<div class="bookmarks-info"> {#if searchResults.events.length > 0 || searchResults.profiles.length > 0}
<p class="text-fog-text dark:text-fog-dark-text text-sm"> <div class="search-results-section">
Showing {paginatedItems.length} of {filteredItems.length} items <h2 class="results-title">Search Results</h2>
{#if allItems.length >= maxTotalItems}
(limited to {maxTotalItems}) {#if searchResults.profiles.length > 0}
<div class="results-group">
<h3>Profiles</h3>
<div class="profile-results">
{#each searchResults.profiles as pubkey}
<a href="/profile/{pubkey}" class="profile-result-card">
<ProfileBadge pubkey={pubkey} />
</a>
{/each}
</div>
</div>
{/if} {/if}
{#if filterResult.value}
(filtered) {#if searchResults.events.length > 0}
<div class="results-group">
<h3>Events ({searchResults.events.length})</h3>
<div class="event-results">
{#each searchResults.events as event}
{#if event.kind === KIND.HIGHLIGHTED_ARTICLE}
<div class="event-result-card">
<HighlightCard highlight={event} onOpenEvent={(e) => goto(`/event/${e.id}`)} />
</div>
{:else}
<a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
{/if}
{/each}
</div>
</div>
{/if} {/if}
</p> </div>
</div> {:else}
<div class="bookmarks-info">
<div class="bookmarks-posts"> <p class="text-fog-text dark:text-fog-dark-text text-sm">
Showing {paginatedItems.length} of {filteredItems.length} items
{#if allItems.length >= maxTotalItems}
(limited to {maxTotalItems})
{/if}
{#if filterResult.value}
(filtered)
{/if}
</p>
</div>
<div class="bookmarks-posts">
{#each paginatedItems as item (item.event.id)} {#each paginatedItems as item (item.event.id)}
<div class="bookmark-item-wrapper"> <div class="bookmark-item-wrapper">
<div class="bookmark-indicator-wrapper"> {#if item.type === 'highlight'}
<span <HighlightCard highlight={item.event} onOpenEvent={(event) => goto(`/event/${event.id}`)} />
class="bookmark-emoji" {:else}
class:grayscale={currentUserPubkey?.toLowerCase() !== item.authorPubkey.toLowerCase()} <div class="bookmark-indicator-wrapper">
title={item.type === 'bookmark' ? 'Bookmark' : 'Highlight'} <span
> class="bookmark-emoji"
{item.type === 'bookmark' ? '🔖' : '✨'} class:grayscale={currentUserPubkey?.toLowerCase() !== item.authorPubkey.toLowerCase()}
</span> title="Bookmark"
</div> >
<FeedPost post={item.event} /> 🔖
</span>
</div>
<FeedPost post={item.event} />
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@ -450,6 +585,7 @@
</button> </button>
</div> </div>
{/if} {/if}
{/if}
{/if} {/if}
</div> </div>
</main> </main>
@ -534,11 +670,42 @@
text-align: center; text-align: center;
} }
.filters-section { .filters-section-sticky {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 1rem;
background: var(--fog-post, #ffffff);
background-color: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
position: sticky;
top: 0;
z-index: 10;
margin-bottom: 1rem; margin-bottom: 1rem;
backdrop-filter: none;
}
:global(.dark) .filters-section-sticky {
background: var(--fog-dark-post, #1f2937);
background-color: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.filters-row {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pagination-top {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .pagination-top {
border-top-color: var(--fog-dark-border, #374151);
} }
.type-filter-section { .type-filter-section {
@ -617,4 +784,109 @@
.bookmark-emoji:not(.grayscale) { .bookmark-emoji:not(.grayscale) {
filter: grayscale(0%); filter: grayscale(0%);
} }
.search-results-section {
margin-top: 2rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 2rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .search-results-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.results-title {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .results-title {
color: var(--fog-dark-text, #cbd5e1);
}
.results-group {
margin-bottom: 2rem;
}
.results-group:last-child {
margin-bottom: 0;
}
.results-group h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
}
.profile-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-result-card {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
text-decoration: none;
transition: all 0.2s;
}
:global(.dark) .profile-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.profile-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .profile-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
.event-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-result-card {
display: block;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
overflow: hidden;
transition: all 0.2s;
text-decoration: none;
color: inherit;
}
:global(.dark) .event-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .event-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
</style> </style>

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

@ -2,6 +2,14 @@
import Header from '../../lib/components/layout/Header.svelte'; import Header from '../../lib/components/layout/Header.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
function handleBack() {
if (typeof window !== 'undefined' && window.history.length > 1) {
window.history.back();
} else {
goto('/');
}
}
import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByKinds, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js'; import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByKinds, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js';
import { cacheEvent } from '../../lib/services/cache/event-cache.js'; import { cacheEvent } from '../../lib/services/cache/event-cache.js';
import type { CachedEvent } from '../../lib/services/cache/event-cache.js'; import type { CachedEvent } from '../../lib/services/cache/event-cache.js';
@ -425,7 +433,16 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="cache-page"> <div class="cache-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Cache</h1> <div class="cache-header">
<button
onclick={handleBack}
class="back-button"
aria-label="Go back to previous page"
>
← Back
</button>
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Cache</h1>
</div>
{#if loading && !stats} {#if loading && !stats}
<div class="loading-state"> <div class="loading-state">
@ -666,6 +683,41 @@
padding: 0 1rem; padding: 0 1rem;
} }
.cache-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.back-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #cbd5e1);
border-radius: 4px;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1e293b);
cursor: pointer;
transition: all 0.2s;
font-size: 0.875em;
white-space: nowrap;
}
.back-button:hover {
background: var(--fog-highlight, #f1f5f9);
border-color: var(--fog-accent, #94a3b8);
}
:global(.dark) .back-button {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f1f5f9);
}
:global(.dark) .back-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #64748b);
}
.stats-section, .stats-section,
.filters-section, .filters-section,
.bulk-actions-section, .bulk-actions-section,

277
src/routes/discussions/+page.svelte

@ -2,15 +2,32 @@
import Header from '../../lib/components/layout/Header.svelte'; import Header from '../../lib/components/layout/Header.svelte';
import DiscussionList from '../../lib/modules/discussions/DiscussionList.svelte'; import DiscussionList from '../../lib/modules/discussions/DiscussionList.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte'; import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import FeedPost from '../../lib/modules/feed/FeedPost.svelte';
import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
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 unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
let discussionListComponent: { sortBy: 'newest' | 'active' | 'upvoted'; showOlder: boolean } | null = $state(null);
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;
} }
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
}); });
@ -20,21 +37,86 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="discussions-content"> <div class="discussions-content">
<div class="discussions-header mb-4"> <div class="discussions-header-sticky">
<div> <div class="discussions-header-top">
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Discussions</h1> <div>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr.</p> <h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Discussions</h1>
<p class="text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr.</p>
</div>
<a href="/write?kind=11" class="write-button" title="Write a new thread">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
<div class="discussions-controls">
<div class="search-section">
<UnifiedSearch
mode="search"
bind:this={unifiedSearchComponent}
allowedKinds={[KIND.DISCUSSION_THREAD]}
hideDropdownResults={true}
onSearchResults={handleSearchResults}
onFilterChange={handleFilterChange}
placeholder="Search kind 11 discussions by pubkey, p, q tags, or content..."
/>
</div>
{#if discussionListComponent && !searchResults.events.length && !searchResults.profiles.length}
<div class="discussions-controls-row">
<select
bind:value={discussionListComponent.sortBy}
class="sort-select"
>
<option value="newest">Newest</option>
<option value="active">Most Active</option>
<option value="upvoted">Most Upvoted</option>
</select>
<label class="show-older-label">
<input
type="checkbox"
bind:checked={discussionListComponent.showOlder}
/>
Show older posts (than 30 days)
</label>
</div>
{/if}
</div> </div>
<a href="/write?kind=11" class="write-button" title="Write a new thread">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
<div class="search-section mb-6">
<UnifiedSearch mode="filter" onFilterChange={handleFilterChange} placeholder="Filter by pubkey, p, q tags, or content..." />
</div> </div>
<DiscussionList filterResult={filterResult} /> {#if searchResults.events.length > 0 || searchResults.profiles.length > 0}
<div class="search-results-section">
<h2 class="results-title">Search Results</h2>
{#if searchResults.profiles.length > 0}
<div class="results-group">
<h3>Profiles</h3>
<div class="profile-results">
{#each searchResults.profiles as pubkey}
<a href="/profile/{pubkey}" class="profile-result-card">
<ProfileBadge pubkey={pubkey} />
</a>
{/each}
</div>
</div>
{/if}
{#if searchResults.events.length > 0}
<div class="results-group">
<h3>Events ({searchResults.events.length})</h3>
<div class="event-results">
{#each searchResults.events as event}
<a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
{/each}
</div>
</div>
{/if}
</div>
{:else}
<DiscussionList filterResult={filterResult} bind:this={discussionListComponent} />
{/if}
</div> </div>
</main> </main>
@ -44,43 +126,75 @@
margin: 0 auto; margin: 0 auto;
} }
.discussions-header { .discussions-header-sticky {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0 1rem; padding: 0 1rem;
position: sticky; position: sticky;
top: 0; top: 0;
background: var(--fog-bg, #ffffff); background: var(--fog-bg, #ffffff);
background-color: var(--fog-bg, #ffffff);
z-index: 10; z-index: 10;
padding-top: 1rem; padding-top: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
backdrop-filter: none;
} }
:global(.dark) .discussions-header { :global(.dark) .discussions-header-sticky {
background: var(--fog-dark-bg, #0f172a); background: var(--fog-dark-bg, #0f172a);
background-color: var(--fog-dark-bg, #0f172a);
border-bottom-color: var(--fog-dark-border, #1e293b); border-bottom-color: var(--fog-dark-border, #1e293b);
} }
.discussions-header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.discussions-controls {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-section { .search-section {
padding: 0 1rem;
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
position: sticky;
top: 0;
background: var(--fog-bg, #ffffff);
z-index: 9;
padding-top: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
} }
:global(.dark) .search-section { .discussions-controls-row {
background: var(--fog-dark-bg, #0f172a); display: flex;
border-bottom-color: var(--fog-dark-border, #1e293b); align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.sort-select {
padding: 0.5rem;
border: 1px solid var(--fog-border, #cbd5e1);
border-radius: 4px;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1e293b);
}
:global(.dark) .sort-select {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f1f5f9);
}
.show-older-label {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--fog-text, #1e293b);
cursor: pointer;
}
:global(.dark) .show-older-label {
color: var(--fog-dark-text, #f1f5f9);
} }
.search-section :global(.unified-search-container) { .search-section :global(.unified-search-container) {
@ -92,4 +206,109 @@
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
} }
.search-results-section {
margin-top: 2rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 2rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .search-results-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.results-title {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .results-title {
color: var(--fog-dark-text, #cbd5e1);
}
.results-group {
margin-bottom: 2rem;
}
.results-group:last-child {
margin-bottom: 0;
}
.results-group h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
}
.profile-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-result-card {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
text-decoration: none;
transition: all 0.2s;
}
:global(.dark) .profile-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.profile-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .profile-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
.event-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-result-card {
display: block;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
overflow: hidden;
transition: all 0.2s;
text-decoration: none;
color: inherit;
}
:global(.dark) .event-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .event-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
</style> </style>

277
src/routes/feed/+page.svelte

@ -2,15 +2,38 @@
import Header from '../../lib/components/layout/Header.svelte'; import Header from '../../lib/components/layout/Header.svelte';
import FeedPage from '../../lib/modules/feed/FeedPage.svelte'; import FeedPage from '../../lib/modules/feed/FeedPage.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte'; import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import FeedPost from '../../lib/modules/feed/FeedPost.svelte';
import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
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 unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
let feedPageComponent: {
loadOlderEvents: () => Promise<void>;
loadingMore: boolean;
hasMoreEvents: boolean;
waitingRoomEvents: NostrEvent[];
loadWaitingRoomEvents: () => void;
} | null = $state(null);
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;
} }
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
}); });
@ -21,21 +44,79 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="feed-content"> <div class="feed-content">
<div class="feed-header mb-6"> <div class="feed-header mb-6">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Feed</h1> <div class="feed-header-top">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Feed</h1>
<a href="/write?kind=1" class="write-button" title="Write a new post">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
<div class="feed-controls"> <div class="feed-controls">
<div class="search-section"> <div class="search-section">
<UnifiedSearch <UnifiedSearch
mode="filter" mode="search"
bind:this={unifiedSearchComponent}
allowedKinds={[KIND.SHORT_TEXT_NOTE]}
hideDropdownResults={true}
onSearchResults={handleSearchResults}
onFilterChange={handleFilterChange} onFilterChange={handleFilterChange}
placeholder="Filter by pubkey, p, q tags, or content..." placeholder="Search kind 1 events by pubkey, p, q tags, or content..."
/> />
</div> </div>
<a href="/write?kind=1" class="write-button" title="Write a new post"> <div class="feed-header-buttons">
<span class="emoji emoji-grayscale"></span> {#if feedPageComponent && feedPageComponent.waitingRoomEvents.length > 0 && !searchResults.events.length && !searchResults.profiles.length}
</a> <button
onclick={() => feedPageComponent?.loadWaitingRoomEvents()}
class="see-new-events-btn-header"
>
See {feedPageComponent.waitingRoomEvents.length} new event{feedPageComponent.waitingRoomEvents.length === 1 ? '' : 's'}
</button>
{/if}
{#if feedPageComponent && feedPageComponent.hasMoreEvents && !searchResults.events.length && !searchResults.profiles.length}
<button
onclick={() => feedPageComponent?.loadOlderEvents()}
disabled={feedPageComponent.loadingMore || !feedPageComponent.hasMoreEvents}
class="see-more-events-btn-header"
>
{feedPageComponent.loadingMore ? 'Loading...' : 'See more events'}
</button>
{/if}
</div>
</div> </div>
</div> </div>
<FeedPage filterResult={filterResult} />
{#if searchResults.events.length > 0 || searchResults.profiles.length > 0}
<div class="search-results-section">
<h2 class="results-title">Search Results</h2>
{#if searchResults.profiles.length > 0}
<div class="results-group">
<h3>Profiles</h3>
<div class="profile-results">
{#each searchResults.profiles as pubkey}
<a href="/profile/{pubkey}" class="profile-result-card">
<ProfileBadge pubkey={pubkey} />
</a>
{/each}
</div>
</div>
{/if}
{#if searchResults.events.length > 0}
<div class="results-group">
<h3>Events ({searchResults.events.length})</h3>
<div class="event-results">
{#each searchResults.events as event}
<a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
{/each}
</div>
</div>
{/if}
</div>
{:else}
<FeedPage filterResult={filterResult} bind:this={feedPageComponent} />
{/if}
</div> </div>
</main> </main>
@ -53,18 +134,28 @@
position: sticky; position: sticky;
top: 0; top: 0;
background: var(--fog-bg, #ffffff); background: var(--fog-bg, #ffffff);
background-color: var(--fog-bg, #ffffff);
z-index: 10; z-index: 10;
padding-top: 1rem; padding-top: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
backdrop-filter: none;
} }
:global(.dark) .feed-header { :global(.dark) .feed-header {
background: var(--fog-dark-bg, #0f172a); background: var(--fog-dark-bg, #0f172a);
background-color: var(--fog-dark-bg, #0f172a);
border-bottom-color: var(--fog-dark-border, #1e293b); border-bottom-color: var(--fog-dark-border, #1e293b);
} }
.feed-header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.feed-controls { .feed-controls {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -75,4 +166,176 @@
.search-section { .search-section {
flex: 1; flex: 1;
} }
.feed-header-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.see-new-events-btn-header {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-accent, #64748b);
border-radius: 4px;
background: var(--fog-accent, #64748b);
color: white;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
font-weight: 500;
}
.see-new-events-btn-header:hover {
background: var(--fog-accent, #475569);
border-color: var(--fog-accent, #475569);
}
:global(.dark) .see-new-events-btn-header {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
color: white;
}
:global(.dark) .see-new-events-btn-header:hover {
background: var(--fog-dark-accent, #7c8a9e);
border-color: var(--fog-dark-accent, #7c8a9e);
}
.see-more-events-btn-header {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #cbd5e1);
border-radius: 4px;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1e293b);
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.see-more-events-btn-header:hover:not(:disabled) {
background: var(--fog-highlight, #f1f5f9);
border-color: var(--fog-accent, #94a3b8);
}
.see-more-events-btn-header:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .see-more-events-btn-header {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f1f5f9);
}
:global(.dark) .see-more-events-btn-header:hover:not(:disabled) {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #64748b);
}
.search-results-section {
margin-top: 2rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 2rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .search-results-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.results-title {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .results-title {
color: var(--fog-dark-text, #cbd5e1);
}
.results-group {
margin-bottom: 2rem;
}
.results-group:last-child {
margin-bottom: 0;
}
.results-group h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
}
.profile-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-result-card {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
text-decoration: none;
transition: all 0.2s;
}
:global(.dark) .profile-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.profile-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .profile-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
.event-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-result-card {
display: block;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
overflow: hidden;
transition: all 0.2s;
text-decoration: none;
color: inherit;
}
:global(.dark) .event-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .event-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
</style> </style>

30
src/routes/find/+page.svelte

@ -9,13 +9,31 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let selectedKind = $state<number | null>(null); let selectedKind = $state<number | null>(null);
let selectedKindString = $state<string>('');
let unifiedSearchComponent: { triggerSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } } | null = $state(null); let unifiedSearchComponent: { triggerSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } } | null = $state(null);
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] }); let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] });
let searching = $state(false); let searching = $state(false);
// Sync selectedKindString with selectedKind
$effect(() => {
selectedKindString = selectedKind?.toString() || '';
});
// Sync selectedKind with selectedKindString when it changes
$effect(() => {
if (selectedKindString === '') {
selectedKind = null;
} else {
const parsed = parseInt(selectedKindString);
if (!isNaN(parsed)) {
selectedKind = parsed;
}
}
});
function handleKindChange(e: Event) { function handleKindChange(e: Event) {
const select = e.target as HTMLSelectElement; const select = e.target as HTMLSelectElement;
selectedKind = select.value === '' ? null : parseInt(select.value); selectedKindString = select.value;
} }
function handleSearch() { function handleSearch() {
@ -25,7 +43,7 @@
} }
} }
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) { function handleSearchResults(results: { events: NostrEvent[]; profiles: string[]; relays?: string[] }) {
searchResults = results; searchResults = results;
searching = false; searching = false;
} }
@ -66,7 +84,7 @@
<label for="kind-filter" class="kind-filter-label">Filter by Kind:</label> <label for="kind-filter" class="kind-filter-label">Filter by Kind:</label>
<select <select
id="kind-filter" id="kind-filter"
value={selectedKind?.toString() || ''} bind:value={selectedKindString}
onchange={handleKindChange} onchange={handleKindChange}
class="kind-filter-select" class="kind-filter-select"
aria-label="Filter by kind" aria-label="Filter by kind"
@ -122,7 +140,9 @@
</section> </section>
{:else if !searching && (unifiedSearchComponent?.getFilterResult()?.value)} {:else if !searching && (unifiedSearchComponent?.getFilterResult()?.value)}
<section class="results-section"> <section class="results-section">
<div class="no-results">No results found</div> <div class="no-results">
No results found on the relays: {searchResults.relays && searchResults.relays.length > 0 ? searchResults.relays.join(', ') : 'No relays available'}
</div>
</section> </section>
{/if} {/if}
</div> </div>

228
src/routes/repos/+page.svelte

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import Header from '../../lib/components/layout/Header.svelte'; import Header from '../../lib/components/layout/Header.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte'; import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import FeedPost from '../../lib/modules/feed/FeedPost.svelte';
import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js'; import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -12,13 +14,24 @@
let repos = $state<NostrEvent[]>([]); let repos = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
let searchQuery = $state('');
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 unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
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;
} }
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
await loadCachedRepos(); await loadCachedRepos();
@ -187,9 +200,9 @@
} }
} }
// Filter by search query if provided // Filter by text search if provided
if (searchQuery.trim()) { if (filterResult.value && filterResult.type === 'text') {
const query = searchQuery.toLowerCase(); const query = filterResult.value.toLowerCase();
filtered = filtered.filter(repo => { filtered = filtered.filter(repo => {
const name = getRepoName(repo).toLowerCase(); const name = getRepoName(repo).toLowerCase();
const desc = getRepoDescription(repo).toLowerCase(); const desc = getRepoDescription(repo).toLowerCase();
@ -212,31 +225,60 @@
</p> </p>
<div class="search-container mb-4"> <div class="search-container mb-4">
<input <UnifiedSearch
type="text" mode="search"
bind:value={searchQuery} bind:this={unifiedSearchComponent}
placeholder="Search repositories..." allowedKinds={[KIND.REPO_ANNOUNCEMENT]}
class="search-input" hideDropdownResults={true}
onSearchResults={handleSearchResults}
onFilterChange={handleFilterChange}
placeholder="Search kind 30040 repositories by pubkey, p, q tags, or content..."
/> />
</div> </div>
<div class="filter-container mb-4">
<UnifiedSearch mode="filter" onFilterChange={handleFilterChange} placeholder="Filter by pubkey..." />
</div>
</div> </div>
{#if loading} {#if searchResults.events.length > 0 || searchResults.profiles.length > 0}
<div class="loading-state"> <div class="search-results-section">
<p class="text-fog-text dark:text-fog-dark-text">Loading repositories...</p> <h2 class="results-title">Search Results</h2>
</div>
{:else if filteredRepos.length === 0} {#if searchResults.profiles.length > 0}
<div class="empty-state"> <div class="results-group">
<p class="text-fog-text dark:text-fog-dark-text"> <h3>Profiles</h3>
{searchQuery ? 'No repositories found matching your search.' : 'No repositories found.'} <div class="profile-results">
</p> {#each searchResults.profiles as pubkey}
</div> <a href="/profile/{pubkey}" class="profile-result-card">
{:else} <ProfileBadge pubkey={pubkey} />
<div class="repos-list"> </a>
{/each}
</div>
</div>
{/if}
{#if searchResults.events.length > 0}
<div class="results-group">
<h3>Events ({searchResults.events.length})</h3>
<div class="event-results">
{#each searchResults.events as event}
<a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
{/each}
</div>
</div>
{/if}
</div>
{:else if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading repositories...</p>
</div>
{:else if filteredRepos.length === 0}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">
No repositories found.
</p>
</div>
{:else}
<div class="repos-list">
{#each filteredRepos as repo (repo.id)} {#each filteredRepos as repo (repo.id)}
<div <div
class="repo-item" class="repo-item"
@ -264,8 +306,8 @@
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
</main> </main>
@ -289,33 +331,6 @@
max-width: 500px; max-width: 500px;
} }
.search-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 1rem;
}
:global(.dark) .search-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .search-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.loading-state, .loading-state,
.empty-state { .empty-state {
padding: 2rem; padding: 2rem;
@ -423,4 +438,109 @@
:global(.dark) .repo-meta { :global(.dark) .repo-meta {
color: var(--fog-dark-text-light, #9ca3af); color: var(--fog-dark-text-light, #9ca3af);
} }
.search-results-section {
margin-top: 2rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 2rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .search-results-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.results-title {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .results-title {
color: var(--fog-dark-text, #cbd5e1);
}
.results-group {
margin-bottom: 2rem;
}
.results-group:last-child {
margin-bottom: 0;
}
.results-group h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .results-group h3 {
color: var(--fog-dark-text-light, #9ca3af);
}
.profile-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-result-card {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
text-decoration: none;
transition: all 0.2s;
}
:global(.dark) .profile-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.profile-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .profile-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
.event-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-result-card {
display: block;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
overflow: hidden;
transition: all 0.2s;
text-decoration: none;
color: inherit;
}
:global(.dark) .event-result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:global(.dark) .event-result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
</style> </style>

55
src/routes/settings/+page.svelte

@ -3,6 +3,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { hasExpiringEventsEnabled } from '../../lib/services/event-expiration.js'; import { hasExpiringEventsEnabled } from '../../lib/services/event-expiration.js';
import { shouldIncludeClientTag, setIncludeClientTag } from '../../lib/services/client-tag-preference.js'; import { shouldIncludeClientTag, setIncludeClientTag } from '../../lib/services/client-tag-preference.js';
import { goto } from '$app/navigation';
type TextSize = 'small' | 'medium' | 'large'; type TextSize = 'small' | 'medium' | 'large';
type LineSpacing = 'tight' | 'normal' | 'loose'; type LineSpacing = 'tight' | 'normal' | 'loose';
@ -96,13 +97,30 @@
includeClientTag = !includeClientTag; includeClientTag = !includeClientTag;
setIncludeClientTag(includeClientTag); setIncludeClientTag(includeClientTag);
} }
function handleBack() {
if (typeof window !== 'undefined' && window.history.length > 1) {
window.history.back();
} else {
goto('/');
}
}
</script> </script>
<Header /> <Header />
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="settings-page"> <div class="settings-page">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Settings</h1> <div class="settings-header">
<button
onclick={handleBack}
class="back-button"
aria-label="Go back to previous page"
>
← Back
</button>
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Settings</h1>
</div>
<div class="space-y-6"> <div class="space-y-6">
<!-- Theme Toggle --> <!-- Theme Toggle -->
@ -274,6 +292,41 @@
padding: 0; padding: 0;
} }
.settings-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.back-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #cbd5e1);
border-radius: 4px;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1e293b);
cursor: pointer;
transition: all 0.2s;
font-size: 0.875em;
white-space: nowrap;
}
.back-button:hover {
background: var(--fog-highlight, #f1f5f9);
border-color: var(--fog-accent, #94a3b8);
}
:global(.dark) .back-button {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f1f5f9);
}
:global(.dark) .back-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #64748b);
}
.preference-section { .preference-section {
margin-bottom: 0; margin-bottom: 0;
} }

62
src/routes/topics/+page.svelte

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import Header from '../../lib/components/layout/Header.svelte'; import Header from '../../lib/components/layout/Header.svelte';
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte'; import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import FeedPost from '../../lib/modules/feed/FeedPost.svelte';
import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js'; import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { getEventsByKind } from '../../lib/services/cache/event-cache.js'; import { getEventsByKind } from '../../lib/services/cache/event-cache.js';
@ -30,11 +32,23 @@
let observer: IntersectionObserver | null = null; let observer: IntersectionObserver | null = null;
let renderedCount = $state(ITEMS_PER_PAGE); let renderedCount = $state(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 unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
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;
} }
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
// Filter topics based on filter result // Filter topics based on filter result
let filteredTopics = $derived.by(() => { let filteredTopics = $derived.by(() => {
if (!filterResult.value || filterResult.type !== 'pubkey') return allTopics; if (!filterResult.value || filterResult.type !== 'pubkey') return allTopics;
@ -223,11 +237,50 @@
<p class="text-fog-text dark:text-fog-dark-text">No topics found.</p> <p class="text-fog-text dark:text-fog-dark-text">No topics found.</p>
{:else} {:else}
<div class="filter-section mb-4"> <div class="filter-section mb-4">
<UnifiedSearch mode="filter" onFilterChange={handleFilterChange} placeholder="Filter by pubkey..." /> <UnifiedSearch
mode="search"
bind:this={unifiedSearchComponent}
allowedKinds={[KIND.DISCUSSION_THREAD]}
hideDropdownResults={true}
onSearchResults={handleSearchResults}
onFilterChange={handleFilterChange}
placeholder="Search kind 11 discussions by pubkey, p, q tags, or content..."
/>
</div> </div>
<div class="topics-container"> {#if searchResults.events.length > 0 || searchResults.profiles.length > 0}
<div class="topics-list"> <div class="search-results-section">
<h2 class="results-title">Search Results</h2>
{#if searchResults.profiles.length > 0}
<div class="results-group">
<h3>Profiles</h3>
<div class="profile-results">
{#each searchResults.profiles as pubkey}
<a href="/profile/{pubkey}" class="profile-result-card">
<ProfileBadge pubkey={pubkey} />
</a>
{/each}
</div>
</div>
{/if}
{#if searchResults.events.length > 0}
<div class="results-group">
<h3>Events ({searchResults.events.length})</h3>
<div class="event-results">
{#each searchResults.events as event}
<a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
{/each}
</div>
</div>
{/if}
</div>
{:else}
<div class="topics-container">
<div class="topics-list">
{#each visibleTopics as topic (topic.name)} {#each visibleTopics as topic (topic.name)}
<div <div
class="topic-item" class="topic-item"
@ -271,7 +324,8 @@
</p> </p>
</div> </div>
{/if} {/if}
</div> </div>
{/if}
{/if} {/if}
</div> </div>
</main> </main>

Loading…
Cancel
Save