Browse Source
- 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
6 changed files with 1147 additions and 34 deletions
@ -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> |
||||||
|
|
||||||
@ -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> |
||||||
|
|
||||||
@ -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}/`, |
||||||
]; |
]; |
||||||
|
|||||||
@ -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); |
||||||
|
} |
||||||
|
|
||||||
Loading…
Reference in new issue