Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
87d55c5919
  1. 4
      src/app.css
  2. 62
      src/lib/components/content/MetadataCard.svelte
  3. 7
      src/lib/components/content/RichTextEditor.svelte
  4. 186
      src/lib/components/find/SearchAddressableEvents.svelte
  5. 25
      src/lib/components/layout/Header.svelte
  6. 8
      src/lib/components/layout/UnifiedSearch.svelte
  7. 2
      src/lib/components/ui/IconButton.svelte
  8. 5
      src/lib/modules/feed/FeedPost.svelte
  9. 6
      src/lib/modules/feed/HighlightCard.svelte
  10. 102
      src/routes/bookmarks/+page.svelte
  11. 9
      src/routes/cache/+page.svelte
  12. 5
      src/routes/discussions/+page.svelte
  13. 5
      src/routes/feed/+page.svelte
  14. 15
      src/routes/find/+page.svelte
  15. 2
      src/routes/relay/+page.svelte
  16. 212
      src/routes/replaceable/[d_tag]/+page.svelte
  17. 2
      src/routes/repos/+page.svelte
  18. 2
      src/routes/repos/[naddr]/+page.svelte
  19. 2
      src/routes/rss/+page.svelte
  20. 3
      src/routes/settings/+page.svelte
  21. 2
      src/routes/topics/+page.svelte
  22. 1
      static/icons/bookmark.svg
  23. 1
      static/icons/database.svg
  24. 1
      static/icons/highlight.svg
  25. 2
      static/icons/key.svg
  26. 2
      static/icons/settings.svg

4
src/app.css

