This commit is contained in:
2025-01-03 22:58:48 -05:00
parent 1e8553c653
commit c1707d94ef
6 changed files with 1516 additions and 483 deletions

View File

@@ -1,150 +0,0 @@
// Global variables
let archiveData = [];
let sortedData = [];
let currentSort = { column: 'created_at', order: 'desc' };
const rowsPerPage = 100;
let currentPage = 1;
// Event Listeners
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('fileInput').addEventListener('change', handleFileLoad);
// Add click listeners to sortable headers
document.querySelectorAll('#dataTable th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
sortTable(th.dataset.sort);
});
});
});
// File handling
function handleFileLoad(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (!Array.isArray(data)) throw new Error('Invalid archive format: Expected an array.');
archiveData = data;
sortedData = [...archiveData];
updateStats();
sortTable(currentSort.column); // Initial sort
} catch (err) {
alert(`Failed to load archive: ${err.message}`);
}
};
reader.readAsText(file);
}
// Statistics
function updateStats() {
const statsDiv = document.getElementById('stats');
const kindCounts = archiveData.reduce((acc, event) => {
acc[event.kind] = (acc[event.kind] || 0) + 1;
return acc;
}, {});
const totalEvents = archiveData.length;
let statsText = `Total Events: ${totalEvents}<br>`;
statsText += 'Most Common Types: ';
const topKinds = Object.entries(kindCounts)
.sort(([,a], [,b]) => b - a)
.slice(0, 3)
.map(([kind, count]) => `${getKindLabel(parseInt(kind))}: ${count}`);
statsText += topKinds.join(' | ');
statsDiv.innerHTML = statsText;
}
// Table rendering
function renderTable() {
const tbody = document.querySelector('#dataTable tbody');
tbody.innerHTML = '';
const start = (currentPage - 1) * rowsPerPage;
const end = start + rowsPerPage;
const pageData = sortedData.slice(start, end);
if (pageData.length === 0) {
tbody.innerHTML = `<tr class="empty-message">
<td colspan="4">No data loaded. Please load a JSON archive file.</td>
</tr>`;
return;
}
pageData.forEach((event, index) => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="number-column">${start + index + 1}</td>
<td>${new Date(event.created_at * 1000).toLocaleString()}</td>
<td>${getKindLabel(event.kind)}</td>
<td class="content-cell">${getContentDisplay(event)}</td>
`;
tbody.appendChild(row);
});
updatePagination(end);
}
// Pagination
function updatePagination(end) {
document.getElementById('prevPage').disabled = currentPage === 1;
document.getElementById('nextPage').disabled = end >= sortedData.length;
document.getElementById('pageInfo').innerText = `Page ${currentPage} of ${Math.ceil(sortedData.length / rowsPerPage)}`;
}
function changePage(offset) {
currentPage += offset;
renderTable();
}
// Sorting
function sortTable(column) {
if (currentSort.column === column) {
currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.order = 'desc';
}
const order = currentSort.order === 'asc' ? 1 : -1;
sortedData.sort((a, b) => {
if (a[column] < b[column]) return -1 * order;
if (a[column] > b[column]) return 1 * order;
return 0;
});
currentPage = 1;
renderTable();
}
// Helper functions
function getKindLabel(kind) {
const kinds = {
0: 'Profile Metadata',
1: 'Short Text Note',
2: 'Recommend Relay',
3: 'Contacts',
4: 'Encrypted DM',
6: 'Repost',
7: 'Reaction',
40: 'Channel Creation',
41: 'Channel Metadata',
42: 'Channel Message',
30023: 'Long-form Content',
// Add more as needed
};
return `${kind} (${kinds[kind] || 'Unknown'})`;
}
function getContentDisplay(event) {
if (event.kind === 4) {
const recipient = event.tags.find(tag => tag[0] === 'p');
return `Encrypted Message: ${event.content || 'N/A'}<br>Recipient: ${recipient ? recipient[1] : 'Unknown'}`;
}
return event.content || 'No content available';
}

View File

@@ -1,107 +0,0 @@
<<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Nostr Archive Collector</title>
<style>
/* Basic styling for better UX */
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 800px;
margin: 50px auto;
background: #fff;
padding: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
}
label {
display: block;
margin-top: 15px;
}
input[type="text"], textarea {
width: 100%;
padding: 10px;
margin-top: 5px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
button {
margin-top: 20px;
padding: 10px 15px;
background: #28a745;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
#status {
margin-top: 20px;
white-space: pre-wrap;
}
#downloadBtn {
background: #007bff;
}
#spinner {
display: none;
text-align: center;
margin-top: 20px;
}
/* Simple CSS Spinner */
.loader {
border: 8px solid #f3f3f3;
border-top: 8px solid #007bff;
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<h1>Nostr Archive Collector</h1>
<form id="archiveForm">
<label for="npub">NPub (Public Key):</label>
<input type="text" id="npub" name="npub" placeholder="Enter NPub" required>
<!-- Removed pattern and title attributes -->
<label for="relays">Relay URLs (one per line):</label>
<textarea id="relays" name="relays" rows="5" placeholder="wss://relay1.example.com
wss://relay2.example.com" required></textarea>
<button type="submit">Start Collecting</button>
</form>
<div id="spinner">
<div class="loader"></div>
</div>
<div id="status"></div>
<button id="downloadBtn" style="display: none;">Download Archive</button>
</div>
<!-- Include nostr-tools library via CDN (correct path) -->
<script src="https://cdn.jsdelivr.net/npm/nostr-tools@1.7.0/lib/nostr.bundle.js"></script>
<script src="script.js"></script>
</body>
</html>

View File

@@ -8,67 +8,169 @@
<meta http-equiv="Expires" content="0">
<title>Archivestr</title>
<meta name="description" content="Archivestr: A Nostr Archive Creation, Browser, and Broadcaster Tool">
<link rel="stylesheet" href="styles.css?v=" + Math.random()>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f9f9f9;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.container {
text-align: center;
background: #fff;
padding: 20px 40px;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
}
h1 {
margin-bottom: 20px;
font-size: 24px;
}
p {
margin-bottom: 30px;
font-size: 16px;
color: #555;
}
a {
display: inline-block;
margin: 10px;
padding: 10px 15px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 16px;
transition: background-color 0.3s;
}
a:hover {
background-color: #0056b3;
}
.github-link {
margin-top: 20px;
display: inline-block;
font-size: 14px;
color: #007bff;
text-decoration: none;
}
.github-link:hover {
text-decoration: underline;
}
</style>
<!-- Corrected Cache-Busting for CSS -->
<link rel="stylesheet" href="styles.css?v=1.0">
</head>
<body>
<div class="container">
<h1>Archivestr</h1>
<p>A Nostr Archive Creation, Browser, and Broadcaster Tool</p>
<a href="collector.html">Collector</a>
<a href="view-archive.html">View Archive</a>
<a class="github-link" href="https://github.com/PR0M3TH3AN/Archivestr" target="_blank">GitHub Repository</a>
<!-- Navigation -->
<div class="nav-links">
<a href="#home" onclick="showSection('home')">Home</a>
<a href="#collector" onclick="showSection('collector')">Collector</a>
<a href="#viewer" onclick="showSection('viewer')">View Archive</a>
<a href="#broadcast" onclick="showSection('broadcast')">Broadcast</a>
</div>
<!-- Home Section -->
<div id="home" class="section active">
<p>Choose an option above to get started!</p>
</div>
<!-- Collector Section -->
<div id="collector" class="section">
<h2>Nostr Archive Collector</h2>
<form id="archiveForm">
<label for="npub">NPub (Public Key):</label>
<input type="text" id="npub" name="npub" placeholder="Enter NPub" required>
<label for="relays">Relay URLs (one per line):</label>
<textarea id="relays" name="relays" rows="5" placeholder="wss://relay1.example.com
wss://relay2.example.com" required></textarea>
<button type="submit">Start Collecting</button>
</form>
<div id="spinner">
<div class="loader"></div>
</div>
<div id="progressContainer" class="progress-container" style="display: none;">
<div id="progressBar" class="progress-bar">
<span class="progress-text">0%</span>
</div>
</div>
<div id="status"></div>
<button id="downloadBtn" style="display: none;">Download Archive</button>
</div>
<!-- Broadcast Section -->
<div id="broadcast" class="section">
<h2>Broadcast Archive</h2>
<p>Select a JSON archive file to broadcast its events to relays.</p>
<input type="file" id="broadcastFileInput" accept=".json">
<div id="broadcastStatus"></div>
<div class="relay-input">
<label for="broadcastRelays">Relay URLs (one per line):</label>
<!-- Corrected Placeholder without Indentation -->
<textarea id="broadcastRelays" rows="5" placeholder="wss://relay1.example.com
wss://relay2.example.com" required></textarea>
</div>
<button id="startBroadcastBtn" style="display: none;">Start Broadcasting</button>
<div id="broadcastProgress" class="progress-container" style="display: none;">
<div class="progress-bar">
<span class="progress-text">0%</span>
</div>
</div>
</div>
<!-- Viewer Section -->
<div id="viewer" class="section">
<h2>Archive Viewer</h2>
<div id="viewerStats" class="stats">No data loaded</div>
<input type="file" id="viewerFileInput" accept=".json">
<!-- Advanced Search Container -->
<div class="search-container">
<div class="search-bar">
<input
type="text"
id="searchInput"
placeholder="Search..."
class="search-input"
>
<button id="advancedSearchToggle" class="search-toggle">
Advanced Search
</button>
</div>
<div id="advancedSearch" class="advanced-search" style="display: none;">
<div class="search-options">
<label>
<input type="checkbox" id="useRegex">
Use Regex
</label>
<label>
<input type="checkbox" id="caseSensitive">
Case Sensitive
</label>
<div class="filter-fields">
<label>Search in:</label>
<label>
<input type="checkbox" id="searchContent" checked>
Content
</label>
<label>
<input type="checkbox" id="searchKind" checked>
Kind
</label>
<label>
<input type="checkbox" id="searchDate" checked>
Date
</label>
</div>
</div>
<div class="kind-filter">
<label>Filter by Kind:</label>
<select id="kindFilter" multiple>
<!-- Options will be populated by JavaScript -->
</select>
</div>
<div class="date-filter">
<label>Date Range:</label>
<input type="datetime-local" id="dateFrom" placeholder="From">
<input type="datetime-local" id="dateTo" placeholder="To">
</div>
</div>
</div>
<!-- Data Table -->
<table id="dataTable">
<thead>
<tr>
<th class="number-column">#</th>
<th data-sort="created_at">Time <span class="sort-indicator"></span></th>
<th data-sort="kind">Kind <span class="sort-indicator"></span></th>
<th>Content</th>
<th>Actions</th> <!-- New Actions Column -->
</tr>
</thead>
<tbody>
<tr class="empty-message">
<td colspan="5">No data loaded. Please load a JSON archive file.</td> <!-- Updated colspan -->
</tr>
</tbody>
</table>
<div class="pagination">
<button id="prevPage" onclick="changePage(-1)" disabled>Previous</button>
<span id="pageInfo">Page 1</span>
<button id="nextPage" onclick="changePage(1)" disabled>Next</button>
</div>
</div>
</div>
<footer>
<div class="footer-content">
<span class="copyright">CC0 - No Rights Reserved</span>
<a href="https://github.com/PR0M3TH3AN/Archivestr" target="_blank" class="footer-link">View on GitHub</a>
</div>
</footer>
<!-- Scripts -->
<!-- Corrected Cache-Busting for JS -->
<script src="https://cdn.jsdelivr.net/npm/nostr-tools@1.7.0/lib/nostr.bundle.js"></script>
<script src="script.js?v=1.0"></script>
</body>
</html>

View File

@@ -1,38 +1,357 @@
// Navigation handling
function showSection(sectionId) {
// Hide all sections
document.querySelectorAll('.section').forEach(section => {
section.classList.remove('active');
});
// Show selected section
document.getElementById(sectionId).classList.add('active');
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
// Check for hash in URL
const hash = window.location.hash.slice(1) || 'home';
showSection(hash);
// Viewer initialization
initializeViewer();
// Collector initialization
initializeCollector();
// Broadcast initialization
initializeBroadcast();
});
// =============== VIEWER FUNCTIONALITY ===============
function initializeViewer() {
// Global variables for viewer
let archiveData = [];
let sortedData = [];
let currentSort = { column: 'created_at', order: 'desc' };
const rowsPerPage = 100;
let currentPage = 1;
// Search configuration
let searchConfig = {
term: '',
useRegex: false,
caseSensitive: false,
searchFields: {
content: true,
kind: true,
date: true
},
selectedKinds: new Set(),
dateRange: {
from: null,
to: null
}
};
// Event Listeners
document.getElementById('viewerFileInput').addEventListener('change', handleViewerFileLoad);
document.querySelectorAll('#dataTable th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
sortTable(th.dataset.sort);
});
});
// Advanced search listeners
document.getElementById('searchInput').addEventListener('input', (e) => {
searchConfig.term = e.target.value;
currentPage = 1;
filterAndRenderTable();
});
document.getElementById('advancedSearchToggle').addEventListener('click', () => {
const advancedSearch = document.getElementById('advancedSearch');
advancedSearch.style.display = advancedSearch.style.display === 'none' ? 'block' : 'none';
});
document.getElementById('useRegex').addEventListener('change', (e) => {
searchConfig.useRegex = e.target.checked;
filterAndRenderTable();
});
document.getElementById('caseSensitive').addEventListener('change', (e) => {
searchConfig.caseSensitive = e.target.checked;
filterAndRenderTable();
});
['Content', 'Kind', 'Date'].forEach(field => {
document.getElementById(`search${field}`).addEventListener('change', (e) => {
searchConfig.searchFields[field.toLowerCase()] = e.target.checked;
filterAndRenderTable();
});
});
document.getElementById('dateFrom').addEventListener('change', (e) => {
searchConfig.dateRange.from = e.target.value ? new Date(e.target.value).getTime() / 1000 : null;
filterAndRenderTable();
});
document.getElementById('dateTo').addEventListener('change', (e) => {
searchConfig.dateRange.to = e.target.value ? new Date(e.target.value).getTime() / 1000 : null;
filterAndRenderTable();
});
function handleViewerFileLoad(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (!Array.isArray(data)) throw new Error('Invalid archive format: Expected an array.');
archiveData = data;
sortedData = [...archiveData];
initializeKindFilter();
updateStats();
sortTable(currentSort.column); // Initial sort
} catch (err) {
alert(`Failed to load archive: ${err.message}`);
}
};
reader.readAsText(file);
}
function initializeKindFilter() {
const kindSelect = document.getElementById('kindFilter');
const uniqueKinds = new Set(archiveData.map(event => event.kind));
kindSelect.innerHTML = Array.from(uniqueKinds)
.sort((a, b) => a - b)
.map(kind => `<option value="${kind}">${getKindLabel(kind)}</option>`)
.join('');
kindSelect.addEventListener('change', (e) => {
searchConfig.selectedKinds = new Set(
Array.from(e.target.selectedOptions).map(option => parseInt(option.value))
);
filterAndRenderTable();
});
}
function filterAndRenderTable() {
if (!searchConfig.term && !searchConfig.selectedKinds.size &&
!searchConfig.dateRange.from && !searchConfig.dateRange.to) {
sortedData = [...archiveData];
} else {
sortedData = archiveData.filter(event => {
// Kind filter
if (searchConfig.selectedKinds.size &&
!searchConfig.selectedKinds.has(event.kind)) {
return false;
}
// Date range filter
if (searchConfig.dateRange.from &&
event.created_at < searchConfig.dateRange.from) {
return false;
}
if (searchConfig.dateRange.to &&
event.created_at > searchConfig.dateRange.to) {
return false;
}
// Text search
if (!searchConfig.term) return true;
let searchPattern = searchConfig.term;
if (!searchConfig.useRegex) {
searchPattern = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
let regex;
try {
const flags = searchConfig.caseSensitive ? 'g' : 'gi';
regex = new RegExp(searchPattern, flags);
} catch (err) {
// Invalid regex
return false;
}
if (searchConfig.searchFields.content) {
const content = getContentDisplay(event, false);
if (regex.test(content)) return true;
}
if (searchConfig.searchFields.kind) {
const kindStr = getKindLabel(event.kind);
if (regex.test(kindStr)) return true;
}
if (searchConfig.searchFields.date) {
const dateStr = new Date(event.created_at * 1000).toLocaleString();
if (regex.test(dateStr)) return true;
}
return false;
});
}
applySorting();
renderTable();
updateStats();
}
function applySorting() {
const order = currentSort.order === 'asc' ? 1 : -1;
sortedData.sort((a, b) => {
let valA = a[currentSort.column];
let valB = b[currentSort.column];
if (currentSort.column === 'created_at') {
valA = parseInt(valA);
valB = parseInt(valB);
}
if (valA < valB) return -1 * order;
if (valA > valB) return 1 * order;
return 0;
});
}
function updateStats() {
const statsDiv = document.getElementById('viewerStats');
const totalEvents = archiveData.length;
const filteredEvents = sortedData.length;
let statsText = `Total Events: ${totalEvents}`;
if (searchConfig.term || searchConfig.selectedKinds.size ||
searchConfig.dateRange.from || searchConfig.dateRange.to) {
statsText += ` (Showing ${filteredEvents} matches)`;
}
statsText += '<br>Most Common Types: ';
const kindCounts = sortedData.reduce((acc, event) => {
acc[event.kind] = (acc[event.kind] || 0) + 1;
return acc;
}, {});
const topKinds = Object.entries(kindCounts)
.sort(([,a], [,b]) => b - a)
.slice(0, 3)
.map(([kind, count]) => `${getKindLabel(parseInt(kind))}: ${count}`);
statsText += topKinds.join(' | ');
statsDiv.innerHTML = statsText;
}
function renderTable() {
const tbody = document.querySelector('#dataTable tbody');
tbody.innerHTML = '';
const start = (currentPage - 1) * rowsPerPage;
const end = start + rowsPerPage;
const pageData = sortedData.slice(start, end);
if (pageData.length === 0) {
tbody.innerHTML = `<tr class="empty-message">
<td colspan="5">No data loaded. Please load a JSON archive file.</td>
</tr>`;
return;
}
pageData.forEach((event, index) => {
const row = document.createElement('tr');
// In the renderTable function, update the row.innerHTML line
row.innerHTML = `
<td class="number-column">${start + index + 1}</td>
<td>${new Date(event.created_at * 1000).toLocaleString()}</td>
<td>${getKindLabel(event.kind)}</td>
<td class="content-cell">${getContentDisplay(event, true)}</td>
<td><button class="broadcast-btn" data-event-id="${event.id}">Broadcast</button></td>
`;
tbody.appendChild(row);
});
// Attach event listeners to the new broadcast buttons
document.querySelectorAll('.broadcast-btn').forEach(button => {
button.addEventListener('click', (e) => {
const eventId = e.target.getAttribute('data-event-id');
const event = archiveData.find(ev => ev.id === eventId);
if (event) {
broadcastSingleEvent(event, e.target);
} else {
alert('Event not found.');
}
});
});
updatePagination(end);
}
function updatePagination(end) {
document.getElementById('prevPage').disabled = currentPage === 1;
document.getElementById('nextPage').disabled = end >= sortedData.length;
document.getElementById('pageInfo').innerText =
`Page ${currentPage} of ${Math.ceil(sortedData.length / rowsPerPage)}`;
}
window.changePage = function(offset) {
currentPage += offset;
renderTable();
};
function sortTable(column) {
if (currentSort.column === column) {
currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.order = 'desc';
}
currentPage = 1;
filterAndRenderTable();
}
}
// =============== COLLECTOR FUNCTIONALITY ===============
function initializeCollector() {
const form = document.getElementById('archiveForm');
const statusDiv = document.getElementById('status');
const downloadBtn = document.getElementById('downloadBtn');
const spinner = document.getElementById('spinner');
const broadcastBtn = document.createElement('button');
const fileInput = document.createElement('input');
// Configure file input for archive selection
fileInput.type = 'file';
fileInput.accept = '.json';
fileInput.style.display = 'none';
form.appendChild(fileInput);
broadcastBtn.textContent = 'Broadcast from Archive';
broadcastBtn.style.display = 'none'; // Initially hidden
form.appendChild(broadcastBtn);
const progressContainer = document.getElementById('progressContainer');
let collectedEvents = [];
let loadedEvents = []; // Events loaded from a file
let subscriptions = []; // Track active subscriptions
let subscriptions = [];
let totalRelays = 0;
let completedRelays = 0;
function updateProgress() {
const progress = (completedRelays / totalRelays) * 100;
const progressBar = progressContainer.querySelector('.progress-bar');
const progressText = progressContainer.querySelector('.progress-text');
if (progressBar && progressText) {
progressBar.style.width = `${progress}%`;
progressText.textContent = `${Math.round(progress)}%`;
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
collectedEvents = []; // Reset previous data
subscriptions = []; // Clear previous subscriptions
collectedEvents = [];
subscriptions = [];
downloadBtn.style.display = 'none';
broadcastBtn.style.display = 'none';
statusDiv.innerHTML = 'Starting collection...';
spinner.style.display = 'block';
progressContainer.style.display = 'block';
updateProgress();
const npub = document.getElementById('npub').value.trim();
const relayInput = document.getElementById('relays').value.trim();
const relayUrls = relayInput.split('\n').map(url => url.trim()).filter(url => url);
if (relayUrls.length === 0) {
statusDiv.innerHTML = 'Please enter at least one relay URL.';
spinner.style.display = 'none';
progressContainer.style.display = 'none';
return;
}
let pubkey;
try {
const decoded = NostrTools.nip19.decode(npub);
@@ -41,25 +360,27 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (error) {
statusDiv.innerHTML = `Invalid NPub: ${error.message}`;
spinner.style.display = 'none';
progressContainer.style.display = 'none';
return;
}
statusDiv.innerHTML = `Connecting to ${relayUrls.length} relay(s)...`;
totalRelays = relayUrls.length;
completedRelays = 0;
updateProgress();
const pool = new NostrTools.SimplePool();
const eoseTracker = relayUrls.reduce((acc, url) => ({ ...acc, [url]: false }), {});
const eventIds = new Set();
// Update the filter to include additional kinds
const filter = {
kinds: [0, 1, 2, 3, 4, 6, 7, 10002, 30023, 10509], // Include all relevant kinds
authors: [pubkey], // Fetch events from the specified pubkey
kinds: [0, 1, 2, 3, 4, 6, 7, 10002, 30023, 10509],
authors: [pubkey],
};
relayUrls.forEach((url) => {
try {
const sub = pool.sub([url], [filter]);
subscriptions.push(sub);
sub.on('event', (event) => {
@@ -86,14 +407,15 @@ document.addEventListener('DOMContentLoaded', () => {
default:
console.log('Other event captured:', event);
}
collectedEvents.push(event); // Store the raw event data
collectedEvents.push(event);
eventIds.add(event.id);
}
});
sub.on('eose', () => {
eoseTracker[url] = true;
completedRelays++;
updateProgress();
console.log(`EOSE received from ${url}`);
statusDiv.innerHTML += `<br>EOSE received from ${url}`;
checkCompletion();
@@ -102,15 +424,23 @@ document.addEventListener('DOMContentLoaded', () => {
sub.on('error', (err) => {
console.error(`Error on ${url}:`, err);
statusDiv.innerHTML += `<br>Error on ${url}: ${err.message}`;
eoseTracker[url] = true;
completedRelays++;
updateProgress();
checkCompletion();
});
} catch (err) {
console.error(`Subscription failed for ${url}:`, err);
statusDiv.innerHTML += `<br>Subscription failed for ${url}: ${err.message}`;
eoseTracker[url] = true;
completedRelays++;
updateProgress();
checkCompletion();
}
});
function checkCompletion() {
if (Object.values(eoseTracker).every((status) => status)) {
if (Object.values(eoseTracker).every(status => status)) {
finishCollection();
}
}
@@ -119,7 +449,6 @@ document.addEventListener('DOMContentLoaded', () => {
spinner.style.display = 'none';
statusDiv.innerHTML += `<br>Collection complete. ${collectedEvents.length} event(s) collected.`;
downloadBtn.style.display = 'block';
broadcastBtn.style.display = 'block';
subscriptions.forEach(sub => sub.unsub());
pool.close();
@@ -127,20 +456,75 @@ document.addEventListener('DOMContentLoaded', () => {
});
downloadBtn.addEventListener('click', () => {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(collectedEvents, null, 2));
const dataStr = "data:text/json;charset=utf-8," +
encodeURIComponent(JSON.stringify(collectedEvents, null, 2));
const downloadAnchor = document.createElement('a');
downloadAnchor.setAttribute("href", dataStr);
const filename = `nostr_archive_${new Date().toISOString()}.json`;
downloadAnchor.setAttribute("download", filename);
downloadAnchor.setAttribute("download",
`nostr_archive_${new Date().toISOString()}.json`);
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
});
}
// Handle file selection for broadcasting
broadcastBtn.addEventListener('click', () => {
fileInput.click(); // Trigger file input
// =============== SHARED HELPER FUNCTIONS ===============
const POPULAR_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://nostr.wine'
];
function validateRelayUrls(urls) {
const validUrlPattern = /^wss?:\/\/[^\s/$.?#].[^\s]*$/i;
return urls.every(url => {
// Check if URL matches basic websocket pattern
if (!validUrlPattern.test(url)) {
return false;
}
try {
// Try to create URL object to validate further
new URL(url);
return true;
} catch {
return false;
}
});
}
// =============== VERIFICATION HELPER FUNCTION ===============
async function verifyEventOnRelay(eventId, relayUrl, existingPool = null) {
const pool = existingPool || new NostrTools.SimplePool();
try {
const filter = {
ids: [eventId]
};
const events = await pool.list([relayUrl], [filter]);
return {
found: events.length > 0,
relay: relayUrl
};
} catch (err) {
console.error(`Error verifying event on ${relayUrl}:`, err);
return {
found: false,
relay: relayUrl,
error: err.message
};
}
}
// =============== BATCH BROADCAST FUNCTIONALITY ===============
function initializeBroadcast() {
const fileInput = document.getElementById('broadcastFileInput');
const statusDiv = document.getElementById('broadcastStatus');
const startButton = document.getElementById('startBroadcastBtn');
const progressContainer = document.getElementById('broadcastProgress');
const progressBar = progressContainer.querySelector('.progress-bar');
const progressText = progressContainer.querySelector('.progress-text');
let loadedEvents = [];
fileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
@@ -151,89 +535,397 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const parsedData = JSON.parse(e.target.result);
if (!Array.isArray(parsedData)) throw new Error('Archive must be an array of events.');
loadedEvents = parsedData; // Safely assign parsed data
statusDiv.innerHTML = `Loaded ${loadedEvents.length} event(s) from the archive. Ready to broadcast.`;
promptAndBroadcast();
loadedEvents = parsedData;
statusDiv.innerHTML = `Loaded ${loadedEvents.length} event(s) from the archive.`;
startButton.style.display = 'inline-block';
} catch (err) {
console.error('Error loading archive:', err);
statusDiv.innerHTML = `Failed to load archive: ${err.message}`;
startButton.style.display = 'none';
}
};
reader.readAsText(file);
});
function promptAndBroadcast() {
const relayInput = prompt('Enter relay URLs (one per line) for broadcasting:');
if (!relayInput) return;
async function processBatch(batch, relayUrls, pool) {
// First, publish all events in the batch in parallel
const publishPromises = batch.map(async event => {
try {
const published = await pool.publish(relayUrls, event);
return {
event,
success: true,
published
};
} catch (err) {
console.error(`Failed to publish event ${event.id}:`, err);
return {
event,
success: false,
error: err.message
};
}
});
const publishResults = await Promise.allSettled(publishPromises);
// Quick pause to let relays catch up
await new Promise(resolve => setTimeout(resolve, 100));
// Now verify all successful publishes in parallel
const verifyPromises = publishResults
.filter(result => result.value?.success)
.map(result => {
const event = result.value.event;
return Promise.all(relayUrls.map(async relay => {
try {
const verified = await verifyEventOnRelay(event.id, relay, pool);
return {
eventId: event.id,
relay,
success: verified.found
};
} catch (err) {
return {
eventId: event.id,
relay,
success: false,
error: err.message
};
}
}));
});
const verifyResults = await Promise.allSettled(verifyPromises);
return {
published: publishResults,
verified: verifyResults
};
}
function updateStatus(currentEvent, stats, batchResults = null) {
const elapsedTime = (Date.now() - stats.startTime) / 1000;
const avgTimePerEvent = stats.completedCount > 0 ? elapsedTime / stats.completedCount : 0;
const remainingEvents = loadedEvents.length - stats.completedCount;
const estimatedRemainingTime = Math.ceil(avgTimePerEvent * remainingEvents);
const progress = (stats.completedCount / loadedEvents.length) * 100;
progressBar.style.width = `${progress}%`;
progressText.textContent = `${Math.round(progress)}% (${stats.completedCount}/${loadedEvents.length})`;
let statusHTML = `
<div class="broadcast-status">
<div class="status-section">
<h3>Progress <span class="spinner"></span></h3>
<p>⏱️ ${Math.floor(elapsedTime / 60)}m ${Math.floor(elapsedTime % 60)}s elapsed</p>
<p>⏳ ~${Math.floor(estimatedRemainingTime / 60)}m remaining</p>
<p>📊 ${stats.completedCount}/${loadedEvents.length} processed</p>
</div>
<div class="status-section">
<h3>Status</h3>
<p>✅ ${stats.successCount} succeeded</p>
<p>❌ ${stats.failureCount} failed</p>
<p>✓ ${stats.verifiedCount} verified</p>
</div>`;
if (currentEvent) {
statusHTML += `
<div class="status-section">
<h3>Current Batch</h3>
<p>📝 Processing Kind ${currentEvent.kind}</p>
<p>
<span class="event-id">${currentEvent.id}</span>
<button class="copy-button" onclick="copyToClipboard('${currentEvent.id}', 'current-copy')">Copy</button>
</p>
</div>`;
}
if (stats.latestVerified) {
statusHTML += `
<div class="status-section">
<h3>Latest Verified</h3>
<p>
<span class="event-id">${stats.latestVerified.eventId}</span>
<button class="copy-button" onclick="copyToClipboard('${stats.latestVerified.eventId}', 'latest-copy')">Copy</button>
</p>
<p>✓ Found on ${stats.latestVerified.count} relays</p>
</div>`;
}
statusHTML += '</div>';
statusDiv.innerHTML = statusHTML;
}
startButton.addEventListener('click', async () => {
const relayUrls = document.getElementById('broadcastRelays')
.value.trim()
.split('\n')
.map(url => url.trim())
.filter(url => url);
const relayUrls = relayInput.split('\n').map(url => url.trim()).filter(url => url);
if (relayUrls.length === 0) {
alert('No valid relays provided.');
alert('Please enter at least one relay URL');
return;
}
if (!validateRelayUrls(relayUrls)) {
alert('One or more relay URLs are invalid. Please check and try again.');
return;
}
if (loadedEvents.length === 0) {
alert('No events loaded to broadcast');
return;
}
startButton.disabled = true;
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
progressText.textContent = '0%';
const stats = {
startTime: Date.now(),
completedCount: 0,
successCount: 0,
failureCount: 0,
verifiedCount: 0,
latestVerified: null
};
const pool = new NostrTools.SimplePool();
statusDiv.innerHTML = `Broadcasting to ${relayUrls.length} relay(s)...`;
const BATCH_SIZE = 10;
let successCount = 0;
let failureCount = 0;
try {
// Process in batches
for (let i = 0; i < loadedEvents.length; i += BATCH_SIZE) {
const batch = loadedEvents.slice(i, i + BATCH_SIZE);
updateStatus(batch[0], stats);
relayUrls.forEach((url) => {
try {
const sub = pool.sub([url], [filter]);
subscriptions.push(sub);
sub.on('event', (event) => {
if (!eventIds.has(event.id)) {
switch (event.kind) {
case 0:
console.log('Profile Metadata captured:', event);
break;
case 2:
console.log('Relay Recommendation captured:', event);
break;
case 3:
console.log('Contact List captured:', event);
break;
case 4:
console.log('Encrypted DM captured:', event);
break;
case 30023:
console.log('Long-Form Content captured:', event);
break;
default:
console.log('Other event captured:', event);
}
collectedEvents.push(event); // Store the raw event data
eventIds.add(event.id);
const results = await processBatch(batch, relayUrls, pool);
// Update stats based on batch results
results.published.forEach(result => {
if (result.value?.success) {
stats.successCount++;
} else {
stats.failureCount++;
}
});
sub.on('eose', () => {
eoseTracker[url] = true;
console.log(`EOSE received from ${url}`);
statusDiv.innerHTML += `<br>EOSE received from ${url}`;
checkCompletion();
results.verified.forEach(verifyGroup => {
if (verifyGroup.value) {
const successfulVerifications = verifyGroup.value.filter(v => v.success).length;
stats.verifiedCount += successfulVerifications;
if (successfulVerifications > 0) {
stats.latestVerified = {
eventId: verifyGroup.value[0].eventId,
count: successfulVerifications
};
}
}
});
sub.on('error', (err) => {
console.error(`Error on ${url}:`, err);
statusDiv.innerHTML += `<br>Error on ${url}: ${err.message}`;
// Mark the relay as complete to avoid stalling collection
eoseTracker[url] = true;
checkCompletion();
});
} catch (err) {
console.error(`Subscription failed for ${url}:`, err);
statusDiv.innerHTML += `<br>Subscription failed for ${url}: ${err.message}`;
// Mark the relay as complete to avoid stalling collection
eoseTracker[url] = true;
checkCompletion();
stats.completedCount += batch.length;
updateStatus(batch[batch.length - 1], stats, results);
}
});
pool.close();
statusDiv.innerHTML += `<br>Broadcast complete: ${successCount} success(es), ${failureCount} failure(s).`;
// Final summary
const successRate = ((stats.successCount / loadedEvents.length) * 100).toFixed(1);
const finalHTML = `
<div class="final-summary">
<h3>📊 Broadcast Complete</h3>
<div class="summary-section">
<p>Total Events: ${loadedEvents.length}</p>
<p>Success Rate: ${successRate}%</p>
<p>✅ ${stats.successCount} succeeded</p>
<p>❌ ${stats.failureCount} failed</p>
<p>✓ ${stats.verifiedCount} verifications</p>
<p>⏱️ Total Time: ${Math.floor((Date.now() - stats.startTime) / 60000)}m ${Math.floor((Date.now() - stats.startTime) / 1000 % 60)}s</p>
</div>
</div>`;
statusDiv.innerHTML += finalHTML;
} catch (err) {
console.error('Unexpected error during batch broadcast:', err);
statusDiv.innerHTML += `
<div class="status-section error">
<h3>⚠️ Error</h3>
<p>${err.message}</p>
</div>`;
} finally {
startButton.disabled = false;
}
});
// Add copy to clipboard function if it doesn't exist
if (!window.copyToClipboard) {
window.copyToClipboard = async function(text, buttonId) {
try {
await navigator.clipboard.writeText(text);
const button = document.getElementById(buttonId);
button.textContent = 'Copied!';
button.classList.add('copied');
setTimeout(() => {
button.textContent = 'Copy';
button.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
}
});
}
// =============== BROADCAST SINGLE EVENT FUNCTIONALITY ===============
async function broadcastSingleEvent(event, buttonElement) {
const relayInput = document.getElementById('broadcastRelays').value.trim();
const broadcastRelays = relayInput.split('\n').map(url => url.trim()).filter(url => url);
if (broadcastRelays.length === 0) {
alert('Please enter at least one relay URL in the Broadcast section.');
return;
}
if (!validateRelayUrls(broadcastRelays)) {
alert('One or more relay URLs are invalid. Please check and try again.');
return;
}
if (!event) {
alert('Invalid event selected.');
return;
}
buttonElement.disabled = true;
buttonElement.textContent = 'Broadcasting...';
const pool = new NostrTools.SimplePool();
const verificationResults = [];
try {
const publishResult = await pool.publish(broadcastRelays, event);
if (publishResult) {
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds
buttonElement.textContent = 'Verifying...';
// Check all relays (broadcast targets + popular relays)
const allRelaysToCheck = [...new Set([...broadcastRelays, ...POPULAR_RELAYS])];
for (const relayUrl of allRelaysToCheck) {
const result = await verifyEventOnRelay(event.id, relayUrl, pool);
verificationResults.push(result);
}
const foundOn = verificationResults.filter(r => r.found).map(r => r.relay);
const nostrBandLink = `https://nostr.band/event/${event.id}`;
let message = `Event ID ${event.id} broadcast complete!\n\n`;
message += `Found on ${foundOn.length} relay(s):\n${foundOn.join('\n')}\n\n`;
message += `View on nostr.band: ${nostrBandLink}`;
// Try to copy to clipboard
try {
await navigator.clipboard.writeText(event.id);
message += '\n\nEvent ID copied to clipboard!';
} catch (err) {
console.error('Failed to copy to clipboard:', err);
}
alert(message);
// Log detailed results to console for debugging
console.log('Verification Results:', {
eventId: event.id,
results: verificationResults
});
} else {
alert(`Event ID ${event.id} failed to broadcast to any relays.`);
}
} catch (err) {
console.error('Unexpected error during broadcasting:', err);
alert(`Unexpected error: ${err.message}`);
} finally {
buttonElement.disabled = false;
buttonElement.textContent = 'Broadcast';
}
}
function getKindLabel(kind) {
const kinds = {
0: ['Profile Metadata', 'Basic profile information like name, about, and picture'],
1: ['Short Text Note', 'Regular text posts and updates'],
2: ['Recommend Relay', 'Relay recommendations for other users'],
3: ['Contacts', 'List of followed users and their relays'],
4: ['Encrypted DM', 'Direct messages encrypted for specific recipients'],
6: ['Repost', 'Reposted content from other users'],
7: ['Reaction', 'Reactions to other posts (likes, emojis, etc.)'],
40: ['Channel Creation', 'Creates a new channel for group discussions'],
41: ['Channel Metadata', 'Updates to channel information'],
42: ['Channel Message', 'Messages posted in channels'],
30023: ['Long-form Content', 'Blog posts, articles, and longer content'],
9734: ['Zap Request', 'Lightning payment requests'],
9735: ['Zap', 'Lightning payment confirmations'],
10002: ['Relay List', 'List of preferred relays'],
10003: ['Bookmark List', 'Saved posts and content'],
30008: ['Profile Badges', 'Badges displayed on profiles'],
30009: ['Badge Definition', 'Creates or defines new badges'],
30078: ['Application Specific', 'Data specific to certain applications'],
31989: ['Handler Recommendation', 'Recommended handlers for protocols'],
31990: ['Handler Information', 'Information about protocol handlers']
};
const [label, description] = kinds[kind] || ['Unknown', 'Custom or application-specific event type'];
return `<div class="tooltip">${kind} (${label})<span class="tooltip-text">${description}</span></div>`;
}
function getContentDisplay(event, highlight = true) {
let content = '';
if (event.kind === 0) {
try {
const profile = JSON.parse(event.content);
content = `Name: ${profile.name || 'N/A'}<br>About: ${profile.about || 'N/A'}`;
} catch {
content = 'Invalid profile data';
}
} else if (event.kind === 4) {
const recipient = event.tags.find(tag => tag[0] === 'p');
content = `Encrypted Message: ${event.content || 'N/A'}<br>Recipient: ${recipient ? recipient[1] : 'Unknown'}`;
} else if (event.kind === 3) {
try {
const contacts = JSON.parse(event.content);
content = `Contact List: ${contacts.length} contacts`;
} catch {
content = 'Invalid contact list data';
}
} else {
content = event.content || 'No content available';
}
if (highlight && window.searchConfig?.term) {
let searchPattern = window.searchConfig.term;
if (!window.searchConfig.useRegex) {
searchPattern = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
let regex;
try {
const flags = window.searchConfig.caseSensitive ? 'g' : 'gi';
regex = new RegExp(searchPattern, flags);
} catch (err) {
// Invalid regex
return content;
}
const highlightClass = window.searchConfig.useRegex ? 'regex-highlight' : 'search-highlight';
content = content.replace(regex, match =>
`<span class="${highlightClass}">${match}</span>`);
}
return content;
}

View File

@@ -1,69 +1,357 @@
/* Base styles */
/* Base layout */
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-elevated: #1a1a1a;
--text-primary: #ffffff;
--text-secondary: #b3b3b3;
--accent-primary: #007bff;
--accent-hover: #0056b3;
--border-color: #404040;
--success-color: #28a745;
}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f9f9f9;
background-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
max-width: 1200px;
width: 95%;
max-width: 1600px;
margin: 20px auto;
padding: 20px;
background: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
padding: 30px 40px;
background: var(--bg-secondary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
flex: 1;
box-sizing: border-box;
border-radius: 8px;
}
/* Header and Stats */
/* Tooltip styles */
.tooltip {
position: relative;
display: inline-block;
}
.tooltip .tooltip-text {
visibility: hidden;
width: 250px;
background-color: var(--bg-elevated);
color: var(--text-primary);
text-align: center;
border-radius: 6px;
padding: 8px;
border: 1px solid var(--border-color);
font-size: 14px;
/* Position the tooltip */
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
/* Animation */
opacity: 0;
transition: opacity 0.3s;
}
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
/* Progress bar styles */
.progress-container {
width: 100%;
background-color: var(--bg-primary);
border-radius: 4px;
margin: 10px 0;
overflow: hidden;
}
.progress-bar {
height: 20px;
background-color: var(--accent-primary);
width: 0%;
transition: width 0.3s ease;
position: relative;
}
.progress-text {
position: absolute;
right: 10px;
color: var(--text-primary);
font-size: 12px;
line-height: 20px;
}
/* Typography */
h1 {
text-align: center;
margin-bottom: 10px;
margin-bottom: 20px;
font-size: 32px;
color: var(--text-primary);
}
.stats {
text-align: center;
color: #666;
h2 {
font-size: 24px;
margin-bottom: 20px;
font-size: 1.1em;
color: var(--text-primary);
}
p {
text-align: center; /* This will center the text */
margin-bottom: 30px;
font-size: 16px;
color: var(--text-secondary);
line-height: 1.5;
}
/* Navigation */
.nav-links {
text-align: center;
margin-bottom: 40px;
padding: 20px 0;
border-bottom: 1px solid var(--border-color);
}
.nav-links a {
display: inline-block;
margin: 10px 20px;
padding: 12px 24px;
background-color: var(--bg-elevated);
color: var(--text-primary);
text-decoration: none;
border-radius: 6px;
font-size: 18px;
transition: all 0.3s ease;
border: 1px solid var(--border-color);
}
.nav-links a:hover {
background-color: var(--accent-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* Section handling */
.section {
display: none;
text-align: center;
}
.section.active {
display: block;
}
/* Form elements */
form {
max-width: 800px;
margin: 0 auto;
text-align: left;
padding: 20px;
}
label {
display: block;
margin-top: 20px;
font-size: 16px;
font-weight: bold;
color: var(--text-secondary);
}
input[type="text"],
input[type="datetime-local"],
textarea {
width: 100%;
padding: 12px;
margin-top: 8px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-sizing: border-box;
font-size: 16px;
color: var(--text-primary);
transition: all 0.3s ease;
}
input[type="text"]:focus,
input[type="datetime-local"]:focus,
textarea:focus {
border-color: var(--accent-primary);
outline: none;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
}
textarea {
min-height: 120px;
resize: vertical;
}
input[type="file"] {
display: block;
margin: 20px auto;
margin: 30px auto;
padding: 10px;
color: var(--text-secondary);
}
button {
margin-top: 20px;
padding: 12px 24px;
background: var(--accent-primary);
color: var(--text-primary);
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
}
#downloadBtn {
background: var(--accent-primary);
display: block; /* Makes the button a block element */
margin: 20px auto; /* Auto margins on left and right will center it */
}
button:hover:not(:disabled) {
background: var(--accent-hover);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
button:disabled {
background: var(--bg-elevated);
cursor: not-allowed;
}
/* Advanced Search Styles */
.search-container {
margin: 20px 0;
padding: 20px;
background: var(--bg-elevated);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.search-bar {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.search-input {
flex: 1;
padding: 12px 20px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 16px;
}
.search-toggle {
padding: 12px 20px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.3s ease;
}
.advanced-search {
padding: 15px;
border-top: 1px solid var(--border-color);
margin-top: 15px;
}
.search-options {
display: flex;
gap: 20px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.filter-fields {
display: flex;
gap: 15px;
align-items: center;
}
.kind-filter {
margin: 15px 0;
}
.kind-filter select {
padding: 8px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
min-width: 200px;
}
.date-filter {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
/* Table styles */
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
border-collapse: separate;
border-spacing: 0;
margin: 30px 0;
background: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
}
th, td {
padding: 10px;
border: 1px solid #ddd;
padding: 15px;
border: 1px solid var(--border-color);
text-align: left;
vertical-align: top;
}
th {
background: var(--bg-elevated);
font-weight: bold;
color: var(--text-primary);
cursor: pointer;
background: #f4f4f4;
position: relative;
transition: background-color 0.3s ease;
}
th:hover {
background: #e0e0e0;
background: var(--accent-primary);
}
.empty-message {
text-align: center;
color: #888;
margin-top: 20px;
td {
color: var(--text-secondary);
}
tr:hover {
background: var(--bg-elevated);
}
.number-column {
width: 50px;
text-align: right;
color: var(--text-secondary);
}
.content-cell {
max-width: 600px;
max-width: 800px;
white-space: pre-wrap;
word-break: break-word;
}
@@ -82,10 +370,39 @@ th:hover {
content: '↓';
}
.number-column {
width: 50px;
text-align: right;
color: #666;
/* Stats and messages */
.stats {
text-align: center;
color: var(--text-secondary);
margin-bottom: 20px;
font-size: 1.1em;
padding: 20px;
background: var(--bg-elevated);
border-radius: 6px;
}
.empty-message {
text-align: center;
color: var(--text-secondary);
}
#status {
margin-top: 20px;
white-space: pre-wrap;
color: var(--text-secondary);
}
/* Search highlighting */
.search-highlight {
background-color: rgba(0, 123, 255, 0.2);
border-radius: 3px;
padding: 2px;
}
.regex-highlight {
background-color: rgba(255, 123, 0, 0.2);
border-radius: 3px;
padding: 2px;
}
/* Pagination */
@@ -97,14 +414,233 @@ th:hover {
.pagination button {
margin: 0 5px;
padding: 5px 10px;
background: #007BFF;
color: white;
background: var(--accent-primary);
color: var(--text-primary);
border: none;
cursor: pointer;
border-radius: 3px;
}
.pagination button:disabled {
background: #ccc;
background: var(--bg-elevated);
cursor: not-allowed;
}
}
/* Spinner */
#spinner {
display: none;
text-align: center;
margin-top: 20px;
}
.loader {
border: 8px solid var(--bg-elevated);
border-top: 8px solid var(--accent-primary);
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Footer */
footer {
background-color: var(--bg-elevated);
color: var(--text-secondary);
padding: 20px;
text-align: center;
margin-top: auto;
}
.footer-content {
max-width: 1600px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.copyright {
color: var(--text-secondary);
font-size: 14px;
}
.footer-link {
color: var(--text-primary);
text-decoration: none;
font-size: 14px;
transition: color 0.3s;
}
.footer-link:hover {
color: var(--accent-primary);
}
/* Responsive adjustments */
@media (max-width: 1200px) {
.container {
width: 95%;
padding: 20px;
}
.content-cell {
max-width: 600px;
}
}
@media (max-width: 768px) {
.container {
width: 100%;
margin: 0;
padding: 15px;
border-radius: 0;
}
.nav-links a {
display: block;
margin: 10px auto;
width: 80%;
max-width: 300px;
}
.search-bar {
flex-direction: column;
}
.date-filter {
flex-direction: column;
align-items: stretch;
}
/* Broadcast section styles */
.relay-input {
max-width: 800px;
margin: 20px auto;
text-align: left;
}
.relay-input textarea {
width: 100%;
margin-top: 10px;
}
#broadcastStatus {
margin: 20px 0;
color: var(--text-secondary);
}
#startBroadcastBtn {
display: block;
margin: 20px auto;
}
#broadcast .progress-container {
max-width: 800px;
margin: 20px auto;
}
.broadcast-status {
margin: 20px 0;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 15px;
}
.status-section {
padding: 10px;
background: #f9f9f9;
border-radius: 4px;
}
.status-section h3 {
margin: 0 0 10px 0;
color: #333;
font-size: 1.1em;
display: flex;
align-items: center;
gap: 8px;
}
.status-section h3 .spinner {
width: 16px;
height: 16px;
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
display: inline-block;
margin-left: 5px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.status-section p {
margin: 5px 0;
color: #666;
display: flex;
align-items: center;
gap: 8px;
}
.event-id {
font-family: monospace;
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
margin-right: 5px;
}
.copy-button {
background: #e0e0e0;
border: none;
border-radius: 3px;
padding: 2px 8px;
cursor: pointer;
font-size: 0.8em;
color: #666;
}
.copy-button:hover {
background: #d0d0d0;
}
.copy-button.copied {
background: #90EE90;
color: #006400;
}
.status-section.error {
background: #fff0f0;
border-left: 3px solid #ff4444;
}
.final-summary {
margin-top: 20px;
padding: 20px;
background: #f0f8ff;
border: 1px solid #b8daff;
border-radius: 5px;
}
.summary-section {
margin: 10px 0;
}
.summary-section h4 {
margin: 10px 0;
color: #2c5282;
}
}

View File

@@ -1,40 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Nostr Archive Viewer</title>
<link rel="stylesheet" href="styles.css?v=" + Math.random()>
</head>
<body>
<div class="container">
<h1>Nostr Archive Viewer</h1>
<div id="stats" class="stats">No data loaded</div>
<input type="file" id="fileInput" accept=".json">
<table id="dataTable">
<thead>
<tr>
<th class="number-column">#</th>
<th data-sort="created_at">Time <span class="sort-indicator"></span></th>
<th data-sort="kind">Kind <span class="sort-indicator"></span></th>
<th>Content</th>
</tr>
</thead>
<tbody>
<tr class="empty-message">
<td colspan="4">No data loaded. Please load a JSON archive file.</td>
</tr>
</tbody>
</table>
<div class="pagination">
<button id="prevPage" onclick="changePage(-1)" disabled>Previous</button>
<span id="pageInfo">Page 1</span>
<button id="nextPage" onclick="changePage(1)" disabled>Next</button>
</div>
</div>
<script src="app.js"></script>
</body>
</html>