Files
p2ns/plugin-sites/peer.directory/www/index.html
T

789 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#000000">
<title>Peer Directory</title>
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/css/tailwind.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<div class="header-content">
<h1>P2NS Directory</h1>
</div>
<div class="header-actions">
<a
href="https://p2ns.admin"
target="_blank"
class="btn btn-primary"
>
Go To Admin
</a>
<div class="status" role="status" aria-live="polite">
<div class="status-indicator" id="statusIndicator" aria-label="Connection status"></div>
<span id="statusText">Connecting...</span>
</div>
</div>
</header>
<!-- Search and Controls -->
<div class="mb-6 space-y-4 flex-shrink-0">
<input
id="searchInput"
type="text"
placeholder="Search domains or hashes..."
class="input"
autofocus>
<div class="flex gap-4 items-center justify-between">
<div class="flex items-center gap-2">
<label for="sortBy" class="text-sm" style="color: var(--text-secondary);">Sort by:</label>
<select
id="sortBy"
class="select"
style="width: auto; min-width: 250px;"
>
<option value="type" selected>Type (Remote → Local → Internal)</option>
<option value="name">Name (A-Z)</option>
<option value="name-desc">Name (Z-A)</option>
<option value="hash">Hash (A-Z)</option>
<option value="hash-desc">Hash (Z-A)</option>
<option value="type-desc">Type (Internal → Local → Remote)</option>
</select>
</div>
</div>
</div>
<!-- Domain Table -->
<div class="table-container">
<table>
<thead>
<tr>
<th>Domain</th>
<th>Hash</th>
<th>Type</th>
</tr>
</thead>
<tbody id="domainList"></tbody>
</table>
<div id="scrollSentinel" style="height: 20px; width: 100%; flex-shrink: 0;"></div>
</div>
<!-- Loading indicator and info -->
<div class="mb-4 text-center text-sm text-tertiary flex-shrink-0" style="min-height: 1.5rem;">
<span id="paginationInfo">Loading...</span>
</div>
</div>
<script src="/js/websocket.js"></script>
<script>
let currentPage = 1;
let currentLimit = 20;
let currentSort = 'type';
let allDomains = [];
let totalDomains = 0;
let isSearching = false;
let isLoading = false;
let hasMore = true;
let scrollObserver = null;
function getTypeBadge(type) {
const badges = {
internal: {
icon: '⚙️',
text: 'Internal',
class: 'badge badge-internal'
},
local: {
icon: '🏠',
text: 'Local',
class: 'badge badge-local'
},
remote: {
icon: '🌐',
text: 'Remote',
class: 'badge badge-remote'
}
};
const badge = badges[type] || badges.remote;
return `<span class="${badge.class}">
<span>${badge.icon}</span>
<span>${badge.text}</span>
</span>`;
}
function renderDomain(domainObj) {
const tr = document.createElement('tr');
// Domain cell with link
const domainCell = document.createElement('td');
const domainLink = document.createElement('a');
domainLink.href = 'https://' + domainObj.domain;
domainLink.textContent = domainObj.domain;
domainLink.target = '_blank';
domainCell.appendChild(domainLink);
// Hash cell
const hashCell = document.createElement('td');
hashCell.style.cssText = 'word-break: break-all; color: var(--text-tertiary);';
hashCell.textContent = domainObj.hash || 'none';
// Type cell with badge
const typeCell = document.createElement('td');
typeCell.innerHTML = getTypeBadge(domainObj.type);
tr.appendChild(domainCell);
tr.appendChild(hashCell);
tr.appendChild(typeCell);
return tr;
}
async function fetchAllDomains(sort = 'type') {
try {
// Fetch all domains with a large limit
const params = new URLSearchParams({ page: '1', limit: '10000', sort });
const response = await fetch('/domains?' + params.toString());
const data = await response.json();
if (data.domains) {
allDomains = data.domains;
totalDomains = data.total;
} else if (Array.isArray(data)) {
// Fallback for old API format
allDomains = data.map(d => typeof d === 'string' ? { domain: d, type: 'remote', hash: 'none', isLocal: false } : d);
totalDomains = allDomains.length;
}
return allDomains;
} catch (err) {
console.error('Failed to fetch all domains:', err);
return [];
}
}
async function fetchDomains(page = 1, limit = 20, sort = 'type', searchQuery = '', append = false, retryCount = 0) {
if (isLoading) return;
// If we have WebSocket data, use it instead of making API calls
if (allDomains.length > 0 && window.wsClient && window.wsClient.isConnected) {
// Use WebSocket data
handleDomainsFromWebSocket(page, limit, sort, searchQuery, append);
return;
}
// Fallback to REST API
await fetchDomainsFromAPI(page, limit, sort, searchQuery, append, retryCount);
}
function handleDomainsFromWebSocket(page = 1, limit = 20, sort = 'type', searchQuery = '', append = false) {
let domains = [];
let total = 0;
if (searchQuery) {
// Filter domains client-side
const query = searchQuery.toLowerCase();
const filtered = allDomains.filter(d =>
d.domain.toLowerCase().includes(query) ||
(d.hash && d.hash.toLowerCase().includes(query))
);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
domains = filtered.slice(startIndex, endIndex);
total = filtered.length;
hasMore = endIndex < filtered.length;
} else {
// Use all domains
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
domains = allDomains.slice(startIndex, endIndex);
total = allDomains.length;
hasMore = endIndex < allDomains.length;
}
renderDomains(domains, append);
updatePaginationInfo(page, total, limit);
// Setup infinite scroll if there's more to load
setTimeout(() => {
if (hasMore) {
setupInfiniteScroll();
} else {
cleanupInfiniteScroll();
}
}, 100);
}
async function fetchDomainsFromAPI(page = 1, limit = 20, sort = 'type', searchQuery = '', append = false, retryCount = 0) {
if (isLoading) return;
try {
isLoading = true;
let domains = [];
let total = 0;
if (searchQuery) {
// When searching, fetch all domains and filter client-side
if (allDomains.length === 0 || currentSort !== sort) {
await fetchAllDomains(sort);
currentSort = sort;
}
const query = searchQuery.toLowerCase();
const filtered = allDomains.filter(d =>
d.domain.toLowerCase().includes(query) ||
(d.hash && d.hash.toLowerCase().includes(query))
);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
domains = filtered.slice(startIndex, endIndex);
total = filtered.length;
hasMore = endIndex < filtered.length;
renderDomains(domains, append);
updatePaginationInfo(page, total, limit);
} else {
// Normal paginated API call
const params = new URLSearchParams({ page: page.toString(), limit: limit.toString(), sort });
const response = await fetch('/domains?' + params.toString());
const data = await response.json();
if (data.domains) {
domains = data.domains;
total = data.total;
const totalPages = data.totalPages;
hasMore = page < totalPages;
renderDomains(domains, append);
updatePaginationInfo(page, total, limit);
totalDomains = data.total;
} else {
// Fallback
if (allDomains.length === 0) {
allDomains = Array.isArray(data) ? data.map(d => typeof d === 'string' ? { domain: d, type: 'remote', hash: 'none', isLocal: false } : d) : [];
totalDomains = allDomains.length;
}
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
domains = allDomains.slice(startIndex, endIndex);
total = allDomains.length;
hasMore = endIndex < allDomains.length;
renderDomains(domains, append);
updatePaginationInfo(page, total, limit);
}
}
// Setup infinite scroll if there's more to load
// Use setTimeout to ensure DOM is updated
setTimeout(() => {
if (hasMore) {
setupInfiniteScroll();
} else {
cleanupInfiniteScroll();
}
}, 100);
// If no domains found and not searching, retry after a delay
if (domains.length === 0 && !searchQuery && !append) {
const domainList = document.getElementById('domainList');
domainList.innerHTML = '<tr><td colspan="3" class="p-8 text-center" style="color: var(--text-tertiary);">No domains found. Retrying...</td>';
// Wait 2 seconds before retrying
await new Promise(resolve => setTimeout(resolve, 2000));
// Retry the request
isLoading = false;
return fetchDomainsFromAPI(page, limit, sort, searchQuery, append, retryCount + 1);
}
} catch (err) {
console.error('Failed to fetch domains:', err);
const domainList = document.getElementById('domainList');
if (!append) {
domainList.innerHTML = '<tr><td colspan="3" class="p-4 text-center" style="color: var(--error);">Failed to load domains. Retrying...</td>';
}
// Wait 2 seconds before retrying on error
await new Promise(resolve => setTimeout(resolve, 2000));
// Retry the request
isLoading = false;
return fetchDomainsFromAPI(page, limit, sort, searchQuery, append, retryCount + 1);
} finally {
isLoading = false;
}
}
function renderDomains(domains, append = false) {
const domainList = document.getElementById('domainList');
const tableContainer = document.querySelector('.table-container');
if (!append) {
domainList.innerHTML = '';
}
if (domains.length === 0 && !append) {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="3" class="p-8 text-center" style="color: var(--text-tertiary);">No domains found.</td>';
domainList.appendChild(tr);
// Remove few-rows class when empty
if (tableContainer) {
tableContainer.classList.remove('few-rows');
}
return;
}
domains.forEach(domainObj => {
const tr = renderDomain(domainObj);
domainList.appendChild(tr);
});
// Add 'few-rows' class if there are 5 or fewer total rows for better appearance
if (tableContainer) {
const totalRows = domainList.querySelectorAll('tr').length;
if (totalRows <= 5) {
tableContainer.classList.add('few-rows');
} else {
tableContainer.classList.remove('few-rows');
}
}
}
function updatePaginationInfo(page, total, limit) {
const paginationInfo = document.getElementById('paginationInfo');
const domainList = document.getElementById('domainList');
const loadedCount = domainList.querySelectorAll('tr').length;
paginationInfo.textContent = `Showing ${loadedCount} of ${total} domains${hasMore ? ' (scroll for more)' : ''}`;
}
function sortDomains(sortBy) {
// Define priority order for domain types
const typeOrder = { remote: 0, local: 1, internal: 2, plugin: 3 };
// Sort by domain name (ascending - A to Z)
if (sortBy === 'name') {
allDomains.sort((a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }));
}
// Sort by domain name (descending - Z to A)
else if (sortBy === 'name-desc') {
allDomains.sort((a, b) => b.domain.localeCompare(a.domain, undefined, { sensitivity: 'base' }));
}
// Sort by hash (ascending - alphabetical)
else if (sortBy === 'hash') {
allDomains.sort((a, b) => {
const hashA = (a.hash || 'none').toLowerCase();
const hashB = (b.hash || 'none').toLowerCase();
return hashA.localeCompare(hashB, undefined, { sensitivity: 'base' });
});
}
// Sort by hash (descending - reverse alphabetical)
else if (sortBy === 'hash-desc') {
allDomains.sort((a, b) => {
const hashA = (a.hash || 'none').toLowerCase();
const hashB = (b.hash || 'none').toLowerCase();
return hashB.localeCompare(hashA, undefined, { sensitivity: 'base' });
});
}
// Sort by type (descending - remote, local, internal) then alphabetically
else if (sortBy === 'type-desc') {
allDomains.sort((a, b) => {
// First compare by type (reverse order)
const typeDiff = typeOrder[b.type] - typeOrder[a.type];
if (typeDiff !== 0) return typeDiff;
// If same type, sort alphabetically by domain name
return a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' });
});
}
// Default: Sort by type (ascending - remote, local, internal) then alphabetically
else {
allDomains.sort((a, b) => {
// First compare by type (remote < local < internal)
const typeDiff = typeOrder[a.type] - typeOrder[b.type];
if (typeDiff !== 0) return typeDiff;
// If same type, sort alphabetically by domain name
return a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' });
});
}
}
function setupInfiniteScroll() {
cleanupInfiniteScroll();
const sentinel = document.getElementById('scrollSentinel');
const tableContainer = document.querySelector('.table-container');
if (!sentinel || !tableContainer) {
// Retry after a short delay if elements aren't ready
setTimeout(setupInfiniteScroll, 100);
return;
}
scrollObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && hasMore && !isLoading) {
console.log('Loading more domains...');
loadMoreDomains();
}
});
}, {
root: tableContainer,
rootMargin: '100px',
threshold: 0.1
});
scrollObserver.observe(sentinel);
console.log('Infinite scroll observer set up');
}
function cleanupInfiniteScroll() {
if (scrollObserver) {
scrollObserver.disconnect();
scrollObserver = null;
}
}
async function loadMoreDomains() {
if (isLoading || !hasMore) return;
currentPage++;
const searchQuery = document.getElementById('searchInput').value;
await fetchDomains(currentPage, currentLimit, currentSort, searchQuery, true);
}
// Event listeners
document.getElementById('searchInput').addEventListener('input', async (e) => {
const searchQuery = e.target.value;
currentPage = 1;
isSearching = searchQuery.length > 0;
cleanupInfiniteScroll();
if (isSearching && allDomains.length === 0) {
// If searching but no data loaded yet, fetch from API
await fetchDomains(currentPage, currentLimit, currentSort, searchQuery, false);
} else {
// Use WebSocket data or cached data
handleDomainsFromWebSocket(currentPage, currentLimit, currentSort, searchQuery, false);
}
});
document.getElementById('sortBy').addEventListener('change', async (e) => {
currentSort = e.target.value;
currentPage = 1;
cleanupInfiniteScroll();
// Sort the domains if we have them
if (allDomains.length > 0) {
sortDomains(currentSort);
const searchQuery = document.getElementById('searchInput').value;
handleDomainsFromWebSocket(currentPage, currentLimit, currentSort, searchQuery, false);
} else {
// No data yet, fetch from API
const searchQuery = document.getElementById('searchInput').value;
await fetchDomains(currentPage, currentLimit, currentSort, searchQuery, false);
}
});
// Dynamic domain update function
function updateDomainsDynamically(newDomains) {
const searchQuery = document.getElementById('searchInput').value;
const isSearching = searchQuery.length > 0;
// Create maps for efficient lookups
const currentDomainMap = new Map(allDomains.map(d => [d.domain, d]));
const newDomainMap = new Map(newDomains.map(d => [d.domain, d]));
// Track changes
const addedDomains = [];
const removedDomains = [];
const changedDomains = [];
// Find added and changed domains
for (const newDomain of newDomains) {
const currentDomain = currentDomainMap.get(newDomain.domain);
if (!currentDomain) {
addedDomains.push(newDomain);
} else if (JSON.stringify(currentDomain) !== JSON.stringify(newDomain)) {
changedDomains.push(newDomain);
}
}
// Find removed domains
for (const currentDomain of allDomains) {
if (!newDomainMap.has(currentDomain.domain)) {
removedDomains.push(currentDomain);
}
}
// Update the master domain list
allDomains = [...newDomains];
// Apply current sorting
sortDomains(currentSort);
// Show brief visual feedback for changes
if (addedDomains.length > 0 || removedDomains.length > 0 || changedDomains.length > 0) {
showUpdateIndicator();
}
console.log(`Domain updates: +${addedDomains.length} -${removedDomains.length} ~${changedDomains.length}`);
// If not searching, update the visible list dynamically
if (!isSearching) {
updateVisibleListDynamically(addedDomains, removedDomains, changedDomains);
} else {
// If searching, re-apply filter and update
const filtered = allDomains.filter(d =>
d.domain.toLowerCase().includes(searchQuery.toLowerCase()) ||
(d.hash && d.hash.toLowerCase().includes(searchQuery.toLowerCase()))
);
// For search mode, do a clean re-render to maintain consistency
renderDomains(filtered.slice(0, currentLimit), false);
updatePaginationInfo(1, filtered.length, currentLimit);
}
}
// Update visible list dynamically without full re-render
function updateVisibleListDynamically(addedDomains, removedDomains, changedDomains) {
const domainList = document.getElementById('domainList');
const tableContainer = document.querySelector('.table-container');
// Update total count
totalDomains = allDomains.length;
// Get current visible domains (first currentLimit items after sorting)
const visibleDomains = allDomains.slice(0, currentLimit);
// For bulk updates with many changes, do a clean re-render to maintain consistency
// For smaller changes, the individual add/remove functions handle it seamlessly
if (addedDomains.length + removedDomains.length + changedDomains.length > 3) {
// Too many changes, do a clean re-render
renderDomains(visibleDomains, false);
} else {
// Handle individual changes seamlessly
// Note: individual add/remove handlers already updated the UI
}
updatePaginationInfo(1, totalDomains, currentLimit);
// Update few-rows class if needed
if (tableContainer) {
const totalRows = domainList.querySelectorAll('tr').length;
if (totalRows <= 5) {
tableContainer.classList.add('few-rows');
} else {
tableContainer.classList.remove('few-rows');
}
}
}
// Add domain to table seamlessly without re-rendering
function seamlesslyAddDomain(domainData) {
const domainList = document.getElementById('domainList');
const tableContainer = document.querySelector('.table-container');
// Only add if we have fewer than currentLimit visible rows
const visibleRows = Array.from(domainList.children).filter(row =>
row.style.display !== 'none' && !row.classList.contains('filtered-out')
);
if (visibleRows.length < currentLimit) {
// Create and insert the new row
const newRow = renderDomain(domainData);
domainList.appendChild(newRow);
// Update few-rows class if needed
const totalRows = domainList.querySelectorAll('tr').length;
if (totalRows <= 5) {
tableContainer.classList.add('few-rows');
}
}
}
// Remove domain from table seamlessly without re-rendering
function seamlesslyRemoveDomain(domainName) {
const domainList = document.getElementById('domainList');
const tableContainer = document.querySelector('.table-container');
// Find and remove the row
const rows = Array.from(domainList.children);
const rowToRemove = rows.find(row => {
const link = row.querySelector('a');
return link && link.textContent === domainName;
});
if (rowToRemove) {
// Add fade-out animation before removing
rowToRemove.style.transition = 'opacity 0.2s ease-out';
rowToRemove.style.opacity = '0';
setTimeout(() => {
if (rowToRemove.parentNode) {
rowToRemove.parentNode.removeChild(rowToRemove);
// Update few-rows class if needed
const totalRows = domainList.querySelectorAll('tr').length;
if (totalRows > 5) {
tableContainer.classList.remove('few-rows');
}
}
}, 200);
}
}
// Show brief visual indicator for updates
function showUpdateIndicator() {
// Add a subtle highlight effect to the table container
const tableContainer = document.querySelector('.table-container');
if (tableContainer) {
tableContainer.style.boxShadow = 'var(--shadow-glow)';
setTimeout(() => {
tableContainer.style.boxShadow = 'var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.05)';
}, 500);
}
}
// WebSocket Integration
let domainsPopulated = false;
let domainRequestInterval = null;
function requestDomainsUntilPopulated() {
if (domainsPopulated) {
if (domainRequestInterval) {
clearInterval(domainRequestInterval);
domainRequestInterval = null;
}
return;
}
if (window.wsClient && window.wsClient.isConnected) {
console.log('Requesting domains until populated...');
window.wsClient.requestDomains();
}
}
function initWebSocket() {
// Connect to WebSocket
window.wsClient.connect();
// Handle initial data from WebSocket
window.wsClient.on('init', (data) => {
console.log('WebSocket init received:', data);
if (data.domains && data.domains.length > 0) {
// Mark domains as populated
domainsPopulated = true;
// Clean up the request interval since we got domains
if (domainRequestInterval) {
clearInterval(domainRequestInterval);
domainRequestInterval = null;
}
// Replace entire domain list with fresh data (used for initial load and reconnection)
allDomains = data.domains;
totalDomains = data.domains.length;
// Clear current search and reset to first page
currentPage = 1;
hasMore = false; // WebSocket provides all data at once
isSearching = false;
cleanupInfiniteScroll();
// Clear search input and apply current sort
document.getElementById('searchInput').value = '';
sortDomains(currentSort);
// Render the complete domain list
renderDomains(allDomains, false);
updatePaginationInfo(1, totalDomains, currentLimit);
console.log(`Loaded ${totalDomains} domains via WebSocket`);
}
});
// Handle individual domain additions
window.wsClient.on('domain-added', (data) => {
console.log('Domain added via WebSocket:', data);
if (data.domain && data.data) {
// Add the domain to our list if not already present
const exists = allDomains.some(d => d.domain === data.domain);
if (!exists) {
allDomains.push(data.data);
sortDomains(currentSort);
showUpdateIndicator();
// Update UI seamlessly if not searching
const searchQuery = document.getElementById('searchInput').value;
if (!searchQuery) {
seamlesslyAddDomain(data.data);
updatePaginationInfo(1, allDomains.length, currentLimit);
totalDomains = allDomains.length;
}
}
}
});
// Handle individual domain removals
window.wsClient.on('domain-removed', (data) => {
console.log('Domain removed via WebSocket:', data);
if (data.domain) {
// Remove the domain from our list
const initialLength = allDomains.length;
allDomains = allDomains.filter(d => d.domain !== data.domain);
if (allDomains.length !== initialLength) {
// Don't re-sort on removal, just update UI
showUpdateIndicator();
// Update UI seamlessly if not searching
const searchQuery = document.getElementById('searchInput').value;
if (!searchQuery) {
seamlesslyRemoveDomain(data.domain);
updatePaginationInfo(1, allDomains.length, currentLimit);
totalDomains = allDomains.length;
}
}
}
});
// Handle real-time updates
window.wsClient.on('update', (data) => {
console.log('WebSocket update received:', data);
if (data.domains) {
updateDomainsDynamically(data.domains);
}
});
// Handle connection status changes
window.wsClient.on('connected', () => {
console.log('WebSocket connected');
// If we don't have domains yet, keep requesting until populated
if (!domainsPopulated) {
// Request immediately
window.wsClient.requestDomains();
// Keep requesting every 2 seconds until domains are populated
domainRequestInterval = setInterval(requestDomainsUntilPopulated, 2000);
}
});
window.wsClient.on('disconnected', () => {
console.log('WebSocket disconnected');
domainsPopulated = false; // Reset flag on disconnect
// Clean up the request interval
if (domainRequestInterval) {
clearInterval(domainRequestInterval);
domainRequestInterval = null;
}
});
}
// Initialize WebSocket on page load
initWebSocket();
// Initial fetch (fallback if WebSocket not available)
fetchDomains(currentPage, currentLimit, currentSort, '', false);
</script>
</body>
</html>