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          >&lt;/&gt;</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