1960 lines
78 KiB
JavaScript
1960 lines
78 KiB
JavaScript
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; |