This commit is contained in:
Raven Scott
2025-12-17 20:05:50 -05:00
commit 742e27d3f7
276 changed files with 89838 additions and 0 deletions
+529
View File
@@ -0,0 +1,529 @@
# Global Profile Plugin
**Status: ✅ Production Ready**
The Global Profile plugin provides a universal user identity system for all P2NS plugins with automatic P2P profile replication. It enables users to create, manage, and share their profiles across the entire P2NS network, with real-time updates and seamless integration with other plugins.
## Overview
Global Profile is a core identity plugin that stores user profile data in a distributed HyperDB database that automatically replicates across all connected peers. Your profile is available to all P2NS plugins, enabling a unified identity experience across the network.
## Key Features
### Core Functionality
- **HyperDB-Backed Storage**: All profile data is stored in HyperDB with automatic P2P replication across the network
- **Multi-Size Avatar System**: Avatars are automatically resized and stored in multiple sizes (16px, 32px, 64px, 128px, 256px, 512px) for optimal performance
- **Real-Time Updates**: Profile changes are broadcast in real-time via WebSocket to all connected clients and peers
- **Extensible Field System**: Plugins can register custom fields that extend the standard profile schema
- **Cross-Plugin Integration**: Profile data is accessible to all P2NS plugins through the unified API
- **Web UI**: Complete web interface for managing your profile with an intuitive, modern design
### Profile Fields
The plugin supports the following standard profile fields:
- `peerId` - Unique identifier for the peer (required, automatically set)
- `displayName` - User's display name
- `bio` - Biography/about text
- `email` - Email address
- `website` - Personal or professional website URL
- `xUsername` - X (Twitter) username
- `github` - GitHub username
- `discord` - Discord username
- `location` - Physical location
- `avatarHash` - Hash of the current avatar image
- `tags` - Array of user-defined tags
- `customFields` - Object for plugin-specific custom fields
- `lastUpdated` - Timestamp of last profile update
### Profile Management Features
- **Search & Filter**: Search profiles by display name or bio, filter by tags
- **Pagination**: Efficient pagination with configurable limits (1-1000 per page)
- **Sorting**: Sort profiles by last updated (newest first) or display name (alphabetical)
- **Online Status**: Real-time online/offline status for each peer
- **Statistics**: Network-wide statistics including total profiles, online counts, and recent activity
## Architecture
### Storage
- **HyperDB**: All profile data and avatars are stored in HyperDB using base64-encoded data for avatars
- Profile collection (`@profile/profiles`): Stores all user profile data
- Avatar collection (`@profile/avatars`): Stores avatar images in multiple sizes
- Fields collection (`@profile/fields`): Stores custom field definitions registered by plugins
### Replication
- **Automatic P2P Replication**: All data automatically replicates across all connected peers using the global replication topic
- **Database Watching**: Real-time database change detection broadcasts updates immediately
- **Peer Connection Handling**: Profile updates are triggered when peers connect or disconnect
### Real-Time Communication
- **WebSocket Server**: Provides real-time updates to connected clients
- `profile-update`: Broadcast when a profile is created or updated
- `profile-deleted`: Broadcast when a profile is deleted
- `replication-status`: Periodic updates about replication status
- **P2P Channels**: Uses P2NS channels to broadcast profile updates across the network
## Web Interface
The plugin includes a complete web interface accessible at the plugin domain root (`/`). The interface provides:
- Profile editing form with all standard fields
- Avatar upload with preview
- Tag management with inline editing
- Real-time profile listing with search
- Online/offline status indicators
- Connection status monitoring
## API Reference
The plugin provides a comprehensive REST API. For interactive documentation with examples and curl commands, visit `/api/docs` when the plugin is enabled.
### Profile Management
#### Get Your Profile
```
GET /api/profile
```
Returns your local profile. Returns a default empty profile if none exists.
#### Update Your Profile (Full)
```
PUT /api/profile
Content-Type: application/json
{
"displayName": "John Doe",
"bio": "Software developer",
"website": "https://example.com",
"email": "[email protected]",
"xUsername": "@johndoe",
"github": "johndoe",
"discord": "johndoe#1234",
"location": "San Francisco, CA",
"tags": ["developer", "opensource"],
"customFields": {}
}
```
Performs a full update of your profile. All fields must be provided.
#### Partially Update Your Profile
```
PATCH /api/profile
Content-Type: application/json
{
"displayName": "John Doe",
"bio": "Updated bio"
}
```
Updates only the provided fields. Fields not included remain unchanged.
#### Get Another Peer's Profile
```
GET /api/profile/:peerId
```
Returns the profile for the specified peer ID. Returns 404 if not found.
#### Delete Your Profile
```
DELETE /api/profile
```
Deletes your profile and all associated avatars.
### Profile Listing
#### List All Profiles
```
GET /api/profiles?search=query&tag=tagName&limit=100&offset=0&sort=lastUpdated
```
**Query Parameters:**
- `search` (optional): Search term to filter by displayName or bio (case-insensitive)
- `tag` (optional): Filter profiles that have this tag
- `limit` (optional): Maximum number of results (1-1000, default: 100)
- `offset` (optional): Number of results to skip for pagination (default: 0)
- `sort` (optional): Sort field - `lastUpdated` (default, newest first) or `displayName` (alphabetical)
**Response:**
```json
{
"profiles": [
{
"peerId": "...",
"displayName": "John Doe",
"bio": "...",
"online": true,
"tags": ["developer"],
"customFields": {},
"lastUpdated": 1234567890
}
],
"pagination": {
"total": 150,
"limit": 100,
"offset": 0,
"hasMore": true
}
}
```
### Avatar Management
#### Upload Avatar
```
POST /api/profile/avatar
Content-Type: multipart/form-data
avatar: <image file>
```
Uploads an avatar image. The image is automatically resized to all supported sizes (16px, 32px, 64px, 128px, 256px, 512px) and stored in HyperDB. Requires the `sharp` library for image processing.
**Response:**
```json
{
"success": true,
"avatarHash": "md5_hash_of_image"
}
```
#### Get Avatar (Default Size)
```
GET /api/profile/avatar/:peerId
```
Redirects to the 64px avatar endpoint.
#### Get Avatar (Specific Size)
```
GET /api/profile/avatar/:peerId/:size
```
Returns the avatar image at the specified size. Valid sizes: `16`, `32`, `64`, `128`, `256`, `512`.
If no avatar exists, returns a default SVG placeholder with the first character of the peer ID.
#### Delete Avatar
```
DELETE /api/profile/avatar
```
Deletes all avatar sizes for your profile.
### Custom Fields
#### Get All Registered Fields
```
GET /api/fields
```
Returns all custom fields registered by plugins.
**Response:**
```json
{
"fields": [
{
"fieldKey": "favoriteColor",
"registeredBy": "example.plugin",
"label": "Favorite Color",
"type": "string",
"description": "User's favorite color",
"defaultValue": "blue"
}
]
}
```
#### Register a Custom Field
```
POST /api/fields
Content-Type: application/json
{
"fieldKey": "favoriteColor",
"label": "Favorite Color",
"type": "string",
"description": "User's favorite color",
"defaultValue": "blue"
}
```
**Required Fields:**
- `fieldKey`: Unique identifier for the field (string)
- `label`: Human-readable label (string)
- `type`: Field type - one of: `string`, `number`, `boolean`, `array`, `object`
**Optional Fields:**
- `description`: Field description (string)
- `defaultValue`: Default value (any JSON-serializable value)
**Note:** Custom fields are stored in the `customFields` object in the profile. Plugins should register their fields on initialization to ensure proper validation and documentation.
### Statistics
#### Get Profile Statistics
```
GET /api/stats
```
**Response:**
```json
{
"total": 150,
"online": 45,
"offline": 105,
"recentActivity": [
{
"peerId": "...",
"displayName": "John Doe",
"hoursAgo": 2.5
}
],
"timestamp": 1234567890
}
```
Returns:
- Total number of profiles in the network
- Count of online vs offline peers
- Recent activity (profiles updated in the last 24 hours, limited to top 10)
### Documentation
#### Interactive API Documentation
```
GET /api/docs
```
Returns an interactive HTML page with complete API documentation, including examples and curl commands for each endpoint.
## WebSocket API
The plugin provides WebSocket endpoints for real-time updates. Connect to the WebSocket server to receive:
### Message Types
#### `profile-update`
Broadcast when a profile is created or updated.
```json
{
"type": "profile-update",
"profile": {
"peerId": "...",
"displayName": "John Doe",
...
},
"timestamp": 1234567890
}
```
#### `profile-deleted`
Broadcast when a profile is deleted.
```json
{
"type": "profile-deleted",
"peerId": "...",
"timestamp": 1234567890
}
```
#### `replication-status`
Periodic updates about replication status (every 2 seconds when status changes).
```json
{
"type": "replication-status",
"data": {
"hyperdb": {
"active": true,
"peers": 5
}
},
"timestamp": 1234567890
}
```
### Client Messages
#### `request-profiles`
Request all profiles (for backward compatibility; use REST API `/api/profiles` instead).
```json
{
"type": "request-profiles"
}
```
#### `request-replication-status`
Request current replication status.
```json
{
"type": "request-replication-status"
}
```
## Configuration
The plugin is enabled by default. Configuration is managed through `config.json`:
```json
{
"name": "Global Profile",
"version": "1.1.0",
"domain": "global.profile",
"enabled": true,
"description": "Universal user identity system for all P2NS plugins with P2P profile replication"
}
```
### Enabling/Disabling
The plugin can be enabled or disabled through:
1. **Admin Panel**: Navigate to the Plugins tab and toggle the Global Profile plugin status
2. **Manual Configuration**: Edit `plugin-sites/global.profile/config.json` and change `"enabled"` to `true` or `false`, then restart P2NS
## Dependencies
### Required
- P2NS core with HyperDB support
- Sharp library for avatar image processing: `npm install sharp`
**Note:** If Sharp is not installed, avatar upload will fail with an error message. The plugin will function normally for all other features.
## Database Schema
The plugin uses HyperDB with the following schema:
### Profile Schema
- `peerId` (string, required): Unique peer identifier (primary key)
- `displayName` (string): Display name
- `bio` (string): Biography
- `website` (string): Website URL
- `email` (string): Email address
- `xUsername` (string): X/Twitter username
- `github` (string): GitHub username
- `discord` (string): Discord username
- `location` (string): Location
- `avatarHash` (string): Avatar hash
- `customFields` (string): JSON-encoded object for custom fields
- `tags` (string): JSON-encoded array of tags
- `lastUpdated` (uint): Timestamp of last update
### Avatar Schema
- `peerId` (string, required): Peer identifier (part of composite key)
- `size` (string, required): Size identifier - one of: `"16"`, `"32"`, `"64"`, `"128"`, `"256"`, `"512"`, `"original"` (part of composite key)
- `data` (string, required): Base64-encoded image data
### Field Definition Schema
- `fieldKey` (string, required): Unique field key (primary key)
- `registeredBy` (string, required): Plugin domain that registered the field
- `label` (string): Human-readable label
- `type` (string): Field type
- `description` (string): Field description
- `defaultValue` (string): JSON-encoded default value
### Indexes
- `profiles-by-displayName`: Case-insensitive search on displayName
## Plugin Integration
Other P2NS plugins can integrate with Global Profile in several ways:
### Reading Profile Data
Plugins can fetch profile data using the REST API:
```javascript
// Get a specific peer's profile
const profile = await fetch(`https://global.profile/api/profile/${peerId}`);
const data = await profile.json();
// Access standard fields
console.log(data.displayName, data.bio, data.avatarHash);
// Access custom fields
console.log(data.customFields.myCustomField);
```
### Registering Custom Fields
Plugins can extend the profile schema by registering custom fields:
```javascript
// Register a custom field on plugin initialization
await fetch('https://global.profile/api/fields', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fieldKey: 'myCustomField',
label: 'My Custom Field',
type: 'string',
description: 'Description of my custom field',
defaultValue: 'default'
})
});
```
### Using Avatars
Avatars can be accessed via URL:
```html
<!-- Default size (64px) -->
<img src="https://global.profile/api/profile/avatar/PEER_ID">
<!-- Specific size -->
<img src="https://global.profile/api/profile/avatar/PEER_ID/128">
```
### WebSocket Integration
Plugins can connect to the WebSocket server to receive real-time profile updates:
```javascript
const ws = new WebSocket('wss://global.profile/ws');
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'profile-update') {
// Handle profile update
console.log('Profile updated:', message.profile);
}
};
```
## Performance Considerations
- **Avatar Sizes**: Avatars are pre-generated in multiple sizes to avoid on-the-fly resizing
- **Database Indexes**: Indexes on displayName and tags enable efficient searching
- **Pagination**: Profile listings use pagination to handle large networks efficiently
- **Caching**: Avatar endpoints include cache headers for optimal performance
- **Base64 Storage**: Avatars are stored as base64 in HyperDB for seamless replication
## Troubleshooting
### Avatar Upload Fails
- Ensure the `sharp` library is installed: `npm install sharp`
- Check that the uploaded file is a valid image format
- Verify file size is reasonable (large files may take time to process)
### Profile Updates Not Replicating
- Verify that database replication is active (check `/api/stats` or WebSocket replication-status)
- Ensure peers are connected and replication is enabled in P2NS
- Check logs for replication errors
### Database Not Ready Errors
- The plugin waits up to 30 seconds for database initialization on startup
- If errors persist, restart P2NS to re-initialize the database
- Check that HyperDB is properly configured in P2NS
## License
MIT
+168
View File
@@ -0,0 +1,168 @@
{
"name": "Global Profile",
"version": "1.2.0",
"domain": "global.profile",
"enabled": true,
"description": "Universal user identity system for all P2NS plugins with P2P profile replication",
"author": "P2NS",
"homepage": "https://github.com/p2ns/p2ns",
"license": "MIT",
"icon": "user",
"dependencies": {},
"www": "www",
"hyperdb": {
"schemas": {
"namespace": "profile",
"structs": [
{
"name": "profile",
"compact": true,
"fields": [
{
"name": "peerId",
"type": "string",
"required": true
},
{
"name": "displayName",
"type": "string"
},
{
"name": "bio",
"type": "string"
},
{
"name": "website",
"type": "string"
},
{
"name": "email",
"type": "string"
},
{
"name": "xUsername",
"type": "string"
},
{
"name": "github",
"type": "string"
},
{
"name": "discord",
"type": "string"
},
{
"name": "location",
"type": "string"
},
{
"name": "avatarHash",
"type": "string"
},
{
"name": "customFields",
"type": "string"
},
{
"name": "tags",
"type": "string"
},
{
"name": "lastUpdated",
"type": "uint"
}
]
},
{
"name": "fieldDefinition",
"compact": true,
"fields": [
{
"name": "fieldKey",
"type": "string",
"required": true
},
{
"name": "registeredBy",
"type": "string",
"required": true
},
{
"name": "label",
"type": "string"
},
{
"name": "type",
"type": "string"
},
{
"name": "description",
"type": "string"
},
{
"name": "defaultValue",
"type": "string"
}
]
},
{
"name": "avatar",
"compact": true,
"fields": [
{
"name": "peerId",
"type": "string",
"required": true
},
{
"name": "size",
"type": "string",
"required": true
},
{
"name": "data",
"type": "string",
"required": true
}
]
}
]
},
"collections": [
{
"name": "profiles",
"schema": "@profile/profile",
"key": [
"peerId"
]
},
{
"name": "fields",
"schema": "@profile/fieldDefinition",
"key": [
"fieldKey"
]
},
{
"name": "avatars",
"schema": "@profile/avatar",
"key": [
"peerId",
"size"
]
}
],
"indexes": [
{
"name": "profiles-by-displayName",
"collection": "@profile/profile",
"unique": false,
"key": {
"type": "string",
"map": "mapDisplayNameToLower"
}
}
],
"helpers": "./helpers.js"
}
}
+285
View File
@@ -0,0 +1,285 @@
/**
* Global Profile Plugin - Database Operations
*
* Contains all database-related operations for profiles and avatars
*/
const sdk = require('../../includes/plugins/sdk');
const crypto = require('crypto');
// Avatar sizes to generate
const AVATAR_SIZES = [16, 32, 64, 128, 256, 512];
// Check if sharp is available for image processing
let sharp = null;
try {
sharp = require('sharp');
} catch (err) {
sdk.log.warn('global.profile', 'sharp library not found. Avatar resizing will be disabled. Install with: npm install sharp');
}
/**
* Get profile from database
*/
async function getProfileFromDB(peerId) {
try {
// Add timeout to prevent hanging
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Database ready timeout')), 5000);
});
await Promise.race([
sdk.db.ready(),
timeoutPromise
]);
const profile = await sdk.db.get('@profile/profiles', { peerId });
return profile;
} catch (err) {
// Silently return null if database not initialized or timeout - this is expected during startup
if (err.message && (err.message.includes('Database not initialized') || err.message.includes('timeout'))) {
sdk.log.debug('global.profile', `Database not ready for ${peerId ? peerId.slice(0, 16) : 'unknown'}: ${err.message}`);
return null;
}
sdk.log.error('global.profile', `Error getting profile for ${peerId ? peerId.slice(0, 16) : 'unknown'}: ${err.message}`);
return null;
}
}
/**
* Save profile to database
*/
async function saveProfileToDB(profile) {
const MAX_RETRIES = 10;
const RETRY_DELAY = 1000;
const startTime = Date.now();
sdk.log.info('global.profile', `[saveProfileToDB] Starting save for peerId: ${profile.peerId?.slice(0, 16)}...`);
sdk.log.debug('global.profile', `[saveProfileToDB] Tags: ${profile.tags}`);
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
// Wait for database to be ready (this will try to re-initialize if needed)
sdk.log.debug('global.profile', `[saveProfileToDB] Attempt ${attempt}: Calling sdk.db.ready()...`);
const readyStart = Date.now();
await sdk.db.ready();
sdk.log.debug('global.profile', `[saveProfileToDB] sdk.db.ready() completed in ${Date.now() - readyStart}ms`);
profile.lastUpdated = Date.now();
sdk.log.debug('global.profile', `[saveProfileToDB] Calling sdk.db.insert()...`);
const insertStart = Date.now();
await sdk.db.insert('@profile/profiles', profile);
sdk.log.debug('global.profile', `[saveProfileToDB] sdk.db.insert() completed in ${Date.now() - insertStart}ms`);
sdk.log.debug('global.profile', `[saveProfileToDB] Calling sdk.db.flush()...`);
const flushStart = Date.now();
await sdk.db.flush();
sdk.log.debug('global.profile', `[saveProfileToDB] sdk.db.flush() completed in ${Date.now() - flushStart}ms`);
// Success!
sdk.log.info('global.profile', `[saveProfileToDB] Save completed successfully in ${Date.now() - startTime}ms`);
return;
} catch (err) {
sdk.log.error('global.profile', `[saveProfileToDB] Attempt ${attempt} failed after ${Date.now() - startTime}ms: ${err.message}`);
// Check if database is not initialized
if (err.message && (err.message.includes('Database not initialized') || err.message.includes('not available'))) {
if (attempt < MAX_RETRIES) {
sdk.log.warn('global.profile', `[saveProfileToDB] Database not initialized, retrying in ${RETRY_DELAY}ms... (${attempt}/${MAX_RETRIES})`);
// Try to re-initialize database if plugin context is available
if (attempt === 1 || attempt % 3 === 0) {
try {
const pluginHandler = require('../../includes/plugins/plugin-handler');
const plugin = pluginHandler.getPlugin('global.profile');
if (plugin && plugin.dbConfig && !plugin.db) {
sdk.log.info('global.profile', '[saveProfileToDB] Attempting to re-initialize database...');
// The database should be initialized by the plugin handler
// We just need to wait for it to be available
}
} catch (reinitErr) {
sdk.log.debug('global.profile', `[saveProfileToDB] Could not check database re-initialization: ${reinitErr.message}`);
}
}
await sdk.utils.sleep(RETRY_DELAY);
continue;
} else {
sdk.log.error('global.profile', `[saveProfileToDB] Database not initialized after ${MAX_RETRIES} retries. Profile save failed.`);
// Try to get more diagnostic info and potentially fix the issue
try {
const pluginHandler = require('../../includes/plugins/plugin-handler');
const plugin = pluginHandler.getPlugin('global.profile');
const dbManager = require('../../includes/plugins/db-manager');
sdk.log.error('global.profile', `[saveProfileToDB] Plugin exists: ${!!plugin}, Plugin has db: ${!!plugin?.db}, DB closed: ${plugin?.db?.closed}`);
sdk.log.error('global.profile', `[saveProfileToDB] Plugin has dbConfig: ${!!plugin?.dbConfig}`);
// Check if database is in the instances map
const dbInstance = dbManager.getDatabaseInstance('global.profile');
sdk.log.error('global.profile', `[saveProfileToDB] Database in instances map: ${!!dbInstance}, closed: ${dbInstance?.closed}`);
// If plugin has db but it's not in the map, this is the root cause
// The database exists but isn't registered in the instances map
if (plugin?.db && !dbInstance && !plugin.db.closed) {
sdk.log.error('global.profile', '[saveProfileToDB] Database exists in plugin object but not registered in databaseInstances map!');
sdk.log.error('global.profile', '[saveProfileToDB] This indicates a bug in the database initialization. The database should be registered during plugin init.');
// Try to manually register it (this is a workaround)
try {
const dbManager = require('../../includes/plugins/db-manager');
// We can't directly access databaseInstances, but we can try to re-initialize
// For now, just log the issue - the database should be re-initialized on next request
} catch (registerErr) {
sdk.log.error('global.profile', `[saveProfileToDB] Could not register database: ${registerErr.message}`);
}
}
} catch (diagErr) {
sdk.log.error('global.profile', `[saveProfileToDB] Could not get diagnostic info: ${diagErr.message}`);
}
throw new Error('Database not available. Please try again in a moment.');
}
} else {
// Other error, don't retry
sdk.log.error('global.profile', `[saveProfileToDB] Error saving profile: ${err.message}`);
sdk.log.error('global.profile', `[saveProfileToDB] Stack: ${err.stack}`);
throw err;
}
}
}
}
/**
* Get all profiles from database
*/
async function getAllProfilesFromDB() {
try {
// Add timeout to prevent hanging
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Database ready timeout')), 5000);
});
await Promise.race([
sdk.db.ready(),
timeoutPromise
]);
const profiles = await sdk.db.find('@profile/profiles', {});
sdk.log.debug('global.profile', `Found ${profiles?.length || 0} profiles in database`);
return profiles || [];
} catch (err) {
// Silently return empty array if database not initialized or timeout - this is expected during startup
if (err.message && (err.message.includes('Database not initialized') || err.message.includes('timeout') || err.message.includes('not available'))) {
sdk.log.debug('global.profile', `Database not ready when getting all profiles: ${err.message}`);
return [];
}
sdk.log.error('global.profile', `Error getting all profiles: ${err.message}`);
return [];
}
}
/**
* Process and store avatar in multiple sizes in HyperDB
*/
async function processAndStoreAvatar(peerId, imageBuffer, originalFilename) {
if (!sharp) {
throw new Error('Avatar processing requires sharp library. Install with: npm install sharp');
}
// Generate and store resized versions in HyperDB using avatar collection
const avatarHash = crypto.createHash('md5').update(imageBuffer).digest('hex');
// Store original as base64 in avatar collection
const originalBase64 = imageBuffer.toString('base64');
await sdk.db.insert('@profile/avatars', {
peerId: peerId,
size: 'original',
data: originalBase64
});
// Generate and store resized versions
for (const size of AVATAR_SIZES) {
try {
const resized = await sharp(imageBuffer)
.resize(size, size, { fit: 'cover', position: 'center' })
.png()
.toBuffer();
// Store as base64 in avatar collection
const resizedBase64 = resized.toString('base64');
await sdk.db.insert('@profile/avatars', {
peerId: peerId,
size: size.toString(),
data: resizedBase64
});
} catch (err) {
sdk.log.error('global.profile', `Error resizing avatar to ${size}px: ${err.message}`);
}
}
// Flush to ensure data is persisted
await sdk.db.flush();
return avatarHash;
}
/**
* Get avatar from HyperDB
*/
async function getAvatarFromDB(peerId, size = 64) {
try {
// Add timeout to prevent hanging
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Database ready timeout')), 5000);
});
await Promise.race([
sdk.db.ready(),
timeoutPromise
]);
// Try to get the size-specific avatar from HyperDB avatar collection
const avatarRecord = await sdk.db.get('@profile/avatars', { peerId: peerId, size: size.toString() });
if (avatarRecord && avatarRecord.data) {
sdk.log.debug('global.profile', `Found avatar for ${peerId.slice(0, 16)} at size ${size}`);
return Buffer.from(avatarRecord.data, 'base64');
}
// If size-specific avatar not found, try to get original and resize on-the-fly if sharp is available
const originalRecord = await sdk.db.get('@profile/avatars', { peerId: peerId, size: 'original' });
if (originalRecord && originalRecord.data && sharp) {
sdk.log.debug('global.profile', `Resizing original avatar for ${peerId.slice(0, 16)} to ${size}px`);
const original = Buffer.from(originalRecord.data, 'base64');
const resized = await sharp(original)
.resize(size, size, { fit: 'cover', position: 'center' })
.png()
.toBuffer();
return resized;
}
// No avatar found
return null;
} catch (err) {
// Silently return null if database not initialized or timeout - this is expected during startup
if (err.message && (err.message.includes('Database not initialized') || err.message.includes('timeout') || err.message.includes('not available'))) {
sdk.log.debug('global.profile', `Database not ready for avatar ${peerId ? peerId.slice(0, 16) : 'unknown'}: ${err.message}`);
return null;
}
sdk.log.error('global.profile', `Error getting avatar for ${peerId ? peerId.slice(0, 16) : 'unknown'}: ${err.message}`);
return null;
}
}
module.exports = {
AVATAR_SIZES,
getProfileFromDB,
saveProfileToDB,
getAllProfilesFromDB,
processAndStoreAvatar,
getAvatarFromDB
};
+44
View File
@@ -0,0 +1,44 @@
/**
* Helper functions for Global Profile HyperDB indexes
*/
/**
* Map display name to lowercase for case-insensitive search
* @param {Object} record - Profile record
* @param {Object} context - Context object
* @returns {Array<string>} Array of lowercase display names
*/
exports.mapDisplayNameToLower = (record, context) => {
if (!record || !record.displayName) return [];
const name = record.displayName.toLowerCase().trim();
return name ? [name] : [];
};
/**
* Map tags string to array of individual tags
* @param {Object} record - Profile record
* @param {Object} context - Context object
* @returns {Array<string>} Array of tag strings
*/
exports.mapTagsToArray = (record, context) => {
console.log('[mapTagsToArray] Called with record.tags:', record?.tags);
if (!record || !record.tags) {
console.log('[mapTagsToArray] No tags, returning []');
return [];
}
try {
const tags = typeof record.tags === 'string' ? JSON.parse(record.tags) : record.tags;
console.log('[mapTagsToArray] Parsed tags:', tags);
if (Array.isArray(tags)) {
const result = tags.map(tag => tag.toLowerCase().trim()).filter(tag => tag.length > 0);
console.log('[mapTagsToArray] Returning:', result);
return result;
}
console.log('[mapTagsToArray] Not an array, returning []');
return [];
} catch (err) {
console.log('[mapTagsToArray] Error:', err.message);
return [];
}
};
+257
View File
@@ -0,0 +1,257 @@
/**
* Global Profile Plugin
*
* Provides universal user identity system across all P2NS plugins with:
* - HyperDB-backed profile storage with P2P replication
* - Multi-size avatar storage via HyperDB (base64 encoded)
* - Extensible field system for plugin-registered custom fields
* - Real-time profile updates via WebSocket
*/
const sdk = require('../../includes/plugins/sdk');
// Import modules
const { getProfileFromDB, saveProfileToDB } = require('./database');
const { broadcastProfileUpdate, setupWebSocketHandlers, getReplicationStatus } = require('./websocket');
const { setupDatabaseWatcher } = require('./watcher');
const { handleProfileRoutes } = require('./routes/profile');
const { handleAvatarRoutes } = require('./routes/avatar');
const { handleProfilesRoutes } = require('./routes/profiles');
const { handleFieldsRoutes } = require('./routes/fields');
const { handleStatsRoutes } = require('./routes/stats');
const { handleDocsRoutes } = require('./routes/docs');
/**
* Plugin Handler
*/
async function handler(req, res) {
try {
// Handle CORS (including OPTIONS preflight)
if (sdk.router.handleCORS(req, res, { origin: '*' })) {
return true; // OPTIONS request handled
}
const { path, query, method } = sdk.router.parseRequest(req);
// Handle root path - serve index.html
if (path === '' || path === '/') {
return false;
}
// Try each route module in order
// Avatar routes must come BEFORE profile routes to avoid conflicts with /api/profile/:peerId
const routeModules = [
{ name: 'avatar', handler: handleAvatarRoutes },
{ name: 'profile', handler: handleProfileRoutes },
{ name: 'profiles', handler: handleProfilesRoutes },
{ name: 'fields', handler: handleFieldsRoutes },
{ name: 'stats', handler: handleStatsRoutes },
{ name: 'docs', handler: handleDocsRoutes }
];
for (const routeModule of routeModules) {
const result = await routeModule.handler(req, res, path, query, method);
if (result !== false) {
return result;
}
}
// Return false for all other routes to allow static file serving
return false;
} catch (err) {
sdk.log.error('global.profile', `Error handling request: ${err.message}`);
return sdk.router.error(res, 'Internal Server Error', 500);
}
}
/**
* Plugin Initialization Hook
*/
async function onInit() {
// Store the plugin domain globally for easy access
global.PLUGIN_DOMAIN_GLOBAL = process.env.PLUGIN_DOMAIN || 'global.profile';
sdk.log.info('global.profile', 'Initializing Global Profile plugin...');
try {
// Wait for database to be available with retries
let dbReady = false;
for (let i = 0; i < 30; i++) {
try {
await sdk.db.ready();
if (!sdk.db.closed) {
dbReady = true;
sdk.log.info('global.profile', 'Database ready');
break;
}
} catch (err) {
if (err.message && err.message.includes('Database not initialized')) {
sdk.log.debug('global.profile', `Database not ready yet, attempt ${i + 1}/30`);
} else {
sdk.log.warn('global.profile', `Database error on attempt ${i + 1}/30: ${err.message}`);
}
}
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
}
if (!dbReady) {
sdk.log.warn('global.profile', 'Database not available after 30 seconds - plugin will continue but database operations may fail');
}
// Avatars are now stored in HyperDB, so no separate drive initialization needed
sdk.log.info('global.profile', 'Avatars will be stored in HyperDB (automatic replication)');
// Create P2P channel for broadcasting profile updates to all peers
try {
sdk.channels.createChannel('profile-updates', {
encoding: 'json',
onMessage: (data, peerId) => {
// Forward profile updates from peers to local WebSocket clients
sdk.log.debug('global.profile', `Received profile update from peer ${peerId.substring(0, 16)}... via channel`);
// Forward to local WebSocket clients
sdk.websocket.broadcast({
...data,
timestamp: data.timestamp || Date.now()
});
},
onOpen: (peerId) => {
sdk.log.debug('global.profile', `Peer ${peerId.substring(0, 16)}... connected to profile-updates channel`);
},
onClose: (peerId) => {
sdk.log.debug('global.profile', `Peer ${peerId.substring(0, 16)}... disconnected from profile-updates channel`);
}
});
sdk.log.info('global.profile', 'P2P channel for profile updates created');
} catch (err) {
sdk.log.error('global.profile', `Error creating profile-updates channel: ${err.message}`);
}
// Setup WebSocket handlers
setupWebSocketHandlers();
// Setup database watcher (async, but don't wait - it will handle errors internally)
setupDatabaseWatcher().catch(err => {
sdk.log.error('global.profile', `Error in database watcher setup: ${err.message}`);
});
// Watch for peer connections/disconnections to update online status
sdk.events.on('peer-connected', async (data) => {
const { getProfileFromDB } = require('./database');
const profile = await getProfileFromDB(data.peerId);
if (profile) {
// Parse JSON fields for broadcast
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
broadcastProfileUpdate({
type: 'profile-update',
profile: profile
});
}
// Trigger active replication update when peer connects (backup mechanism)
// The replication manager will also handle this, but this ensures updates happen
try {
// Give replication a moment to establish
await new Promise(resolve => setTimeout(resolve, 1000));
// Check if database replication is active and trigger update
const dbReplication = sdk.db.replication;
if (dbReplication && dbReplication.isActive()) {
const dbManager = require('../../includes/plugins/db-manager');
const core = dbManager.getPluginCore('global.profile');
if (core && !core.closed) {
const oldLength = core.length;
await core.update({ wait: true });
const newLength = core.length;
if (newLength > oldLength) {
sdk.log.info('global.profile', `Database updated after peer ${data.peerId.slice(0, 16)} connection, length: ${oldLength} -> ${newLength}`);
}
}
}
// Avatars are now in HyperDB, so they replicate automatically with the database
// No separate drive update needed
} catch (err) {
sdk.log.debug('global.profile', `Error triggering replication update after peer connection: ${err.message}`);
}
});
sdk.events.on('peer-disconnected', async (data) => {
const { getProfileFromDB } = require('./database');
const profile = await getProfileFromDB(data.peerId);
if (profile) {
// Parse JSON fields for broadcast
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
broadcastProfileUpdate({
type: 'profile-update',
profile: profile
});
}
});
// Verify replication is enabled
try {
const dbReplication = sdk.db.replication;
if (dbReplication) {
const dbStatus = dbReplication.getStatus();
if (dbStatus && dbStatus.active) {
sdk.log.info('global.profile', `Database replication active (${dbStatus.peers} peers)`);
} else {
sdk.log.info('global.profile', 'Database replication will be enabled automatically');
}
}
} catch (err) {
sdk.log.debug('global.profile', `Could not check database replication status: ${err.message}`);
}
// Replication is now handled automatically via global Corestore and replication managers
// Active updates are triggered both by replication managers and peer connection events
sdk.log.info('global.profile', 'Plugin initialized successfully');
} catch (err) {
sdk.log.error('global.profile', `Error during initialization: ${err.message}`);
if (err.stack) {
sdk.log.error('global.profile', `Stack trace: ${err.stack}`);
}
}
}
/**
* Plugin Shutdown Hook
*/
async function onShutdown() {
sdk.log.info('global.profile', 'Shutting down Global Profile plugin...');
try {
// Close WebSocket connections
sdk.websocket.close();
sdk.log.info('global.profile', 'Plugin shutdown complete');
} catch (err) {
sdk.log.error('global.profile', `Error during shutdown: ${err.message}`);
}
}
// Export the plugin interface
module.exports = {
handler,
onInit,
onShutdown
};
@@ -0,0 +1,250 @@
/**
* Global Profile Plugin - Avatar Routes
*
* Handles avatar-related API endpoints
*/
const sdk = require('../../../includes/plugins/sdk');
const { getLocalPeerId, parseMultipartFormData } = require('../utils');
const { processAndStoreAvatar, getAvatarFromDB, AVATAR_SIZES } = require('../database');
const { broadcastProfileUpdate } = require('../websocket');
/**
* Handle avatar-related routes
*/
async function handleAvatarRoutes(req, res, path, query, method) {
const localPeerId = getLocalPeerId();
// GET /api/profile/avatar/:peerId/:size - Get avatar image
// MUST come BEFORE the default avatar route to avoid redirect loops
const avatarMatch = path.match(/^api\/profile\/avatar\/(.+?)\/(\d+)$/);
if (avatarMatch && method === 'GET') {
try {
const peerId = decodeURIComponent(avatarMatch[1]);
const size = parseInt(avatarMatch[2], 10);
if (!AVATAR_SIZES.includes(size)) {
return sdk.router.error(res, 'Invalid avatar size', 400, req);
}
sdk.log.debug('global.profile', `Requesting avatar for peer ${peerId.slice(0, 16)} at size ${size}`);
const avatarData = await getAvatarFromDB(peerId, size);
if (!avatarData) {
sdk.log.debug('global.profile', `No avatar found for peer ${peerId.slice(0, 16)}, returning default SVG`);
// Return a default SVG avatar instead of 404
// Use first character of peerId or a default icon
const char = peerId && peerId.length > 0 ? peerId.charAt(0).toUpperCase() : '?';
const fontSize = Math.floor(size * 0.4);
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" fill="#4b5563"/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" fill="#d1d5db" font-size="${fontSize}" font-family="Arial, sans-serif" font-weight="bold">${char}</text>
</svg>`;
const headers = {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=3600'
};
sdk.router.setCORS(res, { origin: '*' });
res.writeHead(200, headers);
res.end(svg);
return true;
}
sdk.log.debug('global.profile', `Serving avatar for peer ${peerId.slice(0, 16)} at size ${size} (${avatarData.length} bytes)`);
const headers = {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600'
};
sdk.router.setCORS(res, { origin: '*' });
res.writeHead(200, headers);
res.end(avatarData);
return true;
} catch (err) {
sdk.log.error('global.profile', `Error serving avatar: ${err.message}`);
// Return SVG placeholder on error
const size = parseInt(avatarMatch[2], 10) || 128;
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" fill="#4b5563"/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" fill="#d1d5db" font-size="${Math.floor(size * 0.4)}" font-family="Arial, sans-serif" font-weight="bold">?</text>
</svg>`;
const headers = {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=3600'
};
sdk.router.setCORS(res, { origin: '*' });
res.writeHead(200, headers);
res.end(svg);
return true;
}
}
// GET /api/profile/avatar/:peerId - Get default avatar (64px)
// MUST come AFTER /api/profile/avatar/:peerId/:size to avoid redirect loops
// Only match paths that don't already have a size parameter
const avatarDefaultMatch = path.match(/^api\/profile\/avatar\/(.+)$/);
if (avatarDefaultMatch && method === 'GET') {
// Check if this path already has a size parameter - if so, skip (should have been handled above)
const hasSizeParam = path.match(/^api\/profile\/avatar\/(.+?)\/(\d+)$/);
if (hasSizeParam) {
// This should have been handled by the size-specific route above
return false;
}
try {
const peerId = decodeURIComponent(avatarDefaultMatch[1]);
// Redirect to default size (64px)
sdk.router.setCORS(res, { origin: '*' });
res.writeHead(302, {
'Location': `/api/profile/avatar/${encodeURIComponent(peerId)}/64`
});
res.end();
return true;
} catch (err) {
sdk.log.error('global.profile', `Error redirecting avatar: ${err.message}`);
return sdk.router.error(res, 'Invalid peer ID', 400, req);
}
}
// POST /api/profile/avatar - Upload new avatar
if (path === 'api/profile/avatar' && method === 'POST') {
// Require authentication for write operations
const peerId = await sdk.auth.requireLocalPeer(req, res);
if (!peerId) return true; // 401 already sent
if (!localPeerId) {
return sdk.router.json(res, { error: 'Local peer ID not available' }, 500, req);
}
try {
sdk.log.info('global.profile', 'Parsing multipart form data for avatar upload');
sdk.log.info('global.profile', `Content-Type: ${req.headers['content-type']}`);
sdk.log.info('global.profile', `Content-Length: ${req.headers['content-length']}`);
const { fields, files } = await parseMultipartFormData(req);
sdk.log.info('global.profile', `Parsed fields: ${Object.keys(fields).join(', ')}`);
sdk.log.info('global.profile', `Parsed files: ${Object.keys(files).join(', ')}`);
const avatarFile = files.avatar;
if (!avatarFile) {
sdk.log.error('global.profile', 'No avatar file in parsed files');
sdk.log.error('global.profile', `Available files: ${Object.keys(files).join(', ')}`);
return sdk.router.error(res, 'No avatar file provided', 400, req);
}
if (!avatarFile.data || avatarFile.data.length === 0) {
sdk.log.error('global.profile', 'Avatar file has no data');
return sdk.router.error(res, 'Avatar file is empty', 400, req);
}
sdk.log.info('global.profile', `Avatar file received: ${avatarFile.filename}, size: ${avatarFile.data.length}, type: ${avatarFile.contentType}`);
// Process and store avatar
const avatarHash = await processAndStoreAvatar(localPeerId, avatarFile.data, avatarFile.filename);
// Update profile with avatar hash
const { getProfileFromDB, saveProfileToDB } = require('../database');
let profile = await getProfileFromDB(localPeerId);
if (!profile) {
profile = {
peerId: localPeerId,
displayName: '',
bio: '',
website: '',
email: '',
location: '',
xUsername: '',
github: '',
discord: '',
avatarHash: '',
customFields: JSON.stringify({}),
tags: JSON.stringify([]),
lastUpdated: Date.now()
};
}
profile.avatarHash = avatarHash;
await saveProfileToDB(profile);
// Parse JSON fields for broadcast
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
// Broadcast update
broadcastProfileUpdate({
type: 'profile-update',
profile: profile
});
return sdk.router.json(res, { success: true, avatarHash }, 200, req);
} catch (err) {
sdk.log.error('global.profile', `Error uploading avatar: ${err.message}`);
return sdk.router.error(res, err.message, 500, req);
}
}
// DELETE /api/profile/avatar - Delete local user's avatar
if (path === 'api/profile/avatar' && method === 'DELETE') {
// Require authentication for write operations
const peerId = await sdk.auth.requireLocalPeer(req, res);
if (!peerId) return true; // 401 already sent
if (!localPeerId) {
return sdk.router.json(res, { error: 'Local peer ID not available' }, 500, req);
}
try {
// Delete all avatar records for this peer
await sdk.db.ready();
const avatarRecords = await sdk.db.find('@profile/avatars', { peerId: localPeerId });
for (const record of avatarRecords) {
await sdk.db.delete('@profile/avatars', { peerId: localPeerId, size: record.size });
}
// Clear avatarHash from profile
const { getProfileFromDB, saveProfileToDB } = require('../database');
let profile = await getProfileFromDB(localPeerId);
if (profile) {
profile.avatarHash = '';
await saveProfileToDB(profile);
// Parse JSON fields for broadcast
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
// Broadcast update
broadcastProfileUpdate({
type: 'profile-update',
profile: profile
});
}
await sdk.db.flush();
return sdk.router.json(res, { success: true, message: 'Avatar deleted successfully' }, 200, req);
} catch (err) {
sdk.log.error('global.profile', `Error deleting avatar: ${err.message}`);
return sdk.router.error(res, err.message, 500, req);
}
}
return false; // Not handled by this module
}
module.exports = {
handleAvatarRoutes
};
+654
View File
@@ -0,0 +1,654 @@
/**
* Global Profile Plugin - Docs Routes
*
* Handles API documentation endpoints
*/
const sdk = require('../../../includes/plugins/sdk');
/**
* Handle docs-related routes
*/
async function handleDocsRoutes(req, res, path, query, method) {
// GET /api/docs - Interactive API documentation
if (path === 'api/docs' && method === 'GET') {
const baseUrl = `https://${process.env.PLUGIN_DOMAIN || 'global.profile'}`;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Global Profile API Documentation</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 40px;
}
h1 {
color: #2563eb;
margin-bottom: 10px;
border-bottom: 3px solid #2563eb;
padding-bottom: 10px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
}
.endpoint {
margin-bottom: 40px;
padding: 20px;
background: #f9fafb;
border-left: 4px solid #2563eb;
border-radius: 4px;
}
.endpoint h2 {
color: #1e40af;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.method {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-weight: bold;
font-size: 0.85em;
text-transform: uppercase;
}
.method.get { background: #10b981; color: white; }
.method.post { background: #3b82f6; color: white; }
.method.put { background: #f59e0b; color: white; }
.method.patch { background: #8b5cf6; color: white; }
.method.delete { background: #ef4444; color: white; }
.path {
font-family: 'Monaco', 'Courier New', monospace;
color: #1e40af;
font-size: 1.1em;
}
.description {
margin: 15px 0;
color: #555;
}
.section {
margin: 20px 0;
}
.section h3 {
color: #374151;
margin-bottom: 10px;
font-size: 1.1em;
}
.params {
background: white;
padding: 15px;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.param {
margin: 10px 0;
padding: 10px;
background: #f9fafb;
border-radius: 4px;
}
.param-name {
font-weight: bold;
font-family: 'Monaco', 'Courier New', monospace;
color: #1e40af;
}
.param-type {
color: #059669;
font-size: 0.9em;
margin-left: 8px;
}
.param-desc {
color: #666;
margin-top: 5px;
font-size: 0.95em;
}
.example {
background: #1e293b;
color: #e2e8f0;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
margin: 15px 0;
}
.example-title {
color: #94a3b8;
font-size: 0.85em;
margin-bottom: 10px;
text-transform: uppercase;
}
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
color: #dc2626;
}
.response {
margin-top: 15px;
}
.status-code {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.85em;
font-weight: bold;
margin-right: 8px;
}
.status-200 { background: #10b981; color: white; }
.status-400 { background: #f59e0b; color: white; }
.status-404 { background: #ef4444; color: white; }
.status-500 { background: #dc2626; color: white; }
.copy-btn {
background: #2563eb;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
margin-top: 10px;
}
.copy-btn:hover {
background: #1d4ed8;
}
.toc {
background: #eff6ff;
padding: 20px;
border-radius: 4px;
margin-bottom: 30px;
}
.toc h2 {
color: #1e40af;
margin-bottom: 15px;
}
.toc ul {
list-style: none;
}
.toc li {
margin: 8px 0;
}
.toc a {
color: #2563eb;
text-decoration: none;
}
.toc a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>Global Profile API Documentation</h1>
<p class="subtitle">Complete REST API reference for the Global Profile plugin</p>
<div class="toc">
<h2>Table of Contents</h2>
<ul>
<li><a href="#profile">Profile Management</a></li>
<li><a href="#profiles">Profile Listing</a></li>
<li><a href="#avatar">Avatar Management</a></li>
<li><a href="#fields">Custom Fields</a></li>
<li><a href="#stats">Statistics</a></li>
</ul>
</div>
<section id="profile">
<div class="endpoint">
<h2><span class="method get">GET</span> <span class="path">/api/profile</span></h2>
<div class="description">Get your local profile. Returns default profile if none exists.</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Profile retrieved successfully</div>
<div><span class="status-code status-500">500</span> Local peer ID not available</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X GET ${baseUrl}/api/profile</div>
</div>
<div class="example">
<div class="example-title">Example Response</div>
<div>{
"peerId": "abc123...",
"displayName": "John Doe",
"bio": "Software developer",
"website": "https://example.com",
"email": "[email protected]",
"xUsername": "johndoe",
"github": "johndoe",
"discord": "johndoe#1234",
"location": "San Francisco, CA",
"avatarHash": "abc123...",
"customFields": {},
"tags": ["developer", "opensource"],
"lastUpdated": 1703123456789
}</div>
</div>
</div>
<div class="endpoint">
<h2><span class="method put">PUT</span> <span class="path">/api/profile</span></h2>
<div class="description">Update your profile (full update). All fields must be provided.</div>
<div class="section">
<h3>Request Body</h3>
<div class="params">
<div class="param">
<span class="param-name">displayName</span><span class="param-type">string (optional)</span>
<div class="param-desc">Display name for the profile</div>
</div>
<div class="param">
<span class="param-name">bio</span><span class="param-type">string (optional)</span>
<div class="param-desc">Biography or description</div>
</div>
<div class="param">
<span class="param-name">website</span><span class="param-type">string (optional)</span>
<div class="param-desc">Website URL</div>
</div>
<div class="param">
<span class="param-name">email</span><span class="param-type">string (optional)</span>
<div class="param-desc">Email address</div>
</div>
<div class="param">
<span class="param-name">xUsername</span><span class="param-type">string (optional)</span>
<div class="param-desc">X (Twitter) username</div>
</div>
<div class="param">
<span class="param-name">tags</span><span class="param-type">array (optional)</span>
<div class="param-desc">Array of tag strings</div>
</div>
<div class="param">
<span class="param-name">customFields</span><span class="param-type">object (optional)</span>
<div class="param-desc">Custom fields object</div>
</div>
</div>
</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Profile updated successfully</div>
<div><span class="status-code status-500">500</span> Error updating profile</div>
<div><span class="status-code status-503">503</span> Database not ready</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X PUT ${baseUrl}/api/profile \\
-H "Content-Type: application/json" \\
-d '{
"displayName": "John Doe",
"bio": "Software developer",
"website": "https://example.com",
"email": "[email protected]",
"tags": ["developer"],
"customFields": {}
}'</div>
</div>
</div>
<div class="endpoint">
<h2><span class="method patch">PATCH</span> <span class="path">/api/profile</span></h2>
<div class="description">Partially update your profile. Only provided fields will be updated.</div>
<div class="section">
<h3>Request Body</h3>
<div class="params">
<div class="param">
<span class="param-name">Any profile field</span><span class="param-type">mixed (optional)</span>
<div class="param-desc">Any combination of profile fields to update</div>
</div>
</div>
</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Profile updated successfully</div>
<div><span class="status-code status-400">400</span> Invalid JSON</div>
<div><span class="status-code status-404">404</span> Profile not found</div>
<div><span class="status-code status-500">500</span> Error updating profile</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X PATCH ${baseUrl}/api/profile \\
-H "Content-Type: application/json" \\
-d '{
"displayName": "Jane Doe",
"bio": "Updated bio"
}'</div>
</div>
</div>
<div class="endpoint">
<h2><span class="method delete">DELETE</span> <span class="path">/api/profile</span></h2>
<div class="description">Delete your local profile.</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Profile deleted successfully</div>
<div><span class="status-code status-404">404</span> Profile not found</div>
<div><span class="status-code status-500">500</span> Error deleting profile</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X DELETE ${baseUrl}/api/profile</div>
</div>
</div>
<div class="endpoint">
<h2><span class="method get">GET</span> <span class="path">/api/profile/:peerId</span></h2>
<div class="description">Get a specific peer's profile by their peer ID.</div>
<div class="section">
<h3>Path Parameters</h3>
<div class="params">
<div class="param">
<span class="param-name">peerId</span><span class="param-type">string (required)</span>
<div class="param-desc">The peer ID to get the profile for</div>
</div>
</div>
</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Profile retrieved successfully</div>
<div><span class="status-code status-404">404</span> Profile not found</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X GET ${baseUrl}/api/profile/abc123...</div>
</div>
</div>
</section>
<section id="profiles">
<div class="endpoint">
<h2><span class="method get">GET</span> <span class="path">/api/profiles</span></h2>
<div class="description">List all profiles with optional search, filtering, pagination, and sorting.</div>
<div class="section">
<h3>Query Parameters</h3>
<div class="params">
<div class="param">
<span class="param-name">search</span><span class="param-type">string (optional)</span>
<div class="param-desc">Search in displayName and bio fields</div>
</div>
<div class="param">
<span class="param-name">tag</span><span class="param-type">string (optional)</span>
<div class="param-desc">Filter by tag</div>
</div>
<div class="param">
<span class="param-name">limit</span><span class="param-type">number (optional, default: 100)</span>
<div class="param-desc">Maximum number of results (1-1000)</div>
</div>
<div class="param">
<span class="param-name">offset</span><span class="param-type">number (optional, default: 0)</span>
<div class="param-desc">Number of results to skip</div>
</div>
<div class="param">
<span class="param-name">sort</span><span class="param-type">string (optional, default: "lastUpdated")</span>
<div class="param-desc">Sort field: "lastUpdated" or "displayName"</div>
</div>
</div>
</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Profiles retrieved successfully</div>
<div><span class="status-code status-400">400</span> Invalid query parameters</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X GET "${baseUrl}/api/profiles?search=developer&limit=10&offset=0&sort=lastUpdated"</div>
</div>
<div class="example">
<div class="example-title">Example Response</div>
<div>{
"profiles": [...],
"pagination": {
"total": 50,
"limit": 10,
"offset": 0,
"hasMore": true
}
}</div>
</div>
</div>
</section>
<section id="avatar">
<div class="endpoint">
<h2><span class="method post">POST</span> <span class="path">/api/profile/avatar</span></h2>
<div class="description">Upload a new avatar image. Image will be resized to multiple sizes (16, 32, 64, 128, 256, 512px).</div>
<div class="section">
<h3>Request Body</h3>
<div class="params">
<div class="param">
<span class="param-name">avatar</span><span class="param-type">file (required)</span>
<div class="param-desc">Image file (multipart/form-data)</div>
</div>
</div>
</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Avatar uploaded successfully</div>
<div><span class="status-code status-400">400</span> No avatar file provided or file is empty</div>
<div><span class="status-code status-500">500</span> Error uploading avatar</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X POST ${baseUrl}/api/profile/avatar \\
-F "avatar=@/path/to/image.jpg"</div>
</div>
</div>
<div class="endpoint">
<h2><span class="method delete">DELETE</span> <span class="path">/api/profile/avatar</span></h2>
<div class="description">Delete your avatar image.</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Avatar deleted successfully</div>
<div><span class="status-code status-500">500</span> Error deleting avatar</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X DELETE ${baseUrl}/api/profile/avatar</div>
</div>
</div>
<div class="endpoint">
<h2><span class="method get">GET</span> <span class="path">/api/profile/avatar/:peerId</span></h2>
<div class="description">Get default avatar (64px) for a peer. Redirects to size-specific endpoint.</div>
<div class="section">
<h3>Path Parameters</h3>
<div class="params">
<div class="param">
<span class="param-name">peerId</span><span class="param-type">string (required)</span>
<div class="param-desc">The peer ID to get the avatar for</div>
</div>
</div>
</div>
<div class="response">
<div><span class="status-code status-302">302</span> Redirects to /api/profile/avatar/:peerId/64</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X GET ${baseUrl}/api/profile/avatar/abc123...</div>
</div>
</div>
<div class="endpoint">
<h2><span class="method get">GET</span> <span class="path">/api/profile/avatar/:peerId/:size</span></h2>
<div class="description">Get avatar image for a peer at a specific size. Returns default SVG if no avatar exists.</div>
<div class="section">
<h3>Path Parameters</h3>
<div class="params">
<div class="param">
<span class="param-name">peerId</span><span class="param-type">string (required)</span>
<div class="param-desc">The peer ID to get the avatar for</div>
</div>
<div class="param">
<span class="param-name">size</span><span class="param-type">number (required)</span>
<div class="param-desc">Avatar size: 16, 32, 64, 128, 256, or 512</div>
</div>
</div>
</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Avatar image (PNG or SVG)</div>
<div><span class="status-code status-400">400</span> Invalid avatar size</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X GET ${baseUrl}/api/profile/avatar/abc123.../128</div>
</div>
</div>
</section>
<section id="fields">
<div class="endpoint">
<h2><span class="method get">GET</span> <span class="path">/api/fields</span></h2>
<div class="description">Get all registered custom fields.</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Fields retrieved successfully</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X GET ${baseUrl}/api/fields</div>
</div>
<div class="example">
<div class="example-title">Example Response</div>
<div>{
"fields": [
{
"fieldKey": "favoriteColor",
"registeredBy": "example.plugin",
"label": "Favorite Color",
"type": "string",
"description": "User's favorite color",
"defaultValue": ""
}
]
}</div>
</div>
</div>
<div class="endpoint">
<h2><span class="method post">POST</span> <span class="path">/api/fields</span></h2>
<div class="description">Register a new custom field. Fields can only be registered by the plugin that created them.</div>
<div class="section">
<h3>Request Body</h3>
<div class="params">
<div class="param">
<span class="param-name">fieldKey</span><span class="param-type">string (required)</span>
<div class="param-desc">Unique key for the field</div>
</div>
<div class="param">
<span class="param-name">label</span><span class="param-type">string (required)</span>
<div class="param-desc">Human-readable label</div>
</div>
<div class="param">
<span class="param-name">type</span><span class="param-type">string (required)</span>
<div class="param-desc">Field type (string, number, boolean, etc.)</div>
</div>
<div class="param">
<span class="param-name">description</span><span class="param-type">string (optional)</span>
<div class="param-desc">Field description</div>
</div>
<div class="param">
<span class="param-name">defaultValue</span><span class="param-type">any (optional)</span>
<div class="param-desc">Default value for the field</div>
</div>
</div>
</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Field registered successfully</div>
<div><span class="status-code status-400">400</span> Missing required fields or invalid JSON</div>
<div><span class="status-code status-409">409</span> Field already registered by another plugin</div>
<div><span class="status-code status-500">500</span> Error registering field</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X POST ${baseUrl}/api/fields \\
-H "Content-Type: application/json" \\
-d '{
"fieldKey": "favoriteColor",
"label": "Favorite Color",
"type": "string",
"description": "User'\''s favorite color",
"defaultValue": "blue"
}'</div>
</div>
</div>
</section>
<section id="stats">
<div class="endpoint">
<h2><span class="method get">GET</span> <span class="path">/api/stats</span></h2>
<div class="description">Get profile statistics including total counts, online/offline status, and recent activity.</div>
<div class="response">
<div><span class="status-code status-200">200 OK</span> Statistics retrieved successfully</div>
<div><span class="status-code status-500">500</span> Error getting stats</div>
</div>
<div class="example">
<div class="example-title">Example Request</div>
<div>curl -X GET ${baseUrl}/api/stats</div>
</div>
<div class="example">
<div class="example-title">Example Response</div>
<div>{
"total": 50,
"online": 12,
"offline": 38,
"recentActivity": [
{
"peerId": "abc123...",
"displayName": "John Doe",
"hoursAgo": 2.5
}
],
"timestamp": 1703123456789
}</div>
</div>
</div>
</section>
<div style="margin-top: 40px; padding-top: 20px; border-top: 2px solid #e5e7eb; color: #666; font-size: 0.9em;">
<p><strong>Base URL:</strong> <code>${baseUrl}</code></p>
<p><strong>Note:</strong> All endpoints return JSON unless otherwise specified. Error responses include an <code>error</code> field with a descriptive message.</p>
</div>
</div>
<script>
// Simple copy functionality for code blocks
document.querySelectorAll('.example').forEach(block => {
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = 'Copy';
btn.onclick = () => {
const text = block.querySelector('div:last-child').textContent;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 2000);
});
};
block.appendChild(btn);
});
</script>
</body>
</html>`;
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=3600'
});
res.end(html);
return true;
}
return false; // Not handled by this module
}
module.exports = {
handleDocsRoutes
};
@@ -0,0 +1,108 @@
/**
* Global Profile Plugin - Fields Routes
*
* Handles custom fields management API endpoints
*/
const sdk = require('../../../includes/plugins/sdk');
/**
* Handle fields-related routes
*/
async function handleFieldsRoutes(req, res, path, query, method) {
// GET /api/fields - Get all registered fields
if (path === 'api/fields' && method === 'GET') {
try {
await sdk.db.ready();
const fields = await sdk.db.find('@profile/fields', {});
return sdk.router.json(res, { fields: fields || [] });
} catch (err) {
sdk.log.error('global.profile', `Error getting fields: ${err.message}`);
if (err.message && err.message.includes('Database not initialized')) {
return sdk.router.json(res, { fields: [], error: 'Database not ready' }, 503);
}
return sdk.router.error(res, 'Failed to retrieve fields', 500);
}
}
// POST /api/fields - Register a new custom field
if (path === 'api/fields' && method === 'POST') {
// Require authentication for write operations
const peerId = await sdk.auth.requireLocalPeer(req, res);
if (!peerId) return true; // 401 already sent
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
req.on('end', async () => {
try {
if (!body) {
return sdk.router.error(res, 'Request body is required', 400);
}
let fieldData;
try {
fieldData = JSON.parse(body);
} catch (parseErr) {
return sdk.router.error(res, 'Invalid JSON in request body', 400);
}
const pluginDomain = process.env.PLUGIN_DOMAIN || 'unknown';
if (!fieldData.fieldKey || !fieldData.label || !fieldData.type) {
return sdk.router.error(res, 'Missing required fields: fieldKey, label, type', 400);
}
// Validate fieldKey format
if (typeof fieldData.fieldKey !== 'string' || fieldData.fieldKey.trim() === '') {
return sdk.router.error(res, 'fieldKey must be a non-empty string', 400);
}
// Validate type
const validTypes = ['string', 'number', 'boolean', 'array', 'object'];
if (!validTypes.includes(fieldData.type)) {
return sdk.router.error(res, `type must be one of: ${validTypes.join(', ')}`, 400);
}
await sdk.db.ready();
// Check if field already exists
const existing = await sdk.db.get('@profile/fields', { fieldKey: fieldData.fieldKey });
if (existing && existing.registeredBy !== pluginDomain) {
return sdk.router.error(res, 'Field already registered by another plugin', 409);
}
const fieldDef = {
fieldKey: fieldData.fieldKey,
registeredBy: pluginDomain,
label: fieldData.label,
type: fieldData.type,
description: fieldData.description || '',
defaultValue: fieldData.defaultValue ? JSON.stringify(fieldData.defaultValue) : ''
};
await sdk.db.insert('@profile/fields', fieldDef);
await sdk.db.flush();
return sdk.router.json(res, { success: true, field: fieldDef });
} catch (err) {
sdk.log.error('global.profile', `Error registering field: ${err.message}`);
if (err.message && err.message.includes('Unexpected token')) {
return sdk.router.error(res, 'Invalid JSON in request body', 400);
}
return sdk.router.error(res, err.message, 500);
}
});
return true;
}
return false; // Not handled by this module
}
module.exports = {
handleFieldsRoutes
};
@@ -0,0 +1,417 @@
/**
* Global Profile Plugin - Profile Routes
*
* Handles profile-related API endpoints
*/
const sdk = require('../../../includes/plugins/sdk');
const { getLocalPeerId } = require('../utils');
const { getProfileFromDB, saveProfileToDB } = require('../database');
const { broadcastProfileUpdate } = require('../websocket');
/**
* Helper to call router methods with CORS support
*/
const router = {
json: (res, data, status, req) => sdk.router.json(res, data, status, req),
error: (res, message, status, req) => sdk.router.error(res, message, status, req),
notFound: (res, message, req) => sdk.router.notFound(res, message, req)
};
/**
* Handle profile-related routes
*/
async function handleProfileRoutes(req, res, path, query, method) {
const localPeerId = getLocalPeerId();
// GET /api/profile - Get local user's profile
if (path === 'api/profile' && method === 'GET') {
let requestTimeout = null;
try {
if (!localPeerId) {
return router.json(res, { error: 'Local peer ID not available' }, 500, req);
}
// Add overall timeout for the entire request
requestTimeout = setTimeout(() => {
if (!res.headersSent) {
sdk.log.error('global.profile', 'GET /api/profile request timeout');
try {
router.error(res, 'Request timeout - database may not be ready', 503, req);
} catch (e) {
// Ignore if response already sent
}
}
}, 10000); // 10 second timeout
const profile = await getProfileFromDB(localPeerId);
if (requestTimeout) {
clearTimeout(requestTimeout);
requestTimeout = null;
}
if (!profile) {
// Return default profile
return router.json(res, {
peerId: localPeerId,
displayName: '',
bio: '',
website: '',
email: '',
xUsername: '',
avatarHash: '',
customFields: {},
tags: [],
lastUpdated: null
}, 200, req);
}
// Parse JSON fields
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
return router.json(res, profile, 200, req);
} catch (err) {
if (requestTimeout) {
clearTimeout(requestTimeout);
}
sdk.log.error('global.profile', `Error getting profile: ${err.message}`);
if (!res.headersSent) {
return router.error(res, 'Failed to retrieve profile', 500, req);
}
}
}
// PUT /api/profile - Update local user's profile
if (path === 'api/profile' && method === 'PUT') {
sdk.log.info('global.profile', '[PUT /api/profile] Request received');
const startTime = Date.now();
// Require authentication for write operations
sdk.log.debug('global.profile', '[PUT /api/profile] Checking authentication...');
const peerId = await sdk.auth.requireLocalPeer(req, res);
if (!peerId) {
sdk.log.warn('global.profile', '[PUT /api/profile] Authentication failed');
return true; // 401 already sent
}
sdk.log.debug('global.profile', `[PUT /api/profile] Authenticated as ${peerId.slice(0, 16)}...`);
if (!localPeerId) {
sdk.log.error('global.profile', '[PUT /api/profile] Local peer ID not available');
return router.json(res, { error: 'Local peer ID not available' }, 500, req);
}
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
req.on('end', async () => {
try {
sdk.log.debug('global.profile', `[PUT /api/profile] Body received (${body.length} bytes)`);
if (!body) {
sdk.log.warn('global.profile', '[PUT /api/profile] Empty request body');
return sdk.router.error(res, 'Request body is required', 400, req);
}
let updates;
try {
updates = JSON.parse(body);
sdk.log.debug('global.profile', `[PUT /api/profile] Parsed updates: tags=${JSON.stringify(updates.tags)}`);
} catch (parseErr) {
sdk.log.error('global.profile', `[PUT /api/profile] JSON parse error: ${parseErr.message}`);
return sdk.router.error(res, 'Invalid JSON in request body', 400, req);
}
// Get existing profile or create new (with retry for DB initialization)
sdk.log.debug('global.profile', '[PUT /api/profile] Getting existing profile from DB...');
const getProfileStart = Date.now();
let profile = null;
const MAX_RETRIES = 10;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
profile = await getProfileFromDB(localPeerId);
sdk.log.debug('global.profile', `[PUT /api/profile] Got profile in ${Date.now() - getProfileStart}ms`);
break;
} catch (err) {
if (err.message && err.message.includes('Database not initialized') && attempt < MAX_RETRIES) {
sdk.log.warn('global.profile', `[PUT /api/profile] Database not initialized when getting profile, retrying... (${attempt}/${MAX_RETRIES})`);
await sdk.utils.sleep(1000);
continue;
}
// If it's not a DB init error, or we've exhausted retries, throw
if (!err.message || !err.message.includes('Database not initialized')) {
throw err;
}
// If we've exhausted retries, profile will be null and we'll create a new one
sdk.log.warn('global.profile', '[PUT /api/profile] Exhausted retries, creating new profile');
break;
}
}
if (!profile) {
sdk.log.info('global.profile', '[PUT /api/profile] Creating new profile');
profile = {
peerId: localPeerId,
displayName: '',
bio: '',
website: '',
email: '',
xUsername: '',
avatarHash: '',
customFields: JSON.stringify({}),
tags: JSON.stringify([]),
lastUpdated: Date.now()
};
}
// Update fields
sdk.log.debug('global.profile', '[PUT /api/profile] Updating profile fields...');
if (updates.displayName !== undefined) profile.displayName = updates.displayName;
if (updates.bio !== undefined) profile.bio = updates.bio;
if (updates.website !== undefined) profile.website = updates.website;
if (updates.email !== undefined) profile.email = updates.email;
if (updates.xUsername !== undefined) profile.xUsername = updates.xUsername;
if (updates.tags !== undefined) {
const tagsArray = Array.isArray(updates.tags) ? updates.tags : [];
sdk.log.debug('global.profile', `[PUT /api/profile] Setting tags: ${JSON.stringify(tagsArray)}`);
profile.tags = JSON.stringify(tagsArray);
}
if (updates.customFields !== undefined) {
profile.customFields = JSON.stringify(updates.customFields);
}
sdk.log.info('global.profile', '[PUT /api/profile] Saving profile to DB...');
const saveStart = Date.now();
await saveProfileToDB(profile);
sdk.log.info('global.profile', `[PUT /api/profile] Profile saved in ${Date.now() - saveStart}ms`);
// Parse JSON fields for response
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
// Broadcast update
sdk.log.debug('global.profile', '[PUT /api/profile] Broadcasting update...');
broadcastProfileUpdate({
type: 'profile-update',
profile: profile
});
sdk.log.info('global.profile', `[PUT /api/profile] Request completed in ${Date.now() - startTime}ms`);
return sdk.router.json(res, { success: true, profile }, 200, req);
} catch (err) {
sdk.log.error('global.profile', `[PUT /api/profile] Error after ${Date.now() - startTime}ms: ${err.message}`);
sdk.log.error('global.profile', `[PUT /api/profile] Stack: ${err.stack}`);
// Provide user-friendly error message
let errorMessage = err.message;
if (errorMessage.includes('Database not available') || errorMessage.includes('Database not initialized')) {
errorMessage = 'Database is not ready yet. Please wait a moment and try again.';
return router.error(res, errorMessage, 503, req); // 503 Service Unavailable
}
return router.error(res, errorMessage, 500, req);
}
});
return true;
}
// PATCH /api/profile - Partial update local user's profile
if (path === 'api/profile' && method === 'PATCH') {
// Require authentication for write operations
const peerId = await sdk.auth.requireLocalPeer(req, res);
if (!peerId) return true; // 401 already sent
if (!localPeerId) {
return router.json(res, { error: 'Local peer ID not available' }, 500, req);
}
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
req.on('end', async () => {
try {
if (!body) {
return sdk.router.error(res, 'Request body is required', 400, req);
}
let updates;
try {
updates = JSON.parse(body);
} catch (parseErr) {
return sdk.router.error(res, 'Invalid JSON in request body', 400, req);
}
// Get existing profile
let profile = await getProfileFromDB(localPeerId);
if (!profile) {
return sdk.router.json(res, { error: 'Profile not found' }, 404, req);
}
// Update only provided fields
if (updates.displayName !== undefined) profile.displayName = updates.displayName;
if (updates.bio !== undefined) profile.bio = updates.bio;
if (updates.website !== undefined) profile.website = updates.website;
if (updates.email !== undefined) profile.email = updates.email;
if (updates.xUsername !== undefined) profile.xUsername = updates.xUsername;
if (updates.github !== undefined) profile.github = updates.github;
if (updates.discord !== undefined) profile.discord = updates.discord;
if (updates.location !== undefined) profile.location = updates.location;
if (updates.tags !== undefined) {
profile.tags = JSON.stringify(Array.isArray(updates.tags) ? updates.tags : []);
}
if (updates.customFields !== undefined) {
profile.customFields = JSON.stringify(updates.customFields);
}
await saveProfileToDB(profile);
// Parse JSON fields for response
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
// Broadcast update
broadcastProfileUpdate({
type: 'profile-update',
profile: profile
});
return sdk.router.json(res, { success: true, profile }, 200, req);
} catch (err) {
sdk.log.error('global.profile', `Error patching profile: ${err.message}`);
if (err.message && err.message.includes('Unexpected token')) {
return sdk.router.error(res, 'Invalid JSON in request body', 400, req);
}
let errorMessage = err.message;
if (errorMessage.includes('Database not available') || errorMessage.includes('Database not initialized')) {
errorMessage = 'Database is not ready yet. Please wait a moment and try again.';
return sdk.router.error(res, errorMessage, 503, req);
}
return sdk.router.error(res, errorMessage, 500, req);
}
});
return true;
}
// GET /api/profile/:peerId - Get specific peer's profile
// MUST come after /api/profile/avatar/:peerId/:size to avoid route conflicts
const profileMatch = path.match(/^api\/profile\/(.+)$/);
if (profileMatch && method === 'GET') {
try {
let peerId = profileMatch[1];
// Remove trailing slash if present
if (peerId.endsWith('/')) {
peerId = peerId.slice(0, -1);
}
peerId = decodeURIComponent(peerId);
if (!peerId || peerId.trim() === '') {
return sdk.router.error(res, 'Invalid peer ID', 400, req);
}
sdk.log.debug('global.profile', `Fetching profile for peer: ${peerId.slice(0, 16)}...`);
const profile = await getProfileFromDB(peerId);
if (!profile) {
sdk.log.debug('global.profile', `Profile not found for peer: ${peerId.slice(0, 16)}...`);
// Return a default profile structure instead of 404, so the modal can still display
// This is useful when the profile hasn't been replicated yet
return sdk.router.json(res, {
peerId: peerId,
displayName: '',
bio: '',
website: '',
email: '',
xUsername: '',
avatarHash: '',
customFields: {},
tags: [],
lastUpdated: null
}, 200, req);
}
// Parse JSON fields
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
return sdk.router.json(res, profile, 200, req);
} catch (err) {
sdk.log.error('global.profile', `Error getting peer profile: ${err.message}`);
return sdk.router.error(res, 'Failed to retrieve profile', 500, req);
}
}
// DELETE /api/profile - Delete local user's profile
if (path === 'api/profile' && method === 'DELETE') {
// Require authentication for write operations
const peerId = await sdk.auth.requireLocalPeer(req, res);
if (!peerId) return true; // 401 already sent
if (!localPeerId) {
return sdk.router.json(res, { error: 'Local peer ID not available' }, 500, req);
}
try {
const profile = await getProfileFromDB(localPeerId);
if (!profile) {
return sdk.router.json(res, { error: 'Profile not found' }, 404, req);
}
// Delete all avatars for this profile
try {
const avatarSizes = [64, 128, 256, 512];
for (const size of avatarSizes) {
await sdk.db.delete('@profile/avatars', { peerId: localPeerId, size });
}
sdk.log.debug('global.profile', `Deleted all avatars for ${localPeerId.slice(0, 16)}...`);
} catch (avatarErr) {
sdk.log.warn('global.profile', `Error deleting avatars: ${avatarErr.message}`);
}
// Delete profile from database
await sdk.db.delete('@profile/profiles', { peerId: localPeerId });
// Broadcast deletion
broadcastProfileUpdate({
type: 'profile-deleted',
peerId: localPeerId
});
return sdk.router.json(res, { success: true, message: 'Profile deleted successfully' }, 200, req);
} catch (err) {
sdk.log.error('global.profile', `Error deleting profile: ${err.message}`);
return sdk.router.error(res, err.message, 500, req);
}
}
return false; // Not handled by this module
}
module.exports = {
handleProfileRoutes
};
@@ -0,0 +1,117 @@
/**
* Global Profile Plugin - Profiles Routes
*
* Handles profiles listing and search API endpoints
*/
const sdk = require('../../../includes/plugins/sdk');
const { getAllProfilesFromDB } = require('../database');
/**
* Handle profiles-related routes
*/
async function handleProfilesRoutes(req, res, path, query, method) {
// GET /api/profiles - List all profiles with search/filter, pagination, and sorting
if (path === 'api/profiles' && method === 'GET') {
try {
const search = query.search || '';
const tagFilter = query.tag || '';
const limit = parseInt(query.limit || '100', 10);
const offset = parseInt(query.offset || '0', 10);
const sort = query.sort || 'lastUpdated'; // lastUpdated, displayName
// Validate pagination params
if (limit < 1 || limit > 1000) {
return sdk.router.error(res, 'Limit must be between 1 and 1000', 400);
}
if (offset < 0) {
return sdk.router.error(res, 'Offset must be >= 0', 400);
}
if (sort !== 'lastUpdated' && sort !== 'displayName') {
return sdk.router.error(res, 'Sort must be either "lastUpdated" or "displayName"', 400);
}
const allProfiles = await getAllProfilesFromDB();
let filtered = allProfiles;
// Apply search filter
if (search) {
const searchLower = search.toLowerCase();
filtered = filtered.filter(p =>
(p.displayName && p.displayName.toLowerCase().includes(searchLower)) ||
(p.bio && p.bio.toLowerCase().includes(searchLower))
);
}
// Apply tag filter
if (tagFilter) {
filtered = filtered.filter(p => {
try {
const tags = p.tags ? JSON.parse(p.tags) : [];
return tags.some(tag => tag.toLowerCase() === tagFilter.toLowerCase());
} catch (err) {
return false;
}
});
}
// Sort
if (sort === 'lastUpdated') {
filtered.sort((a, b) => {
const aTime = a.lastUpdated || 0;
const bTime = b.lastUpdated || 0;
return bTime - aTime; // Descending (newest first)
});
} else if (sort === 'displayName') {
filtered.sort((a, b) => {
const aName = (a.displayName || '').toLowerCase();
const bName = (b.displayName || '').toLowerCase();
return aName.localeCompare(bName); // Ascending (A-Z)
});
}
const total = filtered.length;
// Paginate
const paginated = filtered.slice(offset, offset + limit);
// Parse JSON fields and add online status
const profiles = paginated.map(p => {
try {
p.customFields = p.customFields ? JSON.parse(p.customFields) : {};
p.tags = p.tags ? JSON.parse(p.tags) : [];
} catch (err) {
p.customFields = {};
p.tags = [];
}
// Add online status
p.online = sdk.state.peerIds.includes(p.peerId);
return p;
});
return sdk.router.json(res, {
profiles,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total
}
});
} catch (err) {
sdk.log.error('global.profile', `Error getting profiles: ${err.message}`);
return sdk.router.error(res, err.message, 500);
}
}
return false; // Not handled by this module
}
module.exports = {
handleProfilesRoutes
};
@@ -0,0 +1,74 @@
/**
* Global Profile Plugin - Stats Routes
*
* Handles statistics API endpoints
*/
const sdk = require('../../../includes/plugins/sdk');
const { getAllProfilesFromDB } = require('../database');
/**
* Handle stats-related routes
*/
async function handleStatsRoutes(req, res, path, query, method) {
// GET /api/stats - Get profile statistics
if (path === 'api/stats' && method === 'GET') {
try {
const allProfiles = await getAllProfilesFromDB();
const onlinePeerIds = sdk.state.peerIds || [];
// Count online/offline profiles
let onlineCount = 0;
let offlineCount = 0;
const recentActivity = [];
for (const profile of allProfiles) {
const isOnline = onlinePeerIds.includes(profile.peerId);
if (isOnline) {
onlineCount++;
} else {
offlineCount++;
}
// Track recent activity (last 24 hours)
if (profile.lastUpdated) {
const hoursAgo = (Date.now() - profile.lastUpdated) / (1000 * 60 * 60);
if (hoursAgo <= 24) {
recentActivity.push({
peerId: profile.peerId,
displayName: profile.displayName || 'Unknown',
hoursAgo: Math.round(hoursAgo * 10) / 10
});
}
}
}
// Sort recent activity by most recent
recentActivity.sort((a, b) => {
const aProfile = allProfiles.find(p => p.peerId === a.peerId);
const bProfile = allProfiles.find(p => p.peerId === b.peerId);
return (bProfile?.lastUpdated || 0) - (aProfile?.lastUpdated || 0);
});
return sdk.router.json(res, {
total: allProfiles.length,
online: onlineCount,
offline: offlineCount,
recentActivity: recentActivity.slice(0, 10), // Top 10 most recent
timestamp: Date.now()
});
} catch (err) {
sdk.log.error('global.profile', `Error getting stats: ${err.message}`);
return sdk.router.error(res, err.message, 500);
}
}
return false; // Not handled by this module
}
module.exports = {
handleStatsRoutes
};
+147
View File
@@ -0,0 +1,147 @@
/**
* Global Profile Plugin - Utility Functions
*
* Contains utility functions used across the plugin
*/
const sdk = require('../../includes/plugins/sdk');
/**
* Get local peer ID
*/
function getLocalPeerId() {
return sdk.state.localPeerId;
}
/**
* Check if database is available
*/
async function isDatabaseAvailable() {
try {
await sdk.db.ready();
return !sdk.db.closed;
} catch (err) {
return false;
}
}
/**
* Parse multipart form data (simple implementation)
*/
function parseMultipartFormData(req) {
return new Promise((resolve, reject) => {
let body = Buffer.alloc(0);
req.on('data', (chunk) => {
body = Buffer.concat([body, chunk]);
});
req.on('end', () => {
try {
// Simple multipart parsing (for avatar uploads)
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
if (!boundaryMatch) {
sdk.log.error('global.profile', 'No boundary found in Content-Type header');
return reject(new Error('Invalid multipart form data: no boundary'));
}
const boundary = boundaryMatch[1].trim();
sdk.log.info('global.profile', `Parsing multipart with boundary: ${boundary}`);
// Split by boundary, but keep binary data intact
const boundaryBuffer = Buffer.from(`--${boundary}`, 'utf-8');
const parts = [];
let start = 0;
while (true) {
const index = body.indexOf(boundaryBuffer, start);
if (index === -1) break;
if (start < index) {
parts.push(body.slice(start, index));
}
start = index + boundaryBuffer.length;
}
if (start < body.length) {
parts.push(body.slice(start));
}
const fields = {};
const files = {};
for (const part of parts) {
if (part.length === 0) continue;
// Find the header/content separator
const separator = Buffer.from('\r\n\r\n', 'utf-8');
const separatorIndex = part.indexOf(separator);
if (separatorIndex === -1) continue;
const headerBuffer = part.slice(0, separatorIndex);
const contentBuffer = part.slice(separatorIndex + separator.length);
// Remove trailing boundary markers and whitespace
const headerText = headerBuffer.toString('utf-8').trim();
if (!headerText || headerText === '--') continue;
if (headerText.includes('Content-Disposition')) {
const nameMatch = headerText.match(/name="([^"]+)"/);
const filenameMatch = headerText.match(/filename="([^"]+)"/);
if (nameMatch) {
const name = nameMatch[1];
if (filenameMatch) {
// File field
const filename = filenameMatch[1];
const contentTypeMatch = headerText.match(/Content-Type:\s*([^\r\n]+)/i);
// Remove trailing boundary and newlines from content
let fileData = contentBuffer;
const endBoundary = Buffer.from(`\r\n--${boundary}--`, 'utf-8');
const endIndex = fileData.indexOf(endBoundary);
if (endIndex !== -1) {
fileData = fileData.slice(0, endIndex);
}
// Remove trailing newlines
while (fileData.length > 0 && (fileData[fileData.length - 1] === 0x0A || fileData[fileData.length - 1] === 0x0D)) {
fileData = fileData.slice(0, -1);
}
files[name] = {
filename,
contentType: contentTypeMatch ? contentTypeMatch[1].trim() : 'application/octet-stream',
data: fileData
};
sdk.log.info('global.profile', `Parsed file: ${name}, filename: ${filename}, size: ${fileData.length}`);
} else {
// Regular field
const fieldValue = contentBuffer.toString('utf-8').trim();
// Remove trailing boundary
const cleanValue = fieldValue.replace(/\r\n--.*$/, '').trim();
fields[name] = cleanValue;
}
}
}
}
sdk.log.info('global.profile', `Parsed ${Object.keys(files).length} files, ${Object.keys(fields).length} fields`);
resolve({ fields, files });
} catch (err) {
sdk.log.error('global.profile', `Error parsing multipart: ${err.message}`);
sdk.log.error('global.profile', `Error stack: ${err.stack}`);
reject(err);
}
});
req.on('error', reject);
});
}
module.exports = {
getLocalPeerId,
isDatabaseAvailable,
parseMultipartFormData
};
+81
View File
@@ -0,0 +1,81 @@
/**
* Global Profile Plugin - Database Watcher
*
* Handles database change watching and broadcasting updates
*/
const sdk = require('../../includes/plugins/sdk');
const { getProfileFromDB } = require('./database');
const { broadcastProfileUpdate } = require('./websocket');
/**
* Watch for database changes and broadcast updates
*/
async function setupDatabaseWatcher() {
// Wait for database to be available with retries
let retries = 30;
while (retries > 0) {
try {
await sdk.db.ready();
if (!sdk.db.closed) {
sdk.db.watch(async (...args) => {
try {
const update = args[0];
// Check if we have a valid update for profile changes
if (update && typeof update === 'object' && update.collection === '@profile/profiles') {
sdk.log.info('global.profile', `Database watcher detected profile change: ${JSON.stringify(update)}`);
const profile = await getProfileFromDB(update.key?.peerId);
if (profile) {
// Parse JSON fields for broadcast
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
sdk.log.info('global.profile', `Database watcher broadcasting updated profile for ${update.key?.peerId}`);
broadcastProfileUpdate({
type: 'profile-update',
profile: profile
});
} else {
sdk.log.warn('global.profile', `Database watcher detected change for ${update.key?.peerId} but profile not found`);
}
} else if (update) {
sdk.log.debug('global.profile', `Database watcher received update for different collection: ${update.collection || 'unknown'}`);
}
// Ignore other update types or setup calls
} catch (err) {
sdk.log.error('global.profile', `Error in database watcher: ${err.message}`);
}
});
sdk.log.info('global.profile', 'Database watcher set up');
return;
}
} catch (err) {
// Database not ready yet, continue waiting
if (err.message && err.message.includes('Database not initialized')) {
retries--;
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
continue;
}
// Other error - log and return
sdk.log.error('global.profile', `Error setting up database watcher: ${err.message}`);
return;
}
break;
}
if (retries === 0) {
sdk.log.warn('global.profile', 'Database watcher setup timed out - database not available after 30 seconds');
}
}
module.exports = {
setupDatabaseWatcher
};
+159
View File
@@ -0,0 +1,159 @@
/**
* Global Profile Plugin - WebSocket Management
*
* Handles WebSocket connections, broadcasting, and real-time updates
*/
const sdk = require('../../includes/plugins/sdk');
const { getProfileFromDB, getAllProfilesFromDB } = require('./database');
/**
* Broadcast profile update to WebSocket clients and all peers
*/
function broadcastProfileUpdate(data) {
const message = {
...data,
timestamp: Date.now()
};
sdk.log.debug('global.profile', `Broadcasting profile update: ${data.type} for peer ${data.profile?.peerId || data.peerId}`);
// Broadcast to local WebSocket clients
const localClients = sdk.websocket.broadcast(message);
sdk.log.debug('global.profile', `Sent to ${localClients} local WebSocket client(s)`);
// Broadcast to all connected P2P peers via channels
// Peers will forward to their local WebSocket clients
try {
const peerCount = sdk.channels.broadcast('profile-updates', message);
if (peerCount > 0) {
sdk.log.debug('global.profile', `Sent to ${peerCount} peer(s) via P2P channels`);
}
} catch (err) {
sdk.log.debug('global.profile', `Error broadcasting to peers: ${err.message}`);
}
}
/**
* Get replication status for HyperDB (avatars are stored in HyperDB)
*/
function getReplicationStatus() {
const status = {
hyperdb: { active: false, peers: 0 }
};
try {
// Get HyperDB replication status
const dbReplication = sdk.db.replication;
if (dbReplication) {
const dbStatus = dbReplication.getStatus();
if (dbStatus) {
status.hyperdb = {
active: dbStatus.active,
peers: dbStatus.peers || 0
};
}
}
} catch (err) {
sdk.log.debug('global.profile', `Error getting HyperDB replication status: ${err.message}`);
}
// Avatars are now stored in HyperDB, so no separate Hyperdrive status needed
return status;
}
/**
* Setup WebSocket handlers
*/
function setupWebSocketHandlers() {
// Initialize WebSocket server
if (!sdk.websocket.initialize()) {
sdk.log.warn('global.profile', 'Failed to initialize WebSocket server');
return;
}
// Track previous replication status to detect changes
let previousReplicationStatus = JSON.stringify(getReplicationStatus());
sdk.websocket.on('connection', async (ws) => {
const clientCount = sdk.websocket.getClientCount();
sdk.log.info('global.profile', `WebSocket client connected (${clientCount} total)`);
// Initial profile data is now loaded via REST API
// WebSocket is only used for real-time updates (profile-update, profile-deleted)
// The request-profiles handler below is kept for backward compatibility
});
sdk.websocket.on('message', async (ws, message) => {
try {
if (message.type === 'request-profiles') {
try {
const profiles = await getAllProfilesFromDB();
sdk.websocket.send(ws, {
type: 'profiles',
profiles: profiles.map(p => {
try {
p.customFields = p.customFields ? JSON.parse(p.customFields) : {};
p.tags = p.tags ? JSON.parse(p.tags) : [];
} catch (err) {
p.customFields = {};
p.tags = [];
}
p.online = sdk.state.peerIds.includes(p.peerId);
return p;
}),
timestamp: Date.now()
});
} catch (err) {
sdk.log.error('global.profile', `Error getting profiles for WebSocket: ${err.message}`);
sdk.websocket.send(ws, {
type: 'profiles',
profiles: [],
timestamp: Date.now(),
error: 'Database not ready'
});
}
} else if (message.type === 'request-replication-status') {
// Send current replication status
const replicationStatus = getReplicationStatus();
sdk.websocket.send(ws, {
type: 'replication-status',
data: replicationStatus,
timestamp: Date.now()
});
}
} catch (err) {
sdk.log.error('global.profile', `Error handling WebSocket message: ${err.message}`);
}
});
// Broadcast replication status updates periodically
setInterval(() => {
const clientCount = sdk.websocket.getClientCount();
if (clientCount > 0) {
const currentStatus = getReplicationStatus();
const currentStatusStr = JSON.stringify(currentStatus);
// Only broadcast if status changed
if (currentStatusStr !== previousReplicationStatus) {
const sent = sdk.websocket.broadcast({
type: 'replication-status',
data: currentStatus,
timestamp: Date.now()
});
previousReplicationStatus = currentStatusStr;
sdk.log.debug('global.profile', `Broadcasted replication status update to ${sent} client(s)`);
}
}
}, 2000); // Check every 2 seconds
}
module.exports = {
broadcastProfileUpdate,
getReplicationStatus,
setupWebSocketHandlers
};
@@ -0,0 +1,531 @@
/* Profile Modal Styles - Dark Glass Theme */
/* CSS Variables for standalone modal */
: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);
}
.profile-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.profile-modal-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.profile-modal-content {
position: relative;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
border: 1px solid var(--border-color-strong);
border-radius: 1rem;
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-xl), inset 0 1px 0 rgba(255, 255, 255, 0.15);
animation: modalFadeIn 0.2s ease-out;
overflow: hidden;
}
.profile-modal-content::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0) 100%);
pointer-events: none;
border-radius: inherit;
z-index: 0;
}
.profile-modal-content > * {
position: relative;
z-index: 1;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.profile-modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: var(--bg-glass);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
font-size: 1.5rem;
cursor: pointer;
transition: all 0.2s;
line-height: 1;
padding: 0;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
position: relative;
overflow: hidden;
z-index: 10;
}
.profile-modal-close::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;
}
.profile-modal-close:hover {
background: var(--bg-glass-hover);
border-color: var(--border-color-strong);
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08);
transform: rotate(90deg);
}
.profile-modal-loading,
.profile-modal-error {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(99, 102, 241, 0.3);
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.profile-modal-header {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.profile-modal-avatar-container {
position: relative;
flex-shrink: 0;
cursor: pointer;
}
.profile-modal-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #6366f1;
box-shadow: 0 0 20px rgba(99, 102, 241, 0.3);
}
.profile-modal-avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--bg-glass);
backdrop-filter: blur(25px) saturate(200%);
-webkit-backdrop-filter: blur(25px) saturate(200%);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
border: 3px solid var(--border-color);
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.profile-modal-header-info {
flex: 1;
min-width: 0;
}
.profile-modal-name {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.5rem;
}
.bot-badge {
display: inline-block;
background: rgba(99, 102, 241, 0.3);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(99, 102, 241, 0.5);
color: var(--text-primary);
font-size: 0.7rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
vertical-align: middle;
flex-shrink: 0;
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;
}
.bot-badge::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;
}
.profile-modal-peerid {
font-size: 0.85rem;
color: var(--text-tertiary);
font-family: 'Monaco', 'Courier New', monospace;
margin: 0;
word-break: break-all;
}
.profile-modal-bio {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.profile-modal-bio-text {
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
}
/* Profile Tags Section */
.profile-modal-tags {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.profile-modal-tags-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-tertiary);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.profile-modal-tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.profile-modal-tag {
display: inline-block;
padding: 0.375rem 0.75rem;
background: var(--bg-glass);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
color: var(--text-primary);
font-size: 0.8rem;
font-weight: 500;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
position: relative;
overflow: hidden;
transition: all 0.2s;
}
.profile-modal-tag::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;
}
.profile-modal-tag:hover {
background: var(--bg-glass-hover);
border-color: var(--border-color-strong);
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.profile-modal-details {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.profile-modal-detail-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-glass);
backdrop-filter: blur(25px) saturate(200%);
-webkit-backdrop-filter: blur(25px) saturate(200%);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
transition: all 0.2s;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
position: relative;
overflow: hidden;
}
.profile-modal-detail-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 30%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0) 100%);
pointer-events: none;
border-radius: inherit;
}
.profile-modal-detail-item:hover {
background: var(--bg-glass-hover);
border-color: var(--border-color-strong);
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.profile-modal-detail-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.profile-modal-detail-link {
color: var(--primary-light);
text-decoration: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.2s;
position: relative;
z-index: 1;
}
.profile-modal-detail-link:hover {
color: var(--primary);
text-decoration: underline;
}
.profile-modal-detail-text {
color: var(--text-primary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
z-index: 1;
}
/* Scrollbar styling for modal */
.profile-modal-content::-webkit-scrollbar {
width: 8px;
}
.profile-modal-content::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 4px;
}
.profile-modal-content::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
}
.profile-modal-content::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* Profile Modal Actions */
.profile-modal-actions {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
display: flex;
gap: 0.75rem;
flex-direction: column;
}
.profile-modal-action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background: var(--bg-glass);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
width: 100%;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
position: relative;
overflow: hidden;
}
.profile-modal-action-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;
z-index: 0;
}
.profile-modal-action-btn > * {
position: relative;
z-index: 1;
}
.profile-modal-action-btn:hover {
background: var(--bg-glass-hover);
border-color: var(--border-color-strong);
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.profile-modal-action-btn-block {
background: rgba(239, 68, 68, 0.2);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-color: rgba(239, 68, 68, 0.4);
color: var(--text-primary);
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.profile-modal-action-btn-block:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.6);
box-shadow: 0 4px 6px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.profile-modal-action-btn-unblock {
background: rgba(16, 185, 129, 0.2);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-color: rgba(16, 185, 129, 0.4);
color: var(--text-primary);
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.profile-modal-action-btn-unblock:hover {
background: rgba(16, 185, 129, 0.3);
border-color: rgba(16, 185, 129, 0.6);
box-shadow: 0 4px 6px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.profile-modal-action-btn svg {
flex-shrink: 0;
}
@@ -0,0 +1,949 @@
: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 {
height: 100%;
overflow: hidden;
}
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;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
max-width: 1400px;
margin: 0 auto;
padding: var(--spacing-md);
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-sm);
padding: calc(var(--spacing-lg) - 20px) var(--spacing-xl);
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 100;
background: rgba(10, 14, 26, 0.7);
backdrop-filter: blur(30px) saturate(200%);
-webkit-backdrop-filter: blur(30px) saturate(200%);
border-bottom: 1px solid var(--border-color);
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.05);
border-radius: 0;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 30%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0) 100%);
pointer-events: none;
z-index: 0;
}
.header > * {
position: relative;
z-index: 1;
}
.header-content {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.header h1 {
font-size: 1.8rem;
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;
margin-bottom: 0.25rem;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 0;
}
.header-status {
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-md);
font-size: 0.875rem;
color: var(--text-secondary);
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
background: var(--success);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
position: relative;
flex-shrink: 0;
}
.status-indicator::before {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-full);
background: var(--success);
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.status-indicator.disconnected {
background: var(--error);
animation: none;
}
.status-indicator.disconnected::before {
background: var(--error);
animation: none;
}
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
50% {
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
@keyframes pulse-dot {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(0.95);
}
}
.profiles-count {
font-size: 0.875rem;
color: var(--text-secondary);
padding: var(--spacing-xs) var(--spacing-md);
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
}
.profiles-count #profilesCountValue {
font-weight: 600;
color: var(--primary-light);
}
.main-content {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
min-height: 0;
overflow: hidden;
}
.profile-section {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.profile-card {
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);
padding: var(--spacing-md);
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
min-height: 0;
max-height: 100%;
overflow: hidden;
position: relative;
}
.profile-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 30%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0) 100%);
pointer-events: none;
border-radius: inherit;
z-index: 0;
}
.profile-card > * {
position: relative;
z-index: 1;
}
.avatar-section {
text-align: center;
margin-bottom: var(--spacing-md);
flex-shrink: 0;
}
.avatar-container {
position: relative;
display: inline-block;
margin-bottom: var(--spacing-xs);
}
.avatar-image {
width: 80px;
height: 80px;
border-radius: var(--radius-full);
object-fit: cover;
border: 2px solid var(--primary);
box-shadow: var(--shadow-glow);
}
.avatar-placeholder {
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
border: 2px solid var(--border-color);
margin: 0 auto;
}
.avatar-upload-label {
position: absolute;
bottom: 0;
right: 0;
width: 28px;
height: 28px;
background: var(--primary);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 2px solid var(--bg-primary);
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
}
.avatar-upload-label:hover {
background: var(--primary-dark);
transform: scale(1.1);
box-shadow: var(--shadow-glow);
}
.upload-icon {
font-size: 1.2rem;
}
.avatar-hint {
color: var(--text-tertiary);
font-size: 0.75rem;
margin: 0;
}
.profile-form {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: var(--spacing-xs);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
flex-shrink: 0;
min-width: 0;
overflow: hidden;
}
.form-group label {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.85rem;
}
.form-group input,
.form-group textarea {
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;
width: 100%;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.form-group input:focus,
.form-group textarea: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);
}
.form-group input.readonly {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
.form-group textarea {
resize: vertical;
min-height: 60px;
max-height: 80px;
}
.form-section-divider {
margin: var(--spacing-sm) 0 var(--spacing-sm) 0;
padding-top: var(--spacing-sm);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.form-section-divider h3 {
font-size: 0.95rem;
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.form-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xs);
gap: var(--spacing-md);
}
.form-group-header label {
margin-bottom: 0;
}
.toggle-btn {
padding: 0.2rem var(--spacing-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.75rem;
cursor: pointer;
transition: all var(--transition-base);
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.toggle-btn:hover:not(:disabled) {
background: var(--bg-glass-hover);
border-color: var(--primary);
}
.toggle-btn.enabled {
background: var(--success);
border-color: var(--success);
color: white;
}
.toggle-btn.enabled:hover:not(:disabled) {
background: #0ea66e;
}
.toggle-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-icon {
font-size: 0.9rem;
}
.form-group input:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
opacity: 0.6;
}
.form-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
flex-shrink: 0;
}
.btn {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
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-secondary {
background: var(--bg-glass);
color: var(--text-primary);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-glass-hover);
border-color: var(--border-color-strong);
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.btn-danger {
background: var(--error);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
transform: translateY(-2px);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.profiles-section {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.profiles-section h2 {
font-size: 1.1rem;
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
flex-shrink: 0;
}
.search-container {
margin-bottom: var(--spacing-sm);
flex-shrink: 0;
}
.search-input {
width: 100%;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--spacing-sm) var(--spacing-md);
color: var(--text-primary);
font-size: 0.9rem;
transition: all var(--transition-base);
font-family: inherit;
}
.search-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.search-input::placeholder {
color: var(--text-tertiary);
}
#scroll-sentinel {
height: 1px;
width: 100%;
pointer-events: none;
}
.profiles-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.profile-item {
background: var(--bg-glass);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--spacing-sm);
display: flex;
align-items: center;
gap: var(--spacing-sm);
transition: all var(--transition-base);
flex-shrink: 0;
}
.profile-item:hover {
background: var(--bg-glass-hover);
transform: translateX(4px);
box-shadow: var(--shadow-md);
}
.profile-item-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
object-fit: cover;
border: 2px solid var(--primary);
flex-shrink: 0;
}
.profile-item-info {
flex: 1;
min-width: 0;
}
.profile-item-name {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.15rem;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.4rem;
}
.bot-badge {
display: inline-block;
background: var(--primary, #6366f1);
color: white;
font-size: 0.65rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
vertical-align: middle;
flex-shrink: 0;
}
.profile-item-bio {
color: var(--text-secondary);
font-size: 0.8rem;
margin-bottom: 0.15rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
}
.profile-item-details {
color: var(--text-secondary);
font-size: 0.75rem;
margin: 0.15rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
overflow: hidden;
text-overflow: ellipsis;
}
.profile-item-details a {
color: var(--primary-light);
text-decoration: none;
transition: color var(--transition-base);
}
.profile-item-details a:hover {
color: var(--primary);
text-decoration: underline;
}
.profile-item-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.4rem 0;
}
.tag-badge {
display: inline-block;
background: var(--primary);
color: white;
font-size: 0.7rem;
font-weight: 500;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
white-space: nowrap;
transition: all var(--transition-base);
}
.tag-badge:hover {
background: var(--primary-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.profile-item-peerid {
color: var(--text-tertiary);
font-size: 0.7rem;
font-family: 'Monaco', 'Courier New', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 0.3rem;
}
/* Tag Editor */
.tag-editor {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: var(--spacing-xs) var(--spacing-sm);
min-height: 40px;
max-height: 200px;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
align-items: flex-start;
align-content: flex-start;
transition: all var(--transition-base);
overflow-y: auto;
overflow-x: hidden;
margin-top: var(--spacing-xs);
}
.tags-input-inline {
flex: 1;
min-width: 120px;
max-width: 100%;
background: var(--bg-secondary);
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;
}
.tags-input-inline:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.tag-editor:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
flex: 1;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.tag-editor-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: var(--primary);
color: white;
font-size: 0.8rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
white-space: nowrap;
transition: all var(--transition-base);
}
.tag-editor-badge:hover {
background: var(--primary-dark);
}
.tag-remove {
background: transparent;
border: none;
color: white;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: all var(--transition-base);
flex-shrink: 0;
}
.tag-remove:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.tag-remove:active {
transform: scale(0.95);
}
#tags-input::placeholder {
color: var(--text-tertiary);
}
.loading {
text-align: center;
color: var(--text-tertiary);
padding: var(--spacing-md);
font-size: 0.85rem;
}
.toast {
position: fixed;
bottom: var(--spacing-xl);
right: var(--spacing-xl);
background: var(--bg-glass);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-md) var(--spacing-lg);
color: var(--text-primary);
box-shadow: var(--shadow-xl);
opacity: 0;
transform: translateY(20px);
transition: all var(--transition-base);
z-index: 1000;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.success {
border-left: 4px solid var(--success);
}
.toast.error {
border-left: 4px solid var(--error);
}
/* 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) {
.main-content {
grid-template-columns: 1fr;
}
.header {
padding: var(--spacing-md) var(--spacing-lg);
flex-wrap: wrap;
}
.header h1 {
font-size: 1.5rem;
}
.header-content {
flex: 1;
min-width: 0;
}
.header-status {
margin-top: var(--spacing-sm);
width: 100%;
justify-content: flex-end;
}
.container {
padding: var(--spacing-md);
}
}
File diff suppressed because one or more lines are too long
+4
View File
@@ -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="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512H418.3c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304H178.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 305 B

+131
View File
@@ -0,0 +1,131 @@
<!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>Global Profile - P2NS</title>
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/css/style.css">
<script src="/auth-utils.js"></script>
</head>
<body>
<div class="container">
<header class="header">
<div class="header-content">
<h1>Global Profile</h1>
<p class="subtitle">Manage your profile across the P2NS network</p>
</div>
<div class="header-status">
<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>
<main class="main-content">
<div class="profile-section">
<div class="profile-card">
<div class="avatar-section">
<div class="avatar-container">
<img id="avatar-preview" src="" alt="Avatar" class="avatar-image" style="display: none;" onerror="this.style.display='none'; document.getElementById('avatar-placeholder').style.display='block';">
<div id="avatar-placeholder" class="avatar-placeholder" style="display: block;">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<label for="avatar-upload" class="avatar-upload-label">
<input type="file" id="avatar-upload" accept="image/*" style="display: none;">
<span class="upload-icon">📷</span>
</label>
</div>
<p class="avatar-hint">Click to upload avatar</p>
</div>
<form id="profile-form" class="profile-form">
<div class="form-group">
<label for="display-name">Display Name</label>
<input type="text" id="display-name" name="displayName" placeholder="Enter your display name">
</div>
<div class="form-group">
<label for="bio">Bio</label>
<textarea id="bio" name="bio" placeholder="Tell us about yourself..."></textarea>
</div>
<div class="form-group">
<label>Peer ID</label>
<input type="text" id="peer-id" readonly class="readonly">
</div>
<div class="form-section-divider">
<h3>Additional Details</h3>
</div>
<div class="form-group">
<div class="form-group-header">
<label for="email">Email</label>
<button type="button" class="toggle-btn" id="email-toggle" data-field="email">
<span class="toggle-label">Enable</span>
</button>
</div>
<input type="email" id="email" name="email" placeholder="[email protected]" disabled>
</div>
<div class="form-group">
<div class="form-group-header">
<label for="website">Website</label>
<button type="button" class="toggle-btn" id="website-toggle" data-field="website">
<span class="toggle-label">Enable</span>
</button>
</div>
<input type="url" id="website" name="website" placeholder="https://example.com" disabled>
</div>
<div class="form-group">
<div class="form-group-header">
<label for="x-handle">X (Twitter) Handle</label>
<button type="button" class="toggle-btn" id="x-handle-toggle" data-field="xUsername">
<span class="toggle-label">Enable</span>
</button>
</div>
<input type="text" id="x-handle" name="xUsername" placeholder="@username" disabled>
</div>
<div class="form-group">
<input type="text" id="tags-input" name="tags" class="tags-input-inline" placeholder="Type and press Enter to add a tag" autocomplete="off">
<div class="tag-editor">
<div class="tag-list" id="tag-list"></div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="save-btn">
<span class="btn-text">Save Profile</span>
<span class="btn-loader" style="display: none;"></span>
</button>
<button type="button" class="btn btn-danger" id="delete-btn">Delete Profile</button>
</div>
</form>
</div>
</div>
<div class="profiles-section">
<h2>Network Profiles (<span id="profilesCountValue">-</span>)</h2>
<div class="search-container">
<input type="text" id="profile-search" class="search-input" placeholder="Search profiles by name, bio, email, website, handle, or peer ID...">
</div>
<div id="profiles-list" class="profiles-list">
<div class="loading">Loading profiles...</div>
</div>
</div>
</main>
</div>
<div id="toast" class="toast"></div>
<script src="/js/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,433 @@
/**
* Profile Modal Component
* Reusable modal for displaying user profiles across plugins
*
* Usage:
* 1. Include the CSS: <link rel="stylesheet" href="https://global.profile/css/profile-modal.css">
* 2. Include the HTML: (include profile-modal.html content or use iframe/component loader)
* 3. Include this JS: <script src="https://global.profile/js/profile-modal.js"></script>
* 4. Use: window.ProfileModal.open(peerId)
*/
(function() {
'use strict';
// Profile cache with TTL
const profileCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Global profile domain
const GLOBAL_PROFILE_DOMAIN = 'global.profile';
/**
* Get profile from cache or fetch from API
*/
async function getProfile(peerId) {
// Check cache first
const cached = profileCache.get(peerId);
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
return cached.profile;
}
try {
// Fetch from global.profile API
// Use absolute URL to access global.profile domain
const protocol = window.location.protocol;
// Construct URL - use absolute URL for cross-domain access
const profileUrl = `${protocol}//${GLOBAL_PROFILE_DOMAIN}/api/profile/${encodeURIComponent(peerId)}`;
const response = await fetch(profileUrl);
if (!response.ok) {
if (response.status === 404) {
return null; // Profile doesn't exist
}
throw new Error(`Failed to fetch profile: ${response.status}`);
}
const profile = await response.json();
// Cache the profile
profileCache.set(peerId, {
profile,
timestamp: Date.now()
});
return profile;
} catch (err) {
console.error('Error fetching profile:', err);
return null;
}
}
/**
* Get avatar URL for a peer
*/
function getAvatarUrl(peerId, size = 128) {
if (!peerId) return '';
const protocol = window.location.protocol;
return `${protocol}//${GLOBAL_PROFILE_DOMAIN}/api/profile/avatar/${encodeURIComponent(peerId)}/${size}`;
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Open profile modal for a peer
*/
async function openProfileModal(peerId) {
if (!peerId) {
console.error('ProfileModal: peerId is required');
return;
}
const modal = document.getElementById('profile-modal');
if (!modal) {
console.error('ProfileModal: Modal element not found. Make sure profile-modal.html is included.');
return;
}
// Show modal and loading state
modal.style.display = 'flex';
document.getElementById('profile-modal-loading').style.display = 'block';
document.getElementById('profile-modal-error').style.display = 'none';
document.getElementById('profile-modal-body').style.display = 'none';
try {
// Fetch profile
const profile = await getProfile(peerId);
if (!profile) {
// Show error state
document.getElementById('profile-modal-loading').style.display = 'none';
document.getElementById('profile-modal-error').style.display = 'block';
return;
}
// Populate modal with profile data
const displayName = profile.displayName || 'Anonymous';
const isBot = profile.customFields && (profile.customFields.isBot === true || profile.customFields.botTag === 'BOT');
const nameElement = document.getElementById('profile-modal-name');
if (isBot) {
nameElement.innerHTML = escapeHtml(displayName) + ' <span class="bot-badge">BOT</span>';
} else {
nameElement.textContent = displayName;
}
document.getElementById('profile-modal-peerid').textContent = profile.peerId || peerId;
// Avatar
const avatarImg = document.getElementById('profile-modal-avatar');
const avatarPlaceholder = document.getElementById('profile-modal-avatar-placeholder');
const avatarUrl = getAvatarUrl(peerId, 128);
avatarImg.src = avatarUrl + '?t=' + Date.now(); // Add timestamp to bust cache
avatarImg.style.display = 'block';
avatarPlaceholder.style.display = 'none';
avatarImg.onerror = () => {
avatarImg.style.display = 'none';
avatarPlaceholder.style.display = 'flex';
};
// Bio
const bioContainer = document.getElementById('profile-modal-bio-container');
const bioText = document.getElementById('profile-modal-bio');
if (profile.bio && profile.bio.trim()) {
bioText.textContent = profile.bio;
bioContainer.style.display = 'block';
} else {
bioContainer.style.display = 'none';
}
// Additional details - check if fields are enabled (via customFields or direct flags)
const emailItem = document.getElementById('profile-modal-email-item');
const emailLink = document.getElementById('profile-modal-email');
const emailEnabled = profile.email || (profile.customFields && profile.customFields.emailEnabled);
if (emailEnabled && profile.email) {
emailLink.href = `mailto:${escapeHtml(profile.email)}`;
emailLink.textContent = escapeHtml(profile.email);
emailItem.style.display = 'flex';
} else {
emailItem.style.display = 'none';
}
const websiteItem = document.getElementById('profile-modal-website-item');
const websiteLink = document.getElementById('profile-modal-website');
const websiteEnabled = profile.website || (profile.customFields && profile.customFields.websiteEnabled);
if (websiteEnabled && profile.website) {
let websiteUrl = profile.website;
if (!websiteUrl.startsWith('http://') && !websiteUrl.startsWith('https://')) {
websiteUrl = 'https://' + websiteUrl;
}
websiteLink.href = websiteUrl;
websiteLink.textContent = escapeHtml(profile.website);
websiteItem.style.display = 'flex';
} else {
websiteItem.style.display = 'none';
}
const xItem = document.getElementById('profile-modal-x-item');
const xLink = document.getElementById('profile-modal-x');
const xEnabled = profile.xUsername || (profile.customFields && profile.customFields.xHandleEnabled);
if (xEnabled && profile.xUsername) {
const xHandle = profile.xUsername.replace('@', '');
const xUrl = `https://x.com/${xHandle}`;
xLink.href = xUrl;
xLink.textContent = escapeHtml(profile.xUsername);
xItem.style.display = 'flex';
} else {
xItem.style.display = 'none';
}
// GitHub
const githubItem = document.getElementById('profile-modal-github-item');
const githubLink = document.getElementById('profile-modal-github');
if (githubItem && githubLink) {
if (profile.github) {
const githubHandle = profile.github.replace('@', '');
const githubUrl = `https://github.com/${githubHandle}`;
githubLink.href = githubUrl;
githubLink.textContent = escapeHtml(profile.github);
githubItem.style.display = 'flex';
} else {
githubItem.style.display = 'none';
}
}
// Discord
const discordItem = document.getElementById('profile-modal-discord-item');
const discordText = document.getElementById('profile-modal-discord');
if (discordItem && discordText) {
if (profile.discord) {
discordText.textContent = escapeHtml(profile.discord);
discordItem.style.display = 'flex';
} else {
discordItem.style.display = 'none';
}
}
// Location
const locationItem = document.getElementById('profile-modal-location-item');
const locationText = document.getElementById('profile-modal-location');
if (locationItem && locationText) {
if (profile.location) {
locationText.textContent = escapeHtml(profile.location);
locationItem.style.display = 'flex';
} else {
locationItem.style.display = 'none';
}
}
// Tags
const tagsContainer = document.getElementById('profile-modal-tags-container');
const tagsList = document.getElementById('profile-modal-tags-list');
if (tagsContainer && tagsList) {
let tagsArray = [];
if (Array.isArray(profile.tags)) {
tagsArray = profile.tags;
} else if (typeof profile.tags === 'string') {
try {
const parsed = JSON.parse(profile.tags);
tagsArray = Array.isArray(parsed) ? parsed : [];
} catch {
// If it's a comma-separated string, split it
if (profile.tags.trim()) {
tagsArray = profile.tags.split(',').map(t => t.trim()).filter(t => t);
}
}
}
if (tagsArray && tagsArray.length > 0) {
tagsList.innerHTML = '';
tagsArray.forEach(tag => {
const tagElement = document.createElement('span');
tagElement.className = 'profile-modal-tag';
tagElement.textContent = escapeHtml(tag);
tagsList.appendChild(tagElement);
});
tagsContainer.style.display = 'block';
} else {
tagsContainer.style.display = 'none';
}
}
// Show/hide block button (if blocking functions are available from peer.chat or SDK)
const actionsContainer = document.getElementById('profile-modal-actions');
const blockBtn = document.getElementById('profile-modal-block-btn');
const unblockBtn = document.getElementById('profile-modal-unblock-btn');
if (actionsContainer && blockBtn && unblockBtn && typeof window.isPeerBlocked === 'function') {
const isBlocked = window.isPeerBlocked(peerId);
const isOwnProfile = window.localPeerId && peerId === window.localPeerId;
if (!isOwnProfile) {
blockBtn.style.display = isBlocked ? 'none' : 'flex';
unblockBtn.style.display = isBlocked ? 'flex' : 'none';
actionsContainer.style.display = 'flex';
// Update button handlers
blockBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof window.blockPeer === 'function') {
window.blockPeer(peerId);
// Update button state
blockBtn.style.display = 'none';
unblockBtn.style.display = 'flex';
}
};
unblockBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof window.unblockPeer === 'function') {
window.unblockPeer(peerId);
// Update button state
unblockBtn.style.display = 'none';
blockBtn.style.display = 'flex';
}
};
} else {
actionsContainer.style.display = 'none';
}
} else if (actionsContainer) {
actionsContainer.style.display = 'none';
}
// Show body
document.getElementById('profile-modal-loading').style.display = 'none';
document.getElementById('profile-modal-body').style.display = 'block';
} catch (err) {
console.error('Error loading profile:', err);
document.getElementById('profile-modal-loading').style.display = 'none';
document.getElementById('profile-modal-error').style.display = 'block';
}
}
/**
* Close profile modal
*/
function closeProfileModal() {
const modal = document.getElementById('profile-modal');
if (modal) {
modal.style.display = 'none';
}
}
// Track if listeners are already attached
let listenersAttached = false;
/**
* Initialize modal event listeners
* Uses event delegation so it works even if modal is added dynamically
*/
function initModal() {
// Only attach document-level listeners once
if (listenersAttached) {
// But always try to attach direct listeners to the close button if modal exists
attachDirectListeners();
return;
}
listenersAttached = true;
// Use event delegation for close button (check if clicked element or its parent is the close button)
document.addEventListener('click', (e) => {
const closeBtn = e.target.closest('.profile-modal-close');
if (closeBtn) {
e.preventDefault();
e.stopPropagation();
closeProfileModal();
return;
}
// Check backdrop click
if (e.target && e.target.classList.contains('profile-modal-backdrop')) {
closeProfileModal();
}
});
// ESC key - check if modal is visible
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const modal = document.getElementById('profile-modal');
if (modal && modal.style.display === 'flex') {
closeProfileModal();
}
}
});
// Also attach direct listeners if modal already exists
attachDirectListeners();
}
/**
* Attach direct event listeners to the close button
*/
function attachDirectListeners() {
const modal = document.getElementById('profile-modal');
if (modal) {
const closeBtn = modal.querySelector('.profile-modal-close');
if (closeBtn && !closeBtn.hasAttribute('data-listener-attached')) {
closeBtn.setAttribute('data-listener-attached', 'true');
closeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
closeProfileModal();
});
// Also add onclick as fallback
closeBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
closeProfileModal();
};
}
}
}
/**
* Helper function to make avatar clickable
* Call this on avatar elements to make them open the profile modal when clicked
*/
function makeAvatarClickable(avatarElement, peerId) {
if (!avatarElement || !peerId) return;
avatarElement.style.cursor = 'pointer';
avatarElement.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openProfileModal(peerId);
});
}
// Initialize immediately (event delegation will work even if modal doesn't exist yet)
initModal();
// Also try to initialize when DOM is ready (in case modal is already there)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', attachDirectListeners);
}
// Export to global scope
window.ProfileModal = {
open: openProfileModal,
close: closeProfileModal,
getProfile: getProfile,
getAvatarUrl: getAvatarUrl,
makeAvatarClickable: makeAvatarClickable,
init: initModal // Export init so it can be called after HTML is loaded
};
})();
@@ -0,0 +1,33 @@
{
"name": "Global Profile",
"short_name": "Profile",
"description": "Universal user identity system for all P2NS plugins with P2P profile replication",
"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": ["social", "productivity"],
"lang": "en"
}
@@ -0,0 +1,96 @@
<!-- Profile Modal Component -->
<div id="profile-modal" class="profile-modal" style="display: none;">
<div class="profile-modal-backdrop"></div>
<div class="profile-modal-content">
<button class="profile-modal-close" aria-label="Close" onclick="if(window.ProfileModal){window.ProfileModal.close();}">&times;</button>
<div class="profile-modal-loading" id="profile-modal-loading">
<div class="loading-spinner"></div>
<p>Loading profile...</p>
</div>
<div class="profile-modal-error" id="profile-modal-error" style="display: none;">
<p>Failed to load profile</p>
</div>
<div class="profile-modal-body" id="profile-modal-body" style="display: none;">
<div class="profile-modal-header">
<div class="profile-modal-avatar-container">
<img id="profile-modal-avatar" class="profile-modal-avatar" alt="Avatar" onerror="this.style.display='none'; document.getElementById('profile-modal-avatar-placeholder').style.display='flex';">
<div id="profile-modal-avatar-placeholder" class="profile-modal-avatar-placeholder" style="display: none;">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
</div>
<div class="profile-modal-header-info">
<h2 id="profile-modal-name" class="profile-modal-name">Loading...</h2>
<p id="profile-modal-peerid" class="profile-modal-peerid">...</p>
</div>
</div>
<div class="profile-modal-bio" id="profile-modal-bio-container" style="display: none;">
<p id="profile-modal-bio" class="profile-modal-bio-text"></p>
</div>
<div class="profile-modal-tags" id="profile-modal-tags-container" style="display: none;">
<div class="profile-modal-tags-label">Tags</div>
<div class="profile-modal-tags-list" id="profile-modal-tags-list"></div>
</div>
<div class="profile-modal-details" id="profile-modal-details">
<div class="profile-modal-detail-item" id="profile-modal-email-item" style="display: none;">
<span class="profile-modal-detail-icon">📧</span>
<a id="profile-modal-email" href="#" class="profile-modal-detail-link"></a>
</div>
<div class="profile-modal-detail-item" id="profile-modal-website-item" style="display: none;">
<span class="profile-modal-detail-icon">🌐</span>
<a id="profile-modal-website" href="#" target="_blank" rel="noopener noreferrer" class="profile-modal-detail-link"></a>
</div>
<div class="profile-modal-detail-item" id="profile-modal-x-item" style="display: none;">
<span class="profile-modal-detail-icon">🐦</span>
<a id="profile-modal-x" href="#" target="_blank" rel="noopener noreferrer" class="profile-modal-detail-link"></a>
</div>
<div class="profile-modal-detail-item" id="profile-modal-github-item" style="display: none;">
<span class="profile-modal-detail-icon">💻</span>
<a id="profile-modal-github" href="#" target="_blank" rel="noopener noreferrer" class="profile-modal-detail-link"></a>
</div>
<div class="profile-modal-detail-item" id="profile-modal-discord-item" style="display: none;">
<span class="profile-modal-detail-icon">💬</span>
<span id="profile-modal-discord" class="profile-modal-detail-text"></span>
</div>
<div class="profile-modal-detail-item" id="profile-modal-location-item" style="display: none;">
<span class="profile-modal-detail-icon">📍</span>
<span id="profile-modal-location" class="profile-modal-detail-text"></span>
</div>
</div>
<div class="profile-modal-actions" id="profile-modal-actions" style="display: none;">
<button id="profile-modal-block-btn" class="profile-modal-action-btn profile-modal-action-btn-block" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
</svg>
Block
</button>
<button id="profile-modal-unblock-btn" class="profile-modal-action-btn profile-modal-action-btn-unblock" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 6v6l4 2"></path>
</svg>
Unblock
</button>
</div>
</div>
</div>
</div>