Files
next.orly.dev/app/web/src/App.jsx
2025-09-21 18:06:11 +01:00

1960 lines
78 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
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);
// 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,
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);
}
}
// 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 {
navigator.clipboard.writeText(eventJSON);
} catch (error) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = eventJSON;
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>
</>
)}
{/* 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>
<pre className={`text-xs p-2 rounded overflow-auto max-h-40 break-all whitespace-pre-wrap ${getPanelBgClass()} ${getTextClass()}`}>
{JSON.stringify(JSON.parse(event.raw_json), null, 2)}
</pre>
</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>
<pre className={`text-xs p-2 rounded overflow-auto max-h-40 break-all whitespace-pre-wrap ${getPanelBgClass()} ${getTextClass()}`}>
{JSON.stringify(JSON.parse(event.raw_json), null, 2)}
</pre>
</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;