Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
794d3fdbcd
  1. 672
      src/lib/components/find/AdvancedSearch.svelte
  2. 277
      src/lib/components/find/NormalSearch.svelte
  3. 3
      src/lib/components/layout/Header.svelte
  4. 87
      src/lib/components/layout/UnifiedSearch.svelte
  5. 67
      src/lib/modules/profiles/PaymentAddresses.svelte
  6. 105
      src/lib/services/cache/event-archive.ts
  7. 50
      src/lib/services/cache/event-cache.ts
  8. 1
      src/lib/types/nostr.ts
  9. 986
      src/routes/bookmarks/+page.svelte
  10. 126
      src/routes/cache/+page.svelte
  11. 1275
      src/routes/find/+page.svelte

672
src/lib/components/find/AdvancedSearch.svelte

@ -0,0 +1,672 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { nip19 } from 'nostr-tools';
import { KIND_LOOKUP } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) => void;
}
let { onSearchResults }: Props = $props();
// NIP-01 standard filter controls
let filterIds = $state<string>('');
let filterAuthors = $state<string>('');
let filterKinds = $state<string>('');
let filterE = $state<string>('');
let filterP = $state<string>('');
let filterA = $state<string>('');
let filterQ = $state<string>('');
let filterT = $state<string>('');
let filterC = $state<string>('');
let filterD = $state<string>('');
let filterSince = $state<string>('');
let filterUntil = $state<string>('');
let filterLimit = $state<number>(100);
let searching = $state(false);
let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] });
const eventRelayMap = new Map<string, string>();
// Helper function to decode bech32 to hex
async function decodeBech32ToHex(bech32: string, type: 'pubkey' | 'event'): Promise<string | null> {
try {
if (!/^(npub|nprofile|note|nevent|naddr)1[a-z0-9]+$/i.test(bech32)) {
return null;
}
const decoded = nip19.decode(bech32);
if (type === 'pubkey') {
if (decoded.type === 'npub') {
return String(decoded.data).toLowerCase();
} else if (decoded.type === 'nprofile') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
return String(decoded.data.pubkey).toLowerCase();
}
} else if (decoded.type === 'naddr') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
return String(decoded.data.pubkey).toLowerCase();
}
}
} else if (type === 'event') {
if (decoded.type === 'note') {
return String(decoded.data).toLowerCase();
} else if (decoded.type === 'nevent') {
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
return String(decoded.data.id).toLowerCase();
}
}
}
} catch (error) {
console.debug('Error decoding bech32:', error);
}
return null;
}
// Helper function to parse comma-separated values and validate hex strings
async function parseHexList(input: string, type: 'pubkey' | 'event' = 'event'): Promise<string[]> {
const results: string[] = [];
const parts = input.split(',').map(s => s.trim()).filter(s => s.length > 0);
for (const part of parts) {
// Check if it's already a hex string
if (/^[0-9a-f]{64}$/i.test(part)) {
results.push(part.toLowerCase());
} else {
// Try to decode as bech32
const decoded = await decodeBech32ToHex(part, type);
if (decoded) {
results.push(decoded);
}
}
}
return results;
}
// Helper function to parse comma-separated numbers
function parseNumberList(input: string): number[] {
return input
.split(',')
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && n >= 0 && n <= 65535);
}
// Helper function to parse comma-separated addressable event refs
async function parseAddressableList(input: string): Promise<string[]> {
const results: string[] = [];
const parts = input.split(',').map(s => s.trim()).filter(s => s.length > 0);
for (const part of parts) {
const match = part.match(/^(\d+):([^:]+)(?::(.+))?$/);
if (match) {
const [, kind, pubkeyPart, dTag] = match;
let hexPubkey: string | null = null;
if (/^[0-9a-f]{64}$/i.test(pubkeyPart)) {
hexPubkey = pubkeyPart.toLowerCase();
} else {
hexPubkey = await decodeBech32ToHex(pubkeyPart, 'pubkey');
}
if (hexPubkey) {
if (dTag) {
results.push(`${kind}:${hexPubkey}:${dTag}`);
} else {
results.push(`${kind}:${hexPubkey}:`);
}
}
}
}
return results;
}
// Helper function to convert date-time string to unix timestamp
function dateTimeToUnixTimestamp(dateTimeStr: string): number | null {
if (!dateTimeStr.trim()) return null;
try {
const date = new Date(dateTimeStr.trim());
if (isNaN(date.getTime())) {
const timestamp = parseInt(dateTimeStr.trim(), 10);
if (!isNaN(timestamp) && timestamp > 0) {
return timestamp;
}
return null;
}
return Math.floor(date.getTime() / 1000);
} catch (error) {
const timestamp = parseInt(dateTimeStr.trim(), 10);
if (!isNaN(timestamp) && timestamp > 0) {
return timestamp;
}
return null;
}
}
// Build NIP-01 filter from form inputs
async function buildFilter(): Promise<any> {
const filter: any = {};
if (filterIds.trim()) {
const ids = await parseHexList(filterIds, 'event');
if (ids.length > 0) {
filter.ids = ids;
}
}
if (filterAuthors.trim()) {
const authors = await parseHexList(filterAuthors, 'pubkey');
if (authors.length > 0) {
filter.authors = authors;
}
}
if (filterKinds.trim()) {
const kinds = parseNumberList(filterKinds);
if (kinds.length > 0) {
filter.kinds = kinds;
}
}
if (filterE.trim()) {
const eTags = await parseHexList(filterE, 'event');
if (eTags.length > 0) {
filter['#e'] = eTags;
}
}
if (filterP.trim()) {
const pTags = await parseHexList(filterP, 'pubkey');
if (pTags.length > 0) {
filter['#p'] = pTags;
}
}
if (filterA.trim()) {
const aTags = await parseAddressableList(filterA);
if (aTags.length > 0) {
filter['#a'] = aTags;
}
}
if (filterQ.trim()) {
const qTags = await parseHexList(filterQ, 'event');
if (qTags.length > 0) {
filter['#q'] = qTags;
}
}
if (filterT.trim()) {
const tTags = filterT
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
if (tTags.length > 0) {
filter['#T'] = tTags;
}
}
if (filterC.trim()) {
const cTags = filterC
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
if (cTags.length > 0) {
filter['#C'] = cTags;
}
}
if (filterD.trim()) {
const dTags = filterD
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
if (dTags.length > 0) {
filter['#d'] = dTags;
}
}
if (filterSince.trim()) {
const since = dateTimeToUnixTimestamp(filterSince);
if (since !== null && since > 0) {
filter.since = since;
}
}
if (filterUntil.trim()) {
const until = dateTimeToUnixTimestamp(filterUntil);
if (until !== null && until > 0) {
filter.until = until;
}
}
if (filterLimit > 0) {
filter.limit = filterLimit;
} else {
filter.limit = 100;
}
return filter;
}
async function handleSearch() {
searching = true;
const newEventRelayMap = new Map<string, string>();
try {
await nostrClient.initialize();
const relays = relayManager.getAllAvailableRelays();
const filter = await buildFilter();
// Check if filter has any conditions (not just limit)
const hasConditions = Object.keys(filter).some(key => key !== 'limit');
if (!hasConditions) {
// No filter conditions, show error or return empty
searchResults = { events: [], profiles: [], relays };
if (onSearchResults) {
onSearchResults({ events: [], profiles: [], relays, eventRelays: new Map() });
}
searching = false;
return;
}
// Fetch events using the filter
const events = await nostrClient.fetchEvents(
[filter],
relays,
{ useCache: true, cacheResults: true }
);
// Track which relay each event came from
for (const event of events) {
if (!newEventRelayMap.has(event.id)) {
newEventRelayMap.set(event.id, relays[0] || 'unknown');
}
}
// Sort by created_at (newest first)
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at);
searchResults = { events: sortedEvents, profiles: [], relays };
if (onSearchResults) {
onSearchResults({ events: sortedEvents, profiles: [], relays, eventRelays: newEventRelayMap });
}
} catch (error) {
console.error('Advanced search error:', error);
searchResults = { events: [], profiles: [], relays: [] };
if (onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() });
}
} finally {
searching = false;
}
}
export function clearSearch() {
filterIds = '';
filterAuthors = '';
filterKinds = '';
filterE = '';
filterP = '';
filterA = '';
filterQ = '';
filterT = '';
filterC = '';
filterD = '';
filterSince = '';
filterUntil = '';
filterLimit = 100;
searchResults = { events: [], profiles: [] };
eventRelayMap.clear();
if (onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() });
}
}
export function getSearchResults() {
return searchResults;
}
</script>
<div class="advanced-search">
<h2>Advanced Search (NIP-01 Filters)</h2>
<p class="section-description">
Use NIP-01 standard filters to search for events. All filters are optional - combine them as needed.
</p>
<div class="search-container">
<div class="advanced-filters-grid">
<div class="filter-group">
<label for="filter-ids" class="filter-label">IDs (comma-separated event IDs):</label>
<input
id="filter-ids"
type="text"
bind:value={filterIds}
placeholder="64-char hex event IDs or bech32 (note, nevent)"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-authors" class="filter-label">Authors (comma-separated pubkeys):</label>
<input
id="filter-authors"
type="text"
bind:value={filterAuthors}
placeholder="64-char hex pubkeys or bech32 (npub, nprofile)"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-kinds" class="filter-label">Kinds (comma-separated):</label>
<input
id="filter-kinds"
type="text"
bind:value={filterKinds}
placeholder="e.g., 1, 7, 11"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-e" class="filter-label">#e tag (comma-separated event IDs):</label>
<input
id="filter-e"
type="text"
bind:value={filterE}
placeholder="64-char hex event IDs or bech32"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-p" class="filter-label">#p tag (comma-separated pubkeys):</label>
<input
id="filter-p"
type="text"
bind:value={filterP}
placeholder="64-char hex pubkeys or bech32"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-a" class="filter-label">#a tag (comma-separated addressable refs):</label>
<input
id="filter-a"
type="text"
bind:value={filterA}
placeholder="kind:pubkey:d-tag"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-q" class="filter-label">#q tag (comma-separated quoted event IDs):</label>
<input
id="filter-q"
type="text"
bind:value={filterQ}
placeholder="64-char hex event IDs or bech32"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-t" class="filter-label">#T tag (comma-separated topics):</label>
<input
id="filter-t"
type="text"
bind:value={filterT}
placeholder="topic1, topic2, topic3"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-c" class="filter-label">#C tag (comma-separated categories):</label>
<input
id="filter-c"
type="text"
bind:value={filterC}
placeholder="category1, category2, category3"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-d" class="filter-label">#d tag (comma-separated d-tag values):</label>
<input
id="filter-d"
type="text"
bind:value={filterD}
placeholder="d-tag1, d-tag2, d-tag3"
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="filter-since" class="filter-label">Since (date-time or unix timestamp):</label>
<input
id="filter-since"
type="text"
bind:value={filterSince}
placeholder="2024-01-15T14:30:00Z or unix timestamp"
class="filter-input"
/>
<small class="filter-hint">Accepts ISO 8601 format or unix timestamp</small>
</div>
<div class="filter-group">
<label for="filter-until" class="filter-label">Until (date-time or unix timestamp):</label>
<input
id="filter-until"
type="text"
bind:value={filterUntil}
placeholder="2024-01-15T14:30:00Z or unix timestamp"
class="filter-input"
/>
<small class="filter-hint">Accepts ISO 8601 format or unix timestamp</small>
</div>
<div class="filter-group">
<label for="filter-limit" class="filter-label">Limit:</label>
<input
id="filter-limit"
type="number"
bind:value={filterLimit}
min="1"
max="1000"
class="filter-input"
/>
</div>
</div>
<div class="search-button-wrapper">
<button
class="search-button"
onclick={handleSearch}
disabled={searching}
aria-label="Search"
>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
</div>
<style>
.advanced-search {
/* No container styling - parent .find-section handles it */
}
.advanced-search h2 {
margin: 0 0 1rem 0;
font-size: 1.25em;
font-weight: 600;
color: var(--fog-text, #475569);
}
@media (min-width: 640px) {
.advanced-search h2 {
margin: 0 0 1.5rem 0;
}
}
:global(.dark) .advanced-search h2 {
color: var(--fog-dark-text, #cbd5e1);
}
.section-description {
margin: 0 0 1rem 0;
color: var(--fog-text-light, #52667a);
font-size: 0.875em;
}
@media (min-width: 640px) {
.section-description {
margin: 0 0 1.5rem 0;
}
}
:global(.dark) .section-description {
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.advanced-filters-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
padding: 0.75rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.375rem;
border: 1px solid var(--fog-border, #e5e7eb);
}
@media (min-width: 640px) {
.advanced-filters-grid {
gap: 1rem;
padding: 1rem;
}
}
:global(.dark) .advanced-filters-grid {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
@media (min-width: 768px) {
.advanced-filters-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-label {
font-size: 0.875em;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
:global(.dark) .filter-label {
color: var(--fog-dark-text, #f9fafb);
}
.filter-input {
padding: 0.5em;
border: 1px solid var(--fog-border, #cbd5e1);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875em;
font-family: inherit;
width: 100%;
}
.filter-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .filter-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .filter-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.filter-hint {
font-size: 0.75em;
color: var(--fog-text-light, #52667a);
margin-top: 0.25em;
display: block;
}
:global(.dark) .filter-hint {
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-button-wrapper {
display: flex;
justify-content: flex-end;
}
.search-button {
padding: 0.75em 1.5em;
background: var(--fog-accent, #64748b);
color: #ffffff;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875em;
font-weight: 500;
font-family: inherit;
white-space: nowrap;
transition: all 0.2s;
min-width: 100px;
}
:global(.dark) .search-button {
background: var(--fog-dark-accent, #94a3b8);
color: #1f2937;
}
.search-button:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.search-button:active:not(:disabled) {
transform: translateY(0);
}
.search-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

277
src/lib/components/find/NormalSearch.svelte

@ -0,0 +1,277 @@
<script lang="ts">
import UnifiedSearch from '../layout/UnifiedSearch.svelte';
import { KIND_LOOKUP } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) => void;
}
let { onSearchResults }: Props = $props();
let unifiedSearchComponent: { triggerSearch: () => void; clearSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } } | null = $state(null);
let selectedKind = $state<number | null>(null);
let selectedKindString = $state<string>('');
// Get all kinds for dropdown
const allKinds = Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number);
// 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;
selectedKindString = select.value;
}
function handleSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.triggerSearch();
}
}
export function clearSearch() {
if (unifiedSearchComponent) {
unifiedSearchComponent.clearSearch();
}
selectedKind = null;
selectedKindString = '';
if (onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() });
}
}
export function getSearchResults() {
return { events: [], profiles: [] };
}
</script>
<div class="normal-search">
<h2>Normal Search</h2>
<p class="section-description">
Search for events by ID, pubkey, NIP-05, or content. Use the kind filter to narrow results.
</p>
<div class="search-container">
<div class="search-bar-wrapper">
<UnifiedSearch
mode="search"
bind:this={unifiedSearchComponent}
selectedKind={selectedKind}
hideDropdownResults={true}
onSearchResults={onSearchResults}
placeholder="Search events, profiles, pubkeys, or enter event ID..."
/>
</div>
<div class="filter-and-button-wrapper">
<div class="kind-filter-wrapper">
<label for="kind-filter" class="kind-filter-label">Filter by Kind:</label>
<select
id="kind-filter"
bind:value={selectedKindString}
onchange={handleKindChange}
class="kind-filter-select"
aria-label="Filter by kind"
>
<option value="">All Kinds</option>
{#each allKinds as kindInfo}
<option value={kindInfo.number}>{kindInfo.number}: {kindInfo.description}</option>
{/each}
</select>
</div>
<button
class="search-button"
onclick={handleSearch}
disabled={!unifiedSearchComponent || !unifiedSearchComponent.getFilterResult().value?.trim()}
aria-label="Search"
>
Search
</button>
</div>
</div>
</div>
<style>
.normal-search {
/* No container styling - parent .find-section handles it */
}
.normal-search h2 {
margin: 0 0 1rem 0;
font-size: 1.25em;
font-weight: 600;
color: var(--fog-text, #475569);
}
@media (min-width: 640px) {
.normal-search h2 {
margin: 0 0 1.5rem 0;
}
}
:global(.dark) .normal-search h2 {
color: var(--fog-dark-text, #cbd5e1);
}
.section-description {
margin: 0 0 1rem 0;
color: var(--fog-text-light, #52667a);
font-size: 0.875em;
}
@media (min-width: 640px) {
.section-description {
margin: 0 0 1.5rem 0;
}
}
:global(.dark) .section-description {
color: var(--fog-dark-text-light, #a8b8d0);
}
.search-container {
display: flex;
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;
}
}
.kind-filter-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
@media (min-width: 640px) {
.kind-filter-wrapper {
flex-direction: row;
align-items: center;
flex: 1;
}
}
.kind-filter-label {
font-size: 0.875em;
color: var(--fog-text, #1f2937);
font-weight: 500;
white-space: nowrap;
}
:global(.dark) .kind-filter-label {
color: var(--fog-dark-text, #f9fafb);
}
.kind-filter-select {
padding: 0.75em;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875em;
cursor: pointer;
width: 100%;
font-family: inherit;
}
@media (min-width: 640px) {
.kind-filter-select {
width: auto;
min-width: 200px;
}
}
.kind-filter-select:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .kind-filter-select {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
: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);
}
.search-bar-wrapper :global(.unified-search-container) {
max-width: none;
width: 100%;
}
.search-button {
padding: 0.75em 1.5em;
background: var(--fog-accent, #64748b);
color: #ffffff;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875em;
font-weight: 500;
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: #1f2937;
}
.search-button:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.search-button:active:not(:disabled) {
transform: translateY(0);
}
.search-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

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

@ -58,6 +58,9 @@
{#if isLoggedIn} {#if isLoggedIn}
<a href="/write" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Write</a> <a href="/write" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Write</a>
{/if} {/if}
{#if isLoggedIn}
<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>
{/if}
<a href="/find" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Find</a> <a href="/find" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Find</a>
{#if isLoggedIn} {#if isLoggedIn}
<a href="/rss" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/RSS</a> <a href="/rss" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/RSS</a>

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

@ -810,9 +810,11 @@
} }
// 6. Anything else is a full-text search // 6. Anything else is a full-text search
// Try NIP-50 search first (relay-side full-text search), then fallback to client-side filtering
if (mode === 'search') { if (mode === 'search') {
let allEvents: NostrEvent[] = []; let allEvents: NostrEvent[] = [];
const uniqueEventIds = new Set<string>(); const uniqueEventIds = new Set<string>();
const queryLower = query.toLowerCase();
// Helper to add event and notify if needed // Helper to add event and notify if needed
const addEvent = (event: NostrEvent, relay?: string) => { const addEvent = (event: NostrEvent, relay?: string) => {
@ -827,7 +829,6 @@
// If hideDropdownResults, notify incrementally // If hideDropdownResults, notify incrementally
if (hideDropdownResults && onSearchResults) { if (hideDropdownResults && onSearchResults) {
const queryLower = query.toLowerCase();
const matches = allEvents.filter(event => { const matches = allEvents.filter(event => {
const contentMatch = event.content.toLowerCase().includes(queryLower); const contentMatch = event.content.toLowerCase().includes(queryLower);
const titleTag = event.tags.find(t => t[0] === 'title'); const titleTag = event.tags.find(t => t[0] === 'title');
@ -860,11 +861,87 @@
} }
}; };
const relays = relayManager.getAllAvailableRelays();
// Try NIP-50 search first (relay-side full-text search)
// Split query into words for NIP-50 search
const searchTerms = query.trim().split(/\s+/).filter(term => term.length > 0);
if (searchTerms.length > 0) {
try {
// Build NIP-50 filter with search terms
const nip50Filter: any = {
search: searchTerms,
limit: 100
};
// Add kind filter if specified
if (effectiveKinds && effectiveKinds.length > 0) {
nip50Filter.kinds = effectiveKinds;
}
// Try NIP-50 search on relays
const nip50Events = await nostrClient.fetchEvents(
[nip50Filter],
relays,
{
useCache: 'cache-first',
cacheResults: true,
timeout: 10000,
onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => {
// Add events as they arrive (NIP-50 relays already filtered them)
for (const { event, relay } of eventsWithRelay) {
addEvent(event, relay);
}
}
}
);
// If we got results from NIP-50, use them (relays that support it already filtered)
if (nip50Events.length > 0) {
for (const event of nip50Events) {
addEvent(event, relays[0] || 'unknown');
}
// Sort and return NIP-50 results
const sorted = allEvents.sort((a, b) => {
const aExact = a.content.toLowerCase() === queryLower;
const bExact = b.content.toLowerCase() === queryLower;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return b.created_at - a.created_at;
});
const limitedResults = Array.from(new Map(sorted.map(e => [e.id, e])).values()).slice(0, 100);
if (hideDropdownResults && onSearchResults) {
foundEvents = limitedResults;
const foundEventRelays = new Map<string, string>();
for (const event of foundEvents) {
const relay = eventRelayMap.get(event.id);
if (relay) {
foundEventRelays.set(event.id, relay);
}
}
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
} else {
searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content (NIP-50)' }));
showResults = true;
}
searching = false;
resolving = false;
return;
}
} catch (error) {
// NIP-50 search failed or not supported, fall through to client-side search
console.debug('NIP-50 search not available or failed, falling back to client-side search:', error);
}
}
// Fallback to client-side filtering if NIP-50 didn't return results or isn't supported
// If kinds are specified, search from relays // If kinds are specified, search from relays
if (effectiveKinds && effectiveKinds.length > 0) { if (effectiveKinds && effectiveKinds.length > 0) {
const relays = relayManager.getAllAvailableRelays();
const queryLower = query.toLowerCase();
// Search each allowed kind with onUpdate for incremental results // Search each allowed kind with onUpdate for incremental results
for (const kind of effectiveKinds) { for (const kind of effectiveKinds) {
await nostrClient.fetchEvents( await nostrClient.fetchEvents(
@ -914,7 +991,6 @@
} }
} }
const queryLower = query.toLowerCase();
allEvents = allCached.filter(event => { allEvents = allCached.filter(event => {
const contentMatch = event.content.toLowerCase().includes(queryLower); const contentMatch = event.content.toLowerCase().includes(queryLower);
@ -935,7 +1011,6 @@
// Final sort and limit (only if not already handled incrementally) // Final sort and limit (only if not already handled incrementally)
if (!(hideDropdownResults && onSearchResults && effectiveKinds && effectiveKinds.length > 0)) { if (!(hideDropdownResults && onSearchResults && effectiveKinds && effectiveKinds.length > 0)) {
const queryLower = query.toLowerCase();
const sorted = allEvents.sort((a, b) => { const sorted = allEvents.sort((a, b) => {
const aExact = a.content.toLowerCase() === queryLower; const aExact = a.content.toLowerCase() === queryLower;
const bExact = b.content.toLowerCase() === queryLower; const bExact = b.content.toLowerCase() === queryLower;

67
src/lib/modules/profiles/PaymentAddresses.svelte

@ -26,22 +26,21 @@
async function loadPaymentAddresses() { async function loadPaymentAddresses() {
loading = true; loading = true;
try { try {
const config = nostrClient.getConfig(); const { getRecentCachedEvents } = await import('../../services/cache/event-cache.js');
const { relayManager } = await import('../../services/nostr/relay-manager.js');
const { config } = await import('../../services/nostr/config.js');
// Fetch kind 10133 (payment targets) // Try cache first (fast - instant display)
const paymentEvents = await nostrClient.fetchEvents( const cachedEvents = await getRecentCachedEvents([KIND.PAYMENT_ADDRESSES], 60 * 60 * 1000, 100); // 1 hour cache
[{ kinds: [KIND.PAYMENT_ADDRESSES], authors: [pubkey], limit: 1 }], const cachedPaymentEvent = cachedEvents.find(e => e.pubkey === pubkey);
[...config.defaultRelays, ...config.profileRelays],
{ useCache: true, cacheResults: true }
);
const addresses: Array<{ type: string; address: string }> = []; const addresses: Array<{ type: string; address: string }> = [];
const seen = new Set<string>(); const seen = new Set<string>();
// Extract from kind 10133 // Extract from cached event if available
if (paymentEvents.length > 0) { if (cachedPaymentEvent) {
paymentEvent = paymentEvents[0]; paymentEvent = cachedPaymentEvent;
for (const tag of paymentEvent.tags) { for (const tag of cachedPaymentEvent.tags) {
if (tag[0] === 'payto' && tag[1] && tag[2]) { if (tag[0] === 'payto' && tag[1] && tag[2]) {
const key = `${tag[1]}:${tag[2]}`; const key = `${tag[1]}:${tag[2]}`;
if (!seen.has(key)) { if (!seen.has(key)) {
@ -50,9 +49,11 @@
} }
} }
} }
paymentAddresses = addresses;
loading = false; // Show cached content immediately
} }
// Also get lud16 from profile (kind 0) // Also get lud16 from profile (kind 0) - this is usually cached
const profile = await fetchProfile(pubkey); const profile = await fetchProfile(pubkey);
if (profile && profile.lud16) { if (profile && profile.lud16) {
for (const lud16 of profile.lud16) { for (const lud16 of profile.lud16) {
@ -62,9 +63,49 @@
seen.add(key); seen.add(key);
} }
} }
paymentAddresses = addresses;
} }
paymentAddresses = addresses; // Fetch from relays in background (progressive enhancement) if not in cache
if (!cachedPaymentEvent) {
const relays = relayManager.getProfileReadRelays();
const paymentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.PAYMENT_ADDRESSES], authors: [pubkey], limit: 1 }],
relays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout }
);
// Update with fresh data if available
if (paymentEvents.length > 0) {
paymentEvent = paymentEvents[0];
const freshAddresses: Array<{ type: string; address: string }> = [];
const freshSeen = new Set<string>();
// Extract from kind 10133
for (const tag of paymentEvents[0].tags) {
if (tag[0] === 'payto' && tag[1] && tag[2]) {
const key = `${tag[1]}:${tag[2]}`;
if (!freshSeen.has(key)) {
freshAddresses.push({ type: tag[1], address: tag[2] });
freshSeen.add(key);
}
}
}
// Merge with lud16 from profile
if (profile && profile.lud16) {
for (const lud16 of profile.lud16) {
const key = `lightning:${lud16}`;
if (!freshSeen.has(key)) {
freshAddresses.push({ type: 'lightning', address: lud16 });
freshSeen.add(key);
}
}
}
paymentAddresses = freshAddresses;
}
}
} catch (error) { } catch (error) {
console.error('Error loading payment addresses:', error); console.error('Error loading payment addresses:', error);
} finally { } finally {

105
src/lib/services/cache/event-archive.ts vendored

@ -202,8 +202,10 @@ export async function archiveOldEvents(
): Promise<number> { ): Promise<number> {
try { try {
const db = await getDB(); const db = await getDB();
const now = Date.now(); // created_at is in seconds (Unix timestamp), threshold is in milliseconds
const cutoffTime = now - threshold; // Convert threshold to seconds for comparison
const nowSeconds = Math.floor(Date.now() / 1000);
const cutoffTimeSeconds = nowSeconds - Math.floor(threshold / 1000);
// Find old events // Find old events
const tx = db.transaction('events', 'readonly'); const tx = db.transaction('events', 'readonly');
@ -211,7 +213,7 @@ export async function archiveOldEvents(
const oldEvents: CachedEvent[] = []; const oldEvents: CachedEvent[] = [];
for await (const cursor of index.iterate()) { for await (const cursor of index.iterate()) {
if (cursor.value.created_at < cutoffTime) { if (cursor.value.created_at < cutoffTimeSeconds) {
oldEvents.push(cursor.value as CachedEvent); oldEvents.push(cursor.value as CachedEvent);
} }
} }
@ -331,9 +333,13 @@ export async function clearOldArchivedEvents(olderThan: number): Promise<number>
const tx = db.transaction('eventArchive', 'readwrite'); const tx = db.transaction('eventArchive', 'readwrite');
const index = tx.store.index('created_at'); const index = tx.store.index('created_at');
// olderThan is in milliseconds, but created_at is in seconds
// Convert to seconds for comparison
const olderThanSeconds = Math.floor(olderThan / 1000);
const idsToDelete: string[] = []; const idsToDelete: string[] = [];
for await (const cursor of index.iterate()) { for await (const cursor of index.iterate()) {
if (cursor.value.created_at < olderThan) { if (cursor.value.created_at < olderThanSeconds) {
idsToDelete.push(cursor.value.id); idsToDelete.push(cursor.value.id);
} }
} }
@ -373,6 +379,7 @@ export async function getArchiveStats(): Promise<{
for await (const cursor of tx.store.iterate()) { for await (const cursor of tx.store.iterate()) {
totalArchived++; totalArchived++;
totalSize += cursor.value.compressed.length; totalSize += cursor.value.compressed.length;
// created_at is in seconds, keep it as-is for consistency
if (!oldestArchived || cursor.value.created_at < oldestArchived) { if (!oldestArchived || cursor.value.created_at < oldestArchived) {
oldestArchived = cursor.value.created_at; oldestArchived = cursor.value.created_at;
} }
@ -393,3 +400,93 @@ export async function getArchiveStats(): Promise<{
}; };
} }
} }
/**
* Recover all archived events back to main cache (unarchive all)
*/
export async function recoverAllArchivedEvents(): Promise<number> {
try {
const db = await getDB();
const tx = db.transaction(['events', 'eventArchive'], 'readwrite');
const archivedEvents: CachedEvent[] = [];
// Get all archived events
for await (const cursor of tx.objectStore('eventArchive').iterate()) {
const archived = cursor.value as ArchivedEvent;
// Decompress
const decompressed = await decompressJSON(archived.compressed);
archivedEvents.push(decompressed as CachedEvent);
}
await tx.done;
if (archivedEvents.length === 0) return 0;
// Restore in batches to avoid blocking
const BATCH_SIZE = 50;
let recovered = 0;
for (let i = 0; i < archivedEvents.length; i += BATCH_SIZE) {
const batch = archivedEvents.slice(i, i + BATCH_SIZE);
// Use a new transaction for each batch
const batchTx = db.transaction(['events', 'eventArchive'], 'readwrite');
// Restore events to main cache
await Promise.all(batch.map(event => batchTx.objectStore('events').put(event)));
// Remove from archive
await Promise.all(batch.map(event => batchTx.objectStore('eventArchive').delete(event.id)));
await batchTx.done;
recovered += batch.length;
// Yield to browser between batches to avoid blocking
await new Promise(resolve => setTimeout(resolve, 0));
}
return recovered;
} catch (error) {
// Recovery failed (non-critical)
return 0;
}
}
/**
* Find and recover an archived event by ID
*/
export async function recoverArchivedEventById(id: string): Promise<boolean> {
try {
const archived = await getArchivedEvent(id);
if (!archived) return false;
const db = await getDB();
const tx = db.transaction(['events', 'eventArchive'], 'readwrite');
// Put back in main events store
await tx.objectStore('events').put(archived);
// Remove from archive
await tx.objectStore('eventArchive').delete(id);
await tx.done;
return true;
} catch (error) {
// Recovery failed (non-critical)
return false;
}
}
/**
* Check if an event is archived by ID
*/
export async function isEventArchived(id: string): Promise<boolean> {
try {
const db = await getDB();
const archived = await db.get('eventArchive', id);
return archived !== undefined;
} catch (error) {
return false;
}
}

50
src/lib/services/cache/event-cache.ts vendored

@ -90,7 +90,7 @@ export async function getEvent(id: string): Promise<CachedEvent | undefined> {
} }
/** /**
* Get events by kind * Get events by kind (checks both main cache and archive)
*/ */
export async function getEventsByKind(kind: number, limit?: number): Promise<CachedEvent[]> { export async function getEventsByKind(kind: number, limit?: number): Promise<CachedEvent[]> {
try { try {
@ -104,8 +104,28 @@ export async function getEventsByKind(kind: number, limit?: number): Promise<Cac
await tx.done; await tx.done;
// Also check archive for events of this kind
const { getArchivedEventsByKind } = await import('./event-archive.js');
const archivedEvents = await getArchivedEventsByKind(kind, limit);
// Combine and deduplicate (archive might have events that are also in main cache)
const eventMap = new Map<string, CachedEvent>();
// Add main cache events first
for (const event of events) {
eventMap.set(event.id, event as CachedEvent);
}
// Add archived events (won't overwrite if already in main cache)
for (const event of archivedEvents) {
if (!eventMap.has(event.id)) {
eventMap.set(event.id, event);
}
}
// Sort and limit after fetching // Sort and limit after fetching
const sorted = events.sort((a, b) => b.created_at - a.created_at); const allEvents = Array.from(eventMap.values());
const sorted = allEvents.sort((a, b) => b.created_at - a.created_at);
return limit ? sorted.slice(0, limit) : sorted; return limit ? sorted.slice(0, limit) : sorted;
} catch (error) { } catch (error) {
// Cache read failed (non-critical) // Cache read failed (non-critical)
@ -114,7 +134,7 @@ export async function getEventsByKind(kind: number, limit?: number): Promise<Cac
} }
/** /**
* Get events by pubkey * Get events by pubkey (checks both main cache and archive)
*/ */
export async function getEventsByPubkey(pubkey: string, limit?: number): Promise<CachedEvent[]> { export async function getEventsByPubkey(pubkey: string, limit?: number): Promise<CachedEvent[]> {
try { try {
@ -128,8 +148,28 @@ export async function getEventsByPubkey(pubkey: string, limit?: number): Promise
await tx.done; await tx.done;
// Also check archive for events by this pubkey
const { getArchivedEventsByPubkey } = await import('./event-archive.js');
const archivedEvents = await getArchivedEventsByPubkey(pubkey, limit);
// Combine and deduplicate (archive might have events that are also in main cache)
const eventMap = new Map<string, CachedEvent>();
// Add main cache events first
for (const event of events) {
eventMap.set(event.id, event as CachedEvent);
}
// Add archived events (won't overwrite if already in main cache)
for (const event of archivedEvents) {
if (!eventMap.has(event.id)) {
eventMap.set(event.id, event);
}
}
// Sort and limit after fetching // Sort and limit after fetching
const sorted = events.sort((a, b) => b.created_at - a.created_at); const allEvents = Array.from(eventMap.values());
const sorted = allEvents.sort((a, b) => b.created_at - a.created_at);
return limit ? sorted.slice(0, limit) : sorted; return limit ? sorted.slice(0, limit) : sorted;
} catch (error) { } catch (error) {
// Cache read failed (non-critical) // Cache read failed (non-critical)
@ -292,7 +332,7 @@ export async function getCachedReactionsForEvents(eventIds: string[]): Promise<M
for (const reaction of allReactions) { for (const reaction of allReactions) {
// Find the event ID this reaction references (check both 'e' and 'E' tags) // Find the event ID this reaction references (check both 'e' and 'E' tags)
const eventIdTag = reaction.tags.find(t => { const eventIdTag = reaction.tags.find((t: string[]) => {
const tagName = t[0]; const tagName = t[0];
return (tagName === 'e' || tagName === 'E') && t[1] && eventIds.includes(t[1]); return (tagName === 'e' || tagName === 'E') && t[1] && eventIds.includes(t[1]);
}); });

1
src/lib/types/nostr.ts

@ -22,4 +22,5 @@ export interface NostrFilter {
since?: number; since?: number;
until?: number; until?: number;
limit?: number; limit?: number;
search?: string[]; // NIP-50: Full-text search terms
} }

986
src/routes/bookmarks/+page.svelte

File diff suppressed because it is too large Load Diff

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

@ -14,7 +14,7 @@
import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByKinds, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js'; import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByKinds, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js';
import { cacheEvent } from '../../lib/services/cache/event-cache.js'; import { cacheEvent } from '../../lib/services/cache/event-cache.js';
import type { CachedEvent } from '../../lib/services/cache/event-cache.js'; import type { CachedEvent } from '../../lib/services/cache/event-cache.js';
import { getArchiveStats, clearOldArchivedEvents } from '../../lib/services/cache/event-archive.js'; import { getArchiveStats, clearOldArchivedEvents, recoverAllArchivedEvents, recoverArchivedEventById, isEventArchived } from '../../lib/services/cache/event-archive.js';
import { triggerArchive } from '../../lib/services/cache/archive-scheduler.js'; import { triggerArchive } from '../../lib/services/cache/archive-scheduler.js';
import { KIND, getKindInfo } from '../../lib/types/kind-lookup.js'; import { KIND, getKindInfo } from '../../lib/types/kind-lookup.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
@ -42,6 +42,9 @@
let offset = $state(0); let offset = $state(0);
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
let archiving = $state(false); let archiving = $state(false);
let recovering = $state(false);
let eventIdToRecover = $state<string>('');
let recoveringEvent = $state(false);
// Filters // Filters
let selectedKind = $state<number | null>(null); let selectedKind = $state<number | null>(null);
@ -107,6 +110,64 @@
} }
} }
async function handleRecoverAllArchived() {
if (!confirm('Are you sure you want to recover all archived events? This will restore them to the main cache.')) {
return;
}
recovering = true;
try {
const recovered = await recoverAllArchivedEvents();
await loadStats();
await loadArchiveStats();
await loadEvents(true);
alert(`Recovered ${recovered} archived events. They are now back in the main cache.`);
} catch (error) {
alert('Failed to recover archived events');
} finally {
recovering = false;
}
}
async function handleRecoverEventById() {
if (!eventIdToRecover.trim()) {
alert('Please enter an event ID');
return;
}
// Decode bech32 if needed
const hexId = decodeEventIdToHex(eventIdToRecover.trim());
if (!hexId) {
alert('Invalid event ID format');
return;
}
// Check if event is archived
const isArchived = await isEventArchived(hexId);
if (!isArchived) {
alert('Event not found in archive. It may already be in the main cache or may not exist.');
return;
}
recoveringEvent = true;
try {
const recovered = await recoverArchivedEventById(hexId);
if (recovered) {
await loadStats();
await loadArchiveStats();
await loadEvents(true);
alert('Event recovered successfully. It is now back in the main cache.');
eventIdToRecover = '';
} else {
alert('Failed to recover event');
}
} catch (error) {
alert('Failed to recover event');
} finally {
recoveringEvent = false;
}
}
async function loadEvents(reset = false) { async function loadEvents(reset = false) {
if (reset) { if (reset) {
offset = 0; offset = 0;
@ -621,7 +682,7 @@
</p> </p>
{#if archiveStats.oldestArchived} {#if archiveStats.oldestArchived}
<p> <p>
<strong>Oldest Archived:</strong> {new Date(archiveStats.oldestArchived).toLocaleDateString()} <strong>Oldest Archived:</strong> {new Date(archiveStats.oldestArchived * 1000).toLocaleDateString()}
</p> </p>
{/if} {/if}
</div> </div>
@ -633,6 +694,13 @@
> >
{archiving ? 'Archiving...' : 'Archive Events Older Than 30 Days'} {archiving ? 'Archiving...' : 'Archive Events Older Than 30 Days'}
</button> </button>
<button
class="bulk-action-button"
onclick={handleRecoverAllArchived}
disabled={recovering}
>
{recovering ? 'Recovering...' : 'Recover All Archived Events'}
</button>
<button <button
class="bulk-action-button" class="bulk-action-button"
onclick={() => handleClearOldArchived(365)} onclick={() => handleClearOldArchived(365)}
@ -640,6 +708,28 @@
Clear Archived Events Older Than 1 Year Clear Archived Events Older Than 1 Year
</button> </button>
</div> </div>
<div class="recover-event-section">
<h3>Recover Individual Event</h3>
<div class="recover-event-input-group">
<input
type="text"
bind:value={eventIdToRecover}
placeholder="Enter event ID (hex, nevent, note)..."
class="filter-input"
onkeydown={(e) => e.key === 'Enter' && handleRecoverEventById()}
/>
<button
class="bulk-action-button"
onclick={handleRecoverEventById}
disabled={recoveringEvent || !eventIdToRecover.trim()}
>
{recoveringEvent ? 'Recovering...' : 'Recover Event'}
</button>
</div>
<p class="archive-note">
Enter an event ID to check if it's archived and recover it to the main cache.
</p>
</div>
<p class="archive-note"> <p class="archive-note">
Archived events are compressed to save space but remain accessible. Archived events are compressed to save space but remain accessible.
They are automatically decompressed when needed. They are automatically decompressed when needed.
@ -873,6 +963,38 @@
color: var(--fog-dark-text-light, #a8b8d0); color: var(--fog-dark-text-light, #a8b8d0);
} }
.recover-event-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .recover-event-section {
border-top-color: var(--fog-dark-border, #475569);
}
.recover-event-section h3 {
margin: 0 0 1rem 0;
font-size: 1.1em;
color: var(--fog-text, #1f2937);
}
:global(.dark) .recover-event-section h3 {
color: var(--fog-dark-text, #f9fafb);
}
.recover-event-input-group {
display: flex;
gap: 0.75rem;
align-items: flex-start;
flex-wrap: wrap;
}
.recover-event-input-group .filter-input {
flex: 1;
min-width: 250px;
}
.bulk-actions-section, .bulk-actions-section,
.events-section { .events-section {
margin-bottom: 2rem; margin-bottom: 2rem;

1275
src/routes/find/+page.svelte

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save