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 @@
@@ -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 @@
@@ -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 @@
@@ -1,5 +1,6 @@
|
||||
// Default Nostr relays for searching
|
||||
export const DEFAULT_RELAYS = [ |
||||
// 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 @@
@@ -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