diff --git a/dashboard/src/components/EventFiltersBar.tsx b/dashboard/src/components/EventFiltersBar.tsx index a598cf6c..d86ace7e 100644 --- a/dashboard/src/components/EventFiltersBar.tsx +++ b/dashboard/src/components/EventFiltersBar.tsx @@ -2,6 +2,7 @@ import { memo } from 'react'; import { useEventStore } from '../store/eventStore'; import { useEventCount, useEventFilters, useFilterOptions } from '../hooks/useEventSelectors'; import { SearchAutocomplete } from './SearchAutocomplete'; +import { SavedFiltersManager } from './SavedFiltersManager'; export const EventFiltersBar = memo(function EventFiltersBar() { const filters = useEventFilters(); @@ -12,49 +13,57 @@ export const EventFiltersBar = memo(function EventFiltersBar() { const setEventTypeFilter = useEventStore((state) => state.setEventTypeFilter); return ( -
-
- - setSearch(value)} - /> -
+ <> +
+
+ + setSearch(value)} + /> +
-
- - -
+
+ + +
-
- - -
+
+ + +
-

- {totalCount.toLocaleString()} events loaded +

+ {totalCount.toLocaleString()} events loaded +

+
+ + + + ); +});

); }); diff --git a/dashboard/src/components/SavedFiltersManager.tsx b/dashboard/src/components/SavedFiltersManager.tsx new file mode 100644 index 00000000..1fe59b1f --- /dev/null +++ b/dashboard/src/components/SavedFiltersManager.tsx @@ -0,0 +1,165 @@ +import { memo, useState } from 'react'; +import { useEventStore } from '../store/eventStore'; +import { useShallow } from 'zustand/react/shallow'; +import type { SavedFilter } from '../types/event'; + +export const SavedFiltersManager = memo(function SavedFiltersManager() { + const [isOpen, setIsOpen] = useState(false); + const [filterName, setFilterName] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(''); + + const savedFilters = useEventStore((state) => state.savedFilters); + const saveCurrentFilter = useEventStore((state) => state.saveCurrentFilter); + const loadSavedFilter = useEventStore((state) => state.loadSavedFilter); + const renameSavedFilter = useEventStore((state) => state.renameSavedFilter); + const deleteSavedFilter = useEventStore((state) => state.deleteSavedFilter); + + const handleSave = () => { + if (filterName.trim()) { + saveCurrentFilter(filterName.trim()); + setFilterName(''); + } + }; + + const handleLoad = (id: string) => { + loadSavedFilter(id); + setIsOpen(false); + }; + + const handleStartEdit = (filter: SavedFilter) => { + setEditingId(filter.id); + setEditingName(filter.name); + }; + + const handleSaveEdit = () => { + if (editingId && editingName.trim()) { + renameSavedFilter(editingId, editingName.trim()); + setEditingId(null); + setEditingName(''); + } + }; + + const handleCancelEdit = () => { + setEditingId(null); + setEditingName(''); + }; + + const handleDelete = (id: string) => { + if (confirm('Are you sure you want to delete this saved filter?')) { + deleteSavedFilter(id); + if (editingId === id) { + setEditingId(null); + setEditingName(''); + } + } + }; + + return ( +
+
+ +
+ + {isOpen && ( +
+
+ setFilterName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSave()} + className="saved-filters__input" + /> + +
+ + {savedFilters.length > 0 ? ( +
    + {savedFilters.map((filter) => ( +
  • + {editingId === filter.id ? ( +
    + setEditingName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveEdit(); + if (e.key === 'Escape') handleCancelEdit(); + }} + className="saved-filters__input" + autoFocus + /> + + +
    + ) : ( +
    + +
    + + +
    +
    + )} +
  • + ))} +
+ ) : ( +

No saved filters yet

+ )} +
+ )} +
+ ); +}); diff --git a/dashboard/src/index.css b/dashboard/src/index.css index f3b7ac75..77e6182a 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -758,3 +758,172 @@ body { display: none; } } + +/* ─── SavedFiltersManager ────────────────────────────────────────── */ + +.saved-filters { + margin-top: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + overflow: hidden; +} + +.saved-filters__header { + padding: 12px 16px; +} + +.saved-filters__toggle { + width: 100%; + text-align: left; + background: transparent; + border: none; + color: inherit; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + gap: 8px; + transition: color 0.15s ease; +} + +.saved-filters__toggle:hover { + color: #60a5fa; +} + +.saved-filters__panel { + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.saved-filters__save { + display: flex; + gap: 8px; +} + +.saved-filters__input { + flex: 1; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 8px 10px; + background: #12151c; + color: inherit; + font-size: 0.9rem; +} + +.saved-filters__input:focus { + outline: none; + border-color: #60a5fa; +} + +.saved-filters__save-btn { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 8px 16px; + background: rgba(96, 165, 250, 0.15); + color: #60a5fa; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; + white-space: nowrap; +} + +.saved-filters__save-btn:hover:not(:disabled) { + background: rgba(96, 165, 250, 0.25); + border-color: #60a5fa; +} + +.saved-filters__save-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.saved-filters__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.saved-filters__item { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + overflow: hidden; +} + +.saved-filters__view, +.saved-filters__edit { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; +} + +.saved-filters__name { + flex: 1; + text-align: left; + background: transparent; + border: none; + color: inherit; + font-size: 0.95rem; + cursor: pointer; + padding: 4px 0; + transition: color 0.15s ease; +} + +.saved-filters__name:hover { + color: #60a5fa; +} + +.saved-filters__actions { + display: flex; + gap: 4px; +} + +.saved-filters__btn { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + padding: 4px 8px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; + font-size: 0.9rem; +} + +.saved-filters__btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); +} + +.saved-filters__btn--save { + color: #34d399; +} + +.saved-filters__btn--cancel { + color: #9aa0a6; +} + +.saved-filters__btn--edit { + color: #60a5fa; +} + +.saved-filters__btn--delete { + color: #f87171; +} + +.saved-filters__empty { + margin: 0; + color: #9aa0a6; + font-size: 0.9rem; + text-align: center; + padding: 16px; +} diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 724938b1..6c07ae1d 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -34,13 +34,15 @@ export function EventExplorerPage() { const setEvents = useEventStore((state) => state.setEvents); const setLoading = useEventStore((state) => state.setLoading); const setError = useEventStore((state) => state.setError); + const loadSavedFiltersFromStorage = useEventStore((state) => state.loadSavedFiltersFromStorage); const { isLoading, error } = useEventLoadingState(); const filters = useEventFilters(); const filteredEvents = useFilteredEvents(); useEffect(() => { restoreWalletSession(); - }, []); + loadSavedFiltersFromStorage(); + }, [loadSavedFiltersFromStorage]); useEffect(() => { let cancelled = false; diff --git a/dashboard/src/store/eventStore.ts b/dashboard/src/store/eventStore.ts index 5dcd525e..06a1c204 100644 --- a/dashboard/src/store/eventStore.ts +++ b/dashboard/src/store/eventStore.ts @@ -1,10 +1,12 @@ import { create } from 'zustand'; -import type { BlockchainEvent, EventFilters } from '../types/event'; +import type { BlockchainEvent, EventFilters, SavedFilter } from '../types/event'; import { filterEvents } from '../utils/eventData'; +import { getSavedFilters, addSavedFilter, updateSavedFilter, deleteSavedFilter } from '../utils/filterPersistence'; interface EventStoreState { events: BlockchainEvent[]; filters: EventFilters; + savedFilters: SavedFilter[]; isLoading: boolean; error: string | null; setEvents: (events: BlockchainEvent[]) => void; @@ -14,6 +16,11 @@ interface EventStoreState { setEventTypeFilter: (eventType: string) => void; setLoading: (isLoading: boolean) => void; setError: (error: string | null) => void; + saveCurrentFilter: (name: string) => void; + loadSavedFilter: (id: string) => void; + renameSavedFilter: (id: string, newName: string) => void; + deleteSavedFilter: (id: string) => void; + loadSavedFiltersFromStorage: () => void; } function dedupeEventsById(events: BlockchainEvent[]): BlockchainEvent[] { @@ -36,6 +43,7 @@ export const useEventStore = create((set) => ({ contractAddress: 'all', eventType: 'all', }, + savedFilters: [], isLoading: false, error: null, setEvents: (events) => set({ events: dedupeEventsById(events) }), @@ -57,6 +65,37 @@ export const useEventStore = create((set) => ({ })), setLoading: (isLoading) => set({ isLoading }), setError: (error) => set({ error }), + saveCurrentFilter: (name) => + set((state) => { + const newFilter: SavedFilter = { + id: `filter-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + name, + filters: { ...state.filters }, + createdAt: Date.now(), + }; + const updated = addSavedFilter(newFilter); + return { savedFilters: updated }; + }), + loadSavedFilter: (id) => + set((state) => { + const savedFilter = state.savedFilters.find((f) => f.id === id); + if (!savedFilter) return state; + return { filters: { ...savedFilter.filters } }; + }), + renameSavedFilter: (id, newName) => + set(() => { + const updated = updateSavedFilter(id, { name: newName }); + return { savedFilters: updated }; + }), + deleteSavedFilter: (id) => + set(() => { + const updated = deleteSavedFilter(id); + return { savedFilters: updated }; + }), + loadSavedFiltersFromStorage: () => + set(() => ({ + savedFilters: getSavedFilters(), + })), })); export function selectFilteredEvents(state: EventStoreState): BlockchainEvent[] { @@ -90,3 +129,7 @@ export function selectEventTypeOptions(state: EventStoreState): string[] { ) ); } + +export function selectSavedFilters(state: EventStoreState) { + return state.savedFilters; +} diff --git a/dashboard/src/types/event.ts b/dashboard/src/types/event.ts index ca0fd028..c1779c53 100644 --- a/dashboard/src/types/event.ts +++ b/dashboard/src/types/event.ts @@ -15,3 +15,10 @@ export interface EventFilters { contractAddress: string; eventType: string; } + +export interface SavedFilter { + id: string; + name: string; + filters: EventFilters; + createdAt: number; +} diff --git a/dashboard/src/utils/filterPersistence.ts b/dashboard/src/utils/filterPersistence.ts new file mode 100644 index 00000000..7d55d0d9 --- /dev/null +++ b/dashboard/src/utils/filterPersistence.ts @@ -0,0 +1,62 @@ +import type { SavedFilter } from '../types/event'; + +const SAVED_FILTERS_KEY = 'notify-chain:saved-filters'; + +function readStorage(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function writeStorage(key: string, value: string | null): void { + try { + if (value) { + localStorage.setItem(key, value); + } else { + localStorage.removeItem(key); + } + } catch { + // localStorage may be unavailable (private browsing, etc.) + } +} + +export function getSavedFilters(): SavedFilter[] { + const data = readStorage(SAVED_FILTERS_KEY); + if (!data) return []; + + try { + const parsed = JSON.parse(data); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function setSavedFilters(filters: SavedFilter[]): void { + writeStorage(SAVED_FILTERS_KEY, JSON.stringify(filters)); +} + +export function addSavedFilter(filter: SavedFilter): SavedFilter[] { + const existing = getSavedFilters(); + const updated = [...existing, filter]; + setSavedFilters(updated); + return updated; +} + +export function updateSavedFilter(id: string, updates: Partial): SavedFilter[] { + const existing = getSavedFilters(); + const updated = existing.map((filter) => + filter.id === id ? { ...filter, ...updates } : filter + ); + setSavedFilters(updated); + return updated; +} + +export function deleteSavedFilter(id: string): SavedFilter[] { + const existing = getSavedFilters(); + const updated = existing.filter((filter) => filter.id !== id); + setSavedFilters(updated); + return updated; +}