reorg
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Peer Directory Plugin
|
||||
|
||||
A P2NS plugin that provides a web interface for browsing and searching all domains in the P2NS network.
|
||||
|
||||
## Overview
|
||||
|
||||
The Peer Directory plugin serves as a central directory for discovering domains within the P2NS peer-to-peer network. It displays all claimed domains, categorizes them by type, and provides search and pagination functionality.
|
||||
|
||||
## Features
|
||||
|
||||
- **Domain Listing**: View all domains in the P2NS network
|
||||
- **Domain Categorization**: Domains are categorized as:
|
||||
- `remote` - Domains owned by other peers in the network
|
||||
- `local` - Domains you own in the P2P network
|
||||
- `internal` - Internal P2NS system domains (e.g., `p2ns.admin`)
|
||||
- `plugin` - Plugin domains from `plugin-sites/`
|
||||
- **Sorting Options**: Sort domains by type, name (A-Z or Z-A), or hash
|
||||
- **Pagination**: Navigate through large domain lists with page controls
|
||||
- **Real-Time Data**: Domain list is fetched fresh from the P2NS network on each request
|
||||
- **Responsive UI**: Clean, modern interface with Tailwind CSS
|
||||
|
||||
## Access
|
||||
|
||||
Navigate to `https://peer.directory` in your browser (requires P2NS to be running and the root CA to be trusted).
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `GET /domains`
|
||||
|
||||
Returns a paginated list of all domains in the P2NS network.
|
||||
|
||||
**Query Parameters:**
|
||||
- `page` (optional): Page number (1-indexed, default: 1)
|
||||
- `limit` (optional): Domains per page (1-10, default: 10)
|
||||
- `sort` (optional): Sort order - one of:
|
||||
- `type` (default) - Sort by domain type
|
||||
- `type-desc` - Sort by domain type (reversed)
|
||||
- `name` - Sort alphabetically (A-Z)
|
||||
- `name-desc` - Sort alphabetically (Z-A)
|
||||
- `hash` - Sort by hash
|
||||
- `hash-desc` - Sort by hash (reversed)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"domains": [
|
||||
{
|
||||
"domain": "example.tld",
|
||||
"type": "remote",
|
||||
"hash": "hs://s00084bf...",
|
||||
"isLocal": false
|
||||
},
|
||||
{
|
||||
"domain": "peer.directory",
|
||||
"type": "internal",
|
||||
"hash": "internal",
|
||||
"isLocal": true
|
||||
}
|
||||
],
|
||||
"total": 15,
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"totalPages": 2
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Panel Integration
|
||||
|
||||
The plugin registers the following in the P2NS admin panel:
|
||||
|
||||
### Actions
|
||||
- **Refresh Domains**: Manually trigger a domain list refresh
|
||||
|
||||
### Settings
|
||||
- **Default Sort Order**: Choose the default sorting method for the domain list
|
||||
- **Items Per Page**: Set the default number of domains shown per page
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
peer.directory/
|
||||
├── config.json # Plugin configuration
|
||||
├── index.js # Backend handler and API logic
|
||||
├── README.md # This file
|
||||
└── www/ # Frontend files
|
||||
├── index.html # Main HTML page
|
||||
├── icon.svg # Plugin icon
|
||||
├── manifest.json
|
||||
└── css/
|
||||
├── style.css
|
||||
└── tailwind.css
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Domain Discovery**: The plugin fetches all DNS entries from the P2NS network
|
||||
2. **Categorization**: Each domain is categorized based on:
|
||||
- Whether it's a plugin domain (in `plugin-sites/`)
|
||||
- Whether it's an internal domain (like `p2ns.admin`)
|
||||
- Whether the local peer is the resolved claimant (owner)
|
||||
3. **Sorting**: Domains are sorted according to the requested sort option
|
||||
4. **Pagination**: Results are paginated for efficient browsing
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin is configured via `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "peer.directory",
|
||||
"version": "1.0.0",
|
||||
"domain": "peer.directory",
|
||||
"enabled": true,
|
||||
"description": "P2NS peer directory browser - Browse and search P2NS domains",
|
||||
"www": "www"
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- P2NS Plugin SDK
|
||||
- No external dependencies required
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "peer.directory",
|
||||
"version": "1.0.0",
|
||||
"domain": "peer.directory",
|
||||
"enabled": true,
|
||||
"description": "P2NS peer directory browser - Browse and search P2NS domains",
|
||||
"author": "P2NS",
|
||||
"homepage": "https://github.com/p2ns/p2ns",
|
||||
"license": "MIT",
|
||||
"icon": "book",
|
||||
"dependencies": {},
|
||||
"www": "www"
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* Peer Directory Plugin
|
||||
*
|
||||
* Full implementation of the peer.directory website using the P2NS Plugin SDK.
|
||||
* This plugin provides a web interface for browsing and searching P2NS domains.
|
||||
*
|
||||
* The plugin serves:
|
||||
* - Static files from the www/ directory (HTML, CSS, JS, images)
|
||||
* - Dynamic API endpoints for domain listing and search
|
||||
*/
|
||||
|
||||
// Import the P2NS Plugin SDK which provides access to system functionality
|
||||
const sdk = require('../../includes/plugins/sdk');
|
||||
|
||||
/**
|
||||
* Categorize and collect all domains from the P2NS system
|
||||
*
|
||||
* This function:
|
||||
* 1. Fetches all DNS entries from the P2NS network
|
||||
* 2. Extracts domain claims (who owns which domains)
|
||||
* 3. Includes all plugin domains (domains in plugin-sites/)
|
||||
* 4. Categorizes each domain as 'plugin', 'internal', 'local', or 'remote'
|
||||
* 5. Retrieves the hash for each domain
|
||||
* 6. Returns a structured list of domains with metadata
|
||||
*
|
||||
* @returns {Promise<Array>} Array of domain objects with { domain, type, hash, isLocal }
|
||||
*/
|
||||
async function categorizeDomains() {
|
||||
// Check if DNS service is initialized and ready
|
||||
// If not, return empty array to prevent errors
|
||||
if (!sdk.utils.isDNSReady()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch all DNS entries from the P2NS network
|
||||
// These entries contain domain claims, hashes, and other metadata
|
||||
const allEntries = await sdk.dns.getAllEntries();
|
||||
|
||||
// If no entries exist yet (system just started), return at least the plugin domains
|
||||
// Plugin domains are domains in plugin-sites/ with valid config.json files
|
||||
if (!allEntries || allEntries.length === 0) {
|
||||
// Get all plugin domains
|
||||
const pluginDomains = sdk.plugin.getAllPluginDomains();
|
||||
// Get list of internal domains (includes plugins + p2ns.admin)
|
||||
const internalDomains = await sdk.utils.getInternalDomains();
|
||||
|
||||
// Return them formatted as domain objects
|
||||
// Plugin domains get type 'plugin', others get type 'internal'
|
||||
return internalDomains.map(domain => {
|
||||
const isPlugin = pluginDomains.includes(domain);
|
||||
return {
|
||||
domain,
|
||||
type: isPlugin ? 'plugin' : 'internal', // Mark as plugin or internal
|
||||
hash: 'internal', // Internal/plugin domains don't have P2P hashes
|
||||
isLocal: true // Internal/plugin domains are always local
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get the local writer key (this node's identity in the P2P network)
|
||||
// This is used to determine if we own a domain (isLocal = true)
|
||||
// Use the SDK's localPeerId which returns the persistent hyperswarm public key
|
||||
const localWriter = sdk.state.localPeerId;
|
||||
|
||||
// Get list of internal domains for comparison
|
||||
const internalDomains = await sdk.utils.getInternalDomains();
|
||||
|
||||
// Get all domains that have plugins loaded
|
||||
// Plugin domains are domains in plugin-sites/ with valid config.json files
|
||||
const pluginDomains = sdk.plugin.getAllPluginDomains();
|
||||
|
||||
// Convert to Set for faster lookups and ensure we have plugin domains
|
||||
const pluginDomainsSet = new Set(pluginDomains);
|
||||
if (pluginDomains.length > 0) {
|
||||
sdk.log.debug('peer.directory', `Found ${pluginDomains.length} plugin domains: ${pluginDomains.join(', ')}`);
|
||||
}
|
||||
|
||||
// Step 1: Collect all domain claims from DNS entries
|
||||
// DNS entries with keys like "claim:example.tld:claimant-id" indicate ownership
|
||||
// We build a map: domain -> Set of claimant IDs
|
||||
const domainClaimants = new Map();
|
||||
for (const entry of allEntries) {
|
||||
// Check if this entry is a domain claim
|
||||
if (entry.key.startsWith('claim:')) {
|
||||
// Parse the claim key: "claim:domain:claimant"
|
||||
const parts = entry.key.split(':');
|
||||
if (parts.length === 3) {
|
||||
const domain = parts[1]; // The domain being claimed
|
||||
const claimant = parts[2]; // The peer ID claiming the domain
|
||||
|
||||
// Initialize Set for this domain if it doesn't exist
|
||||
if (!domainClaimants.has(domain)) {
|
||||
domainClaimants.set(domain, new Set());
|
||||
}
|
||||
// Add this claimant to the set of claimants for this domain
|
||||
domainClaimants.get(domain).add(claimant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Build a unique set of all domains
|
||||
// Start with all plugin domains and add all claimed domains
|
||||
// This ensures all plugin sites are included in the directory
|
||||
const domains = new Set();
|
||||
|
||||
// Add all plugin domains first (these are guaranteed to be shown)
|
||||
for (const pluginDomain of pluginDomains) {
|
||||
domains.add(pluginDomain);
|
||||
}
|
||||
|
||||
// Add all domains from DNS claims
|
||||
for (const domain of domainClaimants.keys()) {
|
||||
domains.add(domain);
|
||||
}
|
||||
|
||||
// Step 3: Build the final domain list with metadata
|
||||
const domainList = [];
|
||||
for (const domain of domains) {
|
||||
// Check if this domain has a plugin (is in plugin-sites/)
|
||||
// Use Set for faster lookup - plugin domains should always be detected
|
||||
const isPlugin = pluginDomainsSet.has(domain);
|
||||
|
||||
// Debug logging for plugin detection issues
|
||||
if (!isPlugin && pluginDomains.length > 0) {
|
||||
// Log if we have plugin domains but this one isn't detected
|
||||
sdk.log.debug('peer.directory', `Domain ${domain} not in plugin list. Available plugins: ${Array.from(pluginDomainsSet).join(', ')}`);
|
||||
} else if (isPlugin) {
|
||||
sdk.log.debug('peer.directory', `Domain ${domain} correctly detected as plugin`);
|
||||
}
|
||||
|
||||
// Get the P2P hash for this domain (used for routing/connection)
|
||||
// Plugin and internal domains don't have P2P hashes, so use 'internal' or 'none'
|
||||
let hash = await sdk.dns.getHashForDomain(domain);
|
||||
|
||||
// Determine if this is an internal domain (managed by this P2NS instance)
|
||||
// Note: p2ns.admin is always internal but may not have a plugin
|
||||
// Also, any domain with hash='internal' (plugin type) should be marked as internal
|
||||
let isInternal = internalDomains.includes(domain);
|
||||
if (hash === 'internal') {
|
||||
// Domain has an internal hash (plugin type), mark it as internal
|
||||
isInternal = true;
|
||||
}
|
||||
|
||||
// If hash is null, set it based on domain type
|
||||
if (!hash) {
|
||||
hash = (isPlugin || isInternal ? 'internal' : 'none');
|
||||
}
|
||||
|
||||
// Get all claimants for this domain
|
||||
const claimants = domainClaimants.get(domain) || new Set();
|
||||
|
||||
// Check if we (the local node) own this domain by checking the resolved claimant
|
||||
// The resolved claimant is the one that won the consensus vote, not just any claimant
|
||||
let isLocal = false;
|
||||
if (localWriter && (isInternal || isPlugin)) {
|
||||
// Internal and plugin domains are always local
|
||||
isLocal = true;
|
||||
} else if (localWriter) {
|
||||
// For P2P domains, check if we are the resolved claimant (owner)
|
||||
try {
|
||||
const consensusState = await sdk.dns.getConsensusState(domain);
|
||||
isLocal = consensusState.resolvedClaimant === localWriter;
|
||||
} catch (err) {
|
||||
// If consensus check fails, fall back to checking if we're in the claimants set
|
||||
// This is a fallback for edge cases where consensus state might not be available
|
||||
isLocal = claimants.has(localWriter);
|
||||
sdk.log.debug('peer.directory', `Consensus check failed for ${domain}, using fallback: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Categorize the domain type:
|
||||
// - 'local': Domain we own in the P2P network (includes plugin domains)
|
||||
// - 'internal': Internal domain without plugin (e.g., p2ns.admin if no plugin, or domains with hash='internal')
|
||||
// - 'remote': Domain owned by other peers
|
||||
// Domains with hash='internal' (plugin type) should always be 'internal', not 'local'
|
||||
let type;
|
||||
if (hash === 'internal') {
|
||||
// Domains with internal hash (plugin type) are always 'internal'
|
||||
type = 'internal';
|
||||
} else if (isPlugin) {
|
||||
// Plugin domains are 'local'
|
||||
type = 'local';
|
||||
} else if (isInternal) {
|
||||
// Internal domains are 'internal'
|
||||
type = 'internal';
|
||||
} else if (isLocal) {
|
||||
// Locally owned P2P domains are 'local'
|
||||
type = 'local';
|
||||
} else {
|
||||
type = 'remote'; // Remote domains owned by other peers
|
||||
}
|
||||
|
||||
// Add this domain to the list with all its metadata
|
||||
domainList.push({
|
||||
domain, // Domain name (e.g., 'example.tld')
|
||||
type, // 'plugin', 'internal', 'local', or 'remote'
|
||||
hash, // P2P hash or 'internal' or 'none'
|
||||
isLocal: isLocal || isInternal || isPlugin // True if we own it, it's internal, or it's a plugin
|
||||
});
|
||||
}
|
||||
|
||||
return domainList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort domains according to the specified sort option
|
||||
*
|
||||
* This function provides multiple sorting strategies:
|
||||
* - By type (plugin, internal, local, remote) - default
|
||||
* - By domain name (alphabetical, ascending or descending)
|
||||
* - By hash (alphabetical, ascending or descending)
|
||||
*
|
||||
* When sorting by type, domains are ordered: remote (0) < local (1) < internal (2)
|
||||
* Within the same type, domains are sorted alphabetically by name.
|
||||
*
|
||||
* @param {Array} domains - Array of domain objects to sort
|
||||
* @param {string} sortBy - Sort option: 'type', 'name', 'name-desc', 'hash', 'hash-desc', 'type-desc'
|
||||
* @returns {Array} Sorted array of domain objects (mutates original array)
|
||||
*/
|
||||
function sortDomains(domains, sortBy = 'type') {
|
||||
// Define priority order for domain types (lower number = higher priority)
|
||||
// Default order: remote (0) < local (1) < internal (2)
|
||||
const typeOrder = { remote: 0, local: 1, internal: 2, plugin: 3 };
|
||||
|
||||
// Sort by domain name (ascending - A to Z)
|
||||
if (sortBy === 'name') {
|
||||
return domains.sort((a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
// Sort by domain name (descending - Z to A)
|
||||
else if (sortBy === 'name-desc') {
|
||||
return domains.sort((a, b) => b.domain.localeCompare(a.domain, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
// Sort by hash (ascending - alphabetical)
|
||||
else if (sortBy === 'hash') {
|
||||
return domains.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') {
|
||||
return domains.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') {
|
||||
return domains.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 {
|
||||
return domains.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' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Plugin Handler
|
||||
*
|
||||
* This is the main HTTP request handler for the peer.directory plugin.
|
||||
* It processes incoming HTTP requests and routes them to appropriate handlers.
|
||||
*
|
||||
* Request Flow:
|
||||
* 1. Parse the request URL to extract path and query parameters
|
||||
* 2. Check if the path matches any API endpoints we handle
|
||||
* 3. If matched, process the request and return a response
|
||||
* 4. If not matched, return false to allow static file serving from www/
|
||||
*
|
||||
* @param {Object} req - Node.js HTTP request object
|
||||
* @param {Object} res - Node.js HTTP response object
|
||||
* @returns {Promise<boolean>}
|
||||
* - true: Request was handled by this plugin
|
||||
* - false: Request should fall back to static file serving
|
||||
*/
|
||||
async function handler(req, res) {
|
||||
try {
|
||||
// Use SDK router to parse the request URL
|
||||
// This automatically handles domain prefixes (e.g., '/peer.directory/domains' -> 'domains')
|
||||
// Returns: { path: 'domains', query: { page: '1', limit: '10' }, method: 'GET' }
|
||||
const { path, query } = sdk.router.parseRequest(req);
|
||||
|
||||
// Handle root path (/) - return false to serve index.html from www/ directory
|
||||
// The plugin system will automatically serve static files from www/ when handler returns false
|
||||
if (path === '' || path === '/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle the /domains API endpoint
|
||||
// This endpoint returns a paginated, sortable list of all P2NS domains
|
||||
// Example: GET /domains?page=1&limit=10&sort=type
|
||||
if (path === 'domains' || path.startsWith('domains?')) {
|
||||
sdk.log.info('peer.directory', 'Serving domains list');
|
||||
|
||||
// Check if DNS service is ready before attempting to fetch domains
|
||||
// If not ready, return a 503 Service Unavailable response
|
||||
if (!sdk.utils.isDNSReady()) {
|
||||
sdk.log.error('peer.directory', 'DNS service not initialized');
|
||||
// Return empty result set with error status
|
||||
return sdk.router.json(res, { domains: [], total: 0, page: 1, limit: 10, totalPages: 0 }, 503);
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse and validate query parameters
|
||||
// page: Which page to return (1-indexed, minimum 1)
|
||||
const page = Math.max(1, parseInt(query.page) || 1);
|
||||
|
||||
// limit: How many domains per page (between 1 and 10, default 10)
|
||||
const limit = Math.max(1, Math.min(10, parseInt(query.limit) || 10));
|
||||
|
||||
// sort: How to sort the domains (validate against allowed options)
|
||||
const validSorts = ['type', 'name', 'name-desc', 'hash', 'hash-desc', 'type-desc'];
|
||||
const sortBy = validSorts.includes(query.sort) ? query.sort : 'type';
|
||||
|
||||
// Step 1: Fetch and categorize all domains from the P2NS network
|
||||
// This gets domains from DNS entries and categorizes them as internal/local/remote
|
||||
let domainList = await categorizeDomains();
|
||||
|
||||
// Step 2: Apply the requested sorting
|
||||
// This mutates domainList in place according to the sortBy parameter
|
||||
domainList = sortDomains(domainList, sortBy);
|
||||
|
||||
// Step 3: Calculate pagination metadata
|
||||
const total = domainList.length; // Total number of domains
|
||||
const totalPages = Math.ceil(total / limit); // Total number of pages
|
||||
const startIndex = (page - 1) * limit; // Starting index for this page
|
||||
|
||||
// Step 4: Extract the domains for the requested page
|
||||
// slice(startIndex, startIndex + limit) gets the domains for this page
|
||||
const paginatedDomains = domainList.slice(startIndex, startIndex + limit);
|
||||
|
||||
// Step 5: Return the paginated response as JSON
|
||||
// The response includes:
|
||||
// - domains: Array of domain objects for this page
|
||||
// - total: Total number of domains (for pagination UI)
|
||||
// - page: Current page number
|
||||
// - limit: Domains per page
|
||||
// - totalPages: Total number of pages (for pagination UI)
|
||||
return sdk.router.json(res, {
|
||||
domains: paginatedDomains,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages
|
||||
});
|
||||
} catch (err) {
|
||||
// If anything goes wrong while processing, log the error and return empty result
|
||||
sdk.log.error('peer.directory', `Failed to fetch domains: ${err.message}`);
|
||||
return sdk.router.json(res, { domains: [], total: 0, page: 1, limit: 10, totalPages: 0 }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle favicon requests - browsers automatically request /favicon.ico
|
||||
// We don't have a favicon, so return 404
|
||||
if (path === 'favicon.ico') {
|
||||
sdk.log.debug('peer.directory', 'Favicon requested, returning 404');
|
||||
return sdk.router.notFound(res, 'Favicon Not Found');
|
||||
}
|
||||
|
||||
// For all other paths, return false to allow static file serving
|
||||
// This means requests for CSS, JS, images, etc. will be served from www/ directory
|
||||
return false;
|
||||
} catch (err) {
|
||||
// Catch any unexpected errors in the handler
|
||||
// Log the error and return a 500 Internal Server Error response
|
||||
sdk.log.error('peer.directory', `Error handling request: ${err.message}`);
|
||||
return sdk.router.error(res, 'Internal Server Error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin Initialization Hook
|
||||
*
|
||||
* Called automatically when the plugin is loaded by the P2NS system.
|
||||
* This is the place to:
|
||||
* - Initialize resources
|
||||
* - Set up timers or intervals
|
||||
* - Pre-load data
|
||||
* - Check system state
|
||||
*
|
||||
* This function is called once when the plugin is first loaded.
|
||||
*/
|
||||
async function onInit() {
|
||||
sdk.log.info('peer.directory', 'Plugin initialized');
|
||||
|
||||
// Check if DNS service is ready
|
||||
// DNS service may not be ready immediately on startup, so we check and log the status
|
||||
if (sdk.utils.isDNSReady()) {
|
||||
sdk.log.info('peer.directory', 'DNS service is ready');
|
||||
} else {
|
||||
// This is not necessarily an error - DNS service may still be initializing
|
||||
sdk.log.warn('peer.directory', 'DNS service is not ready yet');
|
||||
}
|
||||
|
||||
// Register admin panel actions
|
||||
sdk.admin.registerAction('refreshDomains', async () => {
|
||||
// Force refresh by invalidating any caches
|
||||
// The plugin itself doesn't cache, but this action can be useful
|
||||
return {
|
||||
success: true,
|
||||
message: 'Domain list will be refreshed on next request',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}, {
|
||||
label: 'Refresh Domains',
|
||||
description: 'Trigger a refresh of the domain list',
|
||||
icon: '🔄'
|
||||
});
|
||||
|
||||
// Register admin panel settings
|
||||
sdk.admin.registerSetting('defaultSort', {
|
||||
type: 'select',
|
||||
label: 'Default Sort Order',
|
||||
description: 'Default sorting method for domain list',
|
||||
default: 'type',
|
||||
options: [
|
||||
{ value: 'type', label: 'By Type' },
|
||||
{ value: 'name', label: 'By Name (A-Z)' },
|
||||
{ value: 'name-desc', label: 'By Name (Z-A)' },
|
||||
{ value: 'hash', label: 'By Hash' }
|
||||
]
|
||||
});
|
||||
|
||||
sdk.admin.registerSetting('itemsPerPage', {
|
||||
type: 'number',
|
||||
label: 'Items Per Page',
|
||||
description: 'Number of domains to show per page',
|
||||
default: 10
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin Shutdown Hook
|
||||
*
|
||||
* Called automatically when the plugin is unloaded by the P2NS system.
|
||||
* This is the place to:
|
||||
* - Clean up resources
|
||||
* - Close connections
|
||||
* - Stop timers or intervals
|
||||
* - Save state if needed
|
||||
*
|
||||
* This function is called when:
|
||||
* - The plugin is being reloaded
|
||||
* - The P2NS system is shutting down
|
||||
* - The plugin is being disabled
|
||||
*/
|
||||
async function onShutdown() {
|
||||
sdk.log.info('peer.directory', 'Plugin shutting down');
|
||||
// Currently no cleanup needed, but this is where you would:
|
||||
// - Clear any caches
|
||||
// - Close database connections
|
||||
// - Stop background tasks
|
||||
}
|
||||
|
||||
// Export the plugin interface
|
||||
// The plugin system expects an object with these three optional functions:
|
||||
// - handler: Main HTTP request handler (required if you want dynamic routes)
|
||||
// - onInit: Initialization hook (optional)
|
||||
// - onShutdown: Cleanup hook (optional)
|
||||
module.exports = {
|
||||
handler, // HTTP request handler - processes all incoming requests
|
||||
onInit, // Called when plugin loads
|
||||
onShutdown // Called when plugin unloads
|
||||
};
|
||||
@@ -0,0 +1,547 @@
|
||||
:root {
|
||||
/* Modern Color Palette */
|
||||
--primary: #6366f1;
|
||||
--primary-dark: #4f46e5;
|
||||
--primary-light: #818cf8;
|
||||
--secondary: #8b5cf6;
|
||||
--accent: #ec4899;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
|
||||
/* Background Colors - Darker for glass effect */
|
||||
--bg-primary: #0a0e1a;
|
||||
--bg-secondary: #0f1419;
|
||||
--bg-tertiary: #1a1f2e;
|
||||
--bg-glass: rgba(255, 255, 255, 0.03);
|
||||
--bg-glass-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-glass-strong: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Text Colors */
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
|
||||
/* Borders & Shadows - Enhanced for glass */
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-color-strong: rgba(255, 255, 255, 0.15);
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 30px rgba(99, 102, 241, 0.4);
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.5rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #0a0e1a 0%, #0f1419 50%, #1a1f2e 100%);
|
||||
background-attachment: fixed;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: var(--spacing-lg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-lg) 0;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xs);
|
||||
text-decoration: none;
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 6px -1px rgba(99, 102, 241, 0.2), 0 2px 4px -1px rgba(99, 102, 241, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.5);
|
||||
border-color: rgba(99, 102, 241, 0.7);
|
||||
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.3), 0 4px 6px -2px rgba(99, 102, 241, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Inputs and Selects */
|
||||
.input,
|
||||
.select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: rgba(15, 20, 30, 0.6);
|
||||
backdrop-filter: blur(25px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(200%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--transition-base);
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
background: rgba(15, 20, 30, 0.8);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2), var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
min-height: 0;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(30px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 20%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* When there are few records, still allow scrolling */
|
||||
.table-container.few-rows {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
min-width: 100%;
|
||||
display: table;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: rgba(15, 20, 30, 0.7);
|
||||
backdrop-filter: blur(30px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--spacing-md);
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
transition: background var(--transition-base);
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border-color);
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) {
|
||||
background-color: var(--bg-glass);
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even):hover {
|
||||
background-color: var(--bg-glass-hover);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-internal {
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-local {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-remote {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--primary-light);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--spacing-md);
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Pagination info */
|
||||
#paginationInfo {
|
||||
display: block;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
min-width: 2.5rem;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid rgba(99, 102, 241, 0.5);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagination button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.5);
|
||||
border-color: rgba(99, 102, 241, 0.7);
|
||||
box-shadow: 0 4px 6px rgba(99, 102, 241, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination button.active {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
padding: 0 var(--spacing-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Text Utilities */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-tertiary {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Flex Utilities */
|
||||
.flex {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.p-3 {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.p-8 {
|
||||
padding: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: var(--spacing-md);
|
||||
padding-right: var(--spacing-md);
|
||||
}
|
||||
|
||||
.py-2 {
|
||||
padding-top: var(--spacing-sm);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.space-y-4 > * + * {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="448" height="512">
|
||||
<path fill="#3b82f6" d="M96 0C43 0 0 43 0 96V416c0 53 43 96 96 96H384h32c17.7 0 32-14.3 32-32s-14.3-32-32-32V384c17.7 0 32-14.3 32-32V32c0-17.7-14.3-32-32-32H384 96zm0 384H352v64H96c-17.7 0-32-14.3-32-32s14.3-32 32-32zm32-240c0-8.8 7.2-16 16-16H336c8.8 0 16 7.2 16 16s-7.2 16-16 16H144c-8.8 0-16-7.2-16-16zm16 48H336c8.8 0 16 7.2 16 16s-7.2 16-16 16H144c-8.8 0-16-7.2-16-16s7.2-16 16-16z"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 489 B |
@@ -0,0 +1,377 @@
|
||||
<!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 -->
|
||||
<div class="header">
|
||||
<div class="flex-1"></div>
|
||||
<h1>P2NS Directory</h1>
|
||||
<div class="header-actions">
|
||||
<a
|
||||
href="https://p2ns.admin"
|
||||
target="_blank"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Go To Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
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;
|
||||
|
||||
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 fetchDomains(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 fetchDomains(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 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;
|
||||
hasMore = true;
|
||||
isSearching = searchQuery.length > 0;
|
||||
cleanupInfiniteScroll();
|
||||
if (isSearching) {
|
||||
// Clear cache when search changes to force refetch
|
||||
allDomains = [];
|
||||
}
|
||||
await fetchDomains(currentPage, currentLimit, currentSort, searchQuery, false);
|
||||
});
|
||||
|
||||
document.getElementById('sortBy').addEventListener('change', async (e) => {
|
||||
currentSort = e.target.value;
|
||||
currentPage = 1;
|
||||
hasMore = true;
|
||||
cleanupInfiniteScroll();
|
||||
// Clear cache when sort changes
|
||||
allDomains = [];
|
||||
const searchQuery = document.getElementById('searchInput').value;
|
||||
await fetchDomains(currentPage, currentLimit, currentSort, searchQuery, false);
|
||||
});
|
||||
|
||||
// Initial fetch
|
||||
fetchDomains(currentPage, currentLimit, currentSort, '', false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "peer.directory",
|
||||
"short_name": "Directory",
|
||||
"description": "P2NS peer directory browser - Browse and search P2NS domains",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "productivity"],
|
||||
"lang": "en"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user