@ -175,7 +175,7 @@ main { @@ -175,7 +175,7 @@ main {
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
background: var(--fog-surface, #f8fafc);
color: var(--fog-text, #1f2937);
text-decoration: none;
transition: all 0.2s;
@ -187,7 +187,7 @@ main { @@ -187,7 +187,7 @@ main {
:global(.dark) .write-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
background: var(--fog-dark-surface, #1e293b);
color: var(--fog-dark-text, #f9fafb);
}

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

@ -65,9 +65,11 @@ @@ -65,9 +65,11 @@
);
const hasMetadata = $derived(image || description || summary || author || title);
const hasContent = $derived(event.content && event.content.trim().length > 0);
const shouldShowMetadata = $derived(hasMetadata || !hasContent); // Show metadata if it exists OR if there's no content
</script>
{#if hasMetadata}
{#if shouldShowMetadata}
<div class="metadata-card">
<div class="metadata-header">
{#if title}
@ -99,6 +101,24 @@ @@ -99,6 +101,24 @@
{#if author}
<p class="metadata-author">Author: {author}</p>
{/if}
{#if !hasContent}
<!-- Show all tags when there's no content -->
<div class="metadata-tags">
{#each event.tags as tag}
{#if tag[0] !== 'image' && tag[0] !== 'description' && tag[0] !== 'summary' && tag[0] !== 'author' && tag[0] !== 'title' && tag[0] !== 'd'}
<div class="metadata-tag">
<span class="metadata-tag-name">{tag[0]}:</span>
{#each tag.slice(1) as value}
{#if value}
<span class="metadata-tag-value">{value}</span>
{/if}
{/each}
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
{/if}
@ -176,4 +196,44 @@ @@ -176,4 +196,44 @@
:global(.dark) .metadata-author {
color: var(--fog-dark-text-light, #9ca3af);
}
.metadata-tags {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
:global(.dark) .metadata-tags {
border-top-color: var(--fog-dark-border, #374151);
}
.metadata-tag {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
font-size: 0.875rem;
}
.metadata-tag-name {
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-tag-name {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-tag-value {
color: var(--fog-text-light, #6b7280);
font-family: monospace;
word-break: break-all;
}
:global(.dark) .metadata-tag-value {
color: var(--fog-dark-text-light, #9ca3af);
}
</style>

7
src/lib/components/content/RichTextEditor.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import MentionsAutocomplete from './MentionsAutocomplete.svelte';
import GifPicker from './GifPicker.svelte';
import EmojiPicker from './EmojiPicker.svelte';
import Icon from '../ui/Icon.svelte';
interface Props {
value: string;
@ -174,7 +175,7 @@ @@ -174,7 +175,7 @@
aria-label="Insert GIF"
{disabled}
>
GIF
<Icon name="video" size={16} />
</button>
<button
type="button"
@ -184,7 +185,7 @@ @@ -184,7 +185,7 @@
aria-label="Insert emoji"
{disabled}
>
😀
<Icon name="smile" size={16} />
</button>
<input
type="file"
@ -202,7 +203,7 @@ @@ -202,7 +203,7 @@
title="Upload file (image, video, or audio)"
aria-label="Upload file"
>
📤
<Icon name="upload" size={16} />
</label>
</div>
{/if}

186
src/lib/components/find/SearchAddressableEvents.svelte

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import ProfileBadge from '../layout/ProfileBadge.svelte';
import RelayBadge from '../layout/RelayBadge.svelte';
import CacheBadge from '../layout/CacheBadge.svelte';
import { getKindInfo } from '../../types/kind-lookup.js';
import { getKindInfo, KIND_LOOKUP, isParameterizedReplaceableKind } from '../../types/kind-lookup.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../types/nostr.js';
@ -22,6 +22,11 @@ @@ -22,6 +22,11 @@
const SEARCH_TIMEOUT = 10000; // 10 seconds
const CACHE_SEARCH_DEBOUNCE = 500; // 500ms debounce for cache search
// Get only the parameterized replaceable kinds that are actually defined (computed once)
const parameterizedKinds = Object.keys(KIND_LOOKUP)
.map(Number)
.filter(kind => isParameterizedReplaceableKind(kind));
// Map to track results by event ID to avoid duplicates
const resultsMap = new Map<string, { event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>();
// Map to track cache results by event ID
@ -154,15 +159,10 @@ @@ -154,15 +159,10 @@
}
const queryLower = query.toLowerCase();
// Fetch all cached events in kind range 30000-39999
const kinds: number[] = [];
for (let kind = 30000; kind <= 39999; kind++) {
kinds.push(kind);
}
// Get all cached events for these kinds
const allCachedEvents: NostrEvent[] = [];
for (const kind of kinds) {
for (const kind of parameterizedKinds) {
const cached = await getEventsByKind(kind, 1000); // Get up to 1000 per kind
allCachedEvents.push(...cached);
}
@ -179,24 +179,39 @@ @@ -179,24 +179,39 @@
}
// Debounced cache search
let isSearchingCache = $state(false);
$effect(() => {
// Only react to searchQuery changes, not cacheResults
const query = searchQuery.trim();
// Clear previous timeout
if (cacheSearchTimeoutId) {
clearTimeout(cacheSearchTimeoutId);
cacheSearchTimeoutId = null;
}
if (searchQuery.trim()) {
if (query) {
cacheSearchTimeoutId = setTimeout(() => {
searchCache();
if (!isSearchingCache) {
isSearchingCache = true;
searchCache().finally(() => {
isSearchingCache = false;
});
}
}, CACHE_SEARCH_DEBOUNCE);
} else {
cacheResults = [];
cacheResultsMap.clear();
// Only clear if we're not currently searching
if (!isSearchingCache) {
cacheResults = [];
cacheResultsMap.clear();
}
}
return () => {
if (cacheSearchTimeoutId) {
clearTimeout(cacheSearchTimeoutId);
cacheSearchTimeoutId = null;
}
};
});
@ -268,17 +283,58 @@ @@ -268,17 +283,58 @@
}
const queryLower = query.toLowerCase();
// Fetch all events in kind range 30000-39999
const kinds: number[] = [];
for (let kind = 30000; kind <= 39999; kind++) {
kinds.push(kind);
}
// Normalize query: remove spaces, handle hyphens
const normalizedQuery = queryLower.replace(/\s+/g, '-');
const queryWords = queryLower.split(/[\s-]+/).filter(w => w.length > 0);
const relays = relayManager.getProfileReadRelays();
const allQueries: any[] = [];
// Build queries with tag filters for better search results
// Only query the specific parameterized replaceable kinds we know about
// Search by d-tag (most specific) - try both original and normalized
// Also try each word as a potential d-tag match
allQueries.push(
{ kinds: parameterizedKinds, '#d': [queryLower], limit: 50 },
{ kinds: parameterizedKinds, '#d': [normalizedQuery], limit: 50 }
);
for (const word of queryWords) {
if (word.length > 2) { // Only search for words longer than 2 chars
allQueries.push({ kinds: parameterizedKinds, '#d': [word], limit: 50 });
}
}
// Search by T-tag (topics/tags) for each word
for (const word of queryWords) {
if (word.length > 0) {
allQueries.push({ kinds: parameterizedKinds, '#T': [word], limit: 50 });
}
}
// Search by C-tag (categories) for each word
for (const word of queryWords) {
if (word.length > 0) {
allQueries.push({ kinds: parameterizedKinds, '#C': [word], limit: 50 });
}
}
// Search by title tag
allQueries.push({ kinds: parameterizedKinds, '#title': [queryLower], limit: 50 });
for (const word of queryWords) {
if (word.length > 0) {
allQueries.push({ kinds: parameterizedKinds, '#title': [word], limit: 50 });
}
}
// Also do a broader search without tag filters to catch partial matches
// This will find events where the query appears in d-tag but not as exact match
allQueries.push({ kinds: parameterizedKinds, limit: 200 });
// Use onUpdateWithRelay to process events as they arrive with relay info
await nostrClient.fetchEvents(
[{ kinds, limit: 100 }],
allQueries,
relays,
{
useCache: 'cache-first',
@ -366,7 +422,6 @@ @@ -366,7 +422,6 @@
cacheResults = [];
resultsMap.clear();
cacheResultsMap.clear();
eventRelayMap.clear();
searching = false;
if (timeoutId) {
clearTimeout(timeoutId);
@ -377,6 +432,10 @@ @@ -377,6 +432,10 @@
cacheSearchTimeoutId = null;
}
}
export function hasActiveSearch(): boolean {
return searching || searchQuery.trim().length > 0 || results.length > 0 || cacheResults.length > 0;
}
</script>
<div class="addressable-search">
@ -386,20 +445,25 @@ @@ -386,20 +445,25 @@
</p>
<div class="search-container">
<input
type="text"
bind:value={searchQuery}
placeholder="e.g., jane eyre, jane-eyre, Charlotte Bronte..."
class="search-input"
disabled={searching}
/>
<button
class="search-button"
onclick={search}
disabled={searching || !searchQuery.trim()}
>
{searching ? 'Searching...' : 'Search'}
</button>
<div class="search-bar-wrapper">
<input
type="text"
bind:value={searchQuery}
placeholder="e.g., jane eyre, jane-eyre, Charlotte Bronte..."
class="search-input"
disabled={searching}
/>
</div>
<div class="filter-and-button-wrapper">
<div style="flex: 1;"></div>
<button
class="search-button"
onclick={search}
disabled={searching || !searchQuery.trim()}
>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
{#if cacheResults.length > 0 || results.length > 0}
@ -619,15 +683,7 @@ @@ -619,15 +683,7 @@
<style>
.addressable-search {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 2rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .addressable-search {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
/* No container styling - parent .find-section handles it */
}
.addressable-search h2 {
@ -653,13 +709,30 @@ @@ -653,13 +709,30 @@
.search-container {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
flex-direction: column;
gap: 1rem;
}
.search-bar-wrapper {
width: 100%;
}
.filter-and-button-wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 640px) {
.filter-and-button-wrapper {
flex-direction: row;
align-items: flex-end;
}
}
.search-input {
flex: 1;
padding: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
@ -668,12 +741,20 @@ @@ -668,12 +741,20 @@
font-family: inherit;
}
.search-input::placeholder {
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .search-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .search-input::placeholder {
color: var(--fog-dark-text-light, #6b7280);
}
.search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
@ -693,7 +774,7 @@ @@ -693,7 +774,7 @@
.search-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
color: #ffffff;
border: none;
border-radius: 0.375rem;
cursor: pointer;
@ -702,11 +783,18 @@ @@ -702,11 +783,18 @@
font-family: inherit;
white-space: nowrap;
transition: all 0.2s;
min-width: 100px;
}
@media (min-width: 640px) {
.search-button {
min-width: auto;
}
}
:global(.dark) .search-button {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #1f2937);
color: #1f2937;
}
.search-button:hover:not(:disabled) {
@ -714,6 +802,10 @@ @@ -714,6 +802,10 @@
transform: translateY(-1px);
}
.search-button:active:not(:disabled) {
transform: translateY(0);
}
.search-button:disabled {
opacity: 0.6;
cursor: not-allowed;

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

@ -65,24 +65,23 @@ @@ -65,24 +65,23 @@
<a href="/topics" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Topics</a>
<a href="/repos" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Repos</a>
<a href="/bookmarks" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Bookmarks</a>
<a href="/cache" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Cache</a>
<a href="/settings" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap flex items-center gap-1">
<Icon name="settings" size={14} />
<span>/Settings</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2 md:gap-4 min-w-0 flex-shrink-0 nav-links">
<a href="/cache" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors flex items-center justify-center p-1.5 rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight" title="Cache">
<Icon name="database" size={16} />
</a>
<a href="/settings" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors flex items-center justify-center p-1.5 rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight" title="Settings">
<Icon name="settings" size={16} />
</a>
{#if isLoggedIn && currentPubkey}
<a href="/logout" onclick={(e) => { e.preventDefault(); handleLogout(); }} class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap flex items-center gap-1">
<Icon name="log-out" size={14} />
<span>/Logout</span>
<a href="/logout" onclick={(e) => { e.preventDefault(); handleLogout(); }} class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors flex items-center justify-center p-1.5 rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight" title="Logout">
<Icon name="log-out" size={16} />
</a>
{:else}
<a href="/login" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap flex items-center gap-1">
<Icon name="log-in" size={14} />
<span>/Login</span>
<a href="/login" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors flex items-center justify-center p-1.5 rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight" title="Login">
<Icon name="log-in" size={16} />
</a>
{/if}
</div>
<div class="flex flex-wrap gap-1.5 sm:gap-2 md:gap-4 items-center min-w-0 flex-shrink-0 nav-links">
{#if isLoggedIn && currentPubkey}
<ProfileBadge pubkey={currentPubkey} pictureOnly={true} />
{/if}
@ -124,10 +123,12 @@ @@ -124,10 +123,12 @@
z-index: 100;
background-color: var(--fog-surface, #f8fafc);
backdrop-filter: none;
opacity: 1;
}
:global(.dark) nav {
background-color: var(--fog-dark-surface, #1e293b);
opacity: 1;
}
/* Responsive navigation links */

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

@ -1272,6 +1272,10 @@ @@ -1272,6 +1272,10 @@
transition: all 0.2s;
}
.search-input::placeholder {
color: var(--fog-text-light, #9ca3af);
}
.search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
@ -1288,6 +1292,10 @@ @@ -1288,6 +1292,10 @@
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .search-input::placeholder {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .search-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);

2
src/lib/components/ui/IconButton.svelte

@ -31,7 +31,7 @@ @@ -31,7 +31,7 @@
aria-label={label}
title={label}
>
<Icon {name} {size} />
<Icon name={icon} {size} />
</button>
<style>

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

@ -679,6 +679,11 @@ @@ -679,6 +679,11 @@
</h2>
{/if}
<!-- Show metadata in feed view when content is empty -->
{#if !fullView && (!post.content || !post.content.trim())}
<MetadataCard event={post} hideTitle={true} hideImageIfInMedia={true} />
{/if}
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}>
<p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap">
{#each parseContentWithNIP21Links() as segment}

6
src/lib/modules/feed/HighlightCard.svelte

@ -10,6 +10,7 @@ @@ -10,6 +10,7 @@
import { getHighlightsForEvent } from '../../services/nostr/highlight-service.js';
import { isBookmarked } from '../../services/user-actions.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import Icon from '../../components/ui/Icon.svelte';
interface Props {
highlight: NostrEvent; // The highlight event (kind 9802)
@ -321,7 +322,7 @@ @@ -321,7 +322,7 @@
{/if}
<div class="ml-auto flex items-center gap-2">
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
<Icon name="bookmark" size={16} class="bookmark-indicator bookmarked" title="Bookmarked" />
{/if}
<EventMenu event={highlight} showContentActions={true} onReply={() => {}} />
</div>
@ -477,9 +478,6 @@ @@ -477,9 +478,6 @@
}
.bookmark-indicator {
display: inline-block;
font-size: 1rem;
line-height: 1;
filter: grayscale(100%);
transition: filter 0.2s;
}

102
src/routes/bookmarks/+page.svelte

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
import { KIND } from '../../lib/types/kind-lookup.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import Icon from '../../lib/components/ui/Icon.svelte';
interface BookmarkOrHighlight {
event: NostrEvent;
@ -28,6 +29,8 @@ @@ -28,6 +29,8 @@
let typeFilter = $state<'all' | 'bookmark' | 'highlight'>('all');
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
let seeMyOwn = $state(true); // Checked by default
let hasLoadedOnce = $state(false); // Track if we've loaded at least once
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) {
filterResult = result;
@ -52,6 +55,11 @@ @@ -52,6 +55,11 @@
let filteredItems = $derived.by(() => {
let filtered = allItems;
// Filter by "See my own" checkbox
if (seeMyOwn && currentUserPubkey) {
filtered = filtered.filter(item => item.authorPubkey.toLowerCase() === currentUserPubkey.toLowerCase());
}
// Filter by type (bookmark/highlight)
if (typeFilter === 'bookmark') {
filtered = filtered.filter(item => item.type === 'bookmark');
@ -91,9 +99,19 @@ @@ -91,9 +99,19 @@
const relays = relayManager.getFeedReadRelays();
const items: BookmarkOrHighlight[] = [];
// Build filters based on "See my own" checkbox
const bookmarkListFilter: any = { kinds: [KIND.BOOKMARKS], limit: 400 };
const highlightFilter: any = { kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 };
if (seeMyOwn && currentUserPubkey) {
// Only fetch bookmarks and highlights from the current user
bookmarkListFilter.authors = [currentUserPubkey];
highlightFilter.authors = [currentUserPubkey];
}
// 1. Fetch bookmark lists (kind 10003) - limit 400
const fetchedBookmarkLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.BOOKMARKS], limit: 400 }],
[bookmarkListFilter],
relays,
{
useCache: true,
@ -119,10 +137,9 @@ @@ -119,10 +137,9 @@
const profileRelays = relayManager.getProfileReadRelays();
const allRelaysForHighlights = [...new Set([...relays, ...profileRelays])];
// 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
// Fetch highlights - filtered by "See my own" if enabled
const highlightEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }],
[highlightFilter],
allRelaysForHighlights,
{
useCache: true,
@ -356,6 +373,7 @@ @@ -356,6 +373,7 @@
error = err instanceof Error ? err.message : 'Failed to load bookmarks and highlights';
} finally {
loading = false;
hasLoadedOnce = true;
}
}
@ -363,9 +381,19 @@ @@ -363,9 +381,19 @@
$effect(() => {
filterResult; // Track filterResult
typeFilter; // Track typeFilter
seeMyOwn; // Track seeMyOwn
currentPage = 1;
});
// Reload when "See my own" checkbox or user changes (but not on initial mount)
$effect(() => {
seeMyOwn; // Track seeMyOwn
currentUserPubkey; // Track currentUserPubkey
if (hasLoadedOnce && !loading) {
loadBookmarksAndHighlights();
}
});
onMount(async () => {
await nostrClient.initialize();
await loadBookmarksAndHighlights();
@ -406,6 +434,20 @@ @@ -406,6 +434,20 @@
<option value="highlight">Highlights</option>
</select>
</div>
{#if currentUserPubkey}
<div class="see-my-own-section">
<label for="see-my-own" class="see-my-own-label">
<input
type="checkbox"
id="see-my-own"
bind:checked={seeMyOwn}
class="see-my-own-checkbox"
aria-label="See my own"
/>
<span>See my own</span>
</label>
</div>
{/if}
<div class="search-filter-section">
<UnifiedSearch
mode="search"
@ -508,13 +550,11 @@ @@ -508,13 +550,11 @@
<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>
<Icon
name="bookmark"
size={20}
class={currentUserPubkey?.toLowerCase() !== item.authorPubkey.toLowerCase() ? 'grayscale' : ''}
/>
</div>
<FeedPost post={item.event} />
{/if}
@ -652,12 +692,14 @@ @@ -652,12 +692,14 @@
z-index: 10;
margin-bottom: 1rem;
backdrop-filter: none;
opacity: 1;
}
: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);
opacity: 1;
}
.filters-row {
@ -682,6 +724,37 @@ @@ -682,6 +724,37 @@
gap: 0.5rem;
}
.see-my-own-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.see-my-own-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
cursor: pointer;
user-select: none;
}
:global(.dark) .see-my-own-label {
color: var(--fog-dark-text, #f9fafb);
}
.see-my-own-checkbox {
width: 1rem;
height: 1rem;
cursor: pointer;
accent-color: var(--fog-accent, #64748b);
}
:global(.dark) .see-my-own-checkbox {
accent-color: var(--fog-dark-accent, #94a3b8);
}
.type-filter-label {
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
@ -741,15 +814,12 @@ @@ -741,15 +814,12 @@
padding-left: 2.5rem; /* Make room for the icon */
}
.bookmark-emoji {
display: inline-block;
font-size: 1.25rem;
line-height: 1;
.bookmark-indicator-wrapper :global(.icon-wrapper) {
filter: grayscale(100%);
transition: filter 0.2s;
}
.bookmark-emoji:not(.grayscale) {
.bookmark-indicator-wrapper :global(.icon-wrapper:not(.grayscale)) {
filter: grayscale(0%);
}

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

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
import Header from '../../lib/components/layout/Header.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import Icon from '../../lib/components/ui/Icon.svelte';
function handleBack() {
if (typeof window !== 'undefined' && window.history.length > 1) {
@ -444,14 +445,15 @@ @@ -444,14 +445,15 @@
<main class="container mx-auto px-4 py-8">
<div class="cache-page">
<div class="cache-header">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Cache</h1>
<button
onclick={handleBack}
class="back-button"
class="back-button flex items-center gap-2"
aria-label="Go back to previous page"
>
← Back
<Icon name="arrow-left" size={16} />
<span>Back</span>
</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}
@ -696,6 +698,7 @@ @@ -696,6 +698,7 @@
.cache-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}

5
src/routes/discussions/+page.svelte

@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
import { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte';
import Icon from '../../lib/components/ui/Icon.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: [] });
@ -44,7 +45,7 @@ @@ -44,7 +45,7 @@
<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>
<Icon name="edit" size={20} />
</a>
</div>
@ -138,12 +139,14 @@ @@ -138,12 +139,14 @@
margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
backdrop-filter: none;
opacity: 1;
}
: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);
opacity: 1;
}
.discussions-header-top {

5
src/routes/feed/+page.svelte

@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
import { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte';
import Icon from '../../lib/components/ui/Icon.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: [] });
@ -47,7 +48,7 @@ @@ -47,7 +48,7 @@
<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>
<Icon name="edit" size={20} />
</a>
</div>
<div class="feed-controls">
@ -141,11 +142,13 @@ @@ -141,11 +142,13 @@
margin-bottom: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
backdrop-filter: none;
opacity: 1;
}
:global(.dark) .feed-header {
background: var(--fog-dark-bg, #0f172a);
background-color: var(--fog-dark-bg, #0f172a);
opacity: 1;
border-bottom-color: var(--fog-dark-border, #1e293b);
}

15
src/routes/find/+page.svelte

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
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 }; clearSearch: () => void } | null = $state(null);
let addressableSearchComponent: { clearSearch: () => void } | null = $state(null);
let addressableSearchComponent: { clearSearch: () => void; hasActiveSearch: () => boolean } | null = $state(null);
let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] });
let cacheResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
// Map to track which relay each event came from
@ -32,6 +32,10 @@ @@ -32,6 +32,10 @@
const filterResult = component.getFilterResult();
if (filterResult.value !== null) return true;
}
// Check SearchAddressableEvents component
if (addressableSearchComponent) {
if (addressableSearchComponent.hasActiveSearch()) return true;
}
return false;
});
@ -397,6 +401,11 @@ @@ -397,6 +401,11 @@
width: 100%;
}
.search-bar-wrapper :global(.unified-search-container) {
max-width: none;
width: 100%;
}
.filter-and-button-wrapper {
display: flex;
flex-direction: column;
@ -475,7 +484,7 @@ @@ -475,7 +484,7 @@
.search-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
color: #ffffff;
border: none;
border-radius: 0.375rem;
cursor: pointer;
@ -495,7 +504,7 @@ @@ -495,7 +504,7 @@
:global(.dark) .search-button {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #1f2937);
color: #1f2937;
}
.search-button:hover:not(:disabled) {

2
src/routes/relay/+page.svelte

@ -212,7 +212,7 @@ @@ -212,7 +212,7 @@
<main class="container mx-auto px-4 py-8">
<div class="relay-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Relay</h1>
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Relay</h1>
<!-- Custom Relay Input -->
<section class="relay-category custom-relay-section">

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

@ -7,9 +7,11 @@ @@ -7,9 +7,11 @@
import { page } from '$app/stores';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { goto } from '$app/navigation';
import { nip19 } from 'nostr-tools';
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let dTag = $derived($page.params.d_tag);
onMount(async () => {
@ -27,75 +29,130 @@ @@ -27,75 +29,130 @@
if (!dTag) return;
loading = true;
error = null;
try {
const relays = relayManager.getProfileReadRelays();
// Fetch all replaceable events with matching d-tag
// Use range queries which are more efficient than listing all kinds
// Most relays support range queries or we can use multiple filters
// Use both feed and profile relays for wider coverage
const profileRelays = relayManager.getProfileReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const relays = [...new Set([...profileRelays, ...feedRelays])]; // Deduplicate
const allEvents: NostrEvent[] = [];
// Query basic replaceable kinds (0, 3)
const basicKinds = [0, 3];
const basicEvents = await nostrClient.fetchEvents(
[{ kinds: basicKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
allEvents.push(...basicEvents);
// Query replaceable range (10000-19999) - use a single filter with all kinds
// If relay doesn't support large kind lists, it will return an error and we skip
// First, check cache for events with this d-tag
try {
const replaceableKinds: number[] = [];
for (let kind = 10000; kind < 20000; kind++) {
replaceableKinds.push(kind);
}
const replaceableEvents = await nostrClient.fetchEvents(
[{ kinds: replaceableKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
allEvents.push(...replaceableEvents);
} catch (rangeError) {
// If single query fails (relay limit), fall back to smaller batches
const BATCH_SIZE = 1000;
for (let start = 10000; start < 20000; start += BATCH_SIZE) {
const batchKinds: number[] = [];
for (let kind = start; kind < Math.min(start + BATCH_SIZE, 20000); kind++) {
batchKinds.push(kind);
const { getEventsByKind } = await import('../../../lib/services/cache/event-cache.js');
// Check parameterized replaceable range in cache
// Query each kind individually since getEventsByKind takes a single kind number
for (let kind = 30000; kind < 40000; kind++) {
const cached = await getEventsByKind(kind);
const matching = cached.filter(e => {
const eventDTag = e.tags.find(t => t[0] === 'd')?.[1] || '';
return eventDTag === dTag;
});
if (matching.length > 0) {
allEvents.push(...matching);
console.log(`[Replaceable] Found ${matching.length} cached events with d-tag "${dTag}" for kind ${kind}`);
}
}
} catch (cacheError) {
console.warn('[Replaceable] Error checking cache:', cacheError);
}
try {
const batchEvents = await nostrClient.fetchEvents(
[{ kinds: batchKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
allEvents.push(...batchEvents);
} catch {
// Skip failed batches
// First, try to decode as naddr (if the d-tag is actually an naddr)
let decodedNaddr: { kind: number; pubkey: string; identifier?: string; relays?: string[] } | null = null;
if (/^naddr1[a-z0-9]+$/i.test(dTag)) {
try {
const decoded = nip19.decode(dTag);
if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data) {
decodedNaddr = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] };
console.log('[Replaceable] Decoded naddr:', decodedNaddr);
}
} catch (e) {
console.log('[Replaceable] Not an naddr, treating as d-tag:', e);
}
}
// Query parameterized replaceable range (30000-39999)
try {
const paramReplaceableKinds: number[] = [];
for (let kind = 30000; kind < 40000; kind++) {
paramReplaceableKinds.push(kind);
// If we decoded an naddr, fetch directly by kind, pubkey, and d-tag
if (decodedNaddr) {
const naddrRelays = decodedNaddr.relays && decodedNaddr.relays.length > 0
? decodedNaddr.relays
: relays;
const filter: any = {
kinds: [decodedNaddr.kind],
authors: [decodedNaddr.pubkey],
limit: 1
};
if (decodedNaddr.identifier) {
filter['#d'] = [decodedNaddr.identifier];
}
const paramEvents = await nostrClient.fetchEvents(
[{ kinds: paramReplaceableKinds, '#d': [dTag], limit: 100 }],
relays,
const naddrEvents = await nostrClient.fetchEvents(
[filter],
naddrRelays,
{ useCache: true, cacheResults: true }
);
allEvents.push(...paramEvents);
} catch (rangeError) {
// If single query fails, fall back to smaller batches
const BATCH_SIZE = 1000;
if (naddrEvents.length > 0) {
events = naddrEvents;
loading = false;
return;
} else {
error = `Event not found for naddr. Tried kind ${decodedNaddr.kind}, pubkey ${decodedNaddr.pubkey.substring(0, 16)}..., d-tag: ${decodedNaddr.identifier || 'none'}`;
}
}
// If not an naddr or naddr lookup failed, search by d-tag
// Focus on parameterized replaceable events (30000-39999) first since they commonly use d-tags
console.log('[Replaceable] Searching for events with d-tag:', dTag);
// Query parameterized replaceable range (30000-39999) - these are most likely to have d-tags
// Use smaller batches to avoid relay limits
const BATCH_SIZE = 100;
for (let start = 30000; start < 40000; start += BATCH_SIZE) {
const batchKinds: number[] = [];
for (let kind = start; kind < Math.min(start + BATCH_SIZE, 40000); kind++) {
batchKinds.push(kind);
}
try {
const batchEvents = await nostrClient.fetchEvents(
[{ kinds: batchKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
allEvents.push(...batchEvents);
console.log(`[Replaceable] Found ${batchEvents.length} events in kind range ${start}-${Math.min(start + BATCH_SIZE - 1, 39999)}`);
} catch (e) {
console.warn(`[Replaceable] Failed to query kind range ${start}-${Math.min(start + BATCH_SIZE - 1, 39999)}:`, e);
}
}
// If we found events in cache, extract relay hints from their tags and query those relays too
const additionalRelays = new Set<string>();
for (const event of allEvents) {
// Extract relay hints from r tags
const rTags = event.tags.filter(t => t[0] === 'r' && t[1]);
for (const rTag of rTags) {
if (rTag[1] && (rTag[1].startsWith('ws://') || rTag[1].startsWith('wss://'))) {
additionalRelays.add(rTag[1]);
}
}
// Extract relay hints from a tags (third element is often a relay)
const aTags = event.tags.filter(t => t[0] === 'a' && t.length > 2);
for (const aTag of aTags) {
if (aTag[2] && (aTag[2].startsWith('ws://') || aTag[2].startsWith('wss://'))) {
additionalRelays.add(aTag[2]);
}
}
}
// Query additional relays if we found any
if (additionalRelays.size > 0) {
const additionalRelaysArray = Array.from(additionalRelays);
console.log(`[Replaceable] Querying ${additionalRelaysArray.length} additional relays from event hints:`, additionalRelaysArray);
// Query parameterized replaceable range on additional relays
for (let start = 30000; start < 40000; start += BATCH_SIZE) {
const batchKinds: number[] = [];
for (let kind = start; kind < Math.min(start + BATCH_SIZE, 40000); kind++) {
@ -105,16 +162,31 @@ @@ -105,16 +162,31 @@
try {
const batchEvents = await nostrClient.fetchEvents(
[{ kinds: batchKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
additionalRelaysArray,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
allEvents.push(...batchEvents);
} catch {
// Skip failed batches
console.log(`[Replaceable] Found ${batchEvents.length} additional events from hint relays in kind range ${start}-${Math.min(start + BATCH_SIZE - 1, 39999)}`);
} catch (e) {
console.warn(`[Replaceable] Failed to query hint relays for kind range ${start}-${Math.min(start + BATCH_SIZE - 1, 39999)}:`, e);
}
}
}
if (allEvents.length > 0) {
console.log(`[Replaceable] Found ${allEvents.length} events total (including cache and hint relays)`);
}
// Also check common replaceable kinds that might have d-tags
const commonKinds = [0, 3, 10000, 10001, 10002, 10003, 10004, 10005, 10006, 10007, 10008, 10009, 10010, 10030, 10031, 10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, 10045, 10046, 10047, 10048, 10049, 10050, 10133, 10432, 30315];
const commonEvents = await nostrClient.fetchEvents(
[{ kinds: commonKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
allEvents.push(...commonEvents);
console.log(`[Replaceable] Found ${commonEvents.length} events in common replaceable kinds`);
// For replaceable events, get the newest version of each (by pubkey and kind)
// For parameterized replaceable, get newest by (pubkey, kind, d-tag)
const eventsByKey = new Map<string, NostrEvent>();
@ -134,8 +206,15 @@ @@ -134,8 +206,15 @@
// Sort by created_at descending
events = Array.from(eventsByKey.values()).sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading replaceable events:', error);
console.log(`[Replaceable] Total unique events found: ${events.length}`);
if (events.length === 0 && !error) {
error = `No replaceable events found with d-tag "${dTag}". The event might not be on the queried relays, or the d-tag might be incorrect.`;
}
} catch (err) {
console.error('Error loading replaceable events:', err);
error = err instanceof Error ? err.message : 'Failed to load replaceable events';
events = [];
} finally {
loading = false;
@ -152,7 +231,7 @@ @@ -152,7 +231,7 @@
<main class="container mx-auto px-4 py-8">
<div class="replaceable-content">
<div class="replaceable-header mb-6">
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">
Replaceable Events: {dTag}
</h1>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">
@ -166,7 +245,14 @@ @@ -166,7 +245,14 @@
</div>
{:else if events.length === 0}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No replaceable events found with this d-tag.</p>
<p class="text-fog-text dark:text-fog-dark-text">
{error || 'No replaceable events found with this d-tag.'}
</p>
{#if dTag && !dTag.startsWith('naddr1')}
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2 text-sm">
Tip: If you have an naddr, you can use it directly: <code>/replaceable/naddr1...</code>
</p>
{/if}
</div>
{:else}
<div class="events-list">

2
src/routes/repos/+page.svelte

@ -219,7 +219,7 @@ @@ -219,7 +219,7 @@
<main class="container mx-auto px-4 py-8">
<div class="repos-content">
<div class="repos-header mb-6">
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text font-mono">/Repos</h1>
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Repos</h1>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2 mb-4">
Discover and explore repositories announced on Nostr
</p>

2
src/routes/repos/[naddr]/+page.svelte

@ -908,7 +908,7 @@ @@ -908,7 +908,7 @@
{:else}
<div class="repo-header mb-6">
<div class="repo-title-row">
<h1 class="text-3xl font-bold text-fog-text dark:text-fog-dark-text font-mono mb-2">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">
{getRepoName()}
</h1>
{#if repoEvent}

2
src/routes/rss/+page.svelte

@ -288,7 +288,7 @@ @@ -288,7 +288,7 @@
<main class="container mx-auto px-4 py-8">
<div class="rss-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/RSS</h1>
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/RSS</h1>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading...</p>

3
src/routes/settings/+page.svelte

@ -113,6 +113,7 @@ @@ -113,6 +113,7 @@
<main class="container mx-auto px-4 py-8">
<div class="settings-page">
<div class="settings-header">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Settings</h1>
<button
onclick={handleBack}
class="back-button flex items-center gap-2"
@ -121,7 +122,6 @@ @@ -121,7 +122,6 @@
<Icon name="arrow-left" size={16} />
<span>Back</span>
</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">
@ -301,6 +301,7 @@ @@ -301,6 +301,7 @@
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}

2
src/routes/topics/+page.svelte

@ -229,7 +229,7 @@ @@ -229,7 +229,7 @@
<main class="container mx-auto px-4 py-8">
<div class="topics-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Topics</h1>
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Topics</h1>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading topics...</p>

1
static/icons/bookmark.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></svg>

After

Width:  |  Height:  |  Size: 251 B

1
static/icons/database.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>

After

Width:  |  Height:  |  Size: 319 B

1
static/icons/highlight.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l-6 6v3h3l6-6"/><path d="M22 12l-4.586 4.586a2 2 0 0 1-2.828 0l-5.172-5.172a2 2 0 0 1 0-2.828L12 4l8 8z"/></svg>

After

Width:  |  Height:  |  Size: 308 B

2
static/icons/key.svg

@ -1 +1 @@ @@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-1.76 1.76a2.5 2.5 0 0 0 0 3.52l1.8 1.81-1.8 1.81-4.24-4.24 1.81-1.81a2.5 2.5 0 0 0 0-3.52L21 2"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4.5"/><path d="M15.5 8.5 19 5"/><path d="M17 6 21 2"/><path d="M2 12h5"/><path d="M6 12h2"/></svg>

Before

Width:  |  Height:  |  Size: 336 B

After

Width:  |  Height:  |  Size: 306 B

2
static/icons/settings.svg

@ -1 +1 @@ @@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v18.84a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/><path d="M18.38 6H17a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1.38a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2z"/><path d="M5.62 6H7a2 2 0 0 0 2 2v8a2 2 0 0 0-2 2H5.62a2 2 0 0 0-2-2V8a2 2 0 0 0 2-2z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>

Before

Width:  |  Height:  |  Size: 458 B

After

Width:  |  Height:  |  Size: 965 B

Loading…
Cancel
Save