Browse Source

Implement advanced filtering capabilities in the search interface

- Added a FilterBuilder component to allow users to create complex search filters based on various criteria such as event kinds, authors, and tags.
- Introduced a FilterDisplay component to show active filters and provide an option to clear them.
- Updated the App.svelte to integrate the new filtering features, including handling filter application and clearing.
- Enhanced search functionality to utilize the new filter structure, improving the search results experience.
- Bumped version to v0.26.0 to reflect these changes.
main
mleku 2 months ago
parent
commit
29ab350eed
No known key found for this signature in database
  1. 169
      app/web/src/App.svelte
  2. 689
      app/web/src/FilterBuilder.svelte
  3. 115
      app/web/src/FilterDisplay.svelte
  4. 3
      app/web/src/constants.js
  5. 203
      app/web/src/helpers.tsx
  6. 2
      pkg/version/version

169
app/web/src/App.svelte

@ -10,12 +10,16 @@
import RecoveryView from "./RecoveryView.svelte"; import RecoveryView from "./RecoveryView.svelte";
import SprocketView from "./SprocketView.svelte"; import SprocketView from "./SprocketView.svelte";
import SearchResultsView from "./SearchResultsView.svelte"; import SearchResultsView from "./SearchResultsView.svelte";
import FilterBuilder from "./FilterBuilder.svelte";
import FilterDisplay from "./FilterDisplay.svelte";
import { buildFilter } from "./helpers.tsx";
import { import {
initializeNostrClient, initializeNostrClient,
fetchUserProfile, fetchUserProfile,
fetchAllEvents, fetchAllEvents,
fetchUserEvents, fetchUserEvents,
searchEvents, searchEvents,
fetchEvents,
fetchEventById, fetchEventById,
fetchDeleteEventsByTarget, fetchDeleteEventsByTarget,
queryEvents, queryEvents,
@ -43,7 +47,7 @@
let userSigner = null; let userSigner = null;
let showSettingsDrawer = false; let showSettingsDrawer = false;
let selectedTab = localStorage.getItem("selectedTab") || "export"; let selectedTab = localStorage.getItem("selectedTab") || "export";
let isSearchMode = false; let showFilterBuilder = false; // Show advanced filter builder
let searchQuery = ""; let searchQuery = "";
let searchTabs = []; let searchTabs = [];
let allEvents = []; let allEvents = [];
@ -58,7 +62,7 @@
let viewAsRole = ""; let viewAsRole = "";
// Search results state // Search results state
let searchResults = new Map(); // Map of searchTabId -> { events, isLoading, hasMore, oldestTimestamp } let searchResults = new Map(); // Map of searchTabId -> { filter, events, isLoading, hasMore, oldestTimestamp }
let isLoadingSearch = false; let isLoadingSearch = false;
// Screen-filling events view state // Screen-filling events view state
@ -1671,37 +1675,43 @@
} }
function toggleSearchMode() { function toggleSearchMode() {
isSearchMode = !isSearchMode; showFilterBuilder = !showFilterBuilder;
if (!isSearchMode) { if (!showFilterBuilder) {
searchQuery = ""; searchQuery = "";
} }
} }
function handleSearchKeydown(event) { function handleSearchKeydown(event) {
if (event.key === "Enter" && searchQuery.trim()) { if (event.key === "Enter" && searchQuery.trim()) {
createSearchTab(searchQuery.trim()); createSimpleSearchTab(searchQuery.trim());
searchQuery = ""; searchQuery = "";
isSearchMode = false; showFilterBuilder = false;
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
isSearchMode = false; showFilterBuilder = false;
searchQuery = ""; searchQuery = "";
} }
} }
function createSearchTab(query) { function createSimpleSearchTab(query) {
const filter = buildFilter({ searchText: query, limit: 100 });
createSearchTab(filter, `Search: ${query}`);
}
function createSearchTab(filter, label) {
const searchTabId = `search-${Date.now()}`; const searchTabId = `search-${Date.now()}`;
const newSearchTab = { const newSearchTab = {
id: searchTabId, id: searchTabId,
icon: "🔍", icon: "🔍",
label: query, label: label,
isSearchTab: true, isSearchTab: true,
query: query, filter: filter,
}; };
searchTabs = [...searchTabs, newSearchTab]; searchTabs = [...searchTabs, newSearchTab];
selectedTab = searchTabId; selectedTab = searchTabId;
// Initialize search results for this tab // Initialize search results for this tab
searchResults.set(searchTabId, { searchResults.set(searchTabId, {
filter: filter,
events: [], events: [],
isLoading: false, isLoading: false,
hasMore: true, hasMore: true,
@ -1709,7 +1719,44 @@
}); });
// Start loading search results // Start loading search results
loadSearchResults(searchTabId, query); loadSearchResults(searchTabId, true);
}
function handleFilterApply(event) {
const { searchText, selectedKinds, pubkeys, eventIds, tags, sinceTimestamp, untilTimestamp, limit } = event.detail;
const filter = buildFilter({
searchText,
kinds: selectedKinds,
authors: pubkeys,
ids: eventIds,
tags,
since: sinceTimestamp,
until: untilTimestamp,
limit: limit || 100,
});
let label = "Filter";
if (searchText) {
label = `Search: ${searchText.substring(0, 20)}${searchText.length > 20 ? '...' : ''}`;
} else if (selectedKinds.length > 0) {
label = `Kinds: ${selectedKinds.slice(0, 3).join(', ')}${selectedKinds.length > 3 ? '...' : ''}`;
} else if (pubkeys.length > 0) {
label = `Authors: ${pubkeys.length}`;
}
createSearchTab(filter, label);
showFilterBuilder = false;
}
function handleFilterClear() {
// Just close the filter builder
showFilterBuilder = false;
}
function handleFilterSweep(searchTabId) {
// Close the search tab
closeSearchTab(searchTabId);
} }
function closeSearchTab(tabId) { function closeSearchTab(tabId) {
@ -1720,7 +1767,7 @@
} }
} }
async function loadSearchResults(searchTabId, query, reset = true) { async function loadSearchResults(searchTabId, reset = true) {
const searchResult = searchResults.get(searchTabId); const searchResult = searchResults.get(searchTabId);
if (!searchResult || searchResult.isLoading) return; if (!searchResult || searchResult.isLoading) return;
@ -1729,20 +1776,25 @@
searchResults.set(searchTabId, searchResult); searchResults.set(searchTabId, searchResult);
try { try {
const options = { const filter = { ...searchResult.filter };
limit: reset ? 100 : 200,
until: reset // Apply timestamp-based pagination
? Math.floor(Date.now() / 1000) if (!reset && searchResult.oldestTimestamp) {
: searchResult.oldestTimestamp, filter.until = searchResult.oldestTimestamp;
}; }
// Override limit for pagination
if (!reset) {
filter.limit = 200;
}
console.log( console.log(
"Loading search results for query:", "Loading search results with filter:",
query, filter,
"with options:",
options,
); );
const events = await searchEvents(query, options);
// Use fetchEvents with the filter array
const events = await fetchEvents([filter], { timeout: 30000 });
console.log("Received search results:", events.length, "events"); console.log("Received search results:", events.length, "events");
if (reset) { if (reset) {
@ -1768,7 +1820,7 @@
} }
} }
searchResult.hasMore = events.length === (reset ? 100 : 200); searchResult.hasMore = events.length === (reset ? filter.limit || 100 : 200);
searchResult.isLoading = false; searchResult.isLoading = false;
searchResults.set(searchTabId, searchResult); searchResults.set(searchTabId, searchResult);
} catch (error) { } catch (error) {
@ -1780,10 +1832,7 @@
} }
async function loadMoreSearchResults(searchTabId) { async function loadMoreSearchResults(searchTabId) {
const searchTab = searchTabs.find((tab) => tab.id === searchTabId); await loadSearchResults(searchTabId, false);
if (searchTab) {
await loadSearchResults(searchTabId, searchTab.query, false);
}
} }
function handleSearchScroll(event, searchTabId) { function handleSearchScroll(event, searchTabId) {
@ -2485,7 +2534,7 @@
<!-- Header --> <!-- Header -->
<Header <Header
{isDarkTheme} {isDarkTheme}
{isSearchMode} isSearchMode={showFilterBuilder}
bind:searchQuery bind:searchQuery
{isLoggedIn} {isLoggedIn}
{userRole} {userRole}
@ -2499,6 +2548,18 @@
on:openLoginModal={openLoginModal} on:openLoginModal={openLoginModal}
/> />
<!-- FilterBuilder - shown when search button is clicked -->
{#if showFilterBuilder}
<div class="filter-builder-overlay">
<div class="filter-builder-container">
<FilterBuilder
on:apply={handleFilterApply}
on:clear={handleFilterClear}
/>
</div>
</div>
{/if}
<!-- Main Content Area --> <!-- Main Content Area -->
<div class="app-container" class:dark-theme={isDarkTheme}> <div class="app-container" class:dark-theme={isDarkTheme}>
<!-- Sidebar --> <!-- Sidebar -->
@ -2750,13 +2811,12 @@
{#if searchTab.id === selectedTab} {#if searchTab.id === selectedTab}
<div class="search-results-view"> <div class="search-results-view">
<div class="search-results-header"> <div class="search-results-header">
<h2>🔍 Search Results: "{searchTab.query}"</h2> <h2>🔍 {searchTab.label}</h2>
<button <button
class="refresh-btn" class="refresh-btn"
on:click={() => on:click={() =>
loadSearchResults( loadSearchResults(
searchTab.id, searchTab.id,
searchTab.query,
true, true,
)} )}
disabled={searchResults.get(searchTab.id) disabled={searchResults.get(searchTab.id)
@ -2765,6 +2825,13 @@
🔄 Refresh 🔄 Refresh
</button> </button>
</div> </div>
<!-- FilterDisplay - show active filter -->
<FilterDisplay
filter={searchResults.get(searchTab.id)?.filter || {}}
on:sweep={() => handleFilterSweep(searchTab.id)}
/>
<div <div
class="search-results-content" class="search-results-content"
on:scroll={(e) => on:scroll={(e) =>
@ -2866,7 +2933,7 @@
{:else if !searchResults.get(searchTab.id)?.isLoading} {:else if !searchResults.get(searchTab.id)?.isLoading}
<div class="no-search-results"> <div class="no-search-results">
<p> <p>
No search results found for "{searchTab.query}". No search results found.
</p> </p>
</div> </div>
{/if} {/if}
@ -4067,4 +4134,42 @@
background: var(--header-bg); background: var(--header-bg);
border: none; border: none;
} }
/* Filter Builder Overlay */
.filter-builder-overlay {
position: fixed;
top: 3.5em; /* Below the header */
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
justify-content: center;
align-items: flex-start;
overflow-y: auto;
padding: 1em;
}
.filter-builder-container {
width: 100%;
max-width: 900px;
background: var(--bg-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
margin-top: 2em;
max-height: calc(100vh - 7em);
overflow-y: auto;
}
@media (max-width: 768px) {
.filter-builder-overlay {
top: 3em;
}
.filter-builder-container {
margin-top: 0;
max-height: calc(100vh - 5em);
}
}
</style> </style>

689
app/web/src/FilterBuilder.svelte

@ -0,0 +1,689 @@
<script>
import { createEventDispatcher } from "svelte";
import { KIND_NAMES, isValidPubkey, isValidEventId, isValidTagName, formatDateTimeLocal, parseDateTimeLocal } from "./helpers.tsx";
const dispatch = createEventDispatcher();
// Filter state
export let searchText = "";
export let selectedKinds = [];
export let pubkeys = [];
export let eventIds = [];
export let tags = [];
export let sinceTimestamp = null;
export let untilTimestamp = null;
export let limit = null;
// UI state
let showKindsPicker = false;
let kindSearchQuery = "";
let newPubkey = "";
let newEventId = "";
let newTagName = "";
let newTagValue = "";
let pubkeyError = "";
let eventIdError = "";
let tagNameError = "";
// Get all available kinds as array
$: availableKinds = Object.entries(KIND_NAMES).map(([kind, name]) => ({
kind: parseInt(kind),
name: name
})).sort((a, b) => a.kind - b.kind);
// Filter kinds by search query
$: filteredKinds = availableKinds.filter(k =>
k.kind.toString().includes(kindSearchQuery) ||
k.name.toLowerCase().includes(kindSearchQuery.toLowerCase())
);
function toggleKind(kind) {
if (selectedKinds.includes(kind)) {
selectedKinds = selectedKinds.filter(k => k !== kind);
} else {
selectedKinds = [...selectedKinds, kind].sort((a, b) => a - b);
}
}
function removeKind(kind) {
selectedKinds = selectedKinds.filter(k => k !== kind);
}
function addPubkey() {
const trimmed = newPubkey.trim();
if (!trimmed) return;
if (!isValidPubkey(trimmed)) {
pubkeyError = "Invalid pubkey: must be 64 character hex string";
return;
}
if (pubkeys.includes(trimmed)) {
pubkeyError = "Pubkey already added";
return;
}
pubkeys = [...pubkeys, trimmed];
newPubkey = "";
pubkeyError = "";
}
function removePubkey(pubkey) {
pubkeys = pubkeys.filter(p => p !== pubkey);
}
function addEventId() {
const trimmed = newEventId.trim();
if (!trimmed) return;
if (!isValidEventId(trimmed)) {
eventIdError = "Invalid event ID: must be 64 character hex string";
return;
}
if (eventIds.includes(trimmed)) {
eventIdError = "Event ID already added";
return;
}
eventIds = [...eventIds, trimmed];
newEventId = "";
eventIdError = "";
}
function removeEventId(eventId) {
eventIds = eventIds.filter(id => id !== eventId);
}
function addTag() {
const trimmedName = newTagName.trim();
const trimmedValue = newTagValue.trim();
if (!trimmedName || !trimmedValue) return;
if (!isValidTagName(trimmedName)) {
tagNameError = "Invalid tag name: must be single letter a-z or A-Z";
return;
}
// Check if this exact tag already exists
if (tags.some(t => t.name === trimmedName && t.value === trimmedValue)) {
tagNameError = "Tag already added";
return;
}
tags = [...tags, { name: trimmedName, value: trimmedValue }];
newTagName = "";
newTagValue = "";
tagNameError = "";
}
function removeTag(index) {
tags = tags.filter((_, i) => i !== index);
}
function clearAllFilters() {
searchText = "";
selectedKinds = [];
pubkeys = [];
eventIds = [];
tags = [];
sinceTimestamp = null;
untilTimestamp = null;
limit = null;
dispatch("clear");
}
function applyFilters() {
dispatch("apply", {
searchText,
selectedKinds,
pubkeys,
eventIds,
tags,
sinceTimestamp,
untilTimestamp,
limit
});
}
// Format timestamp for input
function getFormattedSince() {
return sinceTimestamp ? formatDateTimeLocal(sinceTimestamp) : "";
}
function getFormattedUntil() {
return untilTimestamp ? formatDateTimeLocal(untilTimestamp) : "";
}
function handleSinceChange(event) {
const value = event.target.value;
sinceTimestamp = value ? parseDateTimeLocal(value) : null;
}
function handleUntilChange(event) {
const value = event.target.value;
untilTimestamp = value ? parseDateTimeLocal(value) : null;
}
</script>
<div class="filter-builder">
<!-- Search text input at top -->
<div class="filter-section">
<label for="search-text">Search Text (NIP-50)</label>
<input
id="search-text"
type="text"
bind:value={searchText}
placeholder="Search events..."
class="filter-input"
/>
</div>
<!-- Kinds picker -->
<div class="filter-section">
<label>Event Kinds</label>
<button
class="picker-toggle-btn"
on:click={() => showKindsPicker = !showKindsPicker}
>
{showKindsPicker ? "▼" : "▶"} Select Kinds ({selectedKinds.length} selected)
</button>
{#if showKindsPicker}
<div class="kinds-picker">
<input
type="text"
bind:value={kindSearchQuery}
placeholder="Search kinds..."
class="filter-input kind-search"
/>
<div class="kinds-list">
{#each filteredKinds as { kind, name }}
<label class="kind-checkbox">
<input
type="checkbox"
checked={selectedKinds.includes(kind)}
on:change={() => toggleKind(kind)}
/>
<span class="kind-number">{kind}</span>
<span class="kind-name">{name}</span>
</label>
{/each}
</div>
</div>
{/if}
<!-- Selected kinds chips -->
{#if selectedKinds.length > 0}
<div class="chips-container">
{#each selectedKinds as kind}
<div class="chip">
<span class="chip-text">{kind}: {KIND_NAMES[kind] || `Kind ${kind}`}</span>
<button class="chip-remove" on:click={() => removeKind(kind)}>×</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Authors/Pubkeys -->
<div class="filter-section">
<label>Authors (Pubkeys)</label>
<div class="input-group">
<input
type="text"
bind:value={newPubkey}
placeholder="64 character hex pubkey..."
class="filter-input"
maxlength="64"
on:keydown={(e) => e.key === 'Enter' && addPubkey()}
/>
<button class="add-btn" on:click={addPubkey}>Add</button>
</div>
{#if pubkeyError}
<div class="error-message">{pubkeyError}</div>
{/if}
{#if pubkeys.length > 0}
<div class="list-items">
{#each pubkeys as pubkey}
<div class="list-item">
<span class="list-item-text">{pubkey}</span>
<button class="list-item-remove" on:click={() => removePubkey(pubkey)}>×</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Event IDs -->
<div class="filter-section">
<label>Event IDs</label>
<div class="input-group">
<input
type="text"
bind:value={newEventId}
placeholder="64 character hex event ID..."
class="filter-input"
maxlength="64"
on:keydown={(e) => e.key === 'Enter' && addEventId()}
/>
<button class="add-btn" on:click={addEventId}>Add</button>
</div>
{#if eventIdError}
<div class="error-message">{eventIdError}</div>
{/if}
{#if eventIds.length > 0}
<div class="list-items">
{#each eventIds as eventId}
<div class="list-item">
<span class="list-item-text">{eventId}</span>
<button class="list-item-remove" on:click={() => removeEventId(eventId)}>×</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Tags -->
<div class="filter-section">
<label>Tags (e.g., #e, #p, #a)</label>
<div class="tag-input-group">
<span class="hash-prefix">#</span>
<input
type="text"
bind:value={newTagName}
placeholder="Tag"
class="filter-input tag-name-input"
maxlength="1"
/>
<input
type="text"
bind:value={newTagValue}
placeholder="Value..."
class="filter-input tag-value-input"
on:keydown={(e) => e.key === 'Enter' && addTag()}
/>
<button class="add-btn" on:click={addTag}>Add</button>
</div>
{#if tagNameError}
<div class="error-message">{tagNameError}</div>
{/if}
{#if tags.length > 0}
<div class="list-items">
{#each tags as tag, index}
<div class="list-item">
<span class="list-item-text">#{tag.name}: {tag.value}</span>
<button class="list-item-remove" on:click={() => removeTag(index)}>×</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Since/Until timestamps -->
<div class="filter-section timestamps-section">
<div class="timestamp-field">
<label for="since-timestamp">Since</label>
<input
id="since-timestamp"
type="datetime-local"
value={getFormattedSince()}
on:change={handleSinceChange}
class="filter-input"
/>
{#if sinceTimestamp}
<button class="clear-timestamp-btn" on:click={() => sinceTimestamp = null}>×</button>
{/if}
</div>
<div class="timestamp-field">
<label for="until-timestamp">Until</label>
<input
id="until-timestamp"
type="datetime-local"
value={getFormattedUntil()}
on:change={handleUntilChange}
class="filter-input"
/>
{#if untilTimestamp}
<button class="clear-timestamp-btn" on:click={() => untilTimestamp = null}>×</button>
{/if}
</div>
</div>
<!-- Limit -->
<div class="filter-section">
<label for="limit">Limit (optional)</label>
<input
id="limit"
type="number"
bind:value={limit}
placeholder="Max events to return"
class="filter-input"
min="1"
/>
</div>
<!-- Action buttons -->
<div class="filter-actions">
<button class="apply-btn" on:click={applyFilters}>🔍 Apply Filters</button>
<button class="clear-btn" on:click={clearAllFilters}>🧹 Clear All</button>
</div>
</div>
<style>
.filter-builder {
padding: 1em;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
.filter-section {
margin-bottom: 1.25em;
}
.filter-section label {
display: block;
margin-bottom: 0.5em;
font-weight: 600;
color: var(--text-color);
font-size: 0.9em;
}
.filter-input {
width: 100%;
padding: 0.6em;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--input-text-color);
font-size: 0.9em;
box-sizing: border-box;
}
.filter-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
}
.picker-toggle-btn {
width: 100%;
padding: 0.6em;
background: var(--secondary);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
text-align: left;
transition: background-color 0.2s;
}
.picker-toggle-btn:hover {
background: var(--accent-hover-color);
}
.kinds-picker {
margin-top: 0.5em;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5em;
background: var(--card-bg);
}
.kind-search {
margin-bottom: 0.5em;
}
.kinds-list {
max-height: 300px;
overflow-y: auto;
}
.kind-checkbox {
display: flex;
align-items: center;
padding: 0.4em;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
}
.kind-checkbox:hover {
background: var(--bg-color);
}
.kind-checkbox input[type="checkbox"] {
margin-right: 0.5em;
cursor: pointer;
}
.kind-number {
background: var(--primary);
color: var(--text-color);
padding: 0.1em 0.4em;
border-radius: 3px;
font-size: 0.8em;
font-weight: 600;
font-family: monospace;
margin-right: 0.5em;
min-width: 40px;
text-align: center;
display: inline-block;
}
.kind-name {
font-size: 0.85em;
color: var(--text-color);
}
.chips-container {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin-top: 0.5em;
}
.chip {
display: inline-flex;
align-items: center;
background: var(--primary);
color: var(--text-color);
padding: 0.3em 0.6em;
border-radius: 16px;
font-size: 0.85em;
gap: 0.5em;
}
.chip-text {
font-weight: 500;
}
.chip-remove {
background: transparent;
border: none;
color: var(--text-color);
cursor: pointer;
padding: 0;
font-size: 1.2em;
line-height: 1;
opacity: 0.8;
transition: opacity 0.2s;
}
.chip-remove:hover {
opacity: 1;
}
.input-group {
display: flex;
gap: 0.5em;
}
.input-group .filter-input {
flex: 1;
}
.add-btn {
background: var(--primary);
color: var(--text-color);
border: none;
padding: 0.6em 1.2em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
font-weight: 600;
transition: background-color 0.2s;
white-space: nowrap;
}
.add-btn:hover {
background: var(--accent-hover-color);
}
.error-message {
color: var(--danger);
font-size: 0.85em;
margin-top: 0.25em;
}
.list-items {
margin-top: 0.5em;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.list-item {
display: flex;
align-items: center;
padding: 0.5em;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
gap: 0.5em;
}
.list-item-text {
flex: 1;
font-family: monospace;
font-size: 0.85em;
color: var(--text-color);
word-break: break-all;
}
.list-item-remove {
background: var(--danger);
color: var(--text-color);
border: none;
padding: 0.25em 0.5em;
border-radius: 3px;
cursor: pointer;
font-size: 1.2em;
line-height: 1;
transition: background-color 0.2s;
}
.list-item-remove:hover {
filter: brightness(0.9);
}
.tag-input-group {
display: flex;
gap: 0.5em;
align-items: center;
}
.hash-prefix {
font-weight: 700;
font-size: 1.2em;
color: var(--text-color);
}
.tag-name-input {
width: 50px;
}
.tag-value-input {
flex: 1;
}
.timestamps-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
}
.timestamp-field {
position: relative;
}
.clear-timestamp-btn {
position: absolute;
right: 0.5em;
top: 2em;
background: var(--danger);
color: var(--text-color);
border: none;
padding: 0.25em 0.5em;
border-radius: 3px;
cursor: pointer;
font-size: 1em;
line-height: 1;
transition: background-color 0.2s;
}
.clear-timestamp-btn:hover {
filter: brightness(0.9);
}
.filter-actions {
display: flex;
gap: 1em;
padding-top: 1em;
border-top: 1px solid var(--border-color);
}
.apply-btn,
.clear-btn {
flex: 1;
padding: 0.75em 1em;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
font-weight: 600;
transition: all 0.2s;
}
.apply-btn {
background: var(--primary);
color: var(--text-color);
}
.apply-btn:hover {
background: var(--accent-hover-color);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
}
.clear-btn {
background: var(--secondary);
color: var(--text-color);
}
.clear-btn:hover {
background: var(--danger);
transform: translateY(-1px);
}
/* Responsive design */
@media (max-width: 768px) {
.timestamps-section {
grid-template-columns: 1fr;
}
}
</style>

115
app/web/src/FilterDisplay.svelte

@ -0,0 +1,115 @@
<script>
import { createEventDispatcher } from "svelte";
import { prettyPrintFilter } from "./helpers.tsx";
const dispatch = createEventDispatcher();
export let filter = {};
export let showFilter = true;
$: filterJson = prettyPrintFilter(filter);
$: hasFilter = Object.keys(filter).length > 0;
function handleSweep() {
dispatch("sweep");
}
</script>
{#if showFilter && hasFilter}
<div class="filter-display">
<div class="filter-display-header">
<h3>Active Filter</h3>
<button class="sweep-btn" on:click={handleSweep} title="Clear filter">
🧹 Sweep
</button>
</div>
<div class="filter-json-container">
<pre class="filter-json">{filterJson}</pre>
</div>
</div>
{/if}
<style>
.filter-display {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
margin: 1em;
overflow: hidden;
}
.filter-display-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75em 1em;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
.filter-display-header h3 {
margin: 0;
font-size: 1em;
font-weight: 600;
color: var(--text-color);
}
.sweep-btn {
background: var(--danger);
color: var(--text-color);
border: none;
padding: 0.5em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
font-weight: 600;
transition: all 0.2s;
}
.sweep-btn:hover {
filter: brightness(0.9);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(255, 0, 0, 0.3);
}
.filter-json-container {
padding: 1em;
max-height: 200px;
overflow: auto;
}
.filter-json {
background: var(--code-bg);
padding: 1em;
border-radius: 4px;
font-family: 'Courier New', Courier, monospace;
font-size: 0.85em;
line-height: 1.5;
color: var(--code-text);
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
overflow-wrap: anywhere;
}
/* Custom scrollbar for json container */
.filter-json-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.filter-json-container::-webkit-scrollbar-track {
background: var(--bg-color);
}
.filter-json-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.filter-json-container::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
</style>

3
app/web/src/constants.js

@ -1,5 +1,6 @@
// Default Nostr relays for searching // Default Nostr relays for searching
export const DEFAULT_RELAYS = [ export const DEFAULT_RELAYS = [
// Use the local relay WebSocket endpoint // Use the local relay WebSocket endpoint
`wss://${window.location.host}/`, // Automatically use ws:// for http:// and wss:// for https://
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/`,
]; ];

203
app/web/src/helpers.tsx

@ -0,0 +1,203 @@
// Helper functions and constants for the ORLY dashboard
// Comprehensive kind names mapping
export const KIND_NAMES = {
0: "Profile Metadata",
1: "Text Note",
2: "Recommend Relay",
3: "Contacts",
4: "Encrypted DM",
5: "Delete Request",
6: "Repost",
7: "Reaction",
8: "Badge Award",
16: "Generic Repost",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1063: "File Metadata",
1311: "Live Chat Message",
1984: "Reporting",
1985: "Label",
9734: "Zap Request",
9735: "Zap Receipt",
10000: "Mute List",
10001: "Pin List",
10002: "Relay List Metadata",
10003: "Bookmark List",
10004: "Communities List",
10005: "Public Chats List",
10006: "Blocked Relays List",
10007: "Search Relays List",
10009: "User Groups",
10015: "Interests List",
10030: "User Emoji List",
13194: "Wallet Info",
22242: "Client Auth",
23194: "Wallet Request",
23195: "Wallet Response",
24133: "Nostr Connect",
27235: "HTTP Auth",
30000: "Categorized People List",
30001: "Categorized Bookmarks",
30002: "Categorized Relay List",
30003: "Bookmark Sets",
30004: "Curation Sets",
30005: "Video Sets",
30008: "Profile Badges",
30009: "Badge Definition",
30015: "Interest Sets",
30017: "Create/Update Stall",
30018: "Create/Update Product",
30019: "Marketplace UI/UX",
30020: "Product Sold As Auction",
30023: "Long-form Content",
30024: "Draft Long-form Content",
30030: "Emoji Sets",
30063: "Release Artifact Sets",
30078: "Application-specific Data",
30311: "Live Event",
30315: "User Statuses",
30388: "Slide Set",
30402: "Classified Listing",
30403: "Draft Classified Listing",
30617: "Repository Announcement",
30618: "Repository State Announcement",
30818: "Wiki Article",
30819: "Redirects",
31922: "Date-Based Calendar Event",
31923: "Time-Based Calendar Event",
31924: "Calendar",
31925: "Calendar Event RSVP",
31989: "Handler Recommendation",
31990: "Handler Information",
34550: "Community Definition",
34551: "Community Post Approval",
};
// Get human-readable kind name
export function getKindName(kind) {
return KIND_NAMES[kind] || `Kind ${kind}`;
}
// Validate hex string (for pubkeys and event IDs)
export function isValidHex(str, length = null) {
if (!str || typeof str !== "string") return false;
const hexRegex = /^[0-9a-fA-F]+$/;
if (!hexRegex.test(str)) return false;
if (length && str.length !== length) return false;
return true;
}
// Validate pubkey (64 character hex)
export function isValidPubkey(pubkey) {
return isValidHex(pubkey, 64);
}
// Validate event ID (64 character hex)
export function isValidEventId(eventId) {
return isValidHex(eventId, 64);
}
// Validate tag name (single letter a-zA-Z)
export function isValidTagName(tagName) {
return /^[a-zA-Z]$/.test(tagName);
}
// Format timestamp to localized string
export function formatTimestamp(timestamp) {
return new Date(timestamp * 1000).toLocaleString();
}
// Format timestamp for datetime-local input
export function formatDateTimeLocal(timestamp) {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
// Parse datetime-local input to unix timestamp
export function parseDateTimeLocal(dateTimeString) {
return Math.floor(new Date(dateTimeString).getTime() / 1000);
}
// Truncate pubkey for display
export function truncatePubkey(pubkey) {
if (!pubkey) return "";
return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
}
// Truncate content for display
export function truncateContent(content, maxLength = 100) {
if (!content) return "";
return content.length > maxLength ? content.slice(0, maxLength) + "..." : content;
}
// Build Nostr filter from form data
export function buildFilter({
searchText = null,
kinds = [],
authors = [],
ids = [],
tags = [],
since = null,
until = null,
limit = null,
}) {
const filter = {};
if (searchText && searchText.trim()) {
filter.search = searchText.trim();
}
if (kinds && kinds.length > 0) {
filter.kinds = kinds;
}
if (authors && authors.length > 0) {
filter.authors = authors;
}
if (ids && ids.length > 0) {
filter.ids = ids;
}
// Add tag filters (e.g., #e, #p, #a)
if (tags && tags.length > 0) {
tags.forEach(tag => {
if (tag.name && tag.value) {
const tagKey = `#${tag.name}`;
if (!filter[tagKey]) {
filter[tagKey] = [];
}
filter[tagKey].push(tag.value);
}
});
}
if (since) {
filter.since = since;
}
if (until) {
filter.until = until;
}
if (limit && limit > 0) {
filter.limit = limit;
}
return filter;
}
// Pretty print JSON with word breaking for long strings
export function prettyPrintFilter(filter) {
return JSON.stringify(filter, null, 2);
}

2
pkg/version/version

@ -1 +1 @@
v0.25.7 v0.26.0
Loading…
Cancel
Save