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

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

@ -47,7 +47,7 @@ @@ -47,7 +47,7 @@
</div>
<!-- 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-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>
@ -112,6 +112,12 @@ @@ -112,6 +112,12 @@
position: sticky;
top: 0;
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 */

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

@ -17,10 +17,14 @@ @@ -17,10 +17,14 @@
selectedKind?: number | null; // Selected kind for filtering
onKindChange?: (kind: number | null) => void; // Callback when kind filter changes
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 searching = $state(false);
@ -119,7 +123,7 @@ @@ -119,7 +123,7 @@
if (!searchQuery.trim()) {
searchResults = [];
showResults = false;
filterResult = { type: null, value: null, kind: selectedKind };
filterResult = { type: null, value: null, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
return;
}
@ -129,7 +133,10 @@ @@ -129,7 +133,10 @@
resolving = true;
searchResults = [];
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 {
const query = searchQuery.trim();
@ -140,7 +147,7 @@ @@ -140,7 +147,7 @@
let event: NostrEvent | undefined = await getEvent(hexId);
if (!event) {
const relays = relayManager.getFeedReadRelays();
const relays = relayManager.getAllAvailableRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [hexId] }],
relays,
@ -154,68 +161,72 @@ @@ -154,68 +161,72 @@
}
if (event) {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
onSearchResults({ events: foundEvents, profiles: [] });
// If kinds are specified, filter by kind
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) {
// Event found but doesn't match allowed kinds, continue to next check
} else {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
} else {
searchResults = [{ event, matchType: 'Event ID' }];
showResults = true;
}
} else {
searchResults = [{ event, matchType: 'Event ID' }];
showResults = true;
filterResult = { type: 'event', value: event.id, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
}
} else {
filterResult = { type: 'event', value: event.id };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
return;
}
searching = false;
resolving = false;
return;
}
// Event not found, try as pubkey (step 2)
const hexPubkey = hexId.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays();
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
// If kinds are specified, search for events with this pubkey in p/q tags
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getAllAvailableRelays();
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());
onSearchResults({ events: foundEvents, profiles: [] });
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [hexPubkey];
onSearchResults({ events: [], profiles: foundProfiles });
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else {
// For search mode, navigate to profile
handleProfileClick(hexPubkey);
@ -224,7 +235,7 @@ @@ -224,7 +235,7 @@
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: hexPubkey, kind: selectedKind };
filterResult = { type: 'pubkey', value: hexPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
@ -248,49 +259,48 @@ @@ -248,49 +259,48 @@
if (pubkey) {
const normalizedPubkey = pubkey.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays();
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
// If kinds are specified, search for events with this pubkey in p/q tags
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getAllAvailableRelays();
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());
onSearchResults({ events: foundEvents, profiles: [] });
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles });
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else {
handleProfileClick(normalizedPubkey);
}
@ -298,7 +308,7 @@ @@ -298,7 +308,7 @@
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind };
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
@ -318,49 +328,48 @@ @@ -318,49 +328,48 @@
const cachedPubkey = await searchCacheForNIP05(query);
if (cachedPubkey) {
const normalizedPubkey = cachedPubkey.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays();
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
// If kinds are specified, search for events with this pubkey in p/q tags
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getAllAvailableRelays();
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());
onSearchResults({ events: foundEvents, profiles: [] });
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles });
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else {
handleProfileClick(normalizedPubkey);
}
@ -368,7 +377,7 @@ @@ -368,7 +377,7 @@
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind };
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
@ -380,49 +389,48 @@ @@ -380,49 +389,48 @@
const wellKnownPubkey = await resolveNIP05FromWellKnown(query);
if (wellKnownPubkey) {
const normalizedPubkey = wellKnownPubkey.toLowerCase();
// If kind is selected, search for events with this pubkey in p/q tags
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getProfileReadRelays();
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
// If kinds are specified, search for events with this pubkey in p/q tags
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getAllAvailableRelays();
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());
onSearchResults({ events: foundEvents, profiles: [] });
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles });
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else {
handleProfileClick(normalizedPubkey);
}
@ -430,7 +438,7 @@ @@ -430,7 +438,7 @@
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind };
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
@ -464,7 +472,7 @@ @@ -464,7 +472,7 @@
let event: NostrEvent | undefined = await getEvent(eventId);
if (!event) {
const relays = relayManager.getFeedReadRelays();
const relays = relayManager.getAllAvailableRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [eventId] }],
relays,
@ -478,21 +486,26 @@ @@ -478,21 +486,26 @@
}
if (event) {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
onSearchResults({ events: foundEvents, profiles: [] });
// If kinds are specified, filter by kind
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) {
// Event found but doesn't match allowed kinds, continue to next check
} else {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
} else {
searchResults = [{ event, matchType: 'Event ID' }];
showResults = true;
}
} else {
searchResults = [{ event, matchType: 'Event ID' }];
showResults = true;
filterResult = { type: 'event', value: event.id, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
}
} else {
filterResult = { type: 'event', value: event.id, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
return;
}
searching = false;
resolving = false;
return;
}
}
} catch (error) {
@ -502,40 +515,74 @@ @@ -502,40 +515,74 @@
// 6. Anything else is a full-text search
if (mode === 'search') {
// Text search in cached events (title, summary, content)
const allCached: NostrEvent[] = [];
let allEvents: NostrEvent[] = [];
// If kind filter is selected, only search that kind
if (selectedKind !== null) {
const kindEvents = await getEventsByKind(selectedKind, 100);
allCached.push(...kindEvents);
// If kinds are specified, search from relays
if (effectiveKinds && effectiveKinds.length > 0) {
const relays = relayManager.getAllAvailableRelays();
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 {
// 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
// Text search in cached events (title, summary, content)
const allCached: NostrEvent[] = [];
// If kind filter is selected, only search that kind
if (selectedKind !== null) {
const kindEvents = await getEventsByKind(selectedKind, 100);
allCached.push(...kindEvents);
} 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 matches = 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;
});
const sorted = matches.sort((a, b) => {
const sorted = allEvents.sort((a, b) => {
const aExact = a.content.toLowerCase() === queryLower;
const bExact = b.content.toLowerCase() === queryLower;
if (aExact && !bExact) return -1;
@ -543,22 +590,32 @@ @@ -543,22 +590,32 @@
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) {
foundEvents = limitedResults;
onSearchResults({ events: foundEvents, profiles: [] });
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
} else {
searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' }));
showResults = true;
}
} else {
// 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);
}
} catch (error) {
console.error('Search error:', error);
// Ensure we reset state even on error
if (hideDropdownResults && onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: relaysUsed });
}
} finally {
searching = false;
resolving = false;
@ -675,7 +732,7 @@ @@ -675,7 +732,7 @@
<div class="unified-search-container">
<div class="search-input-wrapper">
{#if showKindFilter}
{#if showKindFilter && !allowedKinds}
<select
value={selectedKind?.toString() || ''}
onchange={handleKindChange}
@ -757,11 +814,34 @@ @@ -757,11 +814,34 @@
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
background-color: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: monospace;
cursor: pointer;
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 {
@ -773,9 +853,29 @@ @@ -773,9 +853,29 @@
:global(.dark) .kind-filter-select {
border-color: var(--fog-dark-border, #374151);
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 {
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 {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);

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

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

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

@ -16,6 +16,9 @@ @@ -16,6 +16,9 @@
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)
// 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
@ -623,25 +626,6 @@ @@ -623,25 +626,6 @@
</script>
<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 -->
<div class="mb-6">

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

@ -14,6 +14,10 @@ @@ -14,6 +14,10 @@
}
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
let allEvents = $state<NostrEvent[]>([]);
@ -87,6 +91,7 @@ @@ -87,6 +91,7 @@
}
// Load older events (pagination)
// svelte-ignore non_reactive_update
async function loadOlderEvents() {
if (loadingMore || !hasMoreEvents) return;
@ -304,24 +309,6 @@ @@ -304,24 +309,6 @@
<p class="text-fog-text dark:text-fog-dark-text">No posts found.</p>
</div>
{: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">
{#each events as event (event.id)}
<FeedPost post={event} />
@ -405,21 +392,6 @@ @@ -405,21 +392,6 @@
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 {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
@ -432,30 +404,25 @@ @@ -432,30 +404,25 @@
transition: all 0.2s;
}
.see-new-events-btn:hover,
.see-more-events-btn:hover:not(:disabled) {
background: var(--fog-text, #475569);
}
.see-new-events-btn:disabled,
.see-more-events-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .see-new-events-btn,
:global(.dark) .see-more-events-btn {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .see-new-events-btn:hover:not(:disabled),
:global(.dark) .see-more-events-btn:hover:not(:disabled) {
background: var(--fog-dark-text, #cbd5e1);
}
.load-more-section,
.load-more-section-top {
.load-more-section {
padding: 2rem;
text-align: center;
}

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

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

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

@ -240,6 +240,18 @@ class RelayManager { @@ -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)
*/

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

@ -181,12 +181,12 @@ export function parseUserStatus(event: NostrEvent): string | null { @@ -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,
relays?: string[]
): Promise<string | null> {
): Promise<NostrEvent | null> {
const relayList = relays || [
...config.defaultRelays,
...config.profileRelays
@ -207,7 +207,19 @@ export async function fetchUserStatus( @@ -207,7 +207,19 @@ export async function fetchUserStatus(
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 {

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

@ -67,7 +67,12 @@ export const KIND = { @@ -67,7 +67,12 @@ export const KIND = {
PICTURE_NOTE: 20,
VIDEO_NOTE: 21,
SHORT_VIDEO_NOTE: 22,
PUBLIC_MESSAGE: 24,
LONG_FORM_NOTE: 30023,
WIKI_MARKDOWN: 30817,
WIKI_ASCIIDOC: 30818,
PUBLICATION_INDEX: 30040,
PUBLICATION_CONTENT: 30041,
HIGHLIGHTED_ARTICLE: 9802,
FILE_METADATA: 1063,
POLL: 1068,
@ -76,6 +81,7 @@ export const KIND = { @@ -76,6 +81,7 @@ export const KIND = {
PAYMENT_ADDRESSES: 10133,
LABEL: 1985,
REPORT: 1984,
ZAP_REQUEST: 9734, // NIP-57 Zap Request (not published to relays)
ZAP_RECEIPT: 9735,
RELAY_LIST: 10002,
BLOCKED_RELAYS: 10006,
@ -90,25 +96,30 @@ export const KIND = { @@ -90,25 +96,30 @@ export const KIND = {
MUTE_LIST: 10000,
BADGES: 30008,
FOLOW_SET: 30000,
HTTP_AUTH: 27235, // NIP-98 HTTP Auth (matches nostr-tools and jumble)
REPO_ANNOUNCEMENT: 30617, // NIP-34 Repository Announcement
ISSUE: 1621, // NIP-34 Issue
STATUS_OPEN: 1630, // NIP-34 Status: Open
STATUS_APPLIED: 1631, // NIP-34 Status: Applied/Merged/Resolved
STATUS_CLOSED: 1632, // NIP-34 Status: Closed
STATUS_DRAFT: 1633 // NIP-34 Status: Draft
HTTP_AUTH: 27235,
REPO_ANNOUNCEMENT: 30617,
ISSUE: 1621,
STATUS_OPEN: 1630,
STATUS_APPLIED: 1631,
STATUS_CLOSED: 1632,
STATUS_DRAFT: 1633
} as const;
export const KIND_LOOKUP: Record<number, KindInfo> = {
// Core kinds
[KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Short Text Note', showInFeed: true, isSecondaryKind: false },
[KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Public Message', showInFeed: false, isSecondaryKind: false },
[KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Text Note', showInFeed: true, 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.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
[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.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
[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> = { @@ -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 },
// 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.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.REPORT]: { number: KIND.REPORT, description: 'Report', showInFeed: false, isSecondaryKind: false },
// 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 },
// Relay lists
@ -152,6 +164,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -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.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.HTTP_AUTH]: { number: KIND.HTTP_AUTH, description: 'HTTP Auth', showInFeed: false, isSecondaryKind: false },
// Repository (NIP-34)
[KIND.REPO_ANNOUNCEMENT]: { number: KIND.REPO_ANNOUNCEMENT, description: 'Repository Announcement', showInFeed: false, isSecondaryKind: false },

386
src/routes/bookmarks/+page.svelte

@ -1,7 +1,9 @@ @@ -1,7 +1,9 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.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 ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { config } from '../../lib/services/nostr/config.js';
@ -10,6 +12,7 @@ @@ -10,6 +12,7 @@
import type { NostrEvent } from '../../lib/types/nostr.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
interface BookmarkOrHighlight {
event: NostrEvent;
@ -23,10 +26,22 @@ @@ -23,10 +26,22 @@
let currentPage = $state(1);
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
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 }) {
filterResult = result;
}
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
const itemsPerPage = 100;
const maxTotalItems = 500;
@ -76,9 +91,9 @@ @@ -76,9 +91,9 @@
const relays = relayManager.getFeedReadRelays();
const items: BookmarkOrHighlight[] = [];
// 1. Fetch bookmark lists (kind 10003)
// 1. Fetch bookmark lists (kind 10003) - limit 400
const fetchedBookmarkLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.BOOKMARKS], limit: config.feedLimit }],
[{ kinds: [KIND.BOOKMARKS], limit: 400 }],
relays,
{
useCache: true,
@ -99,7 +114,7 @@ @@ -99,7 +114,7 @@
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)
const profileRelays = relayManager.getProfileReadRelays();
const allRelaysForHighlights = [...new Set([...relays, ...profileRelays])];
@ -107,7 +122,7 @@ @@ -107,7 +122,7 @@
// 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
const highlightEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: config.feedLimit }],
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }],
allRelaysForHighlights,
{
useCache: true,
@ -118,9 +133,11 @@ @@ -118,9 +133,11 @@
console.log(`[Bookmarks] Found ${highlightEvents.length} highlight events from ${allRelaysForHighlights.length} relays`);
// Extract event IDs from highlights (e-tags and a-tags)
const highlightMap = new Map<string, string>(); // eventId -> authorPubkey
// For highlights, we store the highlight event itself, mapped by source event ID
// 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 highlightsWithoutRefs: { highlight: NostrEvent; authorPubkey: string }[] = []; // Highlights without e-tag or a-tag
let highlightsWithETags = 0;
let highlightsWithATags = 0;
@ -133,7 +150,7 @@ @@ -133,7 +150,7 @@
// Extract e-tag (direct event reference)
const eTag = highlight.tags.find(t => t[0] === 'e' && t[1]);
if (eTag && eTag[1]) {
highlightMap.set(eTag[1], highlight.pubkey);
highlightBySourceEvent.set(eTag[1], { highlight, authorPubkey: highlight.pubkey });
highlightsWithETags++;
hasRef = true;
}
@ -148,6 +165,8 @@ @@ -148,6 +165,8 @@
if (!hasRef) {
highlightsWithNoRefs++;
// Store highlights without refs to display them directly
highlightsWithoutRefs.push({ highlight, authorPubkey: highlight.pubkey });
// Log a sample of highlights without refs for debugging
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(', '));
@ -155,7 +174,7 @@ @@ -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`);
// Second pass: fetch events for a-tags in batches
@ -213,12 +232,12 @@ @@ -213,12 +232,12 @@
if (dTag) {
const eventDTag = event.tags.find(t => t[0] === 'd' && t[1]);
if (eventDTag && eventDTag[1] === dTag) {
highlightMap.set(event.id, info.pubkey);
highlightBySourceEvent.set(event.id, { highlight: info.highlight, authorPubkey: info.pubkey });
break;
}
} else {
// 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;
}
}
@ -233,10 +252,12 @@ @@ -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
const allEventIds = new Set([...bookmarkMap.keys(), ...highlightMap.keys()]);
// Combine all event IDs (bookmarks + highlight source events)
const allEventIds = new Set([...bookmarkMap.keys(), ...highlightSourceEventIds]);
if (allEventIds.size === 0) {
loading = false;
@ -249,8 +270,8 @@ @@ -249,8 +270,8 @@
console.log(`[Bookmarks] Limiting to ${maxTotalItems} items (found ${allEventIds.size})`);
}
// Fetch the actual events - batch to avoid relay limits
const batchSize = config.veryLargeBatchLimit;
// Fetch the actual events - batch to avoid relay limits (use smaller batch size to avoid "arr too big" errors)
const batchSize = 100; // Reduced from 500 to avoid relay limits
const allFetchedEvents: NostrEvent[] = [];
console.log(`[Bookmarks] Fetching ${eventIds.length} events in batches of ${batchSize}`);
@ -277,10 +298,13 @@ @@ -277,10 +298,13 @@
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
for (const event of allFetchedEvents) {
const isBookmark = bookmarkMap.has(event.id);
const isHighlight = highlightMap.has(event.id);
const highlightInfo = highlightBySourceEvent.get(event.id);
if (isBookmark) {
items.push({
@ -288,12 +312,40 @@ @@ -288,12 +312,40 @@
type: 'bookmark',
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({
event,
event: highlightInfo.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 @@ @@ -339,50 +391,133 @@
<p class="text-fog-text dark:text-fog-dark-text">No bookmarks or highlights found.</p>
</div>
{:else}
<div class="filters-section mb-4">
<div class="type-filter-section">
<label for="type-filter" class="type-filter-label">Filter:</label>
<select
id="type-filter"
bind:value={typeFilter}
class="type-filter-select"
aria-label="Filter by type"
>
<option value="all">Bookmarks and highlights</option>
<option value="bookmark">Bookmarks</option>
<option value="highlight">Highlights</option>
</select>
</div>
<div class="search-filter-section">
<UnifiedSearch mode="filter" onFilterChange={handleFilterChange} placeholder="Filter by pubkey..." />
<div class="filters-section-sticky mb-4">
<div class="filters-row">
<div class="type-filter-section">
<label for="type-filter" class="type-filter-label">Filter:</label>
<select
id="type-filter"
bind:value={typeFilter}
class="type-filter-select"
aria-label="Filter by type"
>
<option value="all">Bookmarks and highlights</option>
<option value="bookmark">Bookmarks</option>
<option value="highlight">Highlights</option>
</select>
</div>
<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>
{#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 class="bookmarks-info">
<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 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 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}
</p>
</div>
<div class="bookmarks-posts">
</div>
{:else}
<div class="bookmarks-info">
<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)}
<div class="bookmark-item-wrapper">
<div class="bookmark-indicator-wrapper">
<span
class="bookmark-emoji"
class:grayscale={currentUserPubkey?.toLowerCase() !== item.authorPubkey.toLowerCase()}
title={item.type === 'bookmark' ? 'Bookmark' : 'Highlight'}
>
{item.type === 'bookmark' ? '🔖' : '✨'}
</span>
</div>
<FeedPost post={item.event} />
{#if item.type === 'highlight'}
<HighlightCard highlight={item.event} onOpenEvent={(event) => goto(`/event/${event.id}`)} />
{:else}
<div class="bookmark-indicator-wrapper">
<span
class="bookmark-emoji"
class:grayscale={currentUserPubkey?.toLowerCase() !== item.authorPubkey.toLowerCase()}
title="Bookmark"
>
🔖
</span>
</div>
<FeedPost post={item.event} />
{/if}
</div>
{/each}
</div>
@ -450,6 +585,7 @@ @@ -450,6 +585,7 @@
</button>
</div>
{/if}
{/if}
{/if}
</div>
</main>
@ -534,11 +670,42 @@ @@ -534,11 +670,42 @@
text-align: center;
}
.filters-section {
.filters-section-sticky {
display: flex;
flex-direction: column;
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;
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 {
@ -617,4 +784,109 @@ @@ -617,4 +784,109 @@
.bookmark-emoji:not(.grayscale) {
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>

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

@ -2,6 +2,14 @@ @@ -2,6 +2,14 @@
import Header from '../../lib/components/layout/Header.svelte';
import { onMount } from 'svelte';
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 { cacheEvent } from '../../lib/services/cache/event-cache.js';
import type { CachedEvent } from '../../lib/services/cache/event-cache.js';
@ -425,7 +433,16 @@ @@ -425,7 +433,16 @@
<main class="container mx-auto px-4 py-8">
<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}
<div class="loading-state">
@ -666,6 +683,41 @@ @@ -666,6 +683,41 @@
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,
.filters-section,
.bulk-actions-section,

277
src/routes/discussions/+page.svelte

@ -2,15 +2,32 @@ @@ -2,15 +2,32 @@
import Header from '../../lib/components/layout/Header.svelte';
import DiscussionList from '../../lib/modules/discussions/DiscussionList.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 { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte';
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 }) {
filterResult = result;
}
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
onMount(async () => {
await nostrClient.initialize();
});
@ -20,21 +37,86 @@ @@ -20,21 +37,86 @@
<main class="container mx-auto px-4 py-8">
<div class="discussions-content">
<div class="discussions-header mb-4">
<div>
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Discussions</h1>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr.</p>
<div class="discussions-header-sticky">
<div class="discussions-header-top">
<div>
<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>
<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>
<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>
</main>
@ -44,43 +126,75 @@ @@ -44,43 +126,75 @@
margin: 0 auto;
}
.discussions-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
.discussions-header-sticky {
padding: 0 1rem;
position: sticky;
top: 0;
background: var(--fog-bg, #ffffff);
background-color: var(--fog-bg, #ffffff);
z-index: 10;
padding-top: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
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-color: var(--fog-dark-bg, #0f172a);
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 {
padding: 0 1rem;
display: flex;
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 {
background: var(--fog-dark-bg, #0f172a);
border-bottom-color: var(--fog-dark-border, #1e293b);
.discussions-controls-row {
display: flex;
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) {
@ -92,4 +206,109 @@ @@ -92,4 +206,109 @@
max-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>

277
src/routes/feed/+page.svelte

@ -2,15 +2,38 @@ @@ -2,15 +2,38 @@
import Header from '../../lib/components/layout/Header.svelte';
import FeedPage from '../../lib/modules/feed/FeedPage.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 { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte';
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 }) {
filterResult = result;
}
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
onMount(async () => {
await nostrClient.initialize();
});
@ -21,21 +44,79 @@ @@ -21,21 +44,79 @@
<main class="container mx-auto px-4 py-8">
<div class="feed-content">
<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="search-section">
<UnifiedSearch
mode="filter"
mode="search"
bind:this={unifiedSearchComponent}
allowedKinds={[KIND.SHORT_TEXT_NOTE]}
hideDropdownResults={true}
onSearchResults={handleSearchResults}
onFilterChange={handleFilterChange}
placeholder="Filter by pubkey, p, q tags, or content..."
placeholder="Search kind 1 events by pubkey, p, q tags, or content..."
/>
</div>
<a href="/write?kind=1" class="write-button" title="Write a new post">
<span class="emoji emoji-grayscale"></span>
</a>
<div class="feed-header-buttons">
{#if feedPageComponent && feedPageComponent.waitingRoomEvents.length > 0 && !searchResults.events.length && !searchResults.profiles.length}
<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>
<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>
</main>
@ -53,18 +134,28 @@ @@ -53,18 +134,28 @@
position: sticky;
top: 0;
background: var(--fog-bg, #ffffff);
background-color: var(--fog-bg, #ffffff);
z-index: 10;
padding-top: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
backdrop-filter: none;
}
:global(.dark) .feed-header {
background: var(--fog-dark-bg, #0f172a);
background-color: var(--fog-dark-bg, #0f172a);
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 {
display: flex;
justify-content: space-between;
@ -75,4 +166,176 @@ @@ -75,4 +166,176 @@
.search-section {
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>

30
src/routes/find/+page.svelte

@ -9,13 +9,31 @@ @@ -9,13 +9,31 @@
import { onMount } from 'svelte';
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 searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] });
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) {
const select = e.target as HTMLSelectElement;
selectedKind = select.value === '' ? null : parseInt(select.value);
selectedKindString = select.value;
}
function handleSearch() {
@ -25,7 +43,7 @@ @@ -25,7 +43,7 @@
}
}
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[]; relays?: string[] }) {
searchResults = results;
searching = false;
}
@ -66,7 +84,7 @@ @@ -66,7 +84,7 @@
<label for="kind-filter" class="kind-filter-label">Filter by Kind:</label>
<select
id="kind-filter"
value={selectedKind?.toString() || ''}
bind:value={selectedKindString}
onchange={handleKindChange}
class="kind-filter-select"
aria-label="Filter by kind"
@ -122,7 +140,9 @@ @@ -122,7 +140,9 @@
</section>
{:else if !searching && (unifiedSearchComponent?.getFilterResult()?.value)}
<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>
{/if}
</div>

228
src/routes/repos/+page.svelte

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.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 { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { onMount } from 'svelte';
@ -12,13 +14,24 @@ @@ -12,13 +14,24 @@
let repos = $state<NostrEvent[]>([]);
let loading = $state(true);
let searchQuery = $state('');
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 }) {
filterResult = result;
}
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
onMount(async () => {
await nostrClient.initialize();
await loadCachedRepos();
@ -187,9 +200,9 @@ @@ -187,9 +200,9 @@
}
}
// Filter by search query if provided
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
// Filter by text search if provided
if (filterResult.value && filterResult.type === 'text') {
const query = filterResult.value.toLowerCase();
filtered = filtered.filter(repo => {
const name = getRepoName(repo).toLowerCase();
const desc = getRepoDescription(repo).toLowerCase();
@ -212,31 +225,60 @@ @@ -212,31 +225,60 @@
</p>
<div class="search-container mb-4">
<input
type="text"
bind:value={searchQuery}
placeholder="Search repositories..."
class="search-input"
<UnifiedSearch
mode="search"
bind:this={unifiedSearchComponent}
allowedKinds={[KIND.REPO_ANNOUNCEMENT]}
hideDropdownResults={true}
onSearchResults={handleSearchResults}
onFilterChange={handleFilterChange}
placeholder="Search kind 30040 repositories by pubkey, p, q tags, or content..."
/>
</div>
<div class="filter-container mb-4">
<UnifiedSearch mode="filter" onFilterChange={handleFilterChange} placeholder="Filter by pubkey..." />
</div>
</div>
{#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">
{searchQuery ? 'No repositories found matching your search.' : 'No repositories found.'}
</p>
</div>
{:else}
<div class="repos-list">
{#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 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)}
<div
class="repo-item"
@ -264,8 +306,8 @@ @@ -264,8 +306,8 @@
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</main>
@ -289,33 +331,6 @@ @@ -289,33 +331,6 @@
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,
.empty-state {
padding: 2rem;
@ -423,4 +438,109 @@ @@ -423,4 +438,109 @@
:global(.dark) .repo-meta {
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>

55
src/routes/settings/+page.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import { onMount } from 'svelte';
import { hasExpiringEventsEnabled } from '../../lib/services/event-expiration.js';
import { shouldIncludeClientTag, setIncludeClientTag } from '../../lib/services/client-tag-preference.js';
import { goto } from '$app/navigation';
type TextSize = 'small' | 'medium' | 'large';
type LineSpacing = 'tight' | 'normal' | 'loose';
@ -96,13 +97,30 @@ @@ -96,13 +97,30 @@
includeClientTag = !includeClientTag;
setIncludeClientTag(includeClientTag);
}
function handleBack() {
if (typeof window !== 'undefined' && window.history.length > 1) {
window.history.back();
} else {
goto('/');
}
}
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<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">
<!-- Theme Toggle -->
@ -274,6 +292,41 @@ @@ -274,6 +292,41 @@
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 {
margin-bottom: 0;
}

62
src/routes/topics/+page.svelte

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.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 { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { getEventsByKind } from '../../lib/services/cache/event-cache.js';
@ -30,11 +32,23 @@ @@ -30,11 +32,23 @@
let observer: IntersectionObserver | null = null;
let renderedCount = $state(ITEMS_PER_PAGE);
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 }) {
filterResult = result;
}
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) {
searchResults = results;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
// Filter topics based on filter result
let filteredTopics = $derived.by(() => {
if (!filterResult.value || filterResult.type !== 'pubkey') return allTopics;
@ -223,11 +237,50 @@ @@ -223,11 +237,50 @@
<p class="text-fog-text dark:text-fog-dark-text">No topics found.</p>
{:else}
<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 class="topics-container">
<div class="topics-list">
{#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}
<div class="topics-container">
<div class="topics-list">
{#each visibleTopics as topic (topic.name)}
<div
class="topic-item"
@ -271,7 +324,8 @@ @@ -271,7 +324,8 @@
</p>
</div>
{/if}
</div>
</div>
{/if}
{/if}
</div>
</main>

Loading…
Cancel
Save