FilterBuilder.svelte raw
1 <script>
2 import { createEventDispatcher, onDestroy } from "svelte";
3 import { KIND_NAMES, isValidPubkey, isValidEventId, isValidTagName, formatDateTimeLocal, parseDateTimeLocal } from "./helpers.tsx";
4
5 const dispatch = createEventDispatcher();
6
7 // Filter state
8 export let searchText = "";
9 export let selectedKinds = [];
10 export let pubkeys = [];
11 export let eventIds = [];
12 export let tags = [];
13 export let sinceTimestamp = null;
14 export let untilTimestamp = null;
15 export let limit = null;
16
17 // JSON editor state
18 export let showJsonEditor = false;
19 let jsonEditorValue = "";
20 let jsonError = "";
21
22 // UI state
23 let showKindsPicker = false;
24 let kindSearchQuery = "";
25 let newPubkey = "";
26 let newEventId = "";
27 let newTagName = "";
28 let newTagValue = "";
29 let pubkeyError = "";
30 let eventIdError = "";
31 let tagNameError = "";
32
33 // Debounce timer
34 let debounceTimer = null;
35 const DEBOUNCE_MS = 1000;
36 let initialized = false;
37
38 onDestroy(() => {
39 if (debounceTimer) clearTimeout(debounceTimer);
40 });
41
42 // Build filter object from current state
43 function buildFilterObject() {
44 const filter = {};
45 if (selectedKinds.length > 0) filter.kinds = selectedKinds;
46 if (pubkeys.length > 0) filter.authors = pubkeys;
47 if (eventIds.length > 0) filter.ids = eventIds;
48 if (sinceTimestamp) filter.since = sinceTimestamp;
49 if (untilTimestamp) filter.until = untilTimestamp;
50 if (limit) filter.limit = limit;
51 if (searchText) filter.search = searchText;
52 // Tags
53 tags.forEach(tag => {
54 const tagKey = `#${tag.name}`;
55 if (!filter[tagKey]) filter[tagKey] = [];
56 filter[tagKey].push(tag.value);
57 });
58 return filter;
59 }
60
61 // Update JSON editor when filter state changes
62 $: if (showJsonEditor) {
63 const filter = buildFilterObject();
64 jsonEditorValue = JSON.stringify(filter, null, 2);
65 }
66
67 // Debounced auto-apply when any filter value changes (skip initial mount)
68 $: {
69 // Track all filter values
70 const _ = [searchText, selectedKinds, pubkeys, eventIds, tags, sinceTimestamp, untilTimestamp, limit];
71 if (initialized) {
72 debouncedApply();
73 } else {
74 initialized = true;
75 }
76 }
77
78 function debouncedApply() {
79 if (debounceTimer) clearTimeout(debounceTimer);
80 debounceTimer = setTimeout(() => {
81 applyFilters();
82 }, DEBOUNCE_MS);
83 }
84
85 function applyJsonFilter() {
86 try {
87 const parsed = JSON.parse(jsonEditorValue);
88 jsonError = "";
89
90 // Update state from parsed JSON
91 selectedKinds = parsed.kinds || [];
92 pubkeys = parsed.authors || [];
93 eventIds = parsed.ids || [];
94 sinceTimestamp = parsed.since || null;
95 untilTimestamp = parsed.until || null;
96 limit = parsed.limit || null;
97 searchText = parsed.search || "";
98
99 // Extract tags
100 tags = [];
101 Object.keys(parsed).forEach(key => {
102 if (key.startsWith('#') && key.length === 2) {
103 const tagName = key.slice(1);
104 const values = Array.isArray(parsed[key]) ? parsed[key] : [parsed[key]];
105 values.forEach(value => {
106 tags.push({ name: tagName, value: String(value) });
107 });
108 }
109 });
110 tags = tags; // trigger reactivity
111
112 // Apply immediately (skip debounce)
113 if (debounceTimer) clearTimeout(debounceTimer);
114 applyFilters();
115 } catch (e) {
116 jsonError = "Invalid JSON: " + e.message;
117 }
118 }
119
120 // Get all available kinds as array
121 $: availableKinds = Object.entries(KIND_NAMES).map(([kind, name]) => ({
122 kind: parseInt(kind),
123 name: name
124 })).sort((a, b) => a.kind - b.kind);
125
126 // Filter kinds by search query
127 $: filteredKinds = availableKinds.filter(k =>
128 k.kind.toString().includes(kindSearchQuery) ||
129 k.name.toLowerCase().includes(kindSearchQuery.toLowerCase())
130 );
131
132 function toggleKind(kind) {
133 if (selectedKinds.includes(kind)) {
134 selectedKinds = selectedKinds.filter(k => k !== kind);
135 } else {
136 selectedKinds = [...selectedKinds, kind].sort((a, b) => a - b);
137 }
138 }
139
140 function removeKind(kind) {
141 selectedKinds = selectedKinds.filter(k => k !== kind);
142 }
143
144 function addPubkey() {
145 const trimmed = newPubkey.trim();
146 if (!trimmed) return;
147
148 if (!isValidPubkey(trimmed)) {
149 pubkeyError = "Invalid pubkey: must be 64 character hex string";
150 return;
151 }
152
153 if (pubkeys.includes(trimmed)) {
154 pubkeyError = "Pubkey already added";
155 return;
156 }
157
158 pubkeys = [...pubkeys, trimmed];
159 newPubkey = "";
160 pubkeyError = "";
161 }
162
163 function removePubkey(pubkey) {
164 pubkeys = pubkeys.filter(p => p !== pubkey);
165 }
166
167 function addEventId() {
168 const trimmed = newEventId.trim();
169 if (!trimmed) return;
170
171 if (!isValidEventId(trimmed)) {
172 eventIdError = "Invalid event ID: must be 64 character hex string";
173 return;
174 }
175
176 if (eventIds.includes(trimmed)) {
177 eventIdError = "Event ID already added";
178 return;
179 }
180
181 eventIds = [...eventIds, trimmed];
182 newEventId = "";
183 eventIdError = "";
184 }
185
186 function removeEventId(eventId) {
187 eventIds = eventIds.filter(id => id !== eventId);
188 }
189
190 function addTag() {
191 const trimmedName = newTagName.trim();
192 const trimmedValue = newTagValue.trim();
193
194 if (!trimmedName || !trimmedValue) return;
195
196 if (!isValidTagName(trimmedName)) {
197 tagNameError = "Invalid tag name: must be single letter a-z or A-Z";
198 return;
199 }
200
201 // Check if this exact tag already exists
202 if (tags.some(t => t.name === trimmedName && t.value === trimmedValue)) {
203 tagNameError = "Tag already added";
204 return;
205 }
206
207 tags = [...tags, { name: trimmedName, value: trimmedValue }];
208 newTagName = "";
209 newTagValue = "";
210 tagNameError = "";
211 }
212
213 function removeTag(index) {
214 tags = tags.filter((_, i) => i !== index);
215 }
216
217 function clearAllFilters() {
218 searchText = "";
219 selectedKinds = [];
220 pubkeys = [];
221 eventIds = [];
222 tags = [];
223 sinceTimestamp = null;
224 untilTimestamp = null;
225 limit = null;
226 dispatch("clear");
227 }
228
229 function applyFilters() {
230 dispatch("apply", {
231 searchText,
232 selectedKinds,
233 pubkeys,
234 eventIds,
235 tags,
236 sinceTimestamp,
237 untilTimestamp,
238 limit
239 });
240 }
241
242 // Format timestamp for input
243 function getFormattedSince() {
244 return sinceTimestamp ? formatDateTimeLocal(sinceTimestamp) : "";
245 }
246
247 function getFormattedUntil() {
248 return untilTimestamp ? formatDateTimeLocal(untilTimestamp) : "";
249 }
250
251 function handleSinceChange(event) {
252 const value = event.target.value;
253 sinceTimestamp = value ? parseDateTimeLocal(value) : null;
254 }
255
256 function handleUntilChange(event) {
257 const value = event.target.value;
258 untilTimestamp = value ? parseDateTimeLocal(value) : null;
259 }
260 </script>
261
262 <div class="filter-builder">
263 <div class="filter-content">
264 <div class="filter-grid">
265 <!-- Search text -->
266 <label for="search-text">Search Text (NIP-50)</label>
267 <div class="field-content">
268 <input
269 id="search-text"
270 type="text"
271 bind:value={searchText}
272 placeholder="Search events..."
273 class="filter-input"
274 />
275 </div>
276
277 <!-- Kinds picker -->
278 <label>Event Kinds</label>
279 <div class="field-content">
280 <button
281 class="picker-toggle-btn"
282 on:click={() => showKindsPicker = !showKindsPicker}
283 >
284 {showKindsPicker ? "▼" : "▶"} Select Kinds ({selectedKinds.length} selected)
285 </button>
286
287 {#if showKindsPicker}
288 <div class="kinds-picker">
289 <input
290 type="text"
291 bind:value={kindSearchQuery}
292 placeholder="Search kinds..."
293 class="filter-input kind-search"
294 />
295 <div class="kinds-list">
296 {#each filteredKinds as { kind, name }}
297 <label class="kind-checkbox">
298 <input
299 type="checkbox"
300 checked={selectedKinds.includes(kind)}
301 on:change={() => toggleKind(kind)}
302 />
303 <span class="kind-number">{kind}</span>
304 <span class="kind-name">{name}</span>
305 </label>
306 {/each}
307 </div>
308 </div>
309 {/if}
310
311 {#if selectedKinds.length > 0}
312 <div class="chips-container">
313 {#each selectedKinds as kind}
314 <div class="chip">
315 <span class="chip-text">{kind}: {KIND_NAMES[kind] || `Kind ${kind}`}</span>
316 <button class="chip-remove" on:click={() => removeKind(kind)}>×</button>
317 </div>
318 {/each}
319 </div>
320 {/if}
321 </div>
322
323 <!-- Authors/Pubkeys -->
324 <label>Authors (Pubkeys)</label>
325 <div class="field-content">
326 <div class="input-group">
327 <input
328 type="text"
329 bind:value={newPubkey}
330 placeholder="64 character hex pubkey..."
331 class="filter-input"
332 maxlength="64"
333 on:keydown={(e) => e.key === 'Enter' && addPubkey()}
334 />
335 <button class="add-btn" on:click={addPubkey}>Add</button>
336 </div>
337 {#if pubkeyError}
338 <div class="error-message">{pubkeyError}</div>
339 {/if}
340 {#if pubkeys.length > 0}
341 <div class="list-items">
342 {#each pubkeys as pubkey}
343 <div class="list-item">
344 <span class="list-item-text">{pubkey}</span>
345 <button class="list-item-remove" on:click={() => removePubkey(pubkey)}>×</button>
346 </div>
347 {/each}
348 </div>
349 {/if}
350 </div>
351
352 <!-- Event IDs -->
353 <label>Event IDs</label>
354 <div class="field-content">
355 <div class="input-group">
356 <input
357 type="text"
358 bind:value={newEventId}
359 placeholder="64 character hex event ID..."
360 class="filter-input"
361 maxlength="64"
362 on:keydown={(e) => e.key === 'Enter' && addEventId()}
363 />
364 <button class="add-btn" on:click={addEventId}>Add</button>
365 </div>
366 {#if eventIdError}
367 <div class="error-message">{eventIdError}</div>
368 {/if}
369 {#if eventIds.length > 0}
370 <div class="list-items">
371 {#each eventIds as eventId}
372 <div class="list-item">
373 <span class="list-item-text">{eventId}</span>
374 <button class="list-item-remove" on:click={() => removeEventId(eventId)}>×</button>
375 </div>
376 {/each}
377 </div>
378 {/if}
379 </div>
380
381 <!-- Tags -->
382 <label>Tags (#e, #p, #a)</label>
383 <div class="field-content">
384 <div class="tag-input-group">
385 <span class="hash-prefix">#</span>
386 <input
387 type="text"
388 bind:value={newTagName}
389 placeholder="Tag"
390 class="filter-input tag-name-input"
391 maxlength="1"
392 />
393 <input
394 type="text"
395 bind:value={newTagValue}
396 placeholder="Value..."
397 class="filter-input tag-value-input"
398 on:keydown={(e) => e.key === 'Enter' && addTag()}
399 />
400 <button class="add-btn" on:click={addTag}>Add</button>
401 </div>
402 {#if tagNameError}
403 <div class="error-message">{tagNameError}</div>
404 {/if}
405 {#if tags.length > 0}
406 <div class="list-items">
407 {#each tags as tag, index}
408 <div class="list-item">
409 <span class="list-item-text">#{tag.name}: {tag.value}</span>
410 <button class="list-item-remove" on:click={() => removeTag(index)}>×</button>
411 </div>
412 {/each}
413 </div>
414 {/if}
415 </div>
416
417 <!-- Since timestamp -->
418 <label for="since-timestamp">Since</label>
419 <div class="field-content timestamp-field">
420 <input
421 id="since-timestamp"
422 type="datetime-local"
423 value={getFormattedSince()}
424 on:change={handleSinceChange}
425 class="filter-input"
426 />
427 {#if sinceTimestamp}
428 <button class="clear-timestamp-btn" on:click={() => sinceTimestamp = null}>×</button>
429 {/if}
430 </div>
431
432 <!-- Until timestamp -->
433 <label for="until-timestamp">Until</label>
434 <div class="field-content timestamp-field">
435 <input
436 id="until-timestamp"
437 type="datetime-local"
438 value={getFormattedUntil()}
439 on:change={handleUntilChange}
440 class="filter-input"
441 />
442 {#if untilTimestamp}
443 <button class="clear-timestamp-btn" on:click={() => untilTimestamp = null}>×</button>
444 {/if}
445 </div>
446
447 <!-- Limit -->
448 <label for="limit">Limit</label>
449 <div class="field-content">
450 <input
451 id="limit"
452 type="number"
453 bind:value={limit}
454 placeholder="Max events to return"
455 class="filter-input"
456 min="1"
457 />
458 </div>
459
460 <!-- JSON Editor (shown when toggled) - spans both columns -->
461 {#if showJsonEditor}
462 <div class="json-editor-section">
463 <label for="json-editor">Filter JSON</label>
464 <textarea
465 id="json-editor"
466 class="json-editor"
467 bind:value={jsonEditorValue}
468 placeholder={'{"kinds": [1], "limit": 100}'}
469 rows="8"
470 ></textarea>
471 {#if jsonError}
472 <div class="json-error">{jsonError}</div>
473 {/if}
474 <button class="apply-json-btn" on:click={applyJsonFilter}>Apply JSON</button>
475 </div>
476 {/if}
477 </div>
478 </div>
479 <div class="clear-column">
480 <button class="clear-all-btn" on:click={clearAllFilters} title="Clear all filters">🧹</button>
481 <div class="spacer"></div>
482 <button
483 class="json-toggle-btn"
484 class:active={showJsonEditor}
485 on:click={() => dispatch("toggleJson")}
486 title="Edit filter JSON"
487 ></></button>
488 </div>
489 </div>
490
491 <style>
492 .filter-builder {
493 padding: 1em;
494 background: var(--bg-color);
495 border-bottom: 1px solid var(--border-color);
496 display: flex;
497 gap: 1em;
498 }
499
500 .filter-content {
501 flex: 1;
502 min-width: 0;
503 }
504
505 .clear-column {
506 display: flex;
507 flex-direction: column;
508 gap: 0.5em;
509 flex-shrink: 0;
510 width: 2.5em;
511 }
512
513 .clear-column .spacer {
514 flex: 1;
515 }
516
517 .clear-all-btn,
518 .json-toggle-btn {
519 background: var(--secondary);
520 color: var(--text-color);
521 border: none;
522 padding: 0;
523 border-radius: 4px;
524 cursor: pointer;
525 font-size: 1em;
526 transition: filter 0.2s, background-color 0.2s;
527 width: 100%;
528 aspect-ratio: 1;
529 display: flex;
530 align-items: center;
531 justify-content: center;
532 box-sizing: border-box;
533 }
534
535 .clear-all-btn {
536 background: var(--danger);
537 }
538
539 .clear-all-btn:hover {
540 filter: brightness(1.2);
541 }
542
543 .json-toggle-btn {
544 font-family: monospace;
545 font-weight: 600;
546 background: var(--primary);
547 }
548
549 .json-toggle-btn:hover {
550 background: var(--accent-hover-color);
551 }
552
553 .json-toggle-btn.active {
554 background: var(--accent-hover-color);
555 }
556
557 .filter-grid {
558 display: grid;
559 grid-template-columns: auto 1fr;
560 gap: 0.5em 1em;
561 align-items: start;
562 }
563
564 .filter-grid > label {
565 font-weight: 600;
566 color: var(--text-color);
567 font-size: 0.9em;
568 padding-top: 0.6em;
569 white-space: nowrap;
570 }
571
572 .field-content {
573 min-width: 0;
574 }
575
576 .filter-input {
577 width: 100%;
578 padding: 0.6em;
579 border: 1px solid var(--border-color);
580 border-radius: 4px;
581 background: var(--input-bg);
582 color: var(--input-text-color);
583 font-size: 0.9em;
584 box-sizing: border-box;
585 }
586
587 .filter-input:focus {
588 outline: none;
589 border-color: var(--primary);
590 box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
591 }
592
593 .picker-toggle-btn {
594 width: 100%;
595 padding: 0.6em;
596 background: var(--secondary);
597 color: var(--text-color);
598 border: 1px solid var(--border-color);
599 border-radius: 4px;
600 cursor: pointer;
601 font-size: 0.9em;
602 text-align: left;
603 transition: background-color 0.2s;
604 }
605
606 .picker-toggle-btn:hover {
607 background: var(--accent-hover-color);
608 }
609
610 .kinds-picker {
611 margin-top: 0.5em;
612 border: 1px solid var(--border-color);
613 border-radius: 4px;
614 padding: 0.5em;
615 background: var(--card-bg);
616 }
617
618 .kind-search {
619 margin-bottom: 0.5em;
620 }
621
622 .kinds-list {
623 max-height: 300px;
624 overflow-y: auto;
625 }
626
627 .kind-checkbox {
628 display: flex;
629 align-items: center;
630 padding: 0.4em;
631 cursor: pointer;
632 border-radius: 4px;
633 transition: background-color 0.2s;
634 }
635
636 .kind-checkbox:hover {
637 background: var(--bg-color);
638 }
639
640 .kind-checkbox input[type="checkbox"] {
641 margin-right: 0.5em;
642 cursor: pointer;
643 }
644
645 .kind-number {
646 background: var(--primary);
647 color: var(--text-color);
648 padding: 0.1em 0.4em;
649 border-radius: 3px;
650 font-size: 0.8em;
651 font-weight: 600;
652 font-family: monospace;
653 margin-right: 0.5em;
654 min-width: 40px;
655 text-align: center;
656 display: inline-block;
657 }
658
659 .kind-name {
660 font-size: 0.85em;
661 color: var(--text-color);
662 }
663
664 .chips-container {
665 display: flex;
666 flex-wrap: wrap;
667 gap: 0.5em;
668 margin-top: 0.5em;
669 }
670
671 .chip {
672 display: inline-flex;
673 align-items: center;
674 background: var(--primary);
675 color: var(--text-color);
676 padding: 0.2em 0.5em;
677 border-radius: 0.5em;
678 font-size: 0.7em;
679 font-weight: 500;
680 text-transform: uppercase;
681 letter-spacing: 0.5px;
682 gap: 0.4em;
683 }
684
685 .chip-text {
686 line-height: 1;
687 }
688
689 .chip-remove {
690 background: transparent;
691 border: none;
692 color: var(--text-color);
693 cursor: pointer;
694 padding: 0;
695 font-size: 1em;
696 line-height: 1;
697 opacity: 0.8;
698 transition: opacity 0.2s;
699 }
700
701 .chip-remove:hover {
702 opacity: 1;
703 }
704
705 .input-group {
706 display: flex;
707 gap: 0.5em;
708 }
709
710 .input-group .filter-input {
711 flex: 1;
712 }
713
714 .add-btn {
715 background: var(--primary);
716 color: var(--text-color);
717 border: none;
718 padding: 0.6em 1.2em;
719 border-radius: 4px;
720 cursor: pointer;
721 font-size: 0.9em;
722 font-weight: 600;
723 transition: background-color 0.2s;
724 white-space: nowrap;
725 }
726
727 .add-btn:hover {
728 background: var(--accent-hover-color);
729 }
730
731 .error-message {
732 color: var(--danger);
733 font-size: 0.85em;
734 margin-top: 0.25em;
735 }
736
737 .list-items {
738 margin-top: 0.5em;
739 display: flex;
740 flex-direction: column;
741 gap: 0.5em;
742 }
743
744 .list-item {
745 display: flex;
746 align-items: center;
747 padding: 0.5em;
748 background: var(--card-bg);
749 border: 1px solid var(--border-color);
750 border-radius: 4px;
751 gap: 0.5em;
752 }
753
754 .list-item-text {
755 flex: 1;
756 font-family: monospace;
757 font-size: 0.85em;
758 color: var(--text-color);
759 word-break: break-all;
760 }
761
762 .list-item-remove {
763 background: var(--danger);
764 color: var(--text-color);
765 border: none;
766 padding: 0.25em 0.5em;
767 border-radius: 3px;
768 cursor: pointer;
769 font-size: 1.2em;
770 line-height: 1;
771 transition: background-color 0.2s;
772 }
773
774 .list-item-remove:hover {
775 filter: brightness(0.9);
776 }
777
778 .tag-input-group {
779 display: flex;
780 gap: 0.5em;
781 align-items: center;
782 }
783
784 .hash-prefix {
785 font-weight: 700;
786 font-size: 1.2em;
787 color: var(--text-color);
788 }
789
790 .tag-name-input {
791 width: 50px;
792 }
793
794 .tag-value-input {
795 flex: 1;
796 }
797
798 .timestamp-field {
799 position: relative;
800 display: flex;
801 align-items: center;
802 gap: 0.5em;
803 }
804
805 .timestamp-field .filter-input {
806 flex: 1;
807 }
808
809 .clear-timestamp-btn {
810 background: var(--danger);
811 color: var(--text-color);
812 border: none;
813 padding: 0.25em 0.5em;
814 border-radius: 3px;
815 cursor: pointer;
816 font-size: 1em;
817 line-height: 1;
818 transition: background-color 0.2s;
819 flex-shrink: 0;
820 }
821
822 .clear-timestamp-btn:hover {
823 filter: brightness(0.9);
824 }
825
826 .json-editor-section {
827 grid-column: 1 / -1;
828 margin-top: 0.5em;
829 padding-top: 1em;
830 border-top: 1px solid var(--border-color);
831 }
832
833 .json-editor-section label {
834 display: block;
835 font-weight: 600;
836 color: var(--text-color);
837 font-size: 0.9em;
838 margin-bottom: 0.5em;
839 }
840
841 .json-editor {
842 width: 100%;
843 padding: 0.6em;
844 border: 1px solid var(--border-color);
845 border-radius: 4px;
846 background: var(--input-bg);
847 color: var(--input-text-color);
848 font-family: monospace;
849 font-size: 0.85em;
850 resize: vertical;
851 box-sizing: border-box;
852 }
853
854 .json-editor:focus {
855 outline: none;
856 border-color: var(--primary);
857 box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
858 }
859
860 .json-error {
861 color: var(--danger);
862 font-size: 0.85em;
863 margin-top: 0.25em;
864 }
865
866 .apply-json-btn {
867 margin-top: 0.5em;
868 background: var(--primary);
869 color: var(--text-color);
870 border: none;
871 padding: 0.5em 1em;
872 border-radius: 4px;
873 cursor: pointer;
874 font-size: 0.9em;
875 font-weight: 600;
876 transition: background-color 0.2s;
877 }
878
879 .apply-json-btn:hover {
880 background: var(--accent-hover-color);
881 }
882
883 /* Responsive design */
884 @media (max-width: 768px) {
885 .filter-grid {
886 grid-template-columns: 1fr;
887 }
888
889 .filter-grid > label {
890 padding-top: 0;
891 padding-bottom: 0.25em;
892 }
893 }
894 </style>
895
896