You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

2299 lines
92 KiB

import React, { useState, useEffect, useRef } from 'react';
import JSONPretty from 'react-json-pretty';
function PrettyJSONView({ jsonString, maxHeightClass = 'max-h-64' }) {
let data;
try {
data = JSON.parse(jsonString);
} catch (_) {
data = jsonString;
}
return (
<div
className={`text-xs p-2 rounded overflow-auto ${maxHeightClass} break-all break-words whitespace-pre-wrap bg-gray-950 text-white`}
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
>
<JSONPretty data={data} space={2} />
</div>
);
}
function App() {
const [user, setUser] = useState(null);
const [status, setStatus] = useState('Ready to authenticate');
const [statusType, setStatusType] = useState('info');
const [profileData, setProfileData] = useState(null);
// Theme state for dark/light mode
const [isDarkMode, setIsDarkMode] = useState(false);
const [checkingAuth, setCheckingAuth] = useState(true);
// Events log state
const [events, setEvents] = useState([]);
const [eventsLoading, setEventsLoading] = useState(false);
const [eventsOffset, setEventsOffset] = useState(0);
const [eventsHasMore, setEventsHasMore] = useState(true);
const [expandedEventId, setExpandedEventId] = useState(null);
// All Events log state (admin)
const [allEvents, setAllEvents] = useState([]);
const [allEventsLoading, setAllEventsLoading] = useState(false);
const [allEventsOffset, setAllEventsOffset] = useState(0);
const [allEventsHasMore, setAllEventsHasMore] = useState(true);
const [expandedAllEventId, setExpandedAllEventId] = useState(null);
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [searchLoading, setSearchLoading] = useState(false);
const [searchOffset, setSearchOffset] = useState(0);
const [searchHasMore, setSearchHasMore] = useState(true);
const [expandedSearchEventId, setExpandedSearchEventId] = useState(null);
// Profile cache for All Events Log
const [profileCache, setProfileCache] = useState({});
// Function to fetch and cache profile metadata for an author
async function fetchAndCacheProfile(pubkeyHex) {
if (!pubkeyHex || profileCache[pubkeyHex]) {
return profileCache[pubkeyHex] || null;
}
try {
const profile = await fetchKind0FromRelay(pubkeyHex);
if (profile) {
setProfileCache(prev => ({
...prev,
[pubkeyHex]: {
name: profile.name || `user:${pubkeyHex.slice(0, 8)}`,
display_name: profile.display_name,
picture: profile.picture,
about: profile.about
}
}));
return profile;
}
} catch (error) {
console.log('Error fetching profile for', pubkeyHex.slice(0, 8), ':', error);
}
return null;
}
// Function to fetch profiles for all events in a batch
async function fetchProfilesForEvents(events) {
const uniqueAuthors = [...new Set(events.map(event => event.author).filter(Boolean))];
const fetchPromises = uniqueAuthors.map(author => fetchAndCacheProfile(author));
await Promise.allSettled(fetchPromises);
}
// Section revealer states
const [expandedSections, setExpandedSections] = useState({
welcome: true,
exportMine: false,
exportAll: false,
exportSpecific: false,
importEvents: false,
search: true,
eventsLog: false,
allEventsLog: false
});
// Login view layout measurements
const titleRef = useRef(null);
const fileInputRef = useRef(null);
const [loginPadding, setLoginPadding] = useState(16); // default fallback padding in px
useEffect(() => {
function updatePadding() {
if (titleRef.current) {
const h = titleRef.current.offsetHeight || 0;
// Pad area around the text by half the title text height
setLoginPadding(Math.max(0, Math.round(h / 2)));
}
}
updatePadding();
window.addEventListener('resize', updatePadding);
return () => window.removeEventListener('resize', updatePadding);
}, []);
// Effect to detect and track system theme preference
useEffect(() => {
// Check if the browser supports prefers-color-scheme
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
// Set the initial theme based on system preference
setIsDarkMode(darkModeMediaQuery.matches);
// Add listener to respond to system theme changes
const handleThemeChange = (e) => {
setIsDarkMode(e.matches);
};
// Modern browsers
darkModeMediaQuery.addEventListener('change', handleThemeChange);
// Cleanup listener on component unmount
return () => {
darkModeMediaQuery.removeEventListener('change', handleThemeChange);
};
}, []);
useEffect(() => {
// Check authentication status on page load
(async () => {
await checkStatus();
setCheckingAuth(false);
})();
}, []);
// Effect to fetch profile when user changes
useEffect(() => {
if (user?.pubkey) {
fetchUserProfile(user.pubkey);
}
}, [user?.pubkey]);
// Effect to fetch initial events when user is authenticated
useEffect(() => {
if (user?.pubkey) {
fetchEvents(true); // true = reset
// Also fetch all events if user is admin
if (user.permission === 'admin') {
fetchAllEvents(true); // true = reset
}
}
}, [user?.pubkey, user?.permission]);
function relayURL() {
try {
return window.location.protocol.replace('http', 'ws') + '//' + window.location.host;
} catch (_) {
return 'ws://localhost:3333';
}
}
async function checkStatus() {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
if (data.authenticated && data.pubkey) {
// Fetch permission first, then set user and profile
try {
const permResponse = await fetch(`/api/permissions/${data.pubkey}`);
const permData = await permResponse.json();
if (permData && permData.permission) {
const fullUser = { pubkey: data.pubkey, permission: permData.permission };
setUser(fullUser);
updateStatus(`Already authenticated as: ${data.pubkey.slice(0, 16)}...`, 'success');
// Fire and forget profile fetch
fetchUserProfile(data.pubkey);
}
} catch (_) {
// ignore permission fetch errors
}
}
} catch (error) {
// Ignore errors for status check
}
}
function updateStatus(message, type = 'info') {
setStatus(message);
setStatusType(type);
}
function statusClassName() {
const base = 'mt-5 mb-5 p-3 rounded';
// Return theme-appropriate status classes
switch (statusType) {
case 'success':
return base + ' ' + getThemeClasses('bg-green-100 text-green-800', 'bg-green-900 text-green-100');
case 'error':
return base + ' ' + getThemeClasses('bg-red-100 text-red-800', 'bg-red-900 text-red-100');
case 'info':
default:
return base + ' ' + getThemeClasses('bg-cyan-100 text-cyan-800', 'bg-cyan-900 text-cyan-100');
}
}
async function getChallenge() {
try {
const response = await fetch('/api/auth/challenge');
const data = await response.json();
return data.challenge;
} catch (error) {
updateStatus('Failed to get authentication challenge: ' + error.message, 'error');
throw error;
}
}
async function loginWithExtension() {
if (!window.nostr) {
updateStatus('No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.', 'error');
return;
}
try {
updateStatus('Connecting to extension...', 'info');
// Get public key from extension
const pubkey = await window.nostr.getPublicKey();
// Get challenge from server
const challenge = await getChallenge();
// Create authentication event
const authEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayURL()],
['challenge', challenge]
],
content: ''
};
// Sign the event with extension
const signedEvent = await window.nostr.signEvent(authEvent);
// Send to server
await authenticate(signedEvent);
} catch (error) {
updateStatus('Extension login failed: ' + error.message, 'error');
}
}
async function fetchKind0FromRelay(pubkeyHex, timeoutMs = 4000) {
return new Promise((resolve) => {
let resolved = false;
let events = [];
let ws;
let reqSent = false;
try {
ws = new WebSocket(relayURL());
} catch (e) {
resolve(null);
return;
}
const subId = 'profile-' + Math.random().toString(36).slice(2);
const timer = setTimeout(() => {
if (ws && ws.readyState === 1) {
try { ws.close(); } catch (_) {}
}
if (!resolved) {
resolved = true;
resolve(null);
}
}, timeoutMs);
const sendRequest = () => {
if (!reqSent && ws && ws.readyState === 1) {
try {
const req = [
'REQ',
subId,
{ kinds: [0], authors: [pubkeyHex] }
];
ws.send(JSON.stringify(req));
reqSent = true;
} catch (_) {}
}
};
ws.onopen = () => {
sendRequest();
};
ws.onmessage = async (msg) => {
try {
const data = JSON.parse(msg.data);
const type = data[0];
if (type === 'AUTH') {
// Handle authentication challenge
const challenge = data[1];
if (!window.nostr) {
clearTimeout(timer);
if (!resolved) {
resolved = true;
resolve(null);
}
return;
}
try {
// Create authentication event
const authEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayURL()],
['challenge', challenge]
],
content: ''
};
// Sign the auth event with extension
const signedAuthEvent = await window.nostr.signEvent(authEvent);
// Send AUTH response
const authMessage = ['AUTH', signedAuthEvent];
console.log('DEBUG: Sending AUTH response for profile fetch challenge:', challenge.slice(0, 16) + '...');
ws.send(JSON.stringify(authMessage));
} catch (authError) {
clearTimeout(timer);
if (!resolved) {
resolved = true;
resolve(null);
}
}
} else if (type === 'EVENT' && data[1] === subId) {
const event = data[2];
if (event && event.kind === 0 && event.content) {
events.push(event);
}
} else if (type === 'EOSE' && data[1] === subId) {
try {
ws.send(JSON.stringify(['CLOSE', subId]));
} catch (_) {}
try { ws.close(); } catch (_) {}
clearTimeout(timer);
if (!resolved) {
resolved = true;
if (events.length) {
const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
try {
const meta = JSON.parse(latest.content);
resolve(meta || null);
} catch (_) {
resolve(null);
}
} else {
resolve(null);
}
}
} else if (type === 'CLOSED' && data[1] === subId) {
const message = data[2] || '';
if (message.includes('auth-required') && !reqSent) {
// Wait for AUTH challenge, request will be sent after authentication
return;
}
// Subscription was closed, finish processing
clearTimeout(timer);
if (!resolved) {
resolved = true;
if (events.length) {
const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
try {
const meta = JSON.parse(latest.content);
resolve(meta || null);
} catch (_) {
resolve(null);
}
} else {
resolve(null);
}
}
} else if (type === 'OK' && data[1] && data[1].length === 64 && !reqSent) {
// This might be an OK response to our AUTH event
// Send the original request now that we're authenticated
sendRequest();
}
} catch (_) {
// ignore malformed messages
}
};
ws.onerror = () => {
try { ws.close(); } catch (_) {}
clearTimeout(timer);
if (!resolved) {
resolved = true;
resolve(null);
}
};
ws.onclose = () => {
clearTimeout(timer);
if (!resolved) {
resolved = true;
if (events.length) {
const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
try {
const meta = JSON.parse(latest.content);
resolve(meta || null);
} catch (_) {
resolve(null);
}
} else {
resolve(null);
}
}
};
});
}
// Function to fetch user profile metadata (kind 0)
async function fetchUserProfile(pubkeyHex) {
try {
// Create a simple placeholder with the pubkey
const placeholderProfile = {
name: `user:${pubkeyHex.slice(0, 8)}`,
about: 'No profile data available'
};
// Always set the placeholder profile first
setProfileData(placeholderProfile);
// First, try to get profile kind:0 from the relay itself
let relayMetadata = null;
try {
relayMetadata = await fetchKind0FromRelay(pubkeyHex);
} catch (_) {}
if (relayMetadata) {
const parsed = typeof relayMetadata === 'string' ? JSON.parse(relayMetadata) : relayMetadata;
setProfileData({
name: parsed.name || placeholderProfile.name,
display_name: parsed.display_name,
picture: parsed.picture,
banner: parsed.banner,
about: parsed.about || placeholderProfile.about
});
return parsed;
}
// Fallback: try extension metadata if available
if (window.nostr && window.nostr.getPublicKey) {
try {
if (window.nostr.getUserMetadata) {
const metadata = await window.nostr.getUserMetadata();
if (metadata) {
try {
const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
setProfileData({
name: parsedMetadata.name || placeholderProfile.name,
display_name: parsedMetadata.display_name,
picture: parsedMetadata.picture,
banner: parsedMetadata.banner,
about: parsedMetadata.about || placeholderProfile.about
});
return parsedMetadata;
} catch (parseError) {
console.log('Error parsing user metadata:', parseError);
}
}
}
} catch (nostrError) {
console.log('Could not get profile from extension:', nostrError);
}
}
return placeholderProfile;
} catch (error) {
console.error('Error handling profile data:', error);
return null;
}
}
async function authenticate(signedEvent) {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedEvent)
});
const result = await response.json();
if (result.success) {
setUser(result.pubkey);
updateStatus('Successfully authenticated as: ' + result.pubkey.slice(0, 16) + '...', 'success');
// Check permissions after login
const permResponse = await fetch(`/api/permissions/${result.pubkey}`);
const permData = await permResponse.json();
if (permData && permData.permission) {
setUser({pubkey: result.pubkey, permission: permData.permission});
// Fetch user profile data
await fetchUserProfile(result.pubkey);
}
} else {
updateStatus('Authentication failed: ' + result.error, 'error');
}
} catch (error) {
updateStatus('Authentication request failed: ' + error.message, 'error');
}
}
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (_) {}
setUser(null);
setProfileData(null);
// Clear events state
setEvents([]);
setEventsOffset(0);
setEventsHasMore(true);
setExpandedEventId(null);
// Clear all events state
setAllEvents([]);
setAllEventsOffset(0);
setAllEventsHasMore(true);
setExpandedAllEventId(null);
updateStatus('Logged out', 'info');
}
// WebSocket-based function to fetch events from relay
async function fetchEventsFromRelay(reset = false, limit = 50, timeoutMs = 10000) {
if (!user?.pubkey) return;
if (eventsLoading) return;
if (!reset && !eventsHasMore) return;
console.log('DEBUG: fetchEventsFromRelay called, reset:', reset, 'offset:', eventsOffset);
setEventsLoading(true);
return new Promise((resolve) => {
let resolved = false;
let receivedEvents = [];
let ws;
let reqSent = false;
try {
ws = new WebSocket(relayURL());
} catch (e) {
console.error('Failed to create WebSocket:', e);
setEventsLoading(false);
resolve();
return;
}
const subId = 'events-' + Math.random().toString(36).slice(2);
const timer = setTimeout(() => {
if (ws && ws.readyState === 1) {
try { ws.close(); } catch (_) {}
}
if (!resolved) {
resolved = true;
console.log('DEBUG: WebSocket timeout, received events:', receivedEvents.length);
processEventsResponse(receivedEvents, reset);
resolve();
}
}, timeoutMs);
const sendRequest = () => {
if (!reqSent && ws && ws.readyState === 1) {
try {
// Request events from the authenticated user
const req = [
'REQ',
subId,
{ authors: [user.pubkey] }
];
console.log('DEBUG: Sending WebSocket request:', req);
ws.send(JSON.stringify(req));
reqSent = true;
} catch (e) {
console.error('Failed to send WebSocket request:', e);
}
}
};
ws.onopen = () => {
sendRequest();
};
ws.onmessage = async (msg) => {
try {
const data = JSON.parse(msg.data);
const type = data[0];
console.log('DEBUG: WebSocket message:', type, data.length > 2 ? 'with event' : '');
if (type === 'AUTH') {
// Handle authentication challenge
const challenge = data[1];
if (!window.nostr) {
console.error('Authentication required but no Nostr extension found');
clearTimeout(timer);
if (!resolved) {
resolved = true;
processEventsResponse(receivedEvents, reset);
resolve();
}
return;
}
try {
// Create authentication event
const authEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayURL()],
['challenge', challenge]
],
content: ''
};
// Sign the auth event with extension
const signedAuthEvent = await window.nostr.signEvent(authEvent);
// Send AUTH response
const authMessage = ['AUTH', signedAuthEvent];
console.log('DEBUG: Sending AUTH response for events fetch challenge:', challenge.slice(0, 16) + '...');
ws.send(JSON.stringify(authMessage));
} catch (authError) {
console.error('Failed to authenticate:', authError);
clearTimeout(timer);
if (!resolved) {
resolved = true;
processEventsResponse(receivedEvents, reset);
resolve();
}
}
} else if (type === 'EVENT' && data[1] === subId) {
const event = data[2];
if (event) {
// Convert to the expected format
const formattedEvent = {
id: event.id,
kind: event.kind,
created_at: event.created_at,
content: event.content || '',
raw_json: JSON.stringify(event)
};
receivedEvents.push(formattedEvent);
}
} else if (type === 'EOSE' && data[1] === subId) {
try {
ws.send(JSON.stringify(['CLOSE', subId]));
} catch (_) {}
try { ws.close(); } catch (_) {}
clearTimeout(timer);
if (!resolved) {
resolved = true;
console.log('DEBUG: EOSE received, processing events:', receivedEvents.length);
processEventsResponse(receivedEvents, reset);
resolve();
}
} else if (type === 'CLOSED' && data[1] === subId) {
const message = data[2] || '';
console.log('DEBUG: Subscription closed:', message);
if (message.includes('auth-required') && !reqSent) {
// Wait for AUTH challenge, request will be sent after authentication
return;
}
// Subscription was closed, finish processing
clearTimeout(timer);
if (!resolved) {
resolved = true;
processEventsResponse(receivedEvents, reset);
resolve();
}
} else if (type === 'OK' && data[1] && data[1].length === 64 && !reqSent) {
// This might be an OK response to our AUTH event
// Send the original request now that we're authenticated
sendRequest();
}
} catch (e) {
console.error('Error parsing WebSocket message:', e);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
try { ws.close(); } catch (_) {}
clearTimeout(timer);
if (!resolved) {
resolved = true;
processEventsResponse(receivedEvents, reset);
resolve();
}
};
ws.onclose = () => {
clearTimeout(timer);
if (!resolved) {
resolved = true;
console.log('DEBUG: WebSocket closed, processing events:', receivedEvents.length);
processEventsResponse(receivedEvents, reset);
resolve();
}
};
});
}
// Helper function to filter out deleted events and process delete events
function filterDeletedEvents(events) {
// Find all delete events (kind 5)
const deleteEvents = events.filter(event => event.kind === 5);
// Extract the event IDs that have been deleted
const deletedEventIds = new Set();
deleteEvents.forEach(deleteEvent => {
try {
const originalEvent = JSON.parse(deleteEvent.raw_json);
// Look for 'e' tags in the delete event that reference deleted events
if (originalEvent.tags) {
originalEvent.tags.forEach(tag => {
if (tag[0] === 'e' && tag[1]) {
deletedEventIds.add(tag[1]);
}
});
}
} catch (error) {
console.error('Error parsing delete event:', error);
}
});
// Filter out events that have been deleted, but keep delete events themselves
const filteredEvents = events.filter(event => {
// Always show delete events (kind 5)
if (event.kind === 5) {
return true;
}
// Hide events that have been deleted
return !deletedEventIds.has(event.id);
});
console.log('DEBUG: Filtered events - original:', events.length, 'filtered:', filteredEvents.length, 'deleted IDs:', deletedEventIds.size);
return filteredEvents;
}
function processEventsResponse(receivedEvents, reset) {
try {
// Filter out deleted events and ensure delete events are included
const filteredEvents = filterDeletedEvents(receivedEvents);
// Sort events by created_at in descending order (newest first)
const sortedEvents = filteredEvents.sort((a, b) => b.created_at - a.created_at);
// Apply pagination manually since we get all events from WebSocket
const currentOffset = reset ? 0 : eventsOffset;
const limit = 50;
const paginatedEvents = sortedEvents.slice(currentOffset, currentOffset + limit);
console.log('DEBUG: Processing events - total:', sortedEvents.length, 'paginated:', paginatedEvents.length, 'offset:', currentOffset);
if (reset) {
setEvents(paginatedEvents);
setEventsOffset(paginatedEvents.length);
} else {
setEvents(prev => [...prev, ...paginatedEvents]);
setEventsOffset(prev => prev + paginatedEvents.length);
}
// Check if there are more events available
setEventsHasMore(currentOffset + paginatedEvents.length < sortedEvents.length);
console.log('DEBUG: Events updated, displayed count:', paginatedEvents.length, 'has more:', currentOffset + paginatedEvents.length < sortedEvents.length);
} catch (error) {
console.error('Error processing events response:', error);
} finally {
setEventsLoading(false);
}
}
// WebSocket-based function to fetch all events from relay (admin)
async function fetchAllEventsFromRelay(reset = false, limit = 50, timeoutMs = 10000) {
if (!user?.pubkey || user.permission !== 'admin') return;
if (allEventsLoading) return;
if (!reset && !allEventsHasMore) return;
console.log('DEBUG: fetchAllEventsFromRelay called, reset:', reset, 'offset:', allEventsOffset);
setAllEventsLoading(true);
return new Promise((resolve) => {
let resolved = false;
let receivedEvents = [];
let ws;
let reqSent = false;
try {
ws = new WebSocket(relayURL());
} catch (e) {
console.error('Failed to create WebSocket:', e);
setAllEventsLoading(false);
resolve();
return;
}
const subId = 'allevents-' + Math.random().toString(36).slice(2);
const timer = setTimeout(() => {
if (ws && ws.readyState === 1) {
try { ws.close(); } catch (_) {}
}
if (!resolved) {
resolved = true;
console.log('DEBUG: WebSocket timeout, received all events:', receivedEvents.length);
processAllEventsResponse(receivedEvents, reset);
resolve();
}
}, timeoutMs);
const sendRequest = () => {
if (!reqSent && ws && ws.readyState === 1) {
try {
// Request all events (no authors filter for admin)
const req = [
'REQ',
subId,
{}
];
console.log('DEBUG: Sending WebSocket request for all events:', req);
ws.send(JSON.stringify(req));
reqSent = true;
} catch (e) {
console.error('Failed to send WebSocket request:', e);
}
}
};
ws.onopen = () => {
sendRequest();
};
ws.onmessage = async (msg) => {
try {
const data = JSON.parse(msg.data);
const type = data[0];
console.log('DEBUG: WebSocket message:', type, data.length > 2 ? 'with event' : '');
if (type === 'AUTH') {
// Handle authentication challenge
const challenge = data[1];
if (!window.nostr) {
console.error('Authentication required but no Nostr extension found');
clearTimeout(timer);
if (!resolved) {
resolved = true;
processAllEventsResponse(receivedEvents, reset);
resolve();
}
return;
}
try {
// Create authentication event
const authEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayURL()],
['challenge', challenge]
],
content: ''
};
// Sign the auth event with extension
const signedAuthEvent = await window.nostr.signEvent(authEvent);
// Send AUTH response
const authMessage = ['AUTH', signedAuthEvent];
console.log('DEBUG: Sending AUTH response for all events fetch challenge:', challenge.slice(0, 16) + '...');
ws.send(JSON.stringify(authMessage));
} catch (authError) {
console.error('Failed to authenticate:', authError);
clearTimeout(timer);
if (!resolved) {
resolved = true;
processAllEventsResponse(receivedEvents, reset);
resolve();
}
}
} else if (type === 'EVENT' && data[1] === subId) {
const event = data[2];
if (event) {
// Convert to the expected format
const formattedEvent = {
id: event.id,
kind: event.kind,
created_at: event.created_at,
content: event.content || '',
author: event.pubkey || '',
raw_json: JSON.stringify(event)
};
receivedEvents.push(formattedEvent);
}
} else if (type === 'EOSE' && data[1] === subId) {
try {
ws.send(JSON.stringify(['CLOSE', subId]));
} catch (_) {}
try { ws.close(); } catch (_) {}
clearTimeout(timer);
if (!resolved) {
resolved = true;
console.log('DEBUG: EOSE received, processing all events:', receivedEvents.length);
processAllEventsResponse(receivedEvents, reset);
resolve();
}
} else if (type === 'CLOSED' && data[1] === subId) {
const message = data[2] || '';
console.log('DEBUG: All events subscription closed:', message);
if (message.includes('auth-required') && !reqSent) {
// Wait for AUTH challenge, request will be sent after authentication
return;
}
// Subscription was closed, finish processing
clearTimeout(timer);
if (!resolved) {
resolved = true;
processAllEventsResponse(receivedEvents, reset);
resolve();
}
} else if (type === 'OK' && data[1] && data[1].length === 64 && !reqSent) {
// This might be an OK response to our AUTH event
// Send the original request now that we're authenticated
sendRequest();
}
} catch (e) {
console.error('Error parsing WebSocket message:', e);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
try { ws.close(); } catch (_) {}
clearTimeout(timer);
if (!resolved) {
resolved = true;
processAllEventsResponse(receivedEvents, reset);
resolve();
}
};
ws.onclose = () => {
clearTimeout(timer);
if (!resolved) {
resolved = true;
console.log('DEBUG: WebSocket closed, processing all events:', receivedEvents.length);
processAllEventsResponse(receivedEvents, reset);
resolve();
}
};
});
}
function processAllEventsResponse(receivedEvents, reset) {
try {
// Filter out deleted events and ensure delete events are included
const filteredEvents = filterDeletedEvents(receivedEvents);
// Sort events by created_at in descending order (newest first)
const sortedEvents = filteredEvents.sort((a, b) => b.created_at - a.created_at);
// Apply pagination manually since we get all events from WebSocket
const currentOffset = reset ? 0 : allEventsOffset;
const limit = 50;
const paginatedEvents = sortedEvents.slice(currentOffset, currentOffset + limit);
console.log('DEBUG: Processing all events - total:', sortedEvents.length, 'paginated:', paginatedEvents.length, 'offset:', currentOffset);
if (reset) {
setAllEvents(paginatedEvents);
setAllEventsOffset(paginatedEvents.length);
} else {
setAllEvents(prev => [...prev, ...paginatedEvents]);
setAllEventsOffset(prev => prev + paginatedEvents.length);
}
// Check if there are more events available
setAllEventsHasMore(currentOffset + paginatedEvents.length < sortedEvents.length);
// Fetch profiles for the new events
fetchProfilesForEvents(paginatedEvents);
console.log('DEBUG: All events updated, displayed count:', paginatedEvents.length, 'has more:', currentOffset + paginatedEvents.length < sortedEvents.length);
} catch (error) {
console.error('Error processing all events response:', error);
} finally {
setAllEventsLoading(false);
}
}
// Search functions
function processSearchResponse(receivedEvents, reset) {
try {
const filtered = filterDeletedEvents(receivedEvents);
const sorted = filtered.sort((a, b) => b.created_at - a.created_at);
const currentOffset = reset ? 0 : searchOffset;
const limit = 50;
const page = sorted.slice(currentOffset, currentOffset + limit);
if (reset) {
setSearchResults(page);
setSearchOffset(page.length);
} else {
setSearchResults(prev => [...prev, ...page]);
setSearchOffset(prev => prev + page.length);
}
setSearchHasMore(currentOffset + page.length < sorted.length);
// fetch profiles for authors in search results
fetchProfilesForEvents(page);
} catch (e) {
console.error('Error processing search results:', e);
} finally {
setSearchLoading(false);
}
}
async function fetchSearchResultsFromRelay(query, reset = true, limit = 50, timeoutMs = 10000) {
if (!query || !query.trim()) {
// clear results on empty query when resetting
if (reset) {
setSearchResults([]);
setSearchOffset(0);
setSearchHasMore(true);
}
return;
}
if (searchLoading) return;
if (!reset && !searchHasMore) return;
setSearchLoading(true);
return new Promise((resolve) => {
let resolved = false;
let receivedEvents = [];
let ws;
let reqSent = false;
try {
ws = new WebSocket(relayURL());
} catch (e) {
console.error('Failed to create WebSocket:', e);
setSearchLoading(false);
resolve();
return;
}
const subId = 'search-' + Math.random().toString(36).slice(2);
const timer = setTimeout(() => {
if (ws && ws.readyState === 1) {
try { ws.close(); } catch (_) {}
}
if (!resolved) {
resolved = true;
processSearchResponse(receivedEvents, reset);
resolve();
}
}, timeoutMs);
const sendRequest = () => {
if (!reqSent && ws && ws.readyState === 1) {
try {
const req = ['REQ', subId, { search: query }];
ws.send(JSON.stringify(req));
reqSent = true;
} catch (e) {
console.error('Failed to send WebSocket request:', e);
}
}
};
ws.onopen = () => sendRequest();
ws.onmessage = async (msg) => {
try {
const data = JSON.parse(msg.data);
const type = data[0];
if (type === 'AUTH') {
const challenge = data[1];
if (!window.nostr) {
clearTimeout(timer);
if (!resolved) {
resolved = true;
processSearchResponse(receivedEvents, reset);
resolve();
}
return;
}
try {
const authEvent = { kind: 22242, created_at: Math.floor(Date.now()/1000), tags: [['relay', relayURL()], ['challenge', challenge]], content: '' };
const signed = await window.nostr.signEvent(authEvent);
ws.send(JSON.stringify(['AUTH', signed]));
} catch (authErr) {
console.error('Search auth failed:', authErr);
clearTimeout(timer);
if (!resolved) {
resolved = true;
processSearchResponse(receivedEvents, reset);
resolve();
}
}
} else if (type === 'EVENT' && data[1] === subId) {
const ev = data[2];
if (ev) {
receivedEvents.push({
id: ev.id,
kind: ev.kind,
created_at: ev.created_at,
content: ev.content || '',
author: ev.pubkey || '',
raw_json: JSON.stringify(ev)
});
}
} else if (type === 'EOSE' && data[1] === subId) {
try { ws.send(JSON.stringify(['CLOSE', subId])); } catch (_) {}
try { ws.close(); } catch (_) {}
clearTimeout(timer);
if (!resolved) {
resolved = true;
processSearchResponse(receivedEvents, reset);
resolve();
}
} else if (type === 'CLOSED' && data[1] === subId) {
clearTimeout(timer);
if (!resolved) {
resolved = true;
processSearchResponse(receivedEvents, reset);
resolve();
}
} else if (type === 'OK' && data[1] && data[1].length === 64 && !reqSent) {
sendRequest();
}
} catch (e) {
console.error('Search WS message parse error:', e);
}
};
ws.onerror = (err) => {
console.error('Search WS error:', err);
try { ws.close(); } catch (_) {}
clearTimeout(timer);
if (!resolved) {
resolved = true;
processSearchResponse(receivedEvents, reset);
resolve();
}
};
ws.onclose = () => {
clearTimeout(timer);
if (!resolved) {
resolved = true;
processSearchResponse(receivedEvents, reset);
resolve();
}
};
});
}
function toggleSearchEventExpansion(eventId) {
setExpandedSearchEventId(current => current === eventId ? null : eventId);
}
// Events log functions
async function fetchEvents(reset = false) {
await fetchEventsFromRelay(reset);
// Also fetch user's own profile for My Events Log
if (user?.pubkey) {
await fetchAndCacheProfile(user.pubkey);
}
}
async function fetchAllEvents(reset = false) {
await fetchAllEventsFromRelay(reset);
}
function toggleEventExpansion(eventId) {
setExpandedEventId(current => current === eventId ? null : eventId);
}
function toggleAllEventExpansion(eventId) {
setExpandedAllEventId(current => current === eventId ? null : eventId);
}
function copyEventJSON(eventJSON) {
try {
// Ensure minified JSON is copied regardless of input format
let toCopy = eventJSON;
try {
toCopy = JSON.stringify(JSON.parse(eventJSON));
} catch (_) {
// if not valid JSON string, fall back to original
}
navigator.clipboard.writeText(toCopy);
} catch (error) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
let toCopy = eventJSON;
try {
toCopy = JSON.stringify(JSON.parse(eventJSON));
} catch (_) {}
textArea.value = toCopy;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
}
function truncateContent(content, maxLength = 100) {
if (!content || content.length <= maxLength) return content;
return content.substring(0, maxLength) + '...';
}
function formatTimestamp(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
// Function to delete an event by publishing a kind 5 delete event
async function deleteEvent(eventId, eventRawJson, eventAuthor = null) {
if (!user?.pubkey) {
updateStatus('Must be logged in to delete events', 'error');
return;
}
if (!window.nostr) {
updateStatus('Nostr extension not found', 'error');
return;
}
try {
// Parse the original event to get its details
const originalEvent = JSON.parse(eventRawJson);
// Permission check: users can only delete their own events, admins can delete any event
const isOwnEvent = originalEvent.pubkey === user.pubkey;
const isAdmin = user.permission === 'admin';
if (!isOwnEvent && !isAdmin) {
updateStatus('You can only delete your own events', 'error');
return;
}
// Construct the delete event (kind 5) according to NIP-09
const deleteEventTemplate = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', originalEvent.id],
['k', originalEvent.kind.toString()]
],
content: isOwnEvent ? 'Deleted by author' : 'Deleted by admin'
};
// Sign the delete event with extension
const signedDeleteEvent = await window.nostr.signEvent(deleteEventTemplate);
// Publish the delete event to the relay via WebSocket
await publishEventToRelay(signedDeleteEvent);
updateStatus('Delete event published successfully', 'success');
// Refresh the event lists to reflect the deletion
if (isOwnEvent) {
fetchEvents(true); // Refresh My Events
}
if (isAdmin) {
fetchAllEvents(true); // Refresh All Events
}
} catch (error) {
updateStatus('Failed to delete event: ' + error.message, 'error');
}
}
// Function to publish an event to the relay via WebSocket
async function publishEventToRelay(event, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
let resolved = false;
let ws;
let eventSent = false;
// Track auth flow so we can respond and then retry the original event
let awaitingAuth = false;
let authSent = false;
let resentAfterAuth = false;
try {
ws = new WebSocket(relayURL());
} catch (e) {
reject(new Error('Failed to create WebSocket connection'));
return;
}
const timer = setTimeout(() => {
console.log('DEBUG: Timeout occurred - eventSent:', eventSent, 'resolved:', resolved, 'ws.readyState:', ws?.readyState);
if (ws && ws.readyState === 1) {
try { ws.close(); } catch (_) {}
}
if (!resolved) {
resolved = true;
reject(new Error('Timeout publishing event - no status received'));
}
}, timeoutMs);
const sendEvent = () => {
if (!eventSent && ws && ws.readyState === 1) {
try {
const eventMessage = ['EVENT', event];
console.log('DEBUG: Sending event to relay:', event.id, 'kind:', event.kind);
ws.send(JSON.stringify(eventMessage));
eventSent = true;
} catch (e) {
clearTimeout(timer);
if (!resolved) {
resolved = true;
reject(new Error('Failed to send event: ' + e.message));
}
}
}
};
ws.onopen = () => {
sendEvent();
};
ws.onmessage = async (msg) => {
try {
const data = JSON.parse(msg.data);
const type = data[0];
// Debug logging to understand what the relay is sending
console.log('DEBUG: publishEventToRelay received message:', data);
if (type === 'NOTICE') {
const message = data[1] || '';
// Some relays announce auth requirement via NOTICE
if (/auth/i.test(message)) {
console.log('DEBUG: Relay NOTICE indicates auth required');
awaitingAuth = true;
}
return;
}
if (type === 'AUTH') {
// Handle authentication challenge
const challenge = data[1];
if (!window.nostr) {
clearTimeout(timer);
if (!resolved) {
resolved = true;
reject(new Error('Authentication required but no Nostr extension found'));
}
return;
}
try {
// Create authentication event
const authEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayURL()],
['challenge', challenge]
],
content: ''
};
// Sign the auth event with extension
const signedAuthEvent = await window.nostr.signEvent(authEvent);
// Send AUTH response
const authMessage = ['AUTH', signedAuthEvent];
ws.send(JSON.stringify(authMessage));
authSent = true;
// After sending AUTH, resend the original event if it was previously rejected/blocked by auth
if (awaitingAuth && !resentAfterAuth) {
console.log('DEBUG: AUTH sent, resending original event');
// allow sendEvent to send again
eventSent = false;
resentAfterAuth = true;
sendEvent();
}
} catch (authError) {
clearTimeout(timer);
if (!resolved) {
resolved = true;
reject(new Error('Failed to authenticate: ' + authError.message));
}
}
} else if (type === 'OK') {
const eventId = data[1];
const accepted = data[2];
const message = data[3] || '';
console.log('DEBUG: OK message - eventId:', eventId, 'expected:', event.id, 'match:', eventId === event.id);
if (eventId === event.id) {
if (accepted) {
clearTimeout(timer);
try { ws.close(); } catch (_) {}
if (!resolved) {
resolved = true;
resolve();
}
} else {
// If auth is required, wait for AUTH flow then resend
if (/auth/i.test(message)) {
console.log('DEBUG: OK rejection indicates auth required, waiting for AUTH challenge');
awaitingAuth = true;
return; // don't resolve/reject yet, wait for AUTH
}
clearTimeout(timer);
try { ws.close(); } catch (_) {}
if (!resolved) {
resolved = true;
reject(new Error('Event rejected: ' + message));
}
}
} else {
// Some relays may send an OK related to AUTH or other events
if (authSent && awaitingAuth && !resentAfterAuth && accepted) {
console.log('DEBUG: OK after AUTH, resending original event');
eventSent = false;
resentAfterAuth = true;
sendEvent();
}
}
}
} catch (e) {
// Ignore malformed messages
}
};
ws.onerror = (error) => {
clearTimeout(timer);
try { ws.close(); } catch (_) {}
if (!resolved) {
resolved = true;
reject(new Error('WebSocket error'));
}
};
ws.onclose = () => {
clearTimeout(timer);
if (!resolved) {
resolved = true;
reject(new Error('WebSocket connection closed'));
}
};
});
}
// Section revealer functions
function toggleSection(sectionKey) {
setExpandedSections(prev => ({
...prev,
[sectionKey]: !prev[sectionKey]
}));
}
function handleImportButton() {
try {
fileInputRef?.current?.click();
} catch (_) {}
}
async function handleImportChange(e) {
const file = e?.target?.files && e.target.files[0];
if (!file) return;
try {
updateStatus('Uploading import file...', 'info');
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/import', { method: 'POST', body: fd });
if (res.ok) {
updateStatus('Import started. Processing will continue in the background.', 'success');
} else {
const txt = await res.text();
updateStatus('Import failed: ' + txt, 'error');
}
} catch (err) {
updateStatus('Import failed: ' + (err?.message || String(err)), 'error');
} finally {
// reset input so selecting the same file again works
if (e && e.target) e.target.value = '';
}
}
// =========================
// Export Specific Pubkeys UI state and handlers (admin)
// =========================
const [exportPubkeys, setExportPubkeys] = useState([{ value: '' }]);
function isHex64(str) {
if (!str) return false;
const s = String(str).trim();
return /^[0-9a-fA-F]{64}$/.test(s);
}
function normalizeHex(str) {
return String(str || '').trim();
}
function addExportPubkeyField() {
// Add new field at the end of the list so it appears downwards
setExportPubkeys((arr) => [...arr, { value: '' }]);
}
function removeExportPubkeyField(idx) {
setExportPubkeys((arr) => arr.filter((_, i) => i !== idx));
}
function changeExportPubkey(idx, val) {
const v = normalizeHex(val);
setExportPubkeys((arr) => arr.map((item, i) => (i === idx ? { value: v } : item)));
}
function validExportPubkeys() {
return exportPubkeys
.map((p) => normalizeHex(p.value))
.filter((v) => v.length > 0 && isHex64(v));
}
function canExportSpecific() {
// Enable only if every opened field is non-empty and a valid 64-char hex
if (!exportPubkeys || exportPubkeys.length === 0) return false;
return exportPubkeys.every((p) => {
const v = normalizeHex(p.value);
return v.length === 64 && isHex64(v);
});
}
function handleExportSpecific() {
const vals = validExportPubkeys();
if (!vals.length) return;
const qs = vals.map((v) => `pubkey=${encodeURIComponent(v)}`).join('&');
try {
window.location.href = `/api/export?${qs}`;
} catch (_) {}
}
// Theme utility functions for conditional styling
function getThemeClasses(lightClass, darkClass) {
return isDarkMode ? darkClass : lightClass;
}
// Get background color class for container panels
function getPanelBgClass() {
return getThemeClasses('bg-gray-200', 'bg-gray-800');
}
// Get text color class for standard text
function getTextClass() {
return getThemeClasses('text-gray-700', 'text-gray-300');
}
// Get background color for buttons
function getButtonBgClass() {
return getThemeClasses('bg-gray-100', 'bg-gray-700');
}
// Get text color for buttons
function getButtonTextClass() {
return getThemeClasses('text-gray-500', 'text-gray-300');
}
// Get hover classes for buttons
function getButtonHoverClass() {
return getThemeClasses('hover:text-gray-800', 'hover:text-gray-100');
}
// Prevent UI flash: wait until we checked auth status
if (checkingAuth) {
return null;
}
return (
<div className={`min-h-screen ${getThemeClasses('bg-gray-100', 'bg-gray-900')}`}>
{user?.permission ? (
<>
{/* Logged in view with user profile */}
<div className={`sticky top-0 left-0 w-full ${getThemeClasses('bg-gray-100', 'bg-gray-900')} z-50 h-16 flex items-center overflow-hidden`}>
<div className="flex items-center h-full w-full box-border">
<div className="relative overflow-hidden flex flex-grow items-center justify-start h-full">
{profileData?.banner && (
<div className="absolute inset-0 opacity-70 bg-cover bg-center" style={{ backgroundImage: `url(${profileData.banner})` }}></div>
)}
<div className="relative z-10 p-2 flex items-center h-full">
{profileData?.picture && <img src={profileData.picture} alt="User Avatar" className={`w-16 h-16 rounded-full object-cover border-2 ${getThemeClasses('border-white', 'border-gray-600')} mr-2 shadow box-border`} />}
<div className={getTextClass()}>
<div className="font-bold text-base block">
{profileData?.display_name || profileData?.name || user.pubkey.slice(0, 8)}
{profileData?.name && profileData?.display_name && ` (${profileData.name})`}
</div>
<div className="font-bold text-lg text-left">
{user.permission === "admin" ? "Admin Dashboard" : "Subscriber Dashboard"}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end shrink-0 h-full">
<button className={`bg-transparent ${getButtonTextClass()} border-0 text-2xl cursor-pointer flex items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent ${getButtonHoverClass()}`} onClick={logout}></button>
</div>
</div>
</div>
{/* Dashboard content container - stacks vertically and fills remaining space */}
<div className="flex-grow overflow-y-auto p-4">
{/* Hidden file input for import (admin) */}
<input
type="file"
ref={fileInputRef}
onChange={handleImportChange}
accept=".json,.jsonl,text/plain,application/x-ndjson,application/json"
style={{ display: 'none' }}
/>
<div className={`m-2 p-2 w-full ${getPanelBgClass()} rounded-lg`}>
<div
className={`text-lg font-bold flex items-center justify-between cursor-pointer p-2 ${getTextClass()} ${getThemeClasses('hover:bg-gray-300', 'hover:bg-gray-700')} rounded`}
onClick={() => toggleSection('welcome')}
>
<span>Welcome</span>
<span className="text-xl">
{expandedSections.welcome ? '▼' : '▶'}
</span>
</div>
{expandedSections.welcome && (
<div className="p-2">
<p className={getTextClass()}>here you can configure all the things</p>
</div>
)}
</div>
{/* Export only my events */}
<div className={`m-2 p-2 ${getPanelBgClass()} rounded-lg w-full`}>
<div
className={`text-lg font-bold flex items-center justify-between cursor-pointer p-2 ${getTextClass()} ${getThemeClasses('hover:bg-gray-300', 'hover:bg-gray-700')} rounded`}
onClick={() => toggleSection('exportMine')}
>
<span>Export My Events</span>
<span className="text-xl">
{expandedSections.exportMine ? '▼' : '▶'}
</span>
</div>
{expandedSections.exportMine && (
<div className="w-full flex items-center justify-end p-2 bg-gray-900 rounded-lg mt-2">
<div className="pr-2 m-2 w-full">
<p className={`text-sm w-full ${getTextClass()}`}>Download your own events as line-delimited JSON (JSONL/NDJSON). Only events you authored will be included.</p>
</div>
<button
className={`${getButtonBgClass()} ${getButtonTextClass()} border-0 text-2xl cursor-pointer flex items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent ${getButtonHoverClass()}`}
onClick={() => { window.location.href = '/api/export/mine'; }}
aria-label="Download my events as JSONL"
title="Download my events"
>
</button>
</div>
)}
</div>
{user.permission === "admin" && (
<>
<div className={`m-2 p-2 ${getPanelBgClass()} rounded-lg w-full`}>
<div
className={`text-lg font-bold flex items-center justify-between cursor-pointer p-2 ${getTextClass()} ${getThemeClasses('hover:bg-gray-300', 'hover:bg-gray-700')} rounded`}
onClick={() => toggleSection('exportAll')}
>
<span>Export All Events (admin)</span>
<span className="text-xl">
{expandedSections.exportAll ? '▼' : '▶'}
</span>
</div>
{expandedSections.exportAll && (
<div className="flex items-center justify-between p-2 m-4 bg-gray-900 round mt-2">
<div className="pr-2 w-full">
<p className={`text-sm ${getTextClass()}`}>Download all stored events as line-delimited JSON (JSONL/NDJSON). This may take a while on large databases.</p>
</div>
<button
className={`${getButtonBgClass()} ${getButtonTextClass()} border-0 text-2xl cursor-pointer flex m-2 items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent ${getButtonHoverClass()}`}
onClick={() => { window.location.href = '/api/export'; }}
aria-label="Download all events as JSONL"
title="Download all events"
>
</button>
</div>
)}
</div>
{/* Export specific pubkeys (admin) */}
<div className={`m-2 p-2 ${getPanelBgClass()} rounded-lg w-full`}>
<div
className={`text-lg font-bold flex items-center justify-between cursor-pointer p-2 ${getTextClass()} ${getThemeClasses('hover:bg-gray-300', 'hover:bg-gray-700')} rounded`}
onClick={() => toggleSection('exportSpecific')}
>
<span>Export Specific Pubkeys (admin)</span>
<span className="text-xl">
{expandedSections.exportSpecific ? '▼' : '▶'}
</span>
</div>
{expandedSections.exportSpecific && (
<div className="w-full flex items-start justify-between gap-4 m-2 p-2 bg-gray-900 rounded-lg mt-2">
{/* Left: title and help text */}
<div className="flex-1 pr-2 w-full">
<p className={`text-sm ${getTextClass()}`}>Enter one or more author pubkeys (64-character hex). Only valid entries will be exported.</p>
{/* Right: controls (buttons stacked vertically + list below) */}
<div className="flex flex-col items-end gap-2 self-end justify-end p-2">
<button
className={`${getButtonBgClass()} ${getTextClass()} text-base p-4 rounded m-2 ${getThemeClasses('hover:bg-gray-200', 'hover:bg-gray-600')}`}
onClick={addExportPubkeyField}
title="Add another pubkey"
type="button"
>
+ Add
</button>
</div>
<div className="flex flex-col items-end gap-2 min-w-[320px] justify-end p-2">
<div className="gap-2 justify-end">
{exportPubkeys.map((item, idx) => {
const v = (item?.value || '').trim();
const valid = v.length === 0 ? true : isHex64(v);
return (
<div key={idx} className="flex items-center gap-2 ">
<input
type="text"
inputMode="text"
autoComplete="off"
spellCheck="false"
className={`flex-1 text-sm px-2 py-1 border rounded outline-none ${valid
? getThemeClasses('border-gray-300 bg-white text-gray-900 focus:ring-2 focus:ring-blue-200', 'border-gray-600 bg-gray-700 text-gray-100 focus:ring-2 focus:ring-blue-500')
: getThemeClasses('border-red-500 bg-red-50 text-red-800', 'border-red-700 bg-red-900 text-red-200')}`}
placeholder="e.g., 64-hex pubkey"
value={v}
onChange={(e) => changeExportPubkey(idx, e.target.value)}
/>
<button
className={`${getButtonBgClass()} ${getTextClass()} px-2 py-1 rounded ${getThemeClasses('hover:bg-gray-200', 'hover:bg-gray-600')}`}
onClick={() => removeExportPubkeyField(idx)}
title="Remove this pubkey"
type="button"
>
</button>
</div>
);
})}
</div>
</div>
<div className="flex justify-end items-end gap-2 self-end">
<button
className={`${getThemeClasses('bg-blue-600', 'bg-blue-500')} text-white px-3 py-1 rounded disabled:opacity-50 disabled:cursor-not-allowed ${canExportSpecific() ? getThemeClasses('hover:bg-blue-700', 'hover:bg-blue-600') : ''}`}
onClick={handleExportSpecific}
disabled={!canExportSpecific()}
title={canExportSpecific() ? 'Download events for specified pubkeys' : 'Enter a valid 64-character hex pubkey in every field'}
type="button"
>
Export
</button>
</div>
</div>
</div>
)}
</div>
<div className={`m-2 p-2 ${getPanelBgClass()} rounded-lg w-full`}>
<div
className={`text-lg font-bold flex items-center justify-between cursor-pointer p-2 ${getTextClass()} ${getThemeClasses('hover:bg-gray-300', 'hover:bg-gray-700')} rounded`}
onClick={() => toggleSection('importEvents')}
>
<span>Import Events (admin)</span>
<span className="text-xl">
{expandedSections.importEvents ? '▼' : '▶'}
</span>
</div>
{expandedSections.importEvents && (
<div className="flex items-center justify-between p-2 bg-gray-900 rounded-lg mt-2">
<div className="pr-2 w-full">
<p className={`text-sm ${getTextClass()}`}>Upload events in line-delimited JSON (JSONL/NDJSON) to import into the database.</p>
</div>
<button
className={`${getButtonBgClass()} ${getButtonTextClass()} border-0 text-2xl cursor-pointer flex items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent ${getButtonHoverClass()}`}
onClick={handleImportButton}
aria-label="Import events from JSONL"
title="Import events"
>
</button>
</div>
)}
</div>
</>
)}
{/* Search */}
<div className={`m-2 p-2 ${getPanelBgClass()} rounded-lg w-full`}>
<div
className={`text-lg font-bold flex items-center justify-between cursor-pointer p-2 ${getTextClass()} ${getThemeClasses('hover:bg-gray-300', 'hover:bg-gray-700')} rounded`}
onClick={() => toggleSection('search')}
>
<span>Search</span>
<span className="text-xl">
{expandedSections.search ? '▼' : '▶'}
</span>
</div>
{expandedSections.search && (
<div className="p-2 bg-gray-900 rounded-lg mt-2">
<div className="flex gap-2 items-center mb-3">
<input
type="text"
placeholder="Search notes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { fetchSearchResultsFromRelay(searchQuery, true); } }}
className={`${getThemeClasses('bg-white text-black border-gray-300', 'bg-gray-800 text-white border-gray-600')} border rounded px-3 py-2 flex-grow`}
/>
<button
className={`${getThemeClasses('bg-blue-600 hover:bg-blue-700', 'bg-blue-500 hover:bg-blue-600')} text-white px-4 py-2 rounded`}
onClick={() => fetchSearchResultsFromRelay(searchQuery, true)}
disabled={searchLoading}
title="Search"
>
{searchLoading ? 'Searching…' : 'Search'}
</button>
</div>
<div className="space-y-2">
{searchResults.length === 0 && !searchLoading && (
<div className={`text-center py-4 ${getTextClass()}`}>No results</div>
)}
{searchResults.map((event) => (
<div key={event.id} className={`border rounded p-3 ${getThemeClasses('border-gray-300 bg-white', 'border-gray-600 bg-gray-800')}`}>
<div className="cursor-pointer" onClick={() => toggleSearchEventExpansion(event.id)}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-6 w-full">
<div className="flex items-center gap-3 min-w-0">
{event.author && profileCache[event.author] && (
<>
{profileCache[event.author].picture && (
<img
src={profileCache[event.author].picture}
alt={profileCache[event.author].display_name || profileCache[event.author].name || 'User avatar'}
className={`w-8 h-8 rounded-full object-cover border h-16 ${getThemeClasses('border-gray-300', 'border-gray-600')}`}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
)}
<div className="flex flex-col flex-grow w-full">
<span className={`text-sm font-medium ${getTextClass()}`}>
{profileCache[event.author].display_name || profileCache[event.author].name || `${event.author.slice(0, 8)}...`}
</span>
{profileCache[event.author].display_name && profileCache[event.author].name && (
<span className={`text-xs ${getTextClass()} opacity-70`}>
{profileCache[event.author].name}
</span>
)}
</div>
</>
)}
{event.author && !profileCache[event.author] && (
<span className={`text-sm font-medium ${getTextClass()}`}>
{`${event.author.slice(0, 8)}...`}
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className={`font-mono text-sm px-2 py-1 rounded ${getThemeClasses('bg-blue-100 text-blue-800', 'bg-blue-900 text-blue-200')}`}>
Kind {event.kind}
</span>
<span className={`text-sm ${getTextClass()}`}>
{formatTimestamp(event.created_at)}
</span>
</div>
</div>
<div className="justify-end ml-auto rounded-full h-16 w-16 flex items-center justify-center">
<div className={`text-white text-xs px-4 py-4 rounded flex flex-grow items-center ${getThemeClasses('text-gray-700', 'text-gray-300')}`}>
{expandedSearchEventId === event.id ? '▼' : ' '}
</div>
<button
className="bg-red-600 hover:bg-red-700 text-white text-xs px-1 py-1 rounded flex items-center"
onClick={(e) => { e.stopPropagation(); deleteEvent(event.id, event.raw_json, event.author); }}
title="Delete this event"
>
🗑
</button>
</div>
</div>
{event.content && (
<div className={`mt-2 text-sm ${getTextClass()}`}>
{truncateContent(event.content)}
</div>
)}
</div>
{expandedSearchEventId === event.id && (
<div className={`mt-3 p-3 rounded ${getThemeClasses('bg-gray-100', 'bg-gray-900')}`} onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-2">
<span className={`text-sm font-semibold ${getTextClass()}`}>Raw JSON</span>
<button
className={`${getThemeClasses('bg-gray-200 hover:bg-gray-300 text-black', 'bg-gray-800 hover:bg-gray-700 text-white')} text-xs px-2 py-1 rounded`}
onClick={() => copyEventJSON(event.raw_json)}
>
Copy JSON
</button>
</div>
<PrettyJSONView jsonString={event.raw_json} maxHeightClass="max-h-64" />
</div>
)}
</div>
))}
{!searchLoading && searchHasMore && searchResults.length > 0 && (
<div className="text-center py-4">
<button
className={`${getThemeClasses('bg-blue-600 hover:bg-blue-700', 'bg-blue-500 hover:bg-blue-600')} text-white px-4 py-2 rounded`}
onClick={() => fetchSearchResultsFromRelay(searchQuery, false)}
>
Load More
</button>
</div>
)}
</div>
</div>
)}
</div>
{/* My Events Log */}
<div className={`m-2 p-2 ${getPanelBgClass()} rounded-lg w-full`}>
<div
className={`text-lg font-bold flex items-center justify-between cursor-pointer p-2 ${getTextClass()} ${getThemeClasses('hover:bg-gray-300', 'hover:bg-gray-700')} rounded`}
onClick={() => toggleSection('eventsLog')}
>
<span>My Events Log</span>
<span className="text-xl">
{expandedSections.eventsLog ? '▼' : '▶'}
</span>
</div>
{expandedSections.eventsLog && (
<div className="p-2 bg-gray-900 rounded-lg mt-2">
<div className="mb-4">
<p className={`text-sm ${getTextClass()}`}>View all your events in reverse chronological order. Click on any event to view its raw JSON.</p>
</div>
<div
className="block"
style={{
position: 'relative'
}}
>
{events.length === 0 && !eventsLoading ? (
<div className={`text-center py-4 ${getTextClass()}`}>No events found</div>
) : (
<div className="space-y-2">
{events.map((event) => (
<div key={event.id} className={`border rounded p-3 ${getThemeClasses('border-gray-300 bg-white', 'border-gray-600 bg-gray-800')}`}>
<div
className="cursor-pointer"
onClick={() => toggleEventExpansion(event.id)}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-6 w-full">
{/* User avatar and info - separated with more space */}
<div className="flex items-center gap-3 min-w-0">
{user?.pubkey && profileCache[user.pubkey] && (
<>
{profileCache[user.pubkey].picture && (
<img
src={profileCache[user.pubkey].picture}
alt={profileCache[user.pubkey].display_name || profileCache[user.pubkey].name || 'User avatar'}
className={`w-8 h-8 rounded-full object-cover border h-16 ${getThemeClasses('border-gray-300', 'border-gray-600')}`}
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
<div className="flex flex-col flex-grow w-full">
<span className={`text-sm font-medium ${getTextClass()}`}>
{profileCache[user.pubkey].display_name || profileCache[user.pubkey].name || `${user.pubkey.slice(0, 8)}...`}
</span>
{profileCache[user.pubkey].display_name && profileCache[user.pubkey].name && (
<span className={`text-xs ${getTextClass()} opacity-70`}>
{profileCache[user.pubkey].name}
</span>
)}
</div>
</>
)}
{user?.pubkey && !profileCache[user.pubkey] && (
<span className={`text-sm font-medium ${getTextClass()}`}>
{`${user.pubkey.slice(0, 8)}...`}
</span>
)}
</div>
{/* Event metadata - separated to the right */}
<div className="flex items-center gap-3">
<span className={`font-mono text-sm px-2 py-1 rounded ${getThemeClasses('bg-blue-100 text-blue-800', 'bg-blue-900 text-blue-200')}`}>
Kind {event.kind}
</span>
<span className={`text-sm ${getTextClass()}`}>
{formatTimestamp(event.created_at)}
</span>
</div>
</div>
<div className="flex items-center gap-2 ml-auto">
<div className={`text-lg rounded p-16 m-16 ${getThemeClasses('text-gray-700', 'text-gray-300')}`}>
{expandedEventId === event.id ? '▼' : ' '}
</div>
<button
className="bg-red-600 hover:bg-red-700 text-white text-xs px-1 py-1 rounded flex items-center"
onClick={(e) => {
e.stopPropagation();
deleteEvent(event.id, event.raw_json);
}}
title="Delete this event"
>
🗑
</button>
</div>
</div>
{event.content && (
<div className={`mt-2 text-sm ${getTextClass()}`}>
{truncateContent(event.content)}
</div>
)}
</div>
{expandedEventId === event.id && (
<div className="mt-3 border-t pt-3">
<div className="flex items-center justify-between mb-2">
<span className={`text-sm font-medium ${getTextClass()}`}>Raw JSON:</span>
<button
className={`${getThemeClasses('bg-green-600 hover:bg-green-700', 'bg-green-500 hover:bg-green-600')} text-white text-xs px-2 py-1 rounded`}
onClick={(e) => {
e.stopPropagation();
copyEventJSON(event.raw_json);
}}
title="Copy minified JSON"
>
Copy
</button>
</div>
<PrettyJSONView jsonString={event.raw_json} maxHeightClass="max-h-40" />
</div>
)}
</div>
))}
{eventsLoading && (
<div className={`text-center py-4 ${getTextClass()}`}>
<div className="text-sm">Loading more events...</div>
</div>
)}
{!eventsLoading && eventsHasMore && (
<div className="text-center py-4">
<button
className={`${getThemeClasses('bg-blue-600 hover:bg-blue-700', 'bg-blue-500 hover:bg-blue-600')} text-white px-4 py-2 rounded`}
onClick={() => fetchEvents(false)}
>
Load More
</button>
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* All Events Log (admin only) */}
{user.permission === "admin" && (
<div className={`m-2 p-2 ${getPanelBgClass()} rounded-lg w-full`}>
<div
className={`text-lg font-bold flex items-center justify-between cursor-pointer p-2 ${getTextClass()} ${getThemeClasses('hover:bg-gray-300', 'hover:bg-gray-700')} rounded`}
onClick={() => toggleSection('allEventsLog')}
>
<span>All Events Log (admin)</span>
<span className="text-xl">
{expandedSections.allEventsLog ? '▼' : '▶'}
</span>
</div>
{expandedSections.allEventsLog && (
<div className="p-2 bg-gray-900 rounded-lg mt-2 w-full">
<div className="mb-4">
<p className={`text-sm ${getTextClass()}`}>View all events from all users in reverse chronological order. Click on any event to view its raw JSON.</p>
</div>
<div
className="block"
style={{
position: 'relative'
}}
>
{allEvents.length === 0 && !allEventsLoading ? (
<div className={`text-center py-4 ${getTextClass()}`}>No events found</div>
) : (
<div className="space-y-2">
{allEvents.map((event) => (
<div key={event.id} className={`border rounded p-3 ${getThemeClasses('border-gray-300 bg-white', 'border-gray-600 bg-gray-800')}`}>
<div
className="cursor-pointer"
onClick={() => toggleAllEventExpansion(event.id)}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-6 w-full">
{/* User avatar and info - separated with more space */}
<div className="flex items-center gap-3 min-w-0">
{event.author && profileCache[event.author] && (
<>
{profileCache[event.author].picture && (
<img
src={profileCache[event.author].picture}
alt={profileCache[event.author].display_name || profileCache[event.author].name || 'User avatar'}
className={`w-8 h-8 rounded-full object-cover border h-16 ${getThemeClasses('border-gray-300', 'border-gray-600')}`}
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
<div className="flex flex-col flex-grow w-full">
<span className={`text-sm font-medium ${getTextClass()}`}>
{profileCache[event.author].display_name || profileCache[event.author].name || `${event.author.slice(0, 8)}...`}
</span>
{profileCache[event.author].display_name && profileCache[event.author].name && (
<span className={`text-xs ${getTextClass()} opacity-70`}>
{profileCache[event.author].name}
</span>
)}
</div>
</>
)}
{event.author && !profileCache[event.author] && (
<span className={`text-sm font-medium ${getTextClass()}`}>
{`${event.author.slice(0, 8)}...`}
</span>
)}
</div>
{/* Event metadata - separated to the right */}
<div className="flex items-center gap-3">
<span className={`font-mono text-sm px-2 py-1 rounded ${getThemeClasses('bg-blue-100 text-blue-800', 'bg-blue-900 text-blue-200')}`}>
Kind {event.kind}
</span>
<span className={`text-sm ${getTextClass()}`}>
{formatTimestamp(event.created_at)}
</span>
</div>
</div>
<div className="justify-end ml-auto rounded-full h-16 w-16 flex items-center justify-center">
<div className={`text-white text-xs px-4 py-4 rounded flex flex-grow items-center ${getThemeClasses('text-gray-700', 'text-gray-300')}`}>
{expandedAllEventId === event.id ? '▼' : ' '}
</div>
<button
className="bg-red-600 hover:bg-red-700 text-white text-xs px-1 py-1 rounded flex items-center"
onClick={(e) => {
e.stopPropagation();
deleteEvent(event.id, event.raw_json, event.author);
}}
title="Delete this event"
>
🗑
</button>
</div>
</div>
{event.content && (
<div className={`mt-2 text-sm ${getTextClass()}`}>
{truncateContent(event.content)}
</div>
)}
</div>
{expandedAllEventId === event.id && (
<div className="mt-3 border-t pt-3">
<div className="flex items-center justify-between mb-2">
<span className={`text-sm font-medium ${getTextClass()}`}>Raw JSON:</span>
<button
className={`${getThemeClasses('bg-green-600 hover:bg-green-700', 'bg-green-500 hover:bg-green-600')} text-white text-xs px-2 py-1 rounded`}
onClick={(e) => {
e.stopPropagation();
copyEventJSON(event.raw_json);
}}
title="Copy minified JSON"
>
Copy
</button>
</div>
<PrettyJSONView jsonString={event.raw_json} maxHeightClass="max-h-40" />
</div>
)}
</div>
))}
{allEventsLoading && (
<div className={`text-center py-4 ${getTextClass()}`}>
<div className="text-sm">Loading more events...</div>
</div>
)}
{!allEventsLoading && allEventsHasMore && (
<div className="text-center py-4">
<button
className={`${getThemeClasses('bg-blue-600 hover:bg-blue-700', 'bg-blue-500 hover:bg-blue-600')} text-white px-4 py-2 rounded`}
onClick={() => fetchAllEvents(false)}
>
Load More
</button>
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
)}
{/* Empty flex grow box to ensure background fills entire viewport */}
<div className={`flex-grow ${getThemeClasses('bg-gray-100', 'bg-gray-900')}`}></div>
</div>
</>
) : (
// Not logged in view - shows the login form
<div className="w-full h-full flex items-center justify-center">
<div
className={getThemeClasses('bg-gray-100', 'bg-gray-900')}
style={{ width: '800px', maxWidth: '100%', boxSizing: 'border-box', padding: `${loginPadding}px` }}
>
<div className="flex items-center gap-3 mb-3">
<img
src="/orly.png"
alt="Orly logo"
className="object-contain"
style={{ width: '4rem', height: '4rem' }}
onError={(e) => {
// fallback to repo docs image if public asset missing
e.currentTarget.onerror = null;
e.currentTarget.src = "/docs/orly.png";
}}
/>
<h1 ref={titleRef} className={`text-2xl font-bold p-2 ${getTextClass()}`}>ORLY🦉 Dashboard Login</h1>
</div>
<p className={`mb-4 ${getTextClass()}`}>Authenticate to this Nostr relay using your browser extension.</p>
<div className={statusClassName()}>
{status}
</div>
<div className="mb-5">
<button className={`${getThemeClasses('bg-blue-600', 'bg-blue-500')} text-white px-5 py-3 rounded ${getThemeClasses('hover:bg-blue-700', 'hover:bg-blue-600')}`} onClick={loginWithExtension}>Login with Browser Extension (NIP-07)</button>
</div>
</div>
</div>
)}
</div>
);
}
export default App;