1078 lines
40 KiB
JavaScript
1078 lines
40 KiB
JavaScript
// Upload functionality and UI management
|
|
class GatewayUI {
|
|
constructor() {
|
|
this.currentUpload = null;
|
|
this.recentUploads = JSON.parse(localStorage.getItem('recentUploads') || '[]');
|
|
this.serviceStatus = {};
|
|
|
|
this.initializeElements();
|
|
this.attachEventListeners();
|
|
this.initializeTheme();
|
|
this.loadRecentUploads();
|
|
this.checkServiceStatus();
|
|
this.loadServerFiles();
|
|
|
|
// Update service status every 30 seconds
|
|
setInterval(() => this.checkServiceStatus(), 30000);
|
|
|
|
// Refresh file list every 15 seconds to pick up completed transcoding jobs
|
|
setInterval(() => this.loadServerFiles(false), 15000);
|
|
}
|
|
|
|
initializeElements() {
|
|
// Upload elements
|
|
this.uploadArea = document.getElementById('upload-area');
|
|
this.fileInput = document.getElementById('file-input');
|
|
this.uploadProgress = document.getElementById('upload-progress');
|
|
this.progressFill = document.getElementById('progress-fill');
|
|
this.progressPercent = document.getElementById('progress-percent');
|
|
this.progressSpeed = document.getElementById('progress-speed');
|
|
this.progressEta = document.getElementById('progress-eta');
|
|
this.uploadFilename = document.getElementById('upload-filename');
|
|
|
|
// Options
|
|
this.announceDht = document.getElementById('announce-dht');
|
|
this.storeBlossom = document.getElementById('store-blossom');
|
|
|
|
// Lists
|
|
this.uploadsList = document.getElementById('uploads-list');
|
|
|
|
// Toast container
|
|
this.toastContainer = document.getElementById('toast-container');
|
|
}
|
|
|
|
attachEventListeners() {
|
|
// File upload - only add if not already attached
|
|
if (!this.uploadArea.hasAttribute('data-events-attached')) {
|
|
this.uploadArea.addEventListener('click', (e) => {
|
|
// Prevent double clicks
|
|
if (e.detail === 1) {
|
|
this.fileInput.click();
|
|
}
|
|
});
|
|
this.uploadArea.setAttribute('data-events-attached', 'true');
|
|
}
|
|
|
|
this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e.target.files));
|
|
|
|
// Drag and drop
|
|
this.uploadArea.addEventListener('dragover', (e) => this.handleDragOver(e));
|
|
this.uploadArea.addEventListener('dragleave', (e) => this.handleDragLeave(e));
|
|
this.uploadArea.addEventListener('drop', (e) => this.handleDrop(e));
|
|
|
|
// Prevent default drag behaviors on document
|
|
document.addEventListener('dragover', (e) => e.preventDefault());
|
|
document.addEventListener('drop', (e) => e.preventDefault());
|
|
}
|
|
|
|
initializeTheme() {
|
|
const savedTheme = localStorage.getItem('theme') || 'light';
|
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
|
}
|
|
|
|
handleDragOver(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.uploadArea.classList.add('drag-over');
|
|
}
|
|
|
|
handleDragLeave(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.uploadArea.classList.remove('drag-over');
|
|
}
|
|
|
|
handleDrop(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.uploadArea.classList.remove('drag-over');
|
|
|
|
const files = Array.from(e.dataTransfer.files);
|
|
this.handleFileSelect(files);
|
|
}
|
|
|
|
handleFileSelect(files) {
|
|
if (files.length === 0) return;
|
|
|
|
// For now, handle one file at a time
|
|
const file = files[0];
|
|
|
|
// Validate file
|
|
const validation = this.validateFile(file);
|
|
if (!validation.valid) {
|
|
this.showToast(validation.message, 'error');
|
|
this.fileInput.value = ''; // Clear the input
|
|
return;
|
|
}
|
|
|
|
this.uploadFile(file);
|
|
}
|
|
|
|
validateFile(file) {
|
|
// Check file existence
|
|
if (!file) {
|
|
return { valid: false, message: 'No file selected' };
|
|
}
|
|
|
|
// Check file size (10GB default limit - server will enforce actual limit)
|
|
const maxSize = 10 * 1024 * 1024 * 1024; // 10GB
|
|
if (file.size > maxSize) {
|
|
return {
|
|
valid: false,
|
|
message: `File too large. Maximum size is ${this.formatBytes(maxSize)} (selected: ${this.formatBytes(file.size)})`
|
|
};
|
|
}
|
|
|
|
if (file.size === 0) {
|
|
return { valid: false, message: 'Cannot upload empty file' };
|
|
}
|
|
|
|
// Check filename
|
|
if (!file.name || file.name.trim() === '') {
|
|
return { valid: false, message: 'File must have a valid name' };
|
|
}
|
|
|
|
if (file.name.length > 255) {
|
|
return { valid: false, message: 'Filename too long (max 255 characters)' };
|
|
}
|
|
|
|
// Check for dangerous characters in filename
|
|
const dangerousChars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|'];
|
|
for (const char of dangerousChars) {
|
|
if (file.name.includes(char)) {
|
|
return {
|
|
valid: false,
|
|
message: `Filename cannot contain '${char}' character`
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check file type (basic validation)
|
|
const allowedTypes = [
|
|
// Video
|
|
'video/mp4', 'video/avi', 'video/mkv', 'video/mov', 'video/webm',
|
|
// Audio
|
|
'audio/mp3', 'audio/wav', 'audio/flac', 'audio/m4a', 'audio/ogg',
|
|
// Images
|
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp',
|
|
// Documents
|
|
'application/pdf', 'text/plain', 'application/zip', 'application/x-rar-compressed',
|
|
// Archives
|
|
'application/x-7z-compressed', 'application/x-tar', 'application/gzip'
|
|
];
|
|
|
|
// If type is provided and not in allowed list, show warning but allow
|
|
if (file.type && !allowedTypes.includes(file.type) && !file.type.startsWith('application/')) {
|
|
console.warn(`Unusual file type: ${file.type}`);
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
async uploadFile(file) {
|
|
if (this.currentUpload) {
|
|
this.showToast('Another upload is in progress', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Create FormData
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
// Add options
|
|
if (this.announceDht.checked) {
|
|
formData.append('announce_dht', 'true');
|
|
}
|
|
if (this.storeBlossom.checked) {
|
|
formData.append('store_blossom', 'true');
|
|
}
|
|
|
|
// Show progress
|
|
this.showUploadProgress(file.name);
|
|
|
|
// Reset progress
|
|
this.updateProgress(0, 0, file.size);
|
|
|
|
try {
|
|
this.currentUpload = {
|
|
file: file,
|
|
startTime: Date.now(),
|
|
abort: new AbortController()
|
|
};
|
|
|
|
const headers = {};
|
|
if (window.nostrAuth && window.nostrAuth.sessionToken) {
|
|
headers['Authorization'] = `Bearer ${window.nostrAuth.sessionToken}`;
|
|
console.log('Upload with auth token:', window.nostrAuth.sessionToken.substring(0, 20) + '...');
|
|
} else {
|
|
console.log('Upload without auth - nostrAuth:', !!window.nostrAuth, 'sessionToken:', !!window.nostrAuth?.sessionToken);
|
|
}
|
|
|
|
const response = await this.uploadWithProgress('/api/upload', {
|
|
method: 'POST',
|
|
headers: headers,
|
|
body: formData,
|
|
signal: this.currentUpload.abort.signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
this.handleUploadSuccess(file, result);
|
|
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
this.showToast('Upload cancelled', 'warning');
|
|
} else {
|
|
console.error('Upload error:', error);
|
|
|
|
// Provide user-friendly error messages
|
|
let message = 'Upload failed';
|
|
if (error.message.includes('413') || error.message.includes('too large')) {
|
|
message = 'File too large. Please choose a smaller file.';
|
|
} else if (error.message.includes('415') || error.message.includes('unsupported')) {
|
|
message = 'File type not supported. Please try a different file.';
|
|
} else if (error.message.includes('429') || error.message.includes('rate limit')) {
|
|
message = 'Upload rate limit exceeded. Please wait and try again.';
|
|
} else if (error.message.includes('401') || error.message.includes('unauthorized')) {
|
|
message = 'Please login to upload files.';
|
|
} else if (error.message.includes('403') || error.message.includes('forbidden')) {
|
|
message = 'Upload not allowed. Check your permissions.';
|
|
} else if (error.message.includes('507') || error.message.includes('storage')) {
|
|
message = 'Server storage full. Please try again later.';
|
|
} else if (error.message.includes('NetworkError') || error.message.includes('fetch')) {
|
|
message = 'Network error. Please check your connection and try again.';
|
|
} else if (error.message.includes('timeout')) {
|
|
message = 'Upload timed out. Please try again with a smaller file.';
|
|
}
|
|
|
|
this.showToast(message, 'error');
|
|
}
|
|
} finally {
|
|
this.hideUploadProgress();
|
|
this.currentUpload = null;
|
|
}
|
|
}
|
|
|
|
showUploadProgress(filename) {
|
|
this.uploadFilename.textContent = filename;
|
|
this.uploadProgress.classList.remove('hidden');
|
|
this.uploadArea.style.display = 'none';
|
|
}
|
|
|
|
async uploadWithProgress(url, options) {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
|
|
// Track upload progress
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
const percentComplete = (e.loaded / e.total) * 100;
|
|
this.updateProgress(percentComplete, e.loaded, e.total);
|
|
}
|
|
});
|
|
|
|
// Handle completion
|
|
xhr.addEventListener('load', () => {
|
|
// Create a Response-like object
|
|
const responseObj = {
|
|
ok: xhr.status >= 200 && xhr.status < 300,
|
|
status: xhr.status,
|
|
statusText: xhr.statusText,
|
|
json: async () => {
|
|
try {
|
|
return JSON.parse(xhr.responseText);
|
|
} catch (error) {
|
|
throw new Error('Invalid response format');
|
|
}
|
|
}
|
|
};
|
|
resolve(responseObj);
|
|
});
|
|
|
|
// Handle errors
|
|
xhr.addEventListener('error', () => {
|
|
reject(new Error('Network error occurred during upload'));
|
|
});
|
|
|
|
// Handle timeout
|
|
xhr.addEventListener('timeout', () => {
|
|
reject(new Error('Upload timed out'));
|
|
});
|
|
|
|
// Handle abort
|
|
xhr.addEventListener('abort', () => {
|
|
reject(new Error('AbortError'));
|
|
});
|
|
|
|
// Set timeout (10 minutes for large files)
|
|
xhr.timeout = 600000;
|
|
|
|
// Open connection FIRST
|
|
xhr.open(options.method || 'GET', url);
|
|
|
|
// Set headers AFTER opening
|
|
Object.entries(options.headers || {}).forEach(([key, value]) => {
|
|
xhr.setRequestHeader(key, value);
|
|
});
|
|
|
|
// Handle abort signal
|
|
if (options.signal) {
|
|
options.signal.addEventListener('abort', () => {
|
|
xhr.abort();
|
|
});
|
|
}
|
|
|
|
// Send request
|
|
xhr.send(options.body);
|
|
});
|
|
}
|
|
|
|
updateProgress(percent, loaded, total) {
|
|
if (!this.currentUpload) return;
|
|
|
|
const elapsed = (Date.now() - this.currentUpload.startTime) / 1000;
|
|
const speed = loaded / elapsed;
|
|
const remaining = total > loaded ? (total - loaded) / speed : 0;
|
|
|
|
this.progressFill.style.width = `${percent}%`;
|
|
this.progressPercent.textContent = `${Math.round(percent)}%`;
|
|
this.progressSpeed.textContent = this.formatBytes(speed) + '/s';
|
|
this.progressEta.textContent = this.formatTime(remaining);
|
|
}
|
|
|
|
simulateProgress() {
|
|
// Fallback for browsers that don't support progress events
|
|
let progress = 0;
|
|
const startTime = Date.now();
|
|
|
|
const updateProgress = () => {
|
|
if (!this.currentUpload) return;
|
|
|
|
// Simulate realistic progress curve
|
|
progress += (100 - progress) * 0.05;
|
|
|
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
const speed = (this.currentUpload.file.size * (progress / 100)) / elapsed;
|
|
const remaining = (this.currentUpload.file.size - (this.currentUpload.file.size * (progress / 100))) / speed;
|
|
|
|
this.progressFill.style.width = `${progress}%`;
|
|
this.progressPercent.textContent = `${Math.round(progress)}%`;
|
|
this.progressSpeed.textContent = this.formatBytes(speed) + '/s';
|
|
this.progressEta.textContent = this.formatTime(remaining);
|
|
|
|
if (progress < 95 && this.currentUpload) {
|
|
setTimeout(updateProgress, 100);
|
|
}
|
|
};
|
|
|
|
updateProgress();
|
|
}
|
|
|
|
hideUploadProgress() {
|
|
this.uploadProgress.classList.add('hidden');
|
|
this.uploadArea.style.display = 'block';
|
|
this.progressFill.style.width = '0%';
|
|
this.fileInput.value = '';
|
|
}
|
|
|
|
handleUploadSuccess(file, result) {
|
|
this.showToast('File uploaded successfully!', 'success');
|
|
|
|
// Add to recent uploads
|
|
const uploadRecord = {
|
|
id: result.file_hash || result.hash,
|
|
name: file.name,
|
|
size: file.size,
|
|
hash: result.file_hash || result.hash,
|
|
torrentHash: result.torrent_hash,
|
|
magnetLink: result.magnet_link,
|
|
timestamp: Date.now(),
|
|
type: file.type,
|
|
isVideo: file.type.startsWith('video/')
|
|
};
|
|
|
|
this.recentUploads.unshift(uploadRecord);
|
|
this.recentUploads = this.recentUploads.slice(0, 10); // Keep only last 10
|
|
localStorage.setItem('recentUploads', JSON.stringify(this.recentUploads));
|
|
|
|
this.loadServerFiles();
|
|
}
|
|
|
|
async loadServerFiles(showLoading = true) {
|
|
// Show loading state only if explicitly requested
|
|
if (this.uploadsList && showLoading) {
|
|
this.uploadsList.innerHTML = '<div class="loading-state"><div class="spinner"></div>Loading files...</div>';
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/files');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
// Debug logging
|
|
console.log('API /api/files response:', {
|
|
fileCount: data.files ? data.files.length : 0,
|
|
files: data.files ? data.files.map(f => ({
|
|
name: f.name,
|
|
hash: f.file_hash?.substring(0, 8),
|
|
is_video: f.is_video,
|
|
storage_type: f.storage_type
|
|
})) : []
|
|
});
|
|
|
|
if (data.files && data.files.length > 0) {
|
|
// Merge server files with local uploads, avoiding duplicates
|
|
const allFiles = [...data.files];
|
|
|
|
// Add local uploads that might not be on server yet
|
|
this.recentUploads.forEach(localFile => {
|
|
if (!allFiles.find(f => f.file_hash === localFile.hash)) {
|
|
allFiles.unshift({
|
|
file_hash: localFile.hash,
|
|
name: localFile.name,
|
|
size: localFile.size,
|
|
is_video: localFile.isVideo,
|
|
torrent_hash: localFile.torrentHash,
|
|
magnet_link: localFile.magnetLink
|
|
});
|
|
}
|
|
});
|
|
|
|
this.displayFiles(allFiles);
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('Could not load server files, showing local only:', error);
|
|
if (this.uploadsList) {
|
|
this.uploadsList.innerHTML = '<div class="error-state">Failed to load server files. Showing local uploads only.</div>';
|
|
setTimeout(() => this.loadRecentUploads(), 2000);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fallback to local uploads only
|
|
this.loadRecentUploads();
|
|
}
|
|
|
|
loadRecentUploads() {
|
|
if (this.recentUploads.length === 0) {
|
|
this.uploadsList.innerHTML = '<p class="empty-state">No recent uploads</p>';
|
|
return;
|
|
}
|
|
|
|
const files = this.recentUploads.map(upload => ({
|
|
file_hash: upload.hash,
|
|
name: upload.name,
|
|
size: upload.size,
|
|
is_video: upload.isVideo,
|
|
torrent_hash: upload.torrentHash,
|
|
magnet_link: upload.magnetLink
|
|
}));
|
|
|
|
this.displayFiles(files);
|
|
}
|
|
|
|
displayFiles(files) {
|
|
if (files.length === 0) {
|
|
this.uploadsList.innerHTML = '<p class="empty-state">No files uploaded</p>';
|
|
return;
|
|
}
|
|
|
|
this.uploadsList.innerHTML = files.map(file => `
|
|
<div class="upload-item" data-hash="${file.file_hash}">
|
|
<div class="upload-item-header">
|
|
<div class="upload-item-title">${this.escapeHtml(file.name)}</div>
|
|
<div class="upload-item-meta">
|
|
${this.formatBytes(file.size)} • Hash: ${file.file_hash.substring(0, 8)}...
|
|
</div>
|
|
</div>
|
|
<div class="upload-item-actions">
|
|
<button class="action-btn" onclick="gatewayUI.downloadFile('${file.file_hash}')">
|
|
⬇️ Download
|
|
</button>
|
|
<button class="action-btn" onclick="gatewayUI.getTorrent('${file.file_hash}')">
|
|
🧲 Torrent
|
|
</button>
|
|
${file.is_video ? `
|
|
<button class="action-btn" onclick="gatewayUI.playVideo('${file.file_hash}', '${this.escapeHtml(file.name)}')">
|
|
▶️ Play
|
|
</button>
|
|
` : ''}
|
|
<button class="action-btn" onclick="gatewayUI.shareFile('${file.file_hash}', '${this.escapeHtml(file.name)}')">
|
|
📋 Share
|
|
</button>
|
|
<button class="action-btn" onclick="gatewayUI.deleteFile('${file.file_hash}', '${this.escapeHtml(file.name)}')" style="background-color: var(--danger); color: white;">
|
|
🗑️ Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async checkServiceStatus() {
|
|
try {
|
|
// Use the stats API which provides comprehensive service information
|
|
const response = await fetch('/api/stats');
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
// Update service status based on stats data
|
|
this.updateServiceStatus('gateway', data.gateway && data.gateway.status === 'healthy');
|
|
this.updateServiceStatus('blossom', data.blossom && data.blossom.status === 'healthy');
|
|
this.updateServiceStatus('dht', data.dht && data.dht.status === 'healthy');
|
|
} else {
|
|
// If stats API fails, assume all services are down
|
|
this.updateServiceStatus('gateway', false);
|
|
this.updateServiceStatus('blossom', false);
|
|
this.updateServiceStatus('dht', false);
|
|
}
|
|
} catch (error) {
|
|
console.error('Service status check failed:', error);
|
|
// If stats API fails, assume all services are down
|
|
this.updateServiceStatus('gateway', false);
|
|
this.updateServiceStatus('blossom', false);
|
|
this.updateServiceStatus('dht', false);
|
|
}
|
|
|
|
this.updateSystemInfo();
|
|
}
|
|
|
|
updateServiceStatus(serviceName, isOnline) {
|
|
this.serviceStatus[serviceName] = isOnline;
|
|
|
|
const statusElement = document.getElementById(`${serviceName}-status`);
|
|
if (statusElement) {
|
|
statusElement.textContent = isOnline ? '🟢' : '🔴';
|
|
statusElement.className = `status-indicator ${isOnline ? 'online' : 'offline'}`;
|
|
}
|
|
}
|
|
|
|
updateSystemInfo() {
|
|
// Update system information display
|
|
const mode = Object.keys(this.serviceStatus).filter(s => this.serviceStatus[s]).length === 3
|
|
? 'unified' : 'partial';
|
|
|
|
const systemMode = document.getElementById('system-mode');
|
|
if (systemMode) systemMode.textContent = mode;
|
|
|
|
const totalStorage = document.getElementById('system-storage');
|
|
if (totalStorage) {
|
|
const totalSize = this.recentUploads.reduce((sum, upload) => sum + upload.size, 0);
|
|
totalStorage.textContent = this.formatBytes(totalSize);
|
|
}
|
|
|
|
const gatewayUploads = document.getElementById('gateway-uploads');
|
|
if (gatewayUploads) gatewayUploads.textContent = this.recentUploads.length;
|
|
}
|
|
|
|
cancelUpload() {
|
|
if (this.currentUpload) {
|
|
this.currentUpload.abort.abort();
|
|
}
|
|
}
|
|
|
|
downloadFile(hash) {
|
|
const url = `/api/download/${hash}`;
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = '';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
|
|
getTorrent(hash) {
|
|
const url = `/api/torrent/${hash}`;
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${hash}.torrent`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
|
|
playVideo(hash, name) {
|
|
const url = `/api/stream/${hash}`;
|
|
window.open(url, '_blank');
|
|
}
|
|
|
|
async shareFile(hash, name) {
|
|
console.log('shareFile called with:', { hash, name });
|
|
try {
|
|
// Get file metadata to check for streaming info
|
|
const response = await fetch(`/api/metadata/${hash}`);
|
|
let fileData = null;
|
|
|
|
if (response.ok) {
|
|
fileData = await response.json();
|
|
}
|
|
|
|
const baseUrl = window.location.origin;
|
|
|
|
// Use torrent info hash for magnet link if available, otherwise use file hash
|
|
let magnetHash = hash;
|
|
if (fileData && fileData.torrent_hash) {
|
|
magnetHash = fileData.torrent_hash;
|
|
console.log('Using torrent InfoHash for magnet:', magnetHash);
|
|
} else {
|
|
console.log('No torrent InfoHash found, using file hash:', magnetHash);
|
|
}
|
|
|
|
const links = {
|
|
direct: `${baseUrl}/api/download/${hash}`,
|
|
torrent: `${baseUrl}/api/torrent/${hash}`,
|
|
magnet: `magnet:?xt=urn:btih:${magnetHash}&dn=${encodeURIComponent(name)}`
|
|
};
|
|
|
|
// Add streaming links if available
|
|
if (fileData && fileData.streaming_info) {
|
|
links.stream = `${baseUrl}/api/stream/${hash}`;
|
|
links.hls = `${baseUrl}/api/stream/${hash}/playlist.m3u8`;
|
|
}
|
|
|
|
// Add NIP-71 Nostr link if available
|
|
if (fileData && fileData.nip71_share_link) {
|
|
links.nostr = fileData.nip71_share_link;
|
|
}
|
|
|
|
this.showShareModal(name, links);
|
|
} catch (error) {
|
|
console.error('Failed to get file metadata:', error);
|
|
// Fallback to basic links (using file hash for magnet since we can't get torrent info)
|
|
const baseUrl = window.location.origin;
|
|
const links = {
|
|
direct: `${baseUrl}/api/download/${hash}`,
|
|
torrent: `${baseUrl}/api/torrent/${hash}`,
|
|
magnet: `magnet:?xt=urn:btih:${hash}&dn=${encodeURIComponent(name)}`
|
|
};
|
|
this.showShareModal(name, links);
|
|
}
|
|
}
|
|
|
|
showShareModal(fileName, links) {
|
|
console.log('showShareModal called with:', { fileName, links });
|
|
const modal = document.getElementById('share-modal');
|
|
const fileNameEl = document.getElementById('share-file-name');
|
|
const linksContainer = document.getElementById('share-links');
|
|
|
|
console.log('Modal elements found:', {
|
|
modal: !!modal,
|
|
fileNameEl: !!fileNameEl,
|
|
linksContainer: !!linksContainer
|
|
});
|
|
|
|
if (!modal || !fileNameEl || !linksContainer) {
|
|
console.error('Share modal elements not found');
|
|
return;
|
|
}
|
|
|
|
fileNameEl.textContent = fileName;
|
|
|
|
const linkTypes = [
|
|
{ key: 'direct', label: 'Direct Download', icon: '⬇️' },
|
|
{ key: 'torrent', label: 'Torrent File', icon: '🧲' },
|
|
{ key: 'magnet', label: 'Magnet Link', icon: '🔗' },
|
|
{ key: 'stream', label: 'Stream Video', icon: '▶️' },
|
|
{ key: 'hls', label: 'HLS Playlist', icon: '📺' },
|
|
{ key: 'nostr', label: 'Share on Nostr', icon: '⚡' }
|
|
];
|
|
|
|
linksContainer.innerHTML = linkTypes
|
|
.filter(type => links[type.key])
|
|
.map(type => {
|
|
if (type.key === 'nostr') {
|
|
return `
|
|
<div class="share-link nostr-link">
|
|
<label>${type.icon} ${type.label}</label>
|
|
<div class="input-group">
|
|
<input type="text" value="${links[type.key]}" readonly>
|
|
<button onclick="copyToClipboard('${links[type.key].replace(/'/g, '\\\'')}')" class="copy-btn">Copy</button>
|
|
<button onclick="window.open('https://njump.me/${links[type.key]}', '_blank')" class="open-btn">Open</button>
|
|
</div>
|
|
<small>Opens in Nostr clients or web viewer</small>
|
|
</div>
|
|
`;
|
|
}
|
|
return `
|
|
<div class="share-link">
|
|
<label>${type.icon} ${type.label}</label>
|
|
<div class="input-group">
|
|
<input type="text" value="${links[type.key]}" readonly>
|
|
<button onclick="copyToClipboard('${links[type.key].replace(/'/g, '\\\'')}')" class="copy-btn">Copy</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
modal.style.display = 'block';
|
|
}
|
|
|
|
async deleteFile(hash, name) {
|
|
if (!window.nostrAuth || !window.nostrAuth.isAuthenticated()) {
|
|
alert('You must be logged in to delete files');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Are you sure you want to delete "${name}"?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await window.nostrAuth.deleteFile(hash);
|
|
if (result.success) {
|
|
// Remove from recent uploads
|
|
this.recentUploads = this.recentUploads.filter(upload => upload.hash !== hash);
|
|
this.saveRecentUploads();
|
|
|
|
// Refresh file list
|
|
await this.loadServerFiles();
|
|
|
|
alert(`File "${name}" deleted successfully`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Delete failed:', error);
|
|
alert(`Failed to delete file: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
showToast(message, type = 'info') {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
|
|
this.toastContainer.appendChild(toast);
|
|
|
|
// Remove toast after 3 seconds
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 3000);
|
|
}
|
|
|
|
// Utility functions
|
|
formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
formatTime(seconds) {
|
|
if (!isFinite(seconds) || seconds < 0) return '--:--';
|
|
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
formatDate(timestamp) {
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diff = now - date;
|
|
|
|
if (diff < 60000) return 'just now';
|
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
}
|
|
|
|
// Global functions for share modal
|
|
async function copyToClipboard(text) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
// Show a temporary success message
|
|
const copyBtn = event.target;
|
|
const originalText = copyBtn.textContent;
|
|
copyBtn.textContent = 'Copied!';
|
|
copyBtn.style.backgroundColor = '#4CAF50';
|
|
setTimeout(() => {
|
|
copyBtn.textContent = originalText;
|
|
copyBtn.style.backgroundColor = '';
|
|
}, 2000);
|
|
} catch (error) {
|
|
console.error('Failed to copy to clipboard:', error);
|
|
// Fallback for older browsers
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textarea);
|
|
alert('Copied to clipboard!');
|
|
}
|
|
}
|
|
|
|
function closeShareModal() {
|
|
const modal = document.getElementById('share-modal');
|
|
if (modal) {
|
|
modal.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Global functions for navigation and theme
|
|
function showServices() {
|
|
hideAllSections();
|
|
document.getElementById('services-section').classList.add('active');
|
|
gatewayUI.checkServiceStatus();
|
|
}
|
|
|
|
function showAbout() {
|
|
hideAllSections();
|
|
document.getElementById('about-section').classList.add('active');
|
|
}
|
|
|
|
function hideAllSections() {
|
|
document.querySelectorAll('.section').forEach(section => {
|
|
section.classList.remove('active');
|
|
});
|
|
}
|
|
|
|
function showUpload() {
|
|
hideAllSections();
|
|
document.getElementById('upload-section').classList.add('active');
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const currentTheme = document.documentElement.getAttribute('data-theme');
|
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
|
|
document.documentElement.setAttribute('data-theme', newTheme);
|
|
localStorage.setItem('theme', newTheme);
|
|
}
|
|
|
|
function refreshDHTStats() {
|
|
gatewayUI.showToast('DHT stats refreshed', 'success');
|
|
// In a real implementation, this would fetch DHT statistics
|
|
// from a dedicated endpoint
|
|
}
|
|
|
|
function cancelUpload() {
|
|
gatewayUI.cancelUpload();
|
|
}
|
|
|
|
function copyToClipboard(elementId) {
|
|
const element = document.getElementById(elementId);
|
|
element.select();
|
|
document.execCommand('copy');
|
|
gatewayUI.showToast('Copied to clipboard!', 'success');
|
|
}
|
|
|
|
// Initialize the UI when the page loads
|
|
let gatewayUI;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
gatewayUI = new GatewayUI();
|
|
});
|
|
|
|
// Handle browser navigation
|
|
window.addEventListener('hashchange', () => {
|
|
const hash = window.location.hash.slice(1);
|
|
switch (hash) {
|
|
case 'services':
|
|
showServices();
|
|
break;
|
|
case 'about':
|
|
showAbout();
|
|
break;
|
|
case 'upload':
|
|
showUpload();
|
|
break;
|
|
case 'files':
|
|
showFiles();
|
|
break;
|
|
default:
|
|
showAbout(); // Default to About page instead of Upload
|
|
}
|
|
});
|
|
|
|
// Mobile-specific enhancements
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Detect if this is a mobile device
|
|
const isMobile = window.innerWidth <= 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
|
|
if (isMobile) {
|
|
// Add mobile class to body for CSS targeting
|
|
document.body.classList.add('mobile-device');
|
|
|
|
// Show mobile menu toggle only on mobile
|
|
const mobileToggle = document.getElementById('mobile-menu-toggle');
|
|
if (mobileToggle && window.innerWidth <= 768) {
|
|
mobileToggle.style.display = 'flex';
|
|
}
|
|
|
|
// Close mobile menu when clicking nav links
|
|
const navLinks = document.querySelectorAll('nav a');
|
|
navLinks.forEach(link => {
|
|
link.addEventListener('click', () => {
|
|
const nav = document.getElementById('main-nav');
|
|
const toggle = document.getElementById('mobile-menu-toggle');
|
|
nav.classList.remove('mobile-nav-open');
|
|
toggle.classList.remove('active');
|
|
});
|
|
});
|
|
|
|
// Close mobile menu when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
const nav = document.getElementById('main-nav');
|
|
const toggle = document.getElementById('mobile-menu-toggle');
|
|
|
|
if (nav && nav.classList.contains('mobile-nav-open')) {
|
|
if (!nav.contains(e.target) && !toggle.contains(e.target)) {
|
|
nav.classList.remove('mobile-nav-open');
|
|
toggle.classList.remove('active');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Enhance drag and drop for mobile
|
|
const uploadArea = document.getElementById('upload-area');
|
|
if (uploadArea) {
|
|
// Add touch feedback
|
|
uploadArea.addEventListener('touchstart', (e) => {
|
|
uploadArea.classList.add('touched');
|
|
});
|
|
|
|
uploadArea.addEventListener('touchend', (e) => {
|
|
uploadArea.classList.remove('touched');
|
|
});
|
|
|
|
// Improve file input handling on mobile
|
|
const fileInput = document.getElementById('file-input');
|
|
if (fileInput) {
|
|
fileInput.addEventListener('change', (e) => {
|
|
if (e.target.files.length > 0) {
|
|
// Visual feedback that files were selected
|
|
uploadArea.style.borderColor = 'var(--accent-primary)';
|
|
setTimeout(() => {
|
|
uploadArea.style.borderColor = '';
|
|
}, 1000);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Improve modal scrolling on mobile
|
|
const modals = document.querySelectorAll('.modal');
|
|
modals.forEach(modal => {
|
|
modal.addEventListener('touchmove', (e) => {
|
|
// Prevent body scroll when scrolling in modal
|
|
e.stopPropagation();
|
|
});
|
|
});
|
|
|
|
// Add double-tap prevention for buttons
|
|
let lastTouchEnd = 0;
|
|
document.addEventListener('touchend', (e) => {
|
|
const now = (new Date()).getTime();
|
|
if (now - lastTouchEnd <= 300) {
|
|
e.preventDefault();
|
|
}
|
|
lastTouchEnd = now;
|
|
}, false);
|
|
|
|
// Improve copy functionality for mobile
|
|
const originalCopyToClipboard = window.copyToClipboard;
|
|
window.copyToClipboard = async function(text) {
|
|
try {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text);
|
|
if (window.player && window.player.showToast) {
|
|
window.player.showToast('Copied to clipboard!', 'success');
|
|
} else if (gatewayUI) {
|
|
gatewayUI.showToast('Copied to clipboard!', 'success');
|
|
}
|
|
} else {
|
|
// Fallback for older mobile browsers
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = text;
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.left = '-999999px';
|
|
textArea.style.top = '-999999px';
|
|
document.body.appendChild(textArea);
|
|
textArea.focus();
|
|
textArea.select();
|
|
|
|
try {
|
|
document.execCommand('copy');
|
|
if (window.player && window.player.showToast) {
|
|
window.player.showToast('Copied to clipboard!', 'success');
|
|
} else if (gatewayUI) {
|
|
gatewayUI.showToast('Copied to clipboard!', 'success');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
if (window.player && window.player.showToast) {
|
|
window.player.showToast('Failed to copy to clipboard', 'error');
|
|
} else if (gatewayUI) {
|
|
gatewayUI.showToast('Failed to copy to clipboard', 'error');
|
|
}
|
|
} finally {
|
|
document.body.removeChild(textArea);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Copy failed:', err);
|
|
if (window.player && window.player.showToast) {
|
|
window.player.showToast('Failed to copy to clipboard', 'error');
|
|
} else if (gatewayUI) {
|
|
gatewayUI.showToast('Failed to copy to clipboard', 'error');
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// Add viewport height fix for mobile browsers
|
|
function setViewportHeight() {
|
|
const vh = window.innerHeight * 0.01;
|
|
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
|
}
|
|
|
|
// Handle responsive menu switching
|
|
function handleResize() {
|
|
const mobileToggle = document.getElementById('mobile-menu-toggle');
|
|
const nav = document.getElementById('main-nav');
|
|
|
|
if (window.innerWidth > 768) {
|
|
// Desktop mode
|
|
if (mobileToggle) {
|
|
mobileToggle.style.display = 'none';
|
|
mobileToggle.classList.remove('active');
|
|
}
|
|
if (nav) {
|
|
nav.classList.remove('mobile-nav-open');
|
|
}
|
|
} else {
|
|
// Mobile mode
|
|
if (mobileToggle) {
|
|
mobileToggle.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
setViewportHeight();
|
|
}
|
|
|
|
setViewportHeight();
|
|
window.addEventListener('resize', handleResize);
|
|
window.addEventListener('orientationchange', () => {
|
|
setTimeout(handleResize, 100);
|
|
});
|
|
}); |