mirror of
https://github.com/PR0M3TH3AN/Archivestr.git
synced 2025-09-07 14:58:53 +00:00
update
This commit is contained in:
150
src/app.js
150
src/app.js
@@ -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';
|
||||
}
|
@@ -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>
|
214
src/index.html
214
src/index.html
@@ -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>
|
||||
|
886
src/script.js
886
src/script.js
@@ -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;
|
||||
}
|
602
src/styles.css
602
src/styles.css
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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>
|
Reference in New Issue
Block a